Claude Codeで日本語全文検索のn-gramと形態素を比較する手順
同じ3,000本の日本語本文にSQLite FTS5のtrigramと形態素分割の索引を作り、索引サイズ・ヒット数・ノイズを20語で測った結果と、claude -pで再実行する手順です。
日本語の全文検索は、n-gram索引なら取りこぼしが出ず、形態素索引なら索引が小さく雑音も少なくなります。同じ3,000本の本文に両方の索引を作って20語を引いたところ、索引サイズは約1.6倍、ヒットのずれは複合語と表記ゆれに集中しました。
この記事では、その測り方と数字を示します。測定スクリプトはClaude Codeに書かせて回す前提で、非対話モード(claude -p)での再実行手順も付けました。
比較の条件を先に固定する
比べるのは3つの索引です。コーパスは同じ3,000本の日本語本文で、コードブロックとタグは除いてあります。
| 索引 | 作り方 | 検索時の扱い |
|---|---|---|
| trigram | 作り方FTS5のtokenize="trigram"で本文をそのまま登録 | 検索時の扱い3文字以上の部分文字列で一致 |
| 形態素 | 作り方本文を分かち書きし、空白区切りでunicode61に登録 | 検索時の扱い質問語も同じ辞書で分割して句検索 |
| unicode61のみ | 作り方分かち書きせずunicode61に登録 | 検索時の扱い空白と記号でだけ切る(基準線) |
実行環境はSQLite 3.45.2、形態素解析はfugashi 1.5.2とunidic-liteの1.0.8です。コーパスは約3,812万バイト(UTF-8)、1,545万字でした。
「正解」は、本文に質問語が文字列として含まれる記事(LIKE '%語%'の件数)にしました。この定義だと、n-gramは構造上すべて拾います。形態素索引が取りこぼした件数は、正解から形態素のヒットを引いて数えます。雑音は、trigramが拾った記事のうち、語の境界で一致していないものです。境界の判定は形態素の分割列が連続一致するかで行い、辞書の癖がそのまま入ります。
trigramの仕様はSQLite側に書かれています。部分文字列一致であること、3文字未満の語は全文検索の問い合わせで1件も一致しないこと、索引の対象にならないLIKEは全表走査になることの3点が、この後の結果の前提です。
索引サイズは約1.6倍の差になる
optimizeをかけた後のデータベースファイルのサイズは次のとおりです。
| 索引 | サイズ(バイト) | 元の本文との比 |
|---|---|---|
| trigram | サイズ(バイト)103,862,272 | 元の本文との比約2.72倍 |
| 形態素 | サイズ(バイト)64,917,504 | 元の本文との比約1.70倍 |
| unicode61のみ | サイズ(バイト)76,296,192 | 元の本文との比約2.00倍 |
trigramは形態素の約1.6倍です。意外なのはunicode61のみの索引が形態素より大きい点でした。日本語は空白で切れないため、文の連なりが長い1語として登録され、語彙がほとんど重複しなくなります。
検索の速さは、この規模では差が見えません。キャッシュのMATCHはどちらも約0.0002秒でした。1回きりの計測なので、傾向を示す値として読んでください。
20語を引くと、ずれはどこに出るか
下の表は主な語です。「正解」は文字列が含まれる記事数、「ノイズ」はtrigramのヒットのうち語の境界にない件数、「取りこぼし」は形態素索引が拾えなかった件数です。
| 質問語 | 正解 | trigram | ノイズ | 形態素 | 取りこぼし |
|---|---|---|---|---|---|
| キャッシュ | 正解651 | trigram651 | ノイズ3 | 形態素648 | 取りこぼし3 |
| テスト | 正解876 | trigram876 | ノイズ11 | 形態素865 | 取りこぼし11 |
| プラグイン | 正解838 | trigram838 | ノイズ6 | 形態素832 | 取りこぼし6 |
| データベース | 正解209 | trigram209 | ノイズ2 | 形態素207 | 取りこぼし2 |
| サーバー | 正解1,396 | trigram1,396 | ノイズ0 | 形態素1,396 | 取りこぼし0 |
| サーバ | 正解1,455 | trigram1,455 | ノイズ1,390 | 形態素65 | 取りこぼし1,390 |
| 権限設定 | 正解124 | trigram124 | ノイズ0 | 形態素128 | 取りこぼし0 |
| 認証 | 正解1,132 | trigram引けない | ノイズ- | 形態素1,131 | 取りこぼし1 |
| 京都 | 正解3 | trigram引けない | ノイズ- | 形態素0 | 取りこぼし3 |
20語のうち、カタカナ語と2語以上の複合語は、両索引でほぼ同じ件数に収まりました。コンテキスト、環境変数、フック、エラーなどがそうです。差が出たのは次の3つの型でした。
2文字の語はtrigramで引けない
索引、認証、承認、京都はtrigramの全文検索で0件になります。SQLiteが3文字未満を一致させない仕様どおりです。LIKEにすれば件数は出ます(認証が1,132件、承認が978件)。ただし索引は使われず、手元では0.05〜0.09秒かかりました。
落とし穴
2文字の業界用語が0件になる
日本語は2文字の名詞が多く、認証や権限のような中心語ほどtrigramで引けません。FTS5の全文検索構文だけで運用すると、よく使う語の検索が静かに空振りします。2文字の語を扱うなら、LIKEへの切り替えか形態素索引を併用する必要があります。
複合語の内側に当たる
京都をLIKEで引くと3件出ますが、3件とも東京都の内側でした。語としての京都は0件です。形態素索引が0件を返したのはこちらが正しい結果です。同じ構造がテストで11件、プラグインで6件のノイズとして出ています。
表記ゆれは形態素側が弱い
サーバは、本文中にサーバーと書かれた記事まで部分文字列として一致します。trigramは1,455件を拾い、語としてのサーバは65件だけなので、1,390件はノイズ扱いになりました。
読み方は立場で変わります。表記ゆれまで拾いたい検索では、この1,390件は正しいヒットです。厳密な語の検索では雑音です。形態素索引は65件しか返しません。表記ゆれを吸収したいなら、辞書側の正規化が必要です。Sudachiは形態素解析の機能に分割・品詞付け・正規化を挙げています。ただしこの記事では正規化つきの辞書を測っていません。
分かち書きなしの索引は基準線にもならない
比較のために作ったunicode61のみの索引は、日本語ではほぼ使えませんでした。
| 質問語 | 正解 | unicode61のみ | 拾えた割合 |
|---|---|---|---|
| 認証 | 正解1,132 | unicode61のみ130 | 拾えた割合約11% |
| フック | 正解696 | unicode61のみ111 | 拾えた割合約16% |
| 環境変数 | 正解1,247 | unicode61のみ228 | 拾えた割合約18% |
| エラー | 正解1,892 | unicode61のみ44 | 拾えた割合約2% |
| 全文検索 | 正解7 | unicode61のみ2 | 拾えた割合約29% |
日本語の連なりが1語として登録されるため、質問語が連なり全体と一致した記事だけが当たります。MATCHが0件のときに「本文に無い」と読んではいけない例です。日本語を扱うなら、trigramか形態素のどちらかを必ず通す必要があります。
手元のコーパスで同じ表を作る手順
自分のコーパスで確かめるときの順序です。数字は自分の本文で変わるので、質問語は実際に検索される語から選びます。
比較表を作る流れ
- 1
コーパスを1つに固定する
本文からコードとタグを除き、日本語を含む記事だけを残します。両方の索引へ同じ配列を渡すため、件数が食い違いません。
- 2
3種類の索引を作る
trigram、分かち書き後のunicode61、分かち書きなしのunicode61を作り、
optimizeを実行してからファイルサイズを控えます。 - 3
質問語を2文字・3文字以上・カタカナ複合・表記ゆれで揃える
20語前後あれば、型ごとの偏りが見えます。2文字の語と、長音の有無が違う語は必ず入れます。
- 4
正解・ヒット・ノイズ・取りこぼしを数える
正解は
LIKE、ノイズは分割列の連続一致で判定します。数え方は先に決め、結果を見てから変えません。
集計の中心は、質問語ごとに次の集合を作る処理です。
truth = {i for i, d in enumerate(docs) if q in d}
qt = seg(q); n = len(qt)
word = {i for i in truth
if any(toks[i][k:k+n] == qt for k in range(len(toks[i]) - n + 1))}
tri = {r[0] - 1 for r in con.execute(
"select rowid from t where t match ?", ('"' + q + '"',))} if len(q) >= 3 else set()
noise = tri - word # trigram のノイズ
miss = truth - {r[0] - 1 for r in morph.execute(
"select rowid from t where t match ?", ('"' + " ".join(qt) + '"',))} # 形態素の取りこぼしrowidは1始まりなので1を引いて配列の添字に合わせています。docsとtoksの順序が索引への登録順と一致していることが、この集計の前提です。
分割辞書への依存は索引側に残る
形態素索引は、索引時と検索時で同じ辞書、同じ分割ルールを使う前提です。プラグインはunidic-liteではプラグとインに分かれました。質問語も同じ辞書で分割するため、一致はしています。辞書を差し替えて再索引を忘れると、一致が静かに崩れます。
句検索の性質も出ました。権限設定の形態素検索は128件で、正解の124件より多い結果でした。分かち書き後は権限と設定が隣り合っていれば一致するため、句読点や記号を挟んだ並びも拾います。件数が増えるのは雑音の一種です。
別の辞書・別のコーパスでも同じ型が出るか
辞書と規模を変えて、同じ型が再現するかも確かめました。コーパスは日本語の技術記事300本(約190万字、UTF-8で4,129,816バイト)、形態素解析はJanome 0.5.0です。結果は次のとおりでした。
| 質問語 | 部分一致の正解 | trigram(MATCH) | 形態素(句検索) |
|---|---|---|---|
| 検索 | 部分一致の正解92 | trigram(MATCH)0 | 形態素(句検索)92 |
| 権限 | 部分一致の正解186 | trigram(MATCH)0 | 形態素(句検索)186 |
| 設定ファイル | 部分一致の正解88 | trigram(MATCH)88 | 形態素(句検索)88 |
| プロンプト | 部分一致の正解127 | trigram(MATCH)127 | 形態素(句検索)117 |
| ログ | 部分一致の正解201 | trigram(MATCH)0 | 形態素(句検索)117 |
| 型 | 部分一致の正解177 | trigram(MATCH)0 | 形態素(句検索)150 |
索引ファイルは、trigramが13,197,312バイト(元本文の約3.2倍)、形態素が6,934,528バイト(約1.7倍)です。規模が違っても、2文字の語がMATCHで0件になる点と、形態素のほうが小さい点は変わりません。
差分の中身も同じ型でした。ログは部分一致で201文書ですが、語として引ける形態素側は117文書です。差の84文書は、ログインやダイアログ、プログラムの内側に当たった文書が目立ちました。型の差27文書は典型や定型です。
逆に、プロンプトの10文書はプロンプトインジェクションが1語として索引された文書で、辞書にある複合語の内側は形態素索引から引けません。構築時間も違い、trigramは約0.5秒、Janomeの分かち書きを含む形態素側は約35秒でした。コーパスが増えるほど、この差は索引の更新頻度に響きます。
選び方の早見表
| 状況 | 向く索引 | 理由 |
|---|---|---|
| 固有名詞・型番・コード片を部分一致で探す | 向く索引trigram | 理由辞書に無い語でも取りこぼさない |
| 2文字の語が中心の検索 | 向く索引形態素(またはLIKE併用) | 理由trigramは3文字未満を引けない |
| 索引サイズを抑えたい | 向く索引形態素 | 理由今回は約1.6倍の差 |
| 複合語の内側に当たる雑音を減らしたい | 向く索引形態素 | 理由京都が東京都に当たらない |
表記ゆれ(サーバ/サーバー)も拾いたい | 向く索引trigram、または正規化つきの形態素 | 理由辞書の正規化を使うかで分かれる |
trigramの索引サイズが気になるときは、SQLite側に手段があります。detail=noneかdetail=columnを指定すると索引は小さくなり、LIKEとGLOBの専用に使う場合に試す価値があると説明されています。ただしこの指定では、全文検索の問い合わせに3文字を超えるトークンを含められなくなります。今回の計測には使っていません。
Claude Codeに測定を書かせて再実行する
計測そのものは、Claude Codeに依頼して書かせるのが速い題材です。依頼文には、コーパスの場所、3種類の索引、質問語の一覧、数える指標の定義を書きます。次のようなCLAUDE.mdの断片を置くと、定義がぶれません。
## 全文検索の比較計測
- 正解は LIKE '%語%' で数える。FTS の件数とは別に記録する
- ノイズは trigram のヒットのうち、形態素の分割列が連続一致しないもの
- 形態素索引は索引時と検索時で同じ辞書を使う。辞書名とバージョンを出力に含める
- 3文字未満の語は trigram の MATCH ではなく LIKE で数え、注記する測定コードの中心は次の3つです。実行結果の数字は、依頼文の定義どおりに作れているかをこの出力で照合できます。
import sqlite3, fugashi
tg = fugashi.Tagger()
seg = lambda s: [w.surface for w in tg(s)]
con = sqlite3.connect("tri.db")
con.execute('create virtual table t using fts5(a, tokenize="trigram")')
con.executemany("insert into t(a) values(?)", [(d,) for d in docs])
morph = sqlite3.connect("morph.db")
morph.execute('create virtual table t using fts5(a, tokenize="unicode61")')
morph.executemany("insert into t(a) values(?)", [(" ".join(seg(d)),) for d in docs])繰り返し回す段階では、非対話モードに載せられます。-pを付けると対話なしで実行でき、--allowedToolsで使うツールを事前に許可します。--bareを付けると、フックやCLAUDE.mdの自動読み込みを省いて起動します。そのためCLAUDE.mdの定義は、プロンプトか--append-system-prompt-fileなどで渡す必要があります。
export ANTHROPIC_API_KEY=... # --bare はサブスクのログインを読まない
claude --bare -p \
"docs/fts-compare.md の定義に従い、compare.py を実行して表を出力" \
--allowedTools "Read,Bash" \
--output-format json | jq -r '.result'--bareは、同じ結果をどのマシンでも出したいCIやスクリプト向けの起動方法です。--output-format jsonとjqの組み合わせは、結果の本文だけを取り出す例として挙げられています。出力される表の中身は、モデルではなくスクリプトの結果として扱い、数字は手元のSQLiteと照合してください。
-pの使い方は、MySQLのスロークエリを読ませる手順でも別の題材で扱っています。--bareの起動仕様の変更はv2.1.286のリリースノートに、-pと--resumeの組み合わせの注意はキャッシュが効かなくなった問題にあります。
まとめ
n-gramは辞書なしで取りこぼさない代わりに、索引が約1.6倍になり、複合語の内側と表記違いに雑音が出ます。形態素は小さく静かですが、辞書と分割ルールを索引の一部として管理し続ける必要があります。
どちらを選ぶかは、検索対象の語が2文字中心か、固有名詞や型番が中心か、表記ゆれを拾いたいかで決まります。自分のコーパスで同じ20語程度を引き、正解・ノイズ・取りこぼしを並べると、判断の根拠が数字で手に入ります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。