Claude Media
Claude Codeで同義語辞書(synonym_graph)を作り、誤ヒットまで検証する

Claude Codeで同義語辞書(synonym_graph)を作り、誤ヒットまで検証する

検索ログの0件クエリから同義語候補をClaude Codeに出させ、synonym_graphの辞書に反映する。再現率の改善と誤ヒットの混入を、回帰テストのスクリプトで確かめる手順です。

Elasticsearchの同義語辞書は、足した瞬間は検索が良くなったように見えます。数週間後に「関係ないドキュメントが上位に出る」という声が来て、初めて辞書が原因だと気づくことも少なくありません。

この記事では、検索ログの0件クエリをClaude Codeに読ませて同義語の候補を出させ、辞書を反映する手順を組みます。ポイントは、採用の判断を人の勘ではなく回帰テストに預けることです。再現率が上がったか、誤ヒットが混ざっていないかを、辞書を変えるたびにスクリプトが答えます。

同義語辞書で何を確かめるのか

synonym_graphは、複数語の同義語を正しく扱うために、トークン列をグラフとして出力するトークンフィルターです。公式の説明では、検索時のアナライザー専用に設計されており、インデックス時に同義語を展開したい場合は通常のsynonymフィルターを使います。

この性質から、辞書の変更で起きる問題は2種類に分かれます。

くらべる

辞書を変えたときに起きる2種類の失敗

再現率

取りこぼし

0件になっていたクエリが、辞書を足してもまだ0件のままです。ルールの書き方が合っていないか、アナライザーの並びで同義語が壊れています。

適合率

誤ヒット

関係のないドキュメントがヒットするようになります。同義語を広く取りすぎたときに起き、0件の改善だけを見ていると気づけません。

人がログを眺めて辞書を足す運用は、前者しか見ません。後者を見張るには、「このクエリでこのドキュメントは出てはいけない」というケースを辞書と同じ場所に残しておく必要があります。

全体の流れ

手順

辞書を足してから採用するまで

  1. 1

    0件クエリを集計する

    検索ログから、ヒット数0のクエリを出現回数順に並べたファイルを用意します。

  2. 2

    候補をClaude Codeに出させる

    インデックスに実在する語と照らして、同義語ルールの候補と根拠をcandidates.txtに書かせます。

  3. 3

    使い捨てのインデックスに辞書を載せる

    候補を足した辞書でテスト用インデックスを作り直します。

  4. 4

    回帰テストを流す

    取りこぼしが直ったか、誤ヒットが増えていないかを確かめ、通ったルールだけを本番の辞書へ反映します。

Claude Codeに任せるのは2番目の候補づくりと、テストが落ちたときの原因調べです。採用の合否は4番目のスクリプトが決めます。

Elasticsearch側の前提を押さえる

候補を出させる前に、辞書の形式と制約を把握しておきます。Claude Codeに指示するときの前提にもなるからです。

ルールの書式

Solr形式では2種類の書き方があります。

# 等価: 左右どれで検索しても互いにヒットする
ipod, i-pod, i pod
computer, pc, laptop
 
# 明示: 左辺が右辺に展開される
personal computer => pc

等価の行は、既定のexpand: trueのもとでは全組み合わせに展開されます。expand: falseにすると先頭の語が正規形になり、foo, bar, bazはfoo => foo、bar => foo、baz => fooと同じ扱いになります。明示ルール(=>)はexpandの影響を受けません。

設定項目の早見表

設定既定値押さえておく点
synonyms_set既定値なし押さえておく点Synonyms APIで作った辞書を参照する。1フィルターに最大100セット
synonyms_path既定値なし押さえておく点設定ディレクトリ相対のファイル。クラスター全ノードに置く必要がある
synonyms既定値なし押さえておく点設定内に直接書く配列。テスト用インデックス向き
updateable既定値false押さえておく点trueで検索アナライザーの再読み込みに対応。検索アナライザー専用
lenient既定値updateableの値押さえておく点trueだと解析できないルールだけが無視される

辞書セットには、既定で1セットあたり100,000ルールの上限があります(synonyms.max_synonym_rulesクラスター設定で変更可能)。

アナライザー内の順序

同義語フィルターより前に置いたフィルターは、辞書のルール側にも適用されます。ステマーを前に置けば、辞書の語もステムされます。asciifoldingは折りたたんだ形だけを出すので辞書の解析に使えますが、multiplexer、word_delimiter_graph、ngramはエラーになります。

ストップワードのフィルターを同義語より前に置いた場合は、辞書のルールからもストップワードが取り除かれます。lenient: falseでは、これが原因でルールがエラーになり、再読み込みが通らなくなります。ストップワードで消える語を含むルールは、候補の段階で弾いておくのが安全です。

落とし穴

存在しない辞書セットを参照したインデックスは使えなくなる

synonyms_setで指定したセットが無い状態でインデックスを作ると、インデックスは作成途中のまま使えなくなります。復旧は、セットを作ったうえでインデックスを削除して作り直すか、いったん閉じて開き直す方法です。辞書の作成を、インデックス作成より先に置いてください。

Skillに手順を閉じ込める

同じ作業を毎回プロンプトで説明する代わりに、Skillにします。SkillはSKILL.mdのフロントマターと、同じディレクトリのスクリプトで構成できます。

.claude/skills/synonym-review/
├── SKILL.md
├── scripts/
│   ├── build-test-index.sh
│   └── check.sh
└── cases.tsv

SKILL.mdは次のような形になります。

---
name: synonym-review
description: 検索ログの0件クエリから同義語ルールの候補を作り、回帰テストで採用可否を判定する
disable-model-invocation: true
allowed-tools: Bash(${CLAUDE_SKILL_DIR}/scripts/*)
---
 
## 手順
 
1. search_log.tsv の hits が 0 のクエリを、count 降順で上位50件読む。
2. 各クエリについて、インデックスに実在する語を _search で確かめ、
   同義語ルールの候補を candidates.txt に書く。根拠を1行コメントで付ける。
3. candidates.txt を synonyms.txt に追記し、build-test-index.sh を実行する。
4. check.sh を実行する。MISS か FALSE が出たら、そのルールを外すか
   明示ルール(=>)に書き換えて再実行する。
5. 全ケースが通った状態の synonyms.txt だけを最終案として報告する。

フロントマターの要点は2つです。disable-model-invocation: trueにすると、Claudeが自動で呼び出さず、/synonym-reviewと打ったときだけ動きます。辞書の変更は意図したタイミングで実行したいので、この設定が合います。

allowed-toolsは、Skillを呼び出したターンの間、列挙したツールを許可確認なしで使えるようにします。${CLAUDE_SKILL_DIR}はSkillのディレクトリに置き換わるので、同梱のスクリプトだけを事前承認できます。汎用のBashを開けずに済みます。

テスト用インデックスと回帰テストを書く

辞書ごと作り直すスクリプト

テスト用のインデックスは、辞書ファイルの中身をsynonyms配列としてインラインで埋め込みます。毎回作り直すので、再読み込みの手順を挟まずに済みます。

#!/usr/bin/env bash
# build-test-index.sh <index> <synonyms.txt>
set -euo pipefail
IDX=$1; DICT=$2; ES=${ES_URL:-http://localhost:9200}
RULES=$(grep -v '^\s*#' "$DICT" | grep -v '^\s*$' | jq -R . | jq -s .)
 
curl -s -X DELETE "$ES/$IDX" >/dev/null || true
curl -s -X PUT "$ES/$IDX" -H 'Content-Type: application/json' -d "$(
  jq -n --argjson rules "$RULES" '{
    settings: { analysis: {
      filter: { syn: { type: "synonym_graph", synonyms: $rules } },
      analyzer: { search_syn: {
        type: "custom", tokenizer: "standard",
        filter: ["lowercase", "syn"] } }
    }},
    mappings: { properties: { title: {
      type: "text", analyzer: "standard", search_analyzer: "search_syn"
    }}}
  }')"

インデックス側はstandard、検索側だけにsynonym_graphを載せています。これは公式の「検索アナライザー専用」という説明に沿った構成です。日本語のデータでは、standardの代わりに形態素解析のトークナイザーを選んでください。この記事の例は、構造を見やすくするために英語の語で組んでいます。

期待値の表

テストケースは、タブ区切りでクエリ、必ずヒットさせるID、ヒットさせないIDの3列とします。

# query	must_hit	must_not_hit
laptop	d1,d4	-
pc	d1	d7
personal computer	d1	-

空の列は-にします。タブは空白文字扱いなので、空欄にするとreadが列を詰めてしまうためです。

判定スクリプト

#!/usr/bin/env bash
# check.sh <index> <cases.tsv>
set -uo pipefail
IDX=$1; CASES=$2; ES=${ES_URL:-http://localhost:9200}
fail=0
while IFS=$'\t' read -r q must not; do
  [[ -z "$q" || "$q" == \#* ]] && continue
  ids=$(curl -s "$ES/$IDX/_search" -H 'Content-Type: application/json' \
    -d "$(jq -n --arg q "$q" '{_source:false,size:50,query:{match:{title:$q}}}')" \
    | jq -r '[.hits.hits[]._id]|join(",")')
  for id in ${must//,/ }; do
    [[ "$id" == "-" ]] && continue
    [[ ",$ids," == *",$id,"* ]] || { echo "MISS  [$q] $id"; fail=1; }
  done
  for id in ${not//,/ }; do
    [[ "$id" == "-" ]] && continue
    [[ ",$ids," == *",$id,"* ]] && { echo "FALSE [$q] $id"; fail=1; }
  done
done < "$CASES"
exit $fail

MISSは取りこぼし、FALSEは誤ヒットです。終了コードが0以外なら不採用なので、CIにもそのまま載せられます。GitHub Actionsのワークフローをpush前に手元で試す方法はClaude CodeでactをGitHub Actionsのpush前検証に使う手順にあります。

0件クエリから候補を出させる

検索ログは、クエリ、ヒット件数、回数の3列に整形したものを用意します。

awk -F'\t' '$2==0 {c[$1]+=$3} END {for(q in c) print c[q]"\t"q}' \
  raw_search_log.tsv | sort -rn | head -50 > zero_hits.tsv

そのうえでSkillを呼びます。

/synonym-review

Claudeは、0件クエリごとに、近い意味でインデックスに実在する語を_searchや_analyzeで調べて候補を書きます。例えば次のような形になります(形式の例であり、実際の出力ではありません)。

# "notebook pc" 3回: タイトルに "laptop" が実在 (d1, d4)
notebook pc => laptop
# "personal computer" 2回: タイトルに "pc" が実在 (d1)
personal computer => pc

ここで候補に「根拠の1行」を要求する理由は、採用判断のレビューを速くするためです。インデックスに存在しない語への展開は、ヒットを増やさないのに辞書だけを太らせます。

等価ルールより明示ルールから始める

computer, pc, laptopのような等価ルールは、全組み合わせに展開されます。公式の例のcomputer, pc, laptopは、3語のどれで検索しても残りの2語を含むドキュメントにヒットする関係になります。意図した広がりかどうかは、ドメインしだいです。

誤ヒットを避けたいときは、検索語を1方向だけ別の語へ寄せる明示ルール(=>)を先に試します。回帰テストのmust_not_hitが落ちたら、等価ルールを明示ルールに書き換える、という戻し方を、Skillの手順4に入れておきました。

運用で効く3つの習慣

  • ケースを辞書と同じリポジトリに置く。辞書を直した人が、同じコミットでケースも足します。ケースは減らさず、足すだけにします。
  • ヒットさせたくないケースを先に書く。0件の改善は目に見えますが、誤ヒットは起きた後でしか見えません。ルールを足す前にmust_not_hitを足すのが近道です。
  • 本番反映はsynonyms_setとupdateableに寄せる。辞書をSynonyms APIのセットで管理し、検索アナライザーをupdateable: trueにしておけば、検索アナライザーの再読み込みで変更を拾えます。設定のしかたは、公式のSynonyms APIのページにあります。

Skillの効果を測る

Skillを作ったら、効いているかも測れます。公式の手順では、現実的なプロンプトを数本用意し、Skillありとなしで新しいセッションから実行して結果を比べます。個人用・プロジェクト用のSkillは、skillOverridesで"off"にすればSkillなしの条件を作れます(使い方はClaude CodeのskillOverridesとmodelOverridesの使い方で扱っています)。

この記事のSkillで見るなら、Skillなしで同じ依頼をしたときに、回帰テストが落ちるルールを混ぜた辞書が出てくるかどうかが比べどころです。SkillありでFALSEの件数が減るなら、手順4が効いています。

落とし穴

落とし穴

辞書のルールが、アナライザーの前段で変形される

同義語フィルターより前に置いたフィルターは、辞書のルールにも適用されます。ステマーを前に置くと辞書側の語もステムされるので、辞書に書いた形と検索語がずれて見えることがあります。ルールが効かないときは、_analyzeでクエリと辞書の語の両方を通して、トークンを見比べてください。

落とし穴

lenientがtrueのまま、壊れたルールが黙って無視される

updateable: trueにすると、lenientの既定もtrueになります。解析できないルールは無視されるので、辞書に書いたのに効かないルールが静かに残ります。回帰テストのMISSで気づける構成にしておけば、見逃しを防げます。

落とし穴

複数のsynonym_graphを連ねると検索時の挙動が定まらない

辞書を用途別に分けたくなっても、synonym_graphフィルターを鎖のように並べるのは避けます。公式は、1つのフィルターに複数のセットを配列で渡す構成を勧めています。連ねると検索時の挙動が不定になることがあります。

Elasticsearchの接続を直接Claudeに任せたい場合

ここまでの手順は、curlとスクリプトだけで閉じています。Elasticsearchの接続をMCPサーバー経由で任せる方法は、Elasticsearch MCPサーバーの使い方が非推奨化の経緯とあわせてまとめています。

同じ「作らせて、検証コマンドで確かめる」型の別の例には、Claude CodeにAsyncAPIの仕様書を書かせるや、dbtのデータテストを足す手順があります。生成物の正しさを、実行できる検査で担保する考え方は共通です。

まとめ

同義語辞書の難しさは、足す作業ではなく、足したあとの副作用が見えないことにあります。0件クエリからの候補出しはClaude Codeに任せ、採否はMISSとFALSEを数えるスクリプトに任せる分担にすると、辞書の変更を差分としてレビューできます。

まず試すなら、手元の検索ログから0件クエリの上位10件を選び、cases.tsvに3行だけ書いてみてください。must_not_hitの1行があるだけで、辞書を足すときの見え方が変わります。

参照した一次ソース

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

  1. code.claude.com/docs/en/skills
  2. elastic.co/guide/en/elasticsearch/reference/current/analysis-synonym-…
  3. elastic.co/guide/en/elasticsearch/reference/current/synonyms-apis.html
この記事を共有:XはてブLinkedIn