結論: 現時点では 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 秒なので、初回だけ半分以上を食う。
次のどれかが満たされたら再評価する価値がある。
- lindera-sqlite の索引化が線形になる(上流の修正)。メモリ・ウォームアップは既に 条件を満たしているので、速度だけが障害
- vaporetto がモデルをプロセス内で共有する(上流の修正)。速度は既に十分で、 接続ごとの 395MiB だけが障害
- あるいは Chiezo 側で FTS 用の接続を少数に束ねる(スレッドごとではなく上限つきプール + 直列化)。クエリ自体は 0.1ms なので直列化しても LAN 用途では詰まらない。これなら vaporetto を今すぐ使えるが、モデル 1 本ぶん約 400MB は下限として残り、 初回 2.5 秒も消えない
第一候補は lindera-sqlite 2.0.0 だった。
辞書を .so に静的リンクして埋め込む(--features=embed-ipadic で 19MB)ので、
接続ごとにモデルを複製しないはず — つまり上の「採用できる条件 1」を満たすはず、
という読みだった。その読み自体は当たっていたが、別の問題で落ちた。
現行の SQLite ではロード自体に失敗する。原因は 2 つで、両方直して初めて動いた (パッチはこのリポジトリには入れていない。上流の問題なので)。
-
fts5_apiのiVersionを 2 と決め打ちしている(extension.rs)。SQLite 3.43 以降は 3 を返すので版チェックで弾かれる(3.46 / 3.53 のいずれでも確認)。>=にすれば通る -
本命。
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 のままでも効く。