
2026/05/01 3:14
DuckDB を用いたフルテキスト検索
RSS: https://news.ycombinator.com/rss
要約▶
日本語訳:
この記事は、前回の導入をもとに DuckDB の全文検索 (FTS) 機能を詳しく解説しており、Okapi BM25 という高度なチューニング機能について取り上げています。この機能では、用語の出現頻度の加重 (
k1) と長さ正規化 (b) パラメータを調整でき、語幹抽出、停止語の除去、およびアクセント記号の削除にも対応しています。DuckDB は PostgreSQL の ts_headline に比べてネイティブの用語ハイライト機能には不備がありますが、Snowball 語幹抽出を含む堅牢なインデックスオプションを提供しています。これらの機能を有効活用するには、開発者が生のデータを事前処理する必要がある Specifically、Python ライブラリである BeautifulSoup および snowballstemmer を使用してメールファイルからコンテンツを JSON フォーマットに抽出し、DuckDB の FTS 拡張機能を通じてインポートします。この記事では、拡張機能をインストールし、件名と本文フィールドにインデックスを作成し、トランザクショナルなメールをフィルタリングし検索スコアを調整する高度なクエリを実行するための具体的な例を提供しています。これにより、DuckDB から移行することなしに洗練された検索アプリケーションを実現できます。本文
掲載日:2026 年 4 月 29 日
概要
これは、初回投稿「DuckDB: A Drop of DuckDB(ダック DB)」の続編です。DuckDB に初めて触れる場合は、そちらから始めることをお勧めします。
データを素早く簡単に検索可能にするという基本的な DuckDB ワークフローは極めて強力です……しかし、限界も存在します。歴史的出版物のコンテンツを検索したり、あるトランシェ(部分)のエメールを検索したりといったユースケースでは、単純なテキストクエリに制約がかかります。初回投稿で触れた通り、私はより高度な DuckDB 機能の探求に興味を持っており、今回の投稿ではフルテキスト検索(FTS: Full-Text Search)に焦点を当てて解説します。
私は Elasticsearch や Postgres(標準機能および pgvector および pg_search のような拡張機能を含め)といった他の FTS ソリューションを使用する面でかなりの経験を積んでいます。したがって、今回は DuckDB における現在の FTS の状況について、簡単なツアー形式でご紹介します。
FTS に関する簡易概説
完全な FTS トUTORIAL は本稿の範囲外であり、さらに詳しく学びたい場合は Postgres のドキュメントを読むことを推奨します。
FTS を利用することで、SQL オペレーター(
=、ilike、regexp など)だけでは達成できないほど、包括的かつ構成可能なクエリが可能になります。また、Okapi BM25 などのアルゴリズムを用いてスコアを調整することもできます。DuckDB もこのアプローチを採用しています。
インデックスオプション:
- ステム化(Stemming):語根に還元し、一部の活用形を処理します(例: walk, walks, walked, walking など)。しかし、非標準的な形式には対応が不足しています(例: mice と mouse 等)。
- ストップワード:“the”、“and”、“of”などの一般的なストップワードを取り除き、それらの存在が結果にバイアスをかけるのを防ぎます。
- アクセントの削除:“á”、“ä”、“a”などを正規化します。
クエリ関数:
- Okapi BM25 パラメータ
- k₁: 用語頻度(出現回数がどれだけ意味があるか?)
- b: 長さ正規化(長いドキュメントの方がより意味深だとするべきか?)
上記の機能はすべて、DuckDB の FTS 拡張機能に含まれていますが、これらは FTS の可能性のほんの一部に過ぎません……特に、より完全な機能を備えたエンジンを使用する場合です。しかし、DuckDB の機能セットは良好なスタートであり、将来的に機能が追加されたり、新しい拡張機能が出たりするはずです。私は、フレーズクエリ関数やベクトル検索対応、プラグ可能な同義語辞書へのサポートなどが、既にコントリビューターたちが検討している機能であることを想像しています。
私の実験において現在の DuckDB 機能セットで不足していた点の一つは、ソースデータ内のクエリ用語との一致箇所をハイライト表示する手段がなかったことです。Postgres では
ts_headline 関数を用いてこれを対処できますが、私はクエリ結果内で一致箇所を検出するために tmux(永遠に覚えておけないキーストリングの列)を使用して探そうとしたり、クエリ結果を grep にパイプさせたりする必要がありました。少々挫折感を覚えました。
この投稿を作成する過程で、Snowball プロジェクトにも出会いました。「情報検索用のステム化アルゴリズムを作成するための小さな文字処理言語に加え、それを用いて実装されたステム化アルゴリズムのコレクション」を特徴とするプロジェクトです。私の理解では、これはほとんどのデータベースやクライアントライブラリにおけるステム化の基盤となっています。Python の
snowballstemmer ライブラリは、予期せぬステム化の問題(例えば特定の語形が一致しない理由)を迅速にデバッグするために使用できます。
# stemmer.py # 使用方法: uv run stemmer.py # /// script # requires-python = "==3.13" # dependencies = [ # "snowballstemmer==3.0.1", # ] # /// from snowballstemmer import stemmer en = stemmer("english") print(en.stemWord("run")) # -> run print(en.stemWord("running")) # -> run print(en.stemWord("mouse")) # -> mous print(en.stemWord("mice")) # -> mice
セットアップ
フルテキスト検索は DuckDB が標準機能として提供しているものではありませんが、フルテキスト検索拡張機能を利用することで容易に入手可能です。
DuckDB のインストールが済んでいると仮定して、fts 拡張機能をインストールするには、新しいセッションを開始して以下のコマンドを実行するだけです:
INSTALL fts; LOAD fts;
実装上の検証
多 GB サイズのエメール群があり、政治家、ビジネスリーダー、有名人同士の対話内容を検索したいとします。私のコーパスには、
.eml 拡張子を持つエメールが 13,010 通あり、各種 mime-types が含まれています。DuckDB はこれらをネイティブにインポートできないため、データベースを作成し、インデックスを設定してクエリを開始する前に前処理を行う必要があります。類似のエメール群は archive.org、ddosecrets.org、あるいは justice.gov などで入手できるかもしれませんが、これは読者への課題として残しておきます。追ってみたい場合は、.emls のようなエメールコレクションが十分です。
ファイルの前処理
私は Python を用いて生データの処理を行う予定です。YMMV(人による)ですが、Python ツーリングにおいては、他にこれほど簡潔かつ効率的な解決策がないと考えており、そこで
uv を使用します。(私は uv に関するライトニングトークをいくつか行っており、ブログ記事に捧げるべきであると考えています。)
前処理ワークフローは正直に言ってシンプルで手軽なものであり、きれいにパースできないエメールを手軽に破棄します:
- エメールファイルを読み込む
- ボディ(本文)のコンテンツを取り出すことを試みる
- マーケティング向けまたはトランザクショナル向けを識別するために有用なヘッダーやその他のメタデータを解析する
- 成功した場合、JSON をファイルにダンプする
# preprocess-emails.py # 使用方法: uv run preprocess-emails.py # /// script # requires-python = "==3.13" # dependencies = [ # "beautifulsoup4==4.14.3", # ] # /// import email import json from bs4 import BeautifulSoup from email import policy from pathlib import Path def html_to_text(html): soup = BeautifulSoup(html, "html.parser") for tag in soup(["script", "style", "head", "title", "meta"]): tag.decompose() return soup.get_text(" ", strip=True) def extract_body(msg): try: for kind in ("plain", "html"): part = msg.get_body(preferencelist=(kind,)) if part is None: continue try: content = part.get_content() except Exception: continue if not (content and content.strip()): continue return html_to_text(content) if kind == "html" else content return None except Exception as e: print(f"Couldn't parse body: {e}") return None for path in Path(".").glob("*.eml"): try: with open(path, "rb") as f: msg = email.message_from_binary_file(f, policy=policy.default) body = extract_body(msg) if not body: print(f"no body found for {f}") continue row = { "body": body, "date": str(msg["date"]), "file": path.name, "from": str(msg["from"]), "subject": str(msg["subject"]), "to": str(msg["to"]), "list_unsubscribe": str(msg.get("List-Unsubscribe", "")), "list_id": str(msg.get("List-Id", "")), "precedence": str(msg.get("Precedence", "")), "auto_submitted": str(msg.get("Auto-Submitted", "")), "x_mailer": str(msg.get("X-Mailer", "")), "return_path": str(msg.get("Return-Path", "")), } with open(f"{path}.json", "w") as f: f.write(json.dumps(row)) except Exception as e: print(f"error parsing {f}: {e}")
定期的なスケジューリングによるプログラミング:JSON のインポートと DB の populations
CREATE TABLE emails AS SELECT * FROM read_json('*.eml.json');
DuckDB がインポートの進行状況と使用される RAM の量を表示してくれることは、とても気に入っています。
ID カラムの作成・埋め込み
インポート時にこれを達成する方法があるかもしれませんし、前処理ループから ID を挿入することも確かに可能です。しかし、私はこれを行うのを忘れてしまい、ここにその手順を後世に残しておきます。
ALTER TABLE emails ADD COLUMN id INTEGER; UPDATE emails SET id = rowid;
FTS インデックスの作成
1 つまたは複数のカラムをインデックス化でき、ステムマーやストップワードなどの挙動を制御するためのオプションパラメータもあります。詳細はドキュメントをご覧ください。
PRAGMA create_fts_index('emails', 'id', 'subject', 'body');
さあ掘り起こしてみましょう!
必要に応じてクエリを調整または拡張するために使用できる様々なパラメータがあります。詳細はドキュメントをご覧ください。
基本クエリ(デフォルトパラメータを使用し、トランザクショナルメールやマリングリストを除外する試み):
SELECT id, body, fts_main_emails.match_bm25(id, 'talk') AS score FROM emails WHERE list_unsubscribe = '' AND precedence NOT IN ('bulk', 'list', 'junk') AND score IS NOT NULL ORDER BY score DESC;
-- 対象とする結果: "Talking", "talks", "talked" など
連合演算子パラメータを使用し、すべての用語が一致することを要求する:
SELECT id, body, fts_main_emails.match_bm25(id, 'detective trial', conjunctive := 1) AS score FROM emails WHERE list_unsubscribe = '' AND precedence NOT IN ('bulk', 'list', 'junk') AND score IS NOT NULL ORDER BY score DESC;
-- 検索用語の 両方 に一致する結果のみが得られる
Okapi の k₁および b パラメータを使用して、用語頻度とドキュメント長を重み付けする:
b:
-- b = 0: ドキュメント長は無視される効果的に SELECT subject, "from", length(body) AS body_len, fts_main_emails.match_bm25(id, 'delivery', b := 0.0) AS score FROM emails WHERE score IS NOT NULL ORDER BY score DESC LIMIT 1; ┌────────────────────────────────────────┬─────────────────────────────────────────┬──────────┐ │ subject │ from │ body_len │ │ varchar │ varchar │ int64 │ ├────────────────────────────────────────┼─────────────────────────────────────────┼──────────┤ │ Our Best Deal of the Year: Save 50% fo │ The New York Times <nytimes@email.newyo │ 4121 │ │ r 26 Weeks on a Times Subscription. Sa │ rktimes.com> │ │ │ le Ends 12/2 │ │ │ └────────────────────────────────────────┴─────────────────────────────────────────┴──────────┘ -- b = 1: 長いドキュメント(例:ニュースレターや記事)はペナルティを受ける SELECT subject, "from", length(body) AS body_len, fts_main_emails.match_bm25(id, 'delivery', b := 1.0) AS score FROM emails WHERE score IS NOT NULL ORDER BY score DESC LIMIT 1; ┌────────────────────────┬───────────────────────────────────────┬──────────┐ │ subject │ from │ body_len │ │ varchar │ varchar │ int64 │ ├────────────────────────┼───────────────────────────────────────┼──────────┤ │ DELIVERY for Member │ Sapply <newsletter@sapplysamples.com> │ 197 │ └────────────────────────┴───────────────────────────────────────┴──────────┘
k₁: 既存のコーパスを用いて k₁ の有用性の良い例を見つけることができなかったため、k₁ パラメータが単語頻度のスコアをどのように変更するかを示すために、2 つの合成エメール/行を作成しました。1 つのエメールは"budget"を 1 回言及し、もう 1 つは"budget"を繰り返して言及しており、これはそのエメールの焦点です。k₁ が低い場合、スコアは互いに近くなります(このケースでは実際に一致しますが、現実のデータセット全体ではそうとは限りません)。一方、k₁が高い場合、「予算」自体について述べているエメールの方がより高いスコアを得ます。
SELECT file, subject, length(regexp_extract_all(lower(body), 'budget')) AS instances, fts_main_emails.match_bm25(id, 'budget', k := 0.3) AS k_low, fts_main_emails.match_bm25(id, 'budget', k := 3.0) AS k_high FROM emails WHERE file LIKE 'demo-%' ORDER BY k_high DESC; ┌──────────────────────────┬───────────┬────────────────────┬────────────────────┐ │ file │ instances │ k_low │ k_high │ │ varchar │ int64 │ double │ double │ ├──────────────────────────┼───────────┼────────────────────┼────────────────────┤ │ demo-budget-focused.eml │ 8 │ 1.5647278150907307 │ 5.693932504469362 │ │ demo-passing-mention.eml │ 1 │ 1.5647278150907307 │ 3.2223555634031062 │ └──────────────────────────┴───────────┴────────────────────┴────────────────────┘
低い k₁ を使用すると、スコア付け戦略は実質的に「この用語は何度か登場したのか?」となります。一方、高い k₁ を使用すると、繰り返しの出現回数がランキングに影響を与えます。
片付け
インデックスを削除するには以下のコマンドを使用します:
PRAGMA drop_fts_index('emails');
まとめ
DuckDB の FTS 機能セットは、Postgres や Elasticsearch ほど完全なわけではありません。しかし、それでも非常に強力であり、おそらく多くの探査的なユースケースには十分すぎるでしょう。より複雑なソリューションが必要であると判断された場合は、DuckDB をダンプして Postgres または Elasticsearch にインポートすることは容易です。(ほぼ)あらゆるデータソースに対して簡単にかつ迅速にセットアップできる点は非常に魅力的であり、本格的な作業として DuckDB を使用する際に私が手がけるものになるでしょう。
この DuckDB シリーズを継続したいと考えています。次の投稿では、現在ベクトル検索の状況について調査するかもしれません。お楽しみに。