Claude Codeで検索の関連度をnDCGで回帰テストするスキルの作り方
boostやfunction_scoreを変えるたびに、正解付きクエリ集合でnDCGと順位の動きを前後比較するスキルを作ります。Elasticsearchの_rank_eval APIを使う手順です。
検索の関連度チューニングを回帰テストにするには、正解付きのクエリ集合を用意し、変更前後でnDCGとクエリごとの順位の動きを比べます。Elasticsearchなら_rank_evalAPIが、この計算を1リクエストで返します。
この記事では、その比較を毎回同じ手順で回すClaude Codeのスキルを作ります。boostを足した、function_scoreの重みを変えた、といった変更のたびに「全体は良くなったが、特定のクエリが落ちた」を見逃さないための仕組みです。
nDCGで回帰を見るとは何を比べることか
nDCG(normalized discounted cumulative gain、正規化した割引累積利得)は、検索結果の上位に関連度の高い文書がどれだけ並んでいるかを0から1の範囲で表す指標です。上位ほど重く数え、理想の並びに対する割合に直します。
ElasticsearchのRanking evaluation APIでは、metricにdcgを指定し、normalizeをtrueにするとNormalized DCGが返ります。normalizeの既定値はfalseなので、指定しないと正規化されない素のDCGです。kは評価する上位件数で、既定値は10です。
回帰テストとして使うときの基本は次の3点です。
- 正解(各クエリに対する文書の関連度評価)を固定して、変更前後で同じ物差しを使う
- 全体の平均だけでなく、クエリごとのスコアも並べる
- 評価のないまま上位に出てきた文書を拾い、評価セットを育てる
平均が0.01上がっても、重要な3クエリが大きく下がっていることはあります。平均は良い変更の証拠になりますが、悪い変更の不在までは保証しません。だからクエリ単位の差分を必ず出します。
Ranking evaluation APIが受け取るもの
リクエストはrequestsとmetricの2つで構成されます。requestsは典型的な検索リクエストと、その評価(ratings)の配列です。各要素は次の項目を持ちます。
| 項目 | 内容 |
|---|---|
id | 内容検索リクエストのID。結果の詳細をクエリ単位でまとめるのに使う |
request | 内容評価対象のクエリ(Query DSL)。sizeも指定できる |
ratings | 内容文書ごとの評価。_index、_id、rating(数値)の3項目が必須 |
template_id / params | 内容検索テンプレートを使う場合のIDとパラメータ |
レスポンスには全体のmetric_scoreと、リクエストIDをキーにしたdetailsが入ります。detailsの各エントリには、そのクエリが全体スコアにどれだけ寄与したかを示すmetric_scoreと、評価の付いていない上位文書を並べたunrated_docsがあります。unrated_docsは、利用者に評価を付けてもらう用途に使えると説明されています。
このレスポンスの形が、回帰テストにそのまま向いています。クエリごとのスコアが手に入り、評価漏れの文書も一緒に返るからです。
評価セットの置き方
評価セットはリポジトリに置き、レビュー対象にします。1クエリ1オブジェクトのJSONで十分です。
{
"id": "q-refund",
"query": "返金 方法",
"split": "tune",
"ratings": [
{"_index": "docs", "_id": "faq-0412", "rating": 3},
{"_index": "docs", "_id": "guide-0087", "rating": 1}
]
}splitは私が足した項目で、_rank_evalには渡しません。tuneのクエリで調整し、holdoutのクエリは調整中に見ないでおく運用にするためです。boostを評価セットに合わせて詰めると、見えているクエリだけが良くなります。holdoutが同時に上がらなければ、その変更は評価セットへの過適合を疑います。
クエリの選び方で、回帰テストの価値が決まります。次の種類を混ぜておくと、変更の副作用が見つかりやすくなります。
- 検索ログで件数の多い上位クエリ
- 過去に不具合として報告された、順位が崩れたクエリ
- 0件や1文字などの極端なクエリ
- 同義語・表記ゆれ・誤字を含むクエリ
評価(rating)は人が付けます。Claudeに付けさせると、調整する側と採点する側が同じモデルになり、物差しが調整に引きずられます。Claudeに任せるのは、評価漏れ文書の一覧を出すところまでにします。
前後比較を回すスクリプト
比較の対象は、変更前のクエリ(baseline)と変更後のクエリ(candidate)の2つのQuery DSLです。どちらも{{q}}を含むJSONファイルにしておき、評価セットのクエリ文字列を差し込みます。
{
"multi_match": {
"query": "{{q}}",
"fields": ["title^3", "body"]
}
}スクリプトは標準ライブラリだけで書けます。指標はdcgにnormalize: true、kは10にします。以下は考え方を示す例で、URLやインデックス名は環境に合わせて変えてください。
#!/usr/bin/env python3
"""baseline と candidate の nDCG をクエリ単位で比べる。"""
import json, os, sys, urllib.request
ES = os.environ.get("ES_URL", "http://localhost:9200")
INDEX = os.environ.get("ES_INDEX", "docs")
def run(template_path, queries):
tpl = open(template_path).read()
reqs = []
for q in queries:
body = json.loads(tpl.replace("{{q}}", json.dumps(q["query"])[1:-1]))
reqs.append({"id": q["id"], "request": {"query": body},
"ratings": q["ratings"]})
payload = {"requests": reqs,
"metric": {"dcg": {"k": 10, "normalize": True}}}
req = urllib.request.Request(
f"{ES}/{INDEX}/_rank_eval", json.dumps(payload).encode(),
{"Content-Type": "application/json"}, method="GET")
return json.load(urllib.request.urlopen(req))
queries = [json.loads(l) for l in open(sys.argv[1])]
base, cand = run(sys.argv[2], queries), run(sys.argv[3], queries)
print(f"mean nDCG@10 base={base['metric_score']:.3f}"
f" cand={cand['metric_score']:.3f}")
rows = []
for qid, d in cand["details"].items():
b = base["details"][qid]["metric_score"]
rows.append((d["metric_score"] - b, qid, len(d["unrated_docs"])))
for delta, qid, unrated in sorted(rows):
print(f"{delta:+.3f} {qid} unrated={unrated}")
drop = base["metric_score"] - cand["metric_score"]
worst = min(r[0] for r in rows)
sys.exit(1 if drop > 0.01 or worst < -0.2 else 0)終了コードの閾値(平均0.01の低下、1クエリ0.2の低下)は、この例で決めた値です。評価セットの大きさとスコアのばらつきに合わせて、最初の数回の変更で実測してから置き直してください。
評価セットは1行1クエリのJSON Lines形式にしてevals/queries.jsonlに置き、実行は次のとおりです。
python3 scripts/rank_eval.py evals/queries.jsonl \
evals/baseline.json evals/candidate.json出力は、平均の1行と、クエリごとの差分を悪化の大きい順に並べた一覧です。先頭に出るクエリが、その変更で最も犠牲になったものです。
スキルとして包む
手順をスキルにしておくと、/search-relevance-evalの一言で同じ比較が回ります。SKILL.mdはプロジェクトの.claude/skills/search-relevance-eval/に置き、スクリプトはscripts/に同居させます。スキルのディレクトリには補助ファイルを置け、スクリプトは読み込まれず実行されます。パスには${CLAUDE_SKILL_DIR}を使うと、置き場所が変わっても解決されます。
---
name: search-relevance-eval
description: 検索のboostやfunction_scoreを変えたとき、評価セットでnDCGとクエリごとの順位変動を前後比較する。関連度チューニングの回帰確認に使う。
disable-model-invocation: true
allowed-tools: Bash(python3 *)
---
## 手順
1. `$0` のクエリJSONを candidate として、`evals/baseline.json` と比べる。
2. 次のコマンドを実行する。
python3 ${CLAUDE_SKILL_DIR}/scripts/rank_eval.py \
evals/queries.jsonl evals/baseline.json $0
3. 平均の差、悪化したクエリ上位5件、unrated が多いクエリを報告する。
4. `evals/queries.jsonl` の rating は編集しない。評価が欲しい文書は
id を列挙するだけにする。disable-model-invocation: trueにすると、Claudeが自分で呼び出すことはなく、人が/search-relevance-eval candidate.jsonと打ったときだけ走ります。$0には、スキル名のあとに書いた最初の引数が入ります。allowed-toolsは、そのターンの間だけpython3の実行を確認なしで許可します。
スクリプトの出力を、Claudeが読む前にスキルへ埋め込む方法もあります。!`command`の記法は、スキルの内容がClaudeに渡る前にシェルコマンドを実行し、出力で置き換えます。ただし評価のたびに実行時間がかかる場合は、手順としてClaudeに実行させる形のほうが途中経過を扱いやすくなります。
スキルの効きを確かめる
作ったスキル自体が意図どおり働くかは、検索の指標とは別に測ります。docsは、現実的なプロンプトを数個集め、新しいセッションでスキルありとなしを走らせて比べる方法を示しています。個人・プロジェクトのスキルを切るにはskillOverridesで"off"にします。測り方の詳細はskill-creator evalの解説にあります。
このスキルで確認したいのは、たとえば次の点です。
- 「boostを変えたので確認して」という依頼でも、手順どおりに前後比較が走るか
- 悪化したクエリを平均の陰に隠さず、先頭に出すか
ratingを勝手に書き換えないか
スキルが発火しないときは、descriptionにユーザーが言いそうな語(関連度、boost、nDCGなど)を入れて、/search-relevance-evalで直接呼び出して切り分けます。frontmatterのYAMLが壊れていると、本文は読み込まれてもdescriptionでの照合ができません。
変更を回帰として扱う運用
運用の流れは次のとおりです。
関連度の変更を1回回す手順
- 1
candidate.jsonを書く
boostやfunction_scoreを変えたクエリを、baselineと同じ
{{q}}の形で書きます。 - 2
スキルを実行する
/search-relevance-eval evals/candidate.jsonで、平均とクエリ別の差分を出します。 - 3
悪化したクエリを読む
下がったクエリの上位10件を見て、評価が正しいか、変更が意図しない文書を押し上げていないかを確かめます。
- 4
評価漏れを埋める
unratedが多いクエリの文書に、人がratingを付けて評価セットに追記します。 - 5
holdoutを見て採否を決める
tuneで改善してもholdoutが動かない、または下がるなら、変更を採用しません。
評価セットが育つほど、同じ変更でも結果の信頼度が上がります。新しい不具合報告が来るたびに、そのクエリと正解をセットに足しておくと、同じ崩れを二度見逃しません。コンポーネントの描画回数を固定する発想はReactのProfilerテストにも通じ、性能の回帰を数値で止める流れはbenchstatとhookに近い構成です。
ここまでのスキルが測るのは、評価セットに入っているクエリの範囲だけです。評価セットの外にある長尾のクエリは、この比較では見えません。検索ログの上位クエリを定期的に追記して、セットが実際の利用から離れないようにします。
評価の指標を選び直すとき
Ranking evaluation APIはnDCG以外の指標も受け付けます。precision(P@k)、recall(R@k)、mean_reciprocal_rank、expected_reciprocal_rank(ERR)があり、expected_reciprocal_rankでは、評価に使った最大の関連度maximum_relevanceが必須です。precisionとrecall、mean_reciprocal_rankには、関連ありとみなす評価値の閾値relevant_rating_threshold(既定値1)があります。precisionには、評価のない文書を無関係として数えるか無視するかを決めるignore_unlabeled(既定値false)もあります。
関連度を0か1かの二値で付けているなら、P@kや平均逆順位でも足ります。段階評価(0〜3など)を付けたなら、段階を生かせるnDCGが向きます。指標を替えたら、baselineの数値も取り直します。指標が違うスコア同士は比べられません。
よくあるつまずき
評価の付いていない文書が上位に増えた変更では、スコアの上下を信用しにくくなります。unrated_docsの件数をクエリごとに出しているのは、そのためです。件数が増えたら、先に評価を足してから比べ直します。
もう一つは、インデックスの状態が前後で違うことです。baselineとcandidateは同じインデックスに対して、続けて実行します。アナライザーやマッピングを変える変更は、インデックスを作り直すため、この比較の外で扱います。
まとめ
関連度チューニングの回帰テストで、nDCGの平均は入口でしかなく、価値はクエリ別の差分とholdoutの動きにあります。ratingは人が付け、Claudeには比較の実行と悪化クエリの報告、評価漏れ文書の列挙を任せる。この分担にすると、スキルが評価を自分に都合よく書き換える余地がなくなります。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。