Skip to content

Latest commit

 

History

History
137 lines (103 loc) · 7.74 KB

File metadata and controls

137 lines (103 loc) · 7.74 KB

FTS トークナイザの評価(trigram / 形態素)

結論: 現時点では trigram を維持する。形態素トークナイザは検索品質と索引サイズで 明確に勝るが、候補 2 つとも別の理由で運用に載らなかった:

  • sqlite-vaporetto — 速いがモデルを SQLite の接続ごとに持つ。配信側のメモリが 1 桁増え、「配信機は数百 MB で動く」という Chiezo の設計の要を壊す
  • lindera-sqlite — メモリは軽い(辞書を静的リンクで共有)が、索引化が文書長に対して ほぼ O(n²) で、1 万字級の Wikipedia 記事では vaporetto の 480 分の 1 の速度になる

2026-07-27 に本番 jawiki から 3 万文書(2.96 億文字)を抜いて実測した。再現は scripts/fts_lab.py(拡張の入手方法もそこに書いてある)。

なぜ検討したか

FTS5 の trigram は本文を 3 文字の窓に刻んで索引する。辞書が要らず何語でも引ける代わりに:

  • 3 文字未満のクエリが原理的に不可能app/fts.py がタイトル前方一致へ落とすので、 2 文字の固有名詞(「一蘭」「味仙」)は本文検索できていない
  • 「京都」が「東京都」の中の窓に一致するような誤ヒットが出る
  • 索引が大きい

測ったもの

trigram vaporetto lindera
索引サイズ(3 万文書) 1,383 MB 381 MB(−72%) 測れず(下記)
索引構築(3 万文書) 130s 61s 10 分でも終わらず
索引化スループット 4,394 千字/秒 5,774 千字/秒 12 千字/秒
「一蘭」「味仙」(2 文字) 0 件(引けない) 1 位で命中 1 位で命中
「京都」で「東京都」を誤ヒット する しない しない
「浅草」で「浅草寺」 引ける 引ける(浅草 + 寺 に分割) 引けない(1 語として扱う)
「浅草寺」「夏目漱石」(3 文字以上) 同等 同等(誤ヒットぶん trigram が数件多い)
語の途中からの部分一致(「ンスタントラ」) 47 件 0 件 0 件
英語(Tokyo / tokyo / Japan.) 引ける 引ける(ASCII は大小同一視)
接続 1 本の常駐メモリ +2 MiB +395 MiB +95 MiB
接続 8 本の常駐メモリ +3 MiB +926 MiB +227 MiB
接続 1 本目の初回クエリ 0.4 ms 2,500 ms 0 ms
2 回目以降のクエリ 0.1 ms 0.1 ms 0.1 ms

スループットはディスク律速を排除するため、同じ 400 文書(551 万字)をメモリ上の FTS 表へ 投入して測った。分割の質は一長一短で、vaporetto は「浅草寺」を 浅草 + 寺 に割るので 「浅草」で引けるが、lindera(IPADIC)は 1 語として扱うので引けない。

却下の理由

app/db.pyスレッドごとに接続をキャッシュする。uvicorn の既定スレッドプールは 40 本なので、最悪 40 本ぶんのモデルを抱えることになる(概算 3.5GB)。Chiezo は 「取り込みは潤沢メモリのマシン、配信は数百 MB の小型機」という非対称性を設計の要に 置いており(CLAUDE.md「メモリ方針」)、これはその前提を壊す。

初回 2.5 秒も接続ごとに払う。これはモデルの zstd 展開ではなく構築コストで、 非圧縮の .model(58MB)を SQLITE_VAPORETTO_MODEL で渡しても 2.45 秒までしか下がらない。 クエリのタイムアウトは 5 秒なので、初回だけ半分以上を食う。

採用できる条件

次のどれかが満たされたら再評価する価値がある。

  1. lindera-sqlite の索引化が線形になる(上流の修正)。メモリ・ウォームアップは既に 条件を満たしているので、速度だけが障害
  2. vaporetto がモデルをプロセス内で共有する(上流の修正)。速度は既に十分で、 接続ごとの 395MiB だけが障害
  3. あるいは Chiezo 側で FTS 用の接続を少数に束ねる(スレッドごとではなく上限つきプール + 直列化)。クエリ自体は 0.1ms なので直列化しても LAN 用途では詰まらない。これなら vaporetto を今すぐ使えるが、モデル 1 本ぶん約 400MB は下限として残り、 初回 2.5 秒も消えない

lindera を採らなかった理由

第一候補は lindera-sqlite 2.0.0 だった。 辞書を .so に静的リンクして埋め込む(--features=embed-ipadic で 19MB)ので、 接続ごとにモデルを複製しないはず — つまり上の「採用できる条件 1」を満たすはず、 という読みだった。その読み自体は当たっていたが、別の問題で落ちた。

まず、そのままでは動かない(バグ 2 つ)

現行の SQLite ではロード自体に失敗する。原因は 2 つで、両方直して初めて動いた (パッチはこのリポジトリには入れていない。上流の問題なので)。

  1. fts5_apiiVersion2 と決め打ちしている(extension.rs)。SQLite 3.43 以降は 3 を返すので版チェックで弾かれる(3.46 / 3.53 のいずれでも確認)。>= にすれば通る

  2. 本命。sqlite3_bind_pointerポインタの値を渡している:

    target: &mut *mut FTS5API,
    ...
    target.cast::<c_void>(),   // 自動 deref で (*target) = NULL がそのまま渡る

    C の作法は (void*)&pRet(変数のアドレス)。渡っていたのは中身の NULL で、 fts5() は「書き込み先が NULL なら何もしない」ため、エラーにならず黙って NULL のまま返る。(target as *mut *mut FTS5API).cast::<c_void>() が正しい

直しても、索引化が遅すぎる

メモリとウォームアップは読みどおり良かった(接続 1 本 +95MiB、8 本 +227MiB、初回 0ms)。 しかし索引化が vaporetto の約 480 分の 1 で、原因は文書長に対する非線形コスト:

総文字数は同じ 21,000 字
  200 文書 ×    105 字 : 0.02s
   20 文書 ×  1,050 字 : 0.05s
    2 文書 × 10,500 字 : 0.39s   ← 1 文書あたり 195ms

同じ総量でも長い文書ほど急激に遅くなる(ほぼ O(n²))。Wikipedia の記事は平均 1 万字級で まさにこの最悪領域に当たり、jawiki 全体では数十時間規模になる。取り込みが成立しない。

つまり現時点では、軽いほう(lindera)は遅すぎ、速いほう(vaporetto)は重すぎる。 どちらかの上流が直れば採用できる。

再現手順

# 拡張を取る(ビルド不要のバイナリ配布)
curl -sLO https://github.com/hotchpotch/sqlite-vaporetto/releases/download/v0.4.0/\
sqlite-vaporetto-v0.4.0-linux-x86_64-with-model.tar.gz
tar xzf sqlite-vaporetto-*.tar.gz

# 本番 DB から部分コピーを作り、両方の索引を張って比べる
FTS_EXT=./sqlite-vaporetto-*/libsqlite_vaporetto.so \
  python scripts/fts_lab.py build data/jawiki.db lab.db 30000
FTS_EXT=./sqlite-vaporetto-*/libsqlite_vaporetto.so \
  python scripts/fts_lab.py compare lab.db

この評価から実際に入った変更

トークナイザ本体は見送ったが、実験の過程で見つかった問題は別途直してある:

  • 検索の並びに人気度を混ぜた(jawiki の rank_score が 0.0 固定で死んでいた)
  • 並びの第 1 段にタイトル完全一致を置いた(「京都」で記事「京都」が 5 位以内に 入らなかった)

どちらも「2 文字クエリが引けるようになると数千件ヒットする」という、この実験で見えた 問題への対処で、trigram のままでも効く。