DuckDB を用いたフルテキスト検索

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 シリーズを継続したいと考えています。次の投稿では、現在ベクトル検索の状況について調査するかもしれません。お楽しみに。

同じ日のほかのニュース

一覧に戻る →

2026/05/01 4:40

リンクedin は、拡張機能を 6,278 つスキャンし、その結果を全てのリクエストに暗号化して含めています。

## Japanese Translation: LinkedIn は、同意なく特定の Chrome 拡張機能を検出し処罰するために、ユーザーのブラウザを秘密裏にスキャンしており、基本的なプライバシー原則違反となっています。2026 年 4 月現在、そのスキャンカタログには 6,278 の拡張機能エントリが含まれており、少なくとも 2017 年から(当初は 38 から)積極的に維持されています。各拡張機能について、LinkedIn は chrome-extension:// URL に対して fetch() リクエストを發行し、失敗した場合はエラーがログに記録され、成功した場合は無視されて解決し、1 回の訪問あたり最大 6,278 のデータポイントが発生します。~1.6 MB の minified(圧縮された)かつ部分的に暗号化された JavaScript ファイルには、ハードコードされた拡張機能 ID と特定の web_accessible_resources パスが埋め込まれています。スキャンは 2 つのモードで実行されます:Promise.allSettled() を使用した同時並列リクエストと、設定可能な遅延( 때로는 requestIdleCallback に委譲される場合もあり)を持つ順次リクエストであり、パフォーマンスへの影響を隠蔽するためです。二次的なシステム「Spectroscopy」は、ハードコードされたリストに含まれていなくても chrome-extension:// URL を参照するアクティブなインタラクションを検出するために、独立して DOM ツリーを行進します。 拡張機能のみならず、LinkedIn の APFC/DNA ファフィンガープリントでは、キャンバスフィンガープリント、WebGL レンダラー、音声処理、インストール済みフォント、画面解像度、ピクセル比率、ハードウェア並列性、デバイスメモリ、バッテリーレベル、WebRTC によるローカル IP、タイムゾーン、言語など 48 の特性を収集し、これらを開示なしに収穫します。検出された拡張機能 ID は AedEvent および SpectroscopyEvent オブジェクトにパッケージ化され、RSA 公開鍵で暗号化され、LinkedIn の li/track エンドポイントに送信され、セッション中の後続のすべての API リクエストにおいて HTTP ヘッダーとして注入されます。 これらの実践により、求職ツール、政治コンテンツ拡張機能、宗教活動ツール、障害者支援ソフトウェア、神経多様性関連アプリケーションへの執行措置が可能となり、また LinkedIn は個人の詳細(例:アクティブな求職活動)を推測し、従業員間の組織ツールおよびワークフローをマッピングすることが可能です。この暗黙的なスキャンは LinkedIn のプライバシーポリシーに開示されておらず、EU デジタル市場法に違反しており、ゲートキーパーであるマイクロソフト(2024 年に指定)に対し、サードパーティツールを許可し、差別的な執行を禁止することを求めています。browsergate.eu によって公開準備が整っている完全な裁判所文書を通じて、法律当局——バイエルン州中央サイバー犯罪捜査庁(バーミング)など——は刑事調査を開始しました。ユーザーおよび企業は今後、プライバシー侵害とセキュリティ構成の暴露に対するリスクが高まっています。

2026/05/01 1:09

PyTorch Lightning の AI トレーニングライブラリに、神話上の風化獣「シャイ・フールード」をテーマにしたマルウェアが検出された

## Japanese Translation: 人気の PyPI パッケージ「lightning」の脆弱なバージョン 2(2.6.2 および 2.6.3)が、2026 年 4 月 30 日に公開されたことが、"Shai-Hulud"というテーマのオブフスクエードされた JavaScript 負荷を含むサプライチェーン攻撃で利用されました。マルウェアはモジュールをインポートするだけで自動的に実行され、認証情報、認証トークン、環境変数、クラウドシークレット(AWS、Azure Key Vault、GCP Secret Manager)、およびローカルファイルシステムの認証情報ファイルを盗みます。また、「EveryBoiWeBuildIsaWormBoi」という特定の命名規則と、"EveryBoiWeBuildIsAWormyBoi"で始まるコミットメッセージを用いて、公開の GitHub リポジトリを毒付けようとし、さらに C2 サーバーへの HTTPS POST、二重 base64 符号化されたトークンを伴う GitHub コミット検索デッドドロップ、攻撃者による公開リポジトリの利用、および `ghs_` トークンを用いて被害者のリポジトリに直接プッシュする、4 つの並列データ流出チャネルを利用しています。 この攻撃は、悪用された npm 認証情報を使用して公開されるあらゆるパッケージに対して、14.8 MB の `setup.mjs` ドロッパー(Bun ランタイム v1.3.13 をブートストアップする)と `router_runtime.js` ファイルを注入することで、PyPI から npm へと感染を広げます。永続性を確保するために、マルウェアは人気のある開発ツール設定ファイルにフックを注入します:Claude Code の `.claude/settings.json` への "SessionStart"フックと、VS Code の `.vscode/tasks.json` への `runOn: folderOpen` タスクです。攻撃者が書込みアクセス権を持っている場合、「Formatter」という名前の悪意のある GitHub Actions ワークフローがプッシュされ、「format-results」というダウンロード可能なアーティファクトとしてシークレットがダンプされます。さらに、`_runtime/`ディレクトリや `start.py`のようなファイルに隠れたフックも注入されます。 セキュリティ企業 Semgrep は、特定の検出規則を含む緊急のアドバースを発表しており、詳細は https://semgrep.dev/orgs/-/advisories で入手できます。影響を受けたユーザーは、直ちにすべての盗まれた認証情報(GitHub トークン、クラウドキー、API キー)の再発行を行い、`.claude/`、`.vscode/`、`_runtime/`ディレクトリなどに注入された悪意のあるスクリプトを含むプロジェクトを監査し、将来のサプライチェーン侵害を防ぐために厳格な依存関係フィルタを実装する必要があります。

2026/05/01 5:33

アップル、第四半期業績を発表

## Japanese Translation: アップルは、2026 年 3 月 28 日に終了した fiscal second quarter(第 2 四半期)で史上最高益を記録し、売上高は 1,112 億ドル(前年同期比 17% 増)、一株当たり利益は 2.01 ドル(同 22% 増)となりました。この業績は、iPhone 17 シリーズ(新 iPhone 17e を含む)への特異な需要から生じた iPhone 売上高の歴代最高記録、サービスの歴史的な成長、そして M4チップ搭載 iPad Air と MacBook Neo の成功した発売によって牽引されました。稼働キャッシュフローは四半期史上最高の 280 億ドルを超え、アップルの既存基盤はすべての主要製品カテゴリーおよび地域で史上最高に達しました。このモメンタムを報いるため、アップルは一株当たり 0.27 ドルの配当(4% 増)を宣告し、2026 年 5 月 14 日に記録日(レコードデー)として 2026 年 5 月 11 日の株主に対して支払い可能にするほか、追加の 1,000 億ドル規模の自社株式買回プログラムを承認しました。アップルの利益発表会合は、2026 年 4 月 30 日午後 2 時(太平洋標準時間)にライブストリーミング開始され、約 2 週間後のリプレイも利用可能です。詳細は apple.com/investor/earnings-call で確認できます。同社は堅調な財務体質とすべての主要セグメントにおける消費者の積極的な関与を強調しました。

DuckDB を用いたフルテキスト検索 | そっか~ニュース