Claude Media
Claude Codeで日本語全文検索のn-gramと形態素を比較する手順

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ノイズ形態素取りこぼし
キャッシュ正解651trigram651ノイズ3形態素648取りこぼし3
テスト正解876trigram876ノイズ11形態素865取りこぼし11
プラグイン正解838trigram838ノイズ6形態素832取りこぼし6
データベース正解209trigram209ノイズ2形態素207取りこぼし2
サーバー正解1,396trigram1,396ノイズ0形態素1,396取りこぼし0
サーバ正解1,455trigram1,455ノイズ1,390形態素65取りこぼし1,390
権限設定正解124trigram124ノイズ0形態素128取りこぼし0
認証正解1,132trigram引けないノイズ-形態素1,131取りこぼし1
京都正解3trigram引けないノイズ-形態素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,132unicode61のみ130拾えた割合約11%
フック正解696unicode61のみ111拾えた割合約16%
環境変数正解1,247unicode61のみ228拾えた割合約18%
エラー正解1,892unicode61のみ44拾えた割合約2%
全文検索正解7unicode61のみ2拾えた割合約29%

日本語の連なりが1語として登録されるため、質問語が連なり全体と一致した記事だけが当たります。MATCHが0件のときに「本文に無い」と読んではいけない例です。日本語を扱うなら、trigramか形態素のどちらかを必ず通す必要があります。

手元のコーパスで同じ表を作る手順

自分のコーパスで確かめるときの順序です。数字は自分の本文で変わるので、質問語は実際に検索される語から選びます。

手順

比較表を作る流れ

  1. 1

    コーパスを1つに固定する

    本文からコードとタグを除き、日本語を含む記事だけを残します。両方の索引へ同じ配列を渡すため、件数が食い違いません。

  2. 2

    3種類の索引を作る

    trigram、分かち書き後のunicode61、分かち書きなしのunicode61を作り、optimizeを実行してからファイルサイズを控えます。

  3. 3

    質問語を2文字・3文字以上・カタカナ複合・表記ゆれで揃える

    20語前後あれば、型ごとの偏りが見えます。2文字の語と、長音の有無が違う語は必ず入れます。

  4. 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)形態素(句検索)
検索部分一致の正解92trigram(MATCH)0形態素(句検索)92
権限部分一致の正解186trigram(MATCH)0形態素(句検索)186
設定ファイル部分一致の正解88trigram(MATCH)88形態素(句検索)88
プロンプト部分一致の正解127trigram(MATCH)127形態素(句検索)117
ログ部分一致の正解201trigram(MATCH)0形態素(句検索)117
型部分一致の正解177trigram(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語程度を引き、正解・ノイズ・取りこぼしを並べると、判断の根拠が数字で手に入ります。

参照した一次ソース

この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。

  1. code.claude.com/docs/en/headless
  2. sqlite.org/fts5.html
  3. github.com/WorksApplications/Sudachi
この記事を共有:XはてブLinkedIn