Claude Codeでkuromojiを設定し、_analyzeの分割結果をhookで検証する
kuromoji analyzerの定義をClaude Codeに書かせ、_analyze APIの分割結果を期待トークンと突き合わせるテストをPostToolUse hookで回す設定をまとめます。
Elasticsearchの日本語検索は、kuromoji analyzerの設定で結果が大きく変わります。設定をClaude Codeに書かせるのは簡単です。難しいのは、その設定が狙いどおりに単語を分けているかを毎回確かめることです。
この記事の構成は次の3点です。
- 検証用のindexにanalyzerを定義し、
_analyzeAPIで分割結果を取る - 期待トークンをファイルに固定し、差分があれば失敗させる
- Claude Codeが設定ファイルを書き換えるたびに、PostToolUse hookでそのテストを走らせる
分割結果の確認を人の目に頼らず、編集のたびに機械が返す形にします。
kuromojiの確認で何を固定しておくのか
kuromojiは、Lucene由来の日本語の形態素解析をElasticsearchに組み込むプラグインです。プラグインは自己管理のクラスターでは elasticsearch-plugin install analysis-kuromoji で入れます。全ノードに導入し、導入後は各ノードの再起動が必要です。
Claude Codeに「日本語検索を良くして」と頼むと、analyzerの組み合わせは変わるのに、変えた結果が正しいかは誰も見ません。そこで確認の単位を決めます。
| 確認したいこと | 使うanalyzerの部品 | 失敗の現れ方 |
|---|---|---|
| 複合語を分けるか、そのまま残すか | 使うanalyzerの部品kuromoji_tokenizer の mode | 失敗の現れ方「空港」で検索しても「関西国際空港」が当たらない |
| 固有名詞を1語で扱うか | 使うanalyzerの部品user_dictionary_rules | 失敗の現れ方「東京スカイツリー」が意図しない切れ方になる |
| 活用形を原形に寄せるか | 使うanalyzerの部品kuromoji_baseform | 失敗の現れ方「飲み」と「飲む」が別語になる |
| 助詞などを落とすか | 使うanalyzerの部品kuromoji_part_of_speech / ja_stop | 失敗の現れ方検索語に「は」「が」が混ざる |
| 全角英字を正規化するか | 使うanalyzerの部品icu_normalizer 文字フィルター | 失敗の現れ方全角の英語が1文字ずつ割れる |
この5行が、そのままテストケースの種類になります。
検証用indexのanalyzer定義を書く
まず、検証専用のindex設定をリポジトリに置きます。本番indexの設定とは別ファイルにして、search/index-settings.json とします。Claude Codeに書かせる対象はこのファイルです。
次の定義は、各部品の動きを1つずつ見られるように、役割ごとにanalyzerを分けています。定義はElasticsearch公式ドキュメントの例に沿った形です。
{
"settings": {
"index": {
"analysis": {
"tokenizer": {
"kuromoji_user_dict": {
"type": "kuromoji_tokenizer",
"mode": "extended",
"user_dictionary_rules": [
"東京スカイツリー,東京 スカイツリー,トウキョウ スカイツリー,カスタム名詞"
]
},
"kuromoji_search_mode": {
"type": "kuromoji_tokenizer",
"mode": "search"
}
},
"filter": {
"my_posfilter": {
"type": "kuromoji_part_of_speech",
"stoptags": ["助詞-格助詞-一般", "助詞-終助詞"]
},
"ja_stop_custom": {
"type": "ja_stop",
"stopwords": ["_japanese_", "ストップ"]
}
},
"analyzer": {
"ja_user_dict": {
"type": "custom",
"tokenizer": "kuromoji_user_dict"
},
"ja_search_mode": {
"type": "custom",
"tokenizer": "kuromoji_search_mode"
},
"ja_baseform": {
"type": "custom",
"tokenizer": "kuromoji_tokenizer",
"filter": ["kuromoji_baseform"]
},
"ja_pos": {
"type": "custom",
"tokenizer": "kuromoji_tokenizer",
"filter": ["my_posfilter"]
},
"ja_stop_demo": {
"type": "custom",
"tokenizer": "kuromoji_tokenizer",
"filter": ["ja_stop_custom"]
},
"ja_normalize": {
"type": "custom",
"char_filter": ["icu_normalizer"],
"tokenizer": "kuromoji_tokenizer",
"filter": [
"kuromoji_baseform",
"kuromoji_part_of_speech",
"cjk_width",
"ja_stop",
"kuromoji_stemmer",
"lowercase"
]
}
}
}
}
}
}ja_normalize は、kuromoji analyzerと同じフィルター列に icu_normalizer 文字フィルターを足したものです。kuromoji analyzer自体の構成は、CJKWidthCharFilter、kuromoji_tokenizer、kuromoji_baseform、kuromoji_part_of_speech、ja_stop、kuromoji_stemmer、lowercase の順です。ユーザー辞書は、ファイルを $ES_HOME/config/ に置く方法のほかに、上のように user_dictionary_rules へ直接書く方法もあります。検証用の設定は後者にすると、設定ファイル1つで完結します。
icu_normalizer はICU系プラグインの文字フィルターです。検証用のElasticsearchにそのプラグインが入っていないと、index作成の時点で失敗します。この失敗は、次節のスクリプトがそのまま報告します。
期待トークンをファイルに固定する
分割結果の期待値は、search/analyze-cases.jsonl に1行1ケースで書きます。ケースは次の5種を、公式ドキュメントに載っている例から取りました。
| analyzer | 入力 | 期待トークン | 根拠 |
|---|---|---|---|
ja_user_dict | 入力東京スカイツリー | 期待トークン東京, スカイツリー | 根拠ユーザー辞書の応答例 |
ja_baseform | 入力飲み | 期待トークン飲む | 根拠kuromoji_baseform の応答例 |
ja_stop_demo | 入力ストップは消える | 期待トークン消える | 根拠ja_stop の応答例 |
ja_pos | 入力寿司がおいしいね | 期待トークン寿司, おいしい | 根拠kuromoji_part_of_speech の応答例 |
ja_search_mode | 入力関西国際空港 | 期待トークン関西, 関西国際空港, 国際, 空港 | 根拠search モードの出力例 |
全角のケース(ja_normalize に Culture of Japan を渡し、culture, of, japan を期待する)は、公式の説明から導いた値です。icu_normalizer なしだと culture, o, f, japan に割れる、という説明の裏返しなので、応答そのものが載っているわけではありません。初回に1度だけ人の目で実測と見比べておくと安心です。
{"analyzer":"ja_user_dict","text":"東京スカイツリー","tokens":["東京","スカイツリー"]}
{"analyzer":"ja_baseform","text":"飲み","tokens":["飲む"]}
{"analyzer":"ja_stop_demo","text":"ストップは消える","tokens":["消える"]}
{"analyzer":"ja_pos","text":"寿司がおいしいね","tokens":["寿司","おいしい"]}
{"analyzer":"ja_search_mode","text":"関西国際空港","tokens":["関西","関西国際空港","国際","空港"]}
{"analyzer":"ja_normalize","text":"Culture of Japan","tokens":["culture","of","japan"]}期待値の置き方には、守りたい線が1本あります。期待トークンは、実測した出力をそのまま貼って作らないという点です。「テストを通して」という依頼には、落ちたケースの期待値を実測値に書き換えるという近道が開いています。それでは検証になりません。期待値は公式ドキュメントの例か、実際に困った検索クエリから取ります。CLAUDE.mdには次のように書いておきます。
## 日本語検索のanalyzer
- analyzerを変えたら search/analyze-cases.jsonl にケースを足す
- 期待トークンは公式ドキュメントの例か、検索で困った語から作る
- 失敗したケースの期待値を、実測値に合わせて書き換えない
- 期待値を変えたいときは、理由を会話で先に説明する_analyze APIで突き合わせるスクリプト
_analyze APIは、テキストを渡すと分割後のトークンを返します。/{index}/_analyze の形でindexを指定すれば、そのindexに定義したanalyzerを名前で呼べます。スクリプトは、検証用indexを作り、全ケースを流し、indexを消すだけの構成です。
#!/usr/bin/env bash
# scripts/check-analyzer.sh
ES="${ES_URL:-http://localhost:9200}"
IDX="kuromoji-check-$$"
DIR="${CLAUDE_PROJECT_DIR:-.}/search"
# Elasticsearchが起動していなければ検証を省略する
curl -fsS "$ES" >/dev/null 2>&1 || exit 0
curl -fsS -X PUT "$ES/$IDX" -H 'Content-Type: application/json' \
-d @"$DIR/index-settings.json" >/dev/null \
|| { echo "indexの作成に失敗: index-settings.jsonの記述か、プラグインの導入を確認" >&2; exit 2; }
trap 'curl -s -X DELETE "$ES/$IDX" >/dev/null' EXIT
fail=0
while IFS= read -r c; do
[ -z "$c" ] && continue
a=$(jq -r .analyzer <<<"$c")
t=$(jq -r .text <<<"$c")
want=$(jq -c '.tokens | sort' <<<"$c")
got=$(curl -fsS "$ES/$IDX/_analyze" -H 'Content-Type: application/json' \
-d "$(jq -nc --arg a "$a" --arg t "$t" '{analyzer:$a,text:$t}')" \
| jq -c '[.tokens[].token] | sort')
if [ "$got" != "$want" ]; then
echo "NG [$a] $t 期待=$want 実際=$got" >&2
fail=1
fi
done < "$DIR/analyze-cases.jsonl"
exit $((fail * 2))終了コードを 0 か 2 に揃えているのは、次節のhookが2を「Claudeに伝えるべき失敗」として扱うからです。
比較の前に sort を挟んでいるので、トークンの並びは見ません。search モードの複合語は、元の語と分解後の語が混ざって出ます。並びを固定するとモード変更のたびに壊れるため、まず集合で比べる作りにしています。フレーズ検索の位置関係まで守りたいなら、応答の position と start_offset も比較対象に加えます。
スクリプトが1回の実行でやること
- 1
疎通を見る
ES_URLに答えがなければ、何も言わず終了コード0で抜けます。 - 2
検証用indexを作る
index-settings.jsonを使い、プロセスIDを含む名前でindexを作ります。失敗は終了コード2です。 - 3
ケースを流す
analyze-cases.jsonlの各行を_analyzeに渡し、トークンの集合を比べます。 - 4
後始末をする
成否にかかわらず、
trapでindexを削除します。
PostToolUse hookで編集のたびに走らせる
テストを手で回す運用だと、実行し忘れた時点で確認が抜けます。ファイルを書き換えた直後に自動で走らせると、失敗がそのまま次の修正の材料になります。
PostToolUseは、ツールの呼び出しが成功した直後に動くイベントです。入力のJSONには tool_input と tool_response が入り、Write や Edit では tool_input.file_path が絶対パスで渡ります。ここで終了コード2を返すと、stderrがClaudeに見えます。ツールはすでに実行済みなのでブロックはされませんが、失敗の内容を伝える手段としてはこれが正しい選択です。終了コード0で出したstderrは、Claudeには届きません。
.claude/settings.json に次を置きます。
{
"hooks": {
"PostToolUse": [
{
"matcher": "Write|Edit",
"hooks": [
{
"type": "command",
"command": "${CLAUDE_PROJECT_DIR}/.claude/hooks/check-kuromoji.sh",
"timeout": 60
}
]
}
]
}
}.claude/hooks/check-kuromoji.sh は、編集されたファイルが検証対象のときだけ先ほどのスクリプトを呼びます。
#!/usr/bin/env bash
f=$(jq -r '.tool_input.file_path // empty')
case "$f" in
*/search/index-settings.json|*/search/analyze-cases.jsonl) ;;
*) exit 0 ;;
esac
"$CLAUDE_PROJECT_DIR"/scripts/check-analyzer.shchmod +x で実行権限を付けるのを忘れないでください。対象を絞るだけなら、フック定義の if フィールドで "Edit(**/search/*.json)" のように書く方法もあります。if に書けるルールは1つだけで、Write と Edit を両方拾うには、ハンドラを2つに分けます。ファイル名の判定をスクリプト側に持たせたほうが、設定が1つで済みます。
PostToolUseのフックは、Bash コマンドや外部のプロセスがファイルを書き換えたときには動きません。Claude Codeが sed や jq でJSONを直接書き換えるケースが多いなら、CLAUDE.mdに「設定ファイルはEditかWriteで変更する」と添えておきます。
hookの基本はPostToolUse hookの設定手順に、イベント全体の地図はhooksの概要にあります。
Claude Codeへの依頼は「ケースを先に書く」形にする
設定の変更を頼むときは、analyzerの修正より先に、直したい検索語のケースを analyze-cases.jsonl に足させます。たとえば次の依頼です。
「関西空港」で検索しても「関西国際空港」を含む文書が当たりません。
先に search/analyze-cases.jsonl に、この語の期待トークンを足してください。
期待値は私が決めます。足したら一度テストを走らせて、落ちることを見せてから、
search/index-settings.json を直してください。失敗するテストを先に見せる順番にすると、修正後に通ったことの意味がはっきりします。hookは編集のたびに動くため、ケースを足した時点で落ちる様子が会話に出ます。
失敗がClaudeにどう返るか
index設定の mode を search から normal に変えさせたとします。ja_search_mode のケースは、関西国際空港 だけが返るようになり、スクリプトは次のような行を標準エラーに出して終了コード2になります(出力の形はスクリプトの echo が決めたもので、値は例示です)。
NG [ja_search_mode] 関西国際空港 期待=["関西","関西国際空港","国際","空港"] 実際=["関西国際空港"]Claude Codeはこの行を読み、mode を戻すか、「空港」で当たらなくなる意図的な変更かを会話で確認します。差分が「期待値」「実際」の形で並ぶので、どのanalyzerのどの入力が崩れたかを探す手間がありません。
手が止まりやすい5つの場面
落とし穴
全角英字のケースだけ、index作成の時点で落ちる
ja_normalize は icu_normalizer を使います。ICU系プラグインが検証用Elasticsearchに入っていないと、index作成に失敗してスクリプトは終了コード2を返します。ケースの不一致ではなく、プラグインの不足が原因です。
- ユーザー辞書の重複:
kuromoji_tokenizerのlenientは、既定がfalseです。user_dictionaryに重複する行があるとエラーになります。重複を許すには"lenient": "true"を指定します。 - 全角文字の混入:
kuromoji_tokenizerは全角のoやfを辞書の文字として扱うため、全角の英語が思わぬ位置で割れます。icu_normalizerを足す対処は、公式のanalyzerのページに載っています。 - 句読点の扱い:
discard_punctuationの既定はtrueです。句読点を残したいケースを書くなら、トークナイザー側でfalseにしたanalyzerを別に用意します。 - 複合語の原形:
searchモードは元の複合語も一緒に出しますが、discard_compound_tokenをtrueにすると分解後の語だけになります。検索側とインデックス側のどちらに効かせるかで、期待トークンが変わります。
どれも、ケースを1行足せば次の編集から守られます。
この構成が合う場面と合わない場面
この方式は、analyzerの定義を頻繁に触るプロジェクトで効きます。ユーザー辞書の語を足す、品詞フィルターの stoptags を調整する、といった作業は、設定が1行変わるたびに分割結果が変わるためです。
一方、既存の検索サービスの本番indexは、ここで検証した設定とは別物です。検証用indexで合格した設定を本番に反映する作業は、別に手順を用意する必要があります。ElasticsearchをMCPサーバー経由でClaudeにつなぎ、クラスターの中身を直接見せる方法はElasticsearchのMCPサーバーで扱っています。検証だけなら、今回のようにローカルの curl で足ります。
同じ「編集のたびに外部ツールで検証し、失敗を返す」形は、OpenAPIのSpectralによるlintにもあります。検証対象が変わっても、hookの骨組みは同じです。
まとめ
kuromojiの設定は、書くことより、狙いどおりに分かれているかを毎回確かめることのほうに手間がかかります。期待トークンを analyze-cases.jsonl に固定し、_analyze の結果と突き合わせるスクリプトをPostToolUse hookから呼ぶと、その確認がClaude Codeの編集に付いてきます。
肝は期待値の出どころです。実測値の貼り付けで通さず、公式の例か困った検索語から作る。このルールをCLAUDE.mdに1行書いておくだけで、テストが「通すためのもの」に変わるのを防げます。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。