Claude Codeで同義語辞書(synonym_graph)を作り、誤ヒットまで検証する
検索ログの0件クエリから同義語候補をClaude Codeに出させ、synonym_graphの辞書に反映する。再現率の改善と誤ヒットの混入を、回帰テストのスクリプトで確かめる手順です。
Elasticsearchの同義語辞書は、足した瞬間は検索が良くなったように見えます。数週間後に「関係ないドキュメントが上位に出る」という声が来て、初めて辞書が原因だと気づくことも少なくありません。
この記事では、検索ログの0件クエリをClaude Codeに読ませて同義語の候補を出させ、辞書を反映する手順を組みます。ポイントは、採用の判断を人の勘ではなく回帰テストに預けることです。再現率が上がったか、誤ヒットが混ざっていないかを、辞書を変えるたびにスクリプトが答えます。
同義語辞書で何を確かめるのか
synonym_graphは、複数語の同義語を正しく扱うために、トークン列をグラフとして出力するトークンフィルターです。公式の説明では、検索時のアナライザー専用に設計されており、インデックス時に同義語を展開したい場合は通常のsynonymフィルターを使います。
この性質から、辞書の変更で起きる問題は2種類に分かれます。
辞書を変えたときに起きる2種類の失敗
取りこぼし
0件になっていたクエリが、辞書を足してもまだ0件のままです。ルールの書き方が合っていないか、アナライザーの並びで同義語が壊れています。
誤ヒット
関係のないドキュメントがヒットするようになります。同義語を広く取りすぎたときに起き、0件の改善だけを見ていると気づけません。
人がログを眺めて辞書を足す運用は、前者しか見ません。後者を見張るには、「このクエリでこのドキュメントは出てはいけない」というケースを辞書と同じ場所に残しておく必要があります。
全体の流れ
辞書を足してから採用するまで
- 1
0件クエリを集計する
検索ログから、ヒット数0のクエリを出現回数順に並べたファイルを用意します。
- 2
候補をClaude Codeに出させる
インデックスに実在する語と照らして、同義語ルールの候補と根拠を
candidates.txtに書かせます。 - 3
使い捨てのインデックスに辞書を載せる
候補を足した辞書でテスト用インデックスを作り直します。
- 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.tsvSKILL.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 $failMISSは取りこぼし、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-reviewClaudeは、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行があるだけで、辞書を足すときの見え方が変わります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。