Claude CodeでRAGのチャンクサイズを評価する — recall@kで比べる手順
チャンク長とオーバーラップを変えて、正解の根拠が上位k件に入る率で比べる手順です。固定するもの、ラベルの持ち方、比較スクリプトをClaude Codeに書かせて検査まで回す流れを示します。
RAGのチャンクサイズは、文書の種類と質問の傾向で最適値が変わります。一般論で決めるより、手元の文書と質問で測るほうが早く決着します。測り方は単純で、質問ごとに「正解の根拠」を決めておき、チャンク長とオーバーラップを変えたインデックスを作って、根拠を含むチャンクが上位k件に入った質問の割合(recall@k)を比べます。
この比較スクリプトはClaude Codeに書かせられます。ただし任せる前に、人が決めておく部分があります。何を固定し、何を変えるか。正解ラベルをどう持つか。ここを外すと、数字は出ても比べたことになりません。
比較の前に固定する5つのもの
変えるのはチャンク長とオーバーラップの2つだけにします。残りを動かすと、差がどの変更から来たのか分からなくなります。
| 固定するもの | 具体的に決めること |
|---|---|
| 埋め込みモデル | 具体的に決めることモデル名と次元。voyage-4ならコンテキスト長32,000、次元は既定の1024 |
input_type | 具体的に決めること文書側はdocument、質問側はqueryで固定 |
| 質問セット | 具体的に決めること質問文と、その質問に答える根拠の文字列 |
| k | 具体的に決めること見る件数。1・3・5・10の複数を並べる |
| 類似度と並べ方 | 具体的に決めること内積で順位づけ。ベクトルは長さ1に正規化済みで、内積とコサイン類似度は同じ |
埋め込みモデルの行は、Voyage AIの公式表の値です。voyage-4のコンテキスト長は32,000で、次元は1024が既定、256・512・2048も選べます。チャンクが32,000トークンを超えることは、ここで試す幅では起きません。
input_typeは検索用途では指定が前提です。公式のFAQは、RAGなどの検索ではinput_typeを省略したりNoneにしたりしないよう求めています。指定すると、質問には「Represent the query for retrieving supporting documents: 」、文書には「Represent the document for retrieval: 」という文が前置きされます。理由と挙動はVoyage embeddingのinput_typeでquery/documentを分ける理由にまとめています。評価では、文書側と質問側で指定が食い違わないことが肝心です。
次元も固定の対象です。保存コストを下げるために256次元へ切り詰める運用を考えているなら、切り詰めた後の次元で測ります。手順はVoyage embeddingを次元削減する方法の通りです。本番と評価で次元が違えば、評価結果は本番の参考になりません。
正解ラベルはチャンクIDでなく根拠の文字列で持つ
最初にやりがちな失敗が、正解を「チャンク17番」のようにIDで持つことです。チャンク長を変えた瞬間に、17番が指す範囲が変わります。設定ごとに正解を付け直すことになり、比較が成立しません。
そこで正解は、文書名と「質問に答える根拠の文字列」で持ちます。文書側の位置はスクリプトが文字列検索で求めます。どの設定でも、判定は同じ問いになります。根拠の位置を覆うチャンクが上位k件にあるか、です。
質問セットは1行1問のJSONLにします。
{"id": "q01", "question": "返品は何日以内に申請できますか", "doc": "returns.md", "evidence": "商品到着から14日以内に申請してください"}evidenceが文書にそのまま存在することを、スクリプトの起動時に検査します。見つからなければ、その場で止めます。後で説明する通り、この検査がClaude Codeに質問を作らせるときの安全弁になります。
判定には2つの流儀があり、どちらも持っておくと便利です。
- 既定: 根拠の先頭位置がチャンクに含まれていれば当たり
--strict: 根拠の全体がチャンクに収まっていないと当たりにしない
オーバーラップの効果が見えるのは、後者です。根拠がチャンクの境界をまたぐと、--strictでは当たりになりません。オーバーラップを増やすと、またぐ根拠が減って点数が上がります。既定の判定ではこの差が出にくいので、2つを並べて見ます。
質問セットの作り方と偏り
質問は、実際の問い合わせ履歴があればそこから取ります。無い場合は、Claude Codeに文書を読ませて質問と根拠の組を作らせます。このとき、次の偏りを意識しておきます。
文書を見ながら作った質問は、文書の言い回しをなぞりがちです。検索には易しい質問になり、チャンクサイズの差が出にくくなります。人が書いた質問を一部混ぜるか、「文書中の語を使わずに言い換える」よう指示に入れます。
件数は、1問の重みで考えます。50問なら1問が2ポイント、100問なら1ポイントです。設定間の差が1〜2問分なら、差とは見なさないのが安全です。設定を絞った後で質問を増やして測り直すと、その判断が楽になります。
また、質問を半分に分けておくと過学習を避けられます。前半で設定を選び、後半で選んだ設定が崩れないかを確かめます。
Claude Codeに比較スクリプトを書かせる
公式のベストプラクティスは、Claudeに「合否が返ってくる確認手段」を渡すことを勧めています。確認手段があれば、変更し、実行し、失敗を読んで直すところまで自分で回せるという説明です。評価スクリプトの場合、その確認手段はラベル検査です。
プロンプトは次のように、固定するものと検査を明示します。
corpus/ の文書と questions.jsonl を使い、チャンク長とオーバーラップ別に
recall@1/3/5/10 を出す eval_chunks.py を書いてください。
- 埋め込みは voyage-4。文書は input_type="document"、質問は "query"
- 正解は evidence 文字列の位置で判定し、チャンクIDでは持たない
- 起動時に evidence が文書に存在するか検査し、無ければ止める
- ベクトルはチャンク設定ごとに .cache/ へ保存して再利用する
書いたら --check-labels を実行し、そのコマンドと出力を見せてください。最後の1行が要です。同じベストプラクティスは、「完了しました」の一言では実際の状態がほとんど分からないとして、実行したコマンドと出力、つまり証拠を求めるよう勧めています。
出来上がるスクリプトの骨格は、たとえば次のようになります(公式の埋め込みの使い方に沿った例です)。
import argparse, hashlib, json, pathlib
import numpy as np
MODEL = "voyage-4" # 固定する。変えるなら全設定を測り直す
KS = (1, 3, 5, 10)
def load(corpus_dir, qfile):
docs = {p.name: p.read_text(encoding="utf-8")
for p in sorted(pathlib.Path(corpus_dir).glob("*.md"))}
qs = [json.loads(l) for l in open(qfile, encoding="utf-8") if l.strip()]
for q in qs: # ラベル検査: 根拠が文書にそのまま在ること
pos = docs[q["doc"]].find(q["evidence"])
if pos < 0:
raise SystemExit(f"根拠が見つかりません: {q['id']}")
q["start"], q["end"] = pos, pos + len(q["evidence"])
return docs, qs
def chunk(text, size, overlap):
step = size - overlap
return [(s, min(s + size, len(text))) for s in range(0, len(text), step)]
def embed(vo, texts, input_type):
out = []
for i in range(0, len(texts), 64):
r = vo.embed(texts[i:i + 64], model=MODEL, input_type=input_type)
out += r.embeddings
return np.array(out)
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--corpus", default="corpus")
ap.add_argument("--questions", default="questions.jsonl")
ap.add_argument("--sizes", default="300,600,1000")
ap.add_argument("--overlaps", default="0,100")
ap.add_argument("--strict", action="store_true")
ap.add_argument("--check-labels", action="store_true")
a = ap.parse_args()
docs, qs = load(a.corpus, a.questions)
if a.check_labels:
print(f"OK: {len(qs)} 問")
return
import voyageai
vo = voyageai.Client()
cache = pathlib.Path(".cache"); cache.mkdir(exist_ok=True)
qv = embed(vo, [q["question"] for q in qs], "query")
print("size overlap chunks " + " ".join(f"R@{k}" for k in KS))
for size in map(int, a.sizes.split(",")):
for ov in map(int, a.overlaps.split(",")):
if ov >= size:
continue
spans = [(n, s, e) for n, t in docs.items()
for s, e in chunk(t, size, ov)]
tag = f"{MODEL}|{size}|{ov}|{a.corpus}"
key = hashlib.sha1(tag.encode()).hexdigest()
f = cache / f"{key}.npy"
if f.exists():
dv = np.load(f)
else:
dv = embed(vo, [docs[n][s:e] for n, s, e in spans], "document")
np.save(f, dv)
order = np.argsort(-(qv @ dv.T), axis=1)
hits = {k: 0 for k in KS}
for q, ranked in zip(qs, order):
for rank, i in enumerate(ranked[:max(KS)], 1):
n, s, e = spans[i]
ok = (s <= q["start"] and q["end"] <= e) if a.strict \
else (s <= q["start"] < e)
if n == q["doc"] and ok:
for k in KS:
hits[k] += rank <= k
break
row = " ".join(f"{hits[k] / len(qs):.2f}" for k in KS)
print(f"{size:>4} {ov:>7} {len(spans):>6} {row}")
if __name__ == "__main__":
main()実行は、pip install -U voyageai numpyのあとに環境変数VOYAGE_API_KEYを設定して行います。
python eval_chunks.py --check-labels
python eval_chunks.py --sizes 300,600,1000 --overlaps 0,100
python eval_chunks.py --sizes 300,600,1000 --overlaps 0,100 --strictコマンド出力は、設定1行につき、チャンク数と各kのrecallが並ぶ表になります。
起動時の検査をStop hookに載せる
質問セットを増やすたびに、evidenceのずれが混ざる可能性があります。ラベルの検査は、Claudeが作業を終えようとするたびに自動で走らせられます。公式の例は、Stopフックのコマンドが終了コード2で終わると停止がブロックされ、標準エラーの内容が理由としてClaudeに返る、というものです。
{
"hooks": {
"Stop": [
{
"hooks": [
{
"type": "command",
"command": "python eval_chunks.py --check-labels 1>&2 || exit 2"
}
]
}
]
}
}これを.claude/settings.jsonに置くと、ラベルが壊れたまま作業を終えようとしたとき、Claudeは検査の出力を読んで質問セットを直しに戻ります。ブロックが8回連続で、間にツール呼び出しも挟まない場合、Claude Codeは次のブロックを無視してターンを終えます。上限はCLAUDE_CODE_STOP_HOOK_BLOCK_CAPで変えられます。
質問の追加を何度も頼む運用でなければ、フックは要りません。プロンプトに検査コマンドを書くだけで足ります。
結果の読み方と、次に試す設定の絞り方
最初は粗い格子で測ります。チャンク長を300・600・1000の3段階、オーバーラップを0と100の2通りにすれば、6設定です。ここで差が出た領域の周辺だけを、細かい刻みで測り直します。
表を読むときの見どころは3つあります。
- kを大きくしたときの伸び: recall@10では差が消え、recall@1・3で差が出る設定は、順位づけの質が違います。
- 既定判定と
--strictの差: 開きが大きい設定は、根拠がチャンク境界をまたいで切れています。オーバーラップを増やす余地があります。 - チャンク数: チャンクが短いほど数が増え、埋め込みの呼び出し量と保存量が増えます。
もう1つ、見落としやすい点があります。同じkでも、チャンク長が違えば、上位k件に含まれる文章の総量が違います。長いチャンクは、少ない件数で多くの文章を拾います。後段でClaudeに渡す文字数を揃えて比べたいなら、「300字×5件」と「1000字×1件」のように、k×チャンク長が近い組を並べる比較も加えます。
後段にリランカーを置く構成なら、recall@kの見方が変わります。Voyage AIのリランカー(rerank-3など)は、質問と文書の一覧を受け取って関連度順に並べ直します。この場合、埋め込み検索が担うのは「リランカーに渡す候補に正解を残すこと」です。測るkは、リランカーに渡す候補数(たとえば20や50)に合わせます。
設定を変えたら測り直すもの
チャンク設定の次に動くものは、埋め込みモデルです。モデルを替えれば、チャンク長の最適値も動く可能性があります。たとえばモデルをvoyage-4から別の世代に替えるときは、同じ質問セットで測り直します。
キャッシュのキーに入れ忘れやすいのも、モデル名です。スクリプトはキーにモデル名・チャンク長・オーバーラップ・コーパスの場所を含めています。文書の中身を更新したときは、.cache/を消してください。キーが同じでも、中身が古いベクトルが使われます。
チャンクの前処理を変える場合も同じです。チャンクの先頭に文脈の一文を足すContextual Retrievalは、チャンク幅を決めた後に重ねる手法で、効果はContextual Retrievalに整理しています。重ねたら、同じ質問セットとスクリプトで差を測ります。構築手順の全体像はClaude RAGの構築手順にあります。
よくあるつまずき
つまずきは、数字が出てしまうところにあります。
- 文字数とトークン数を混ぜる: 上のスクリプトは文字数で切っています。トークン数で揃えたい場合は、トークナイザの扱いが必要です。日本語は、同じ文字数でもトークン数が文書によって違います。
- 根拠が文書に複数ある:
findは最初の1か所だけ返します。同じ文が繰り返し現れる文書では、どこが正解かを決められません。根拠に前後の語を足して一意にします。 - 質問ごとに複数の文書が正解: このスクリプトは1問につき1つの文書を想定しています。正解が複数あるなら、根拠を配列にして、いずれかを含めば当たりにします。
- 差が小さいのに決める: 先に見たとおり、50問で1問の差は2ポイントです。偶然の範囲かもしれません。
まとめ
チャンクサイズは、固定するものを先に決めてから比べます。正解は根拠の文字列で持ち、チャンク設定を変えても同じ問いで判定できるようにします。スクリプトはClaude Codeに書かせ、ラベル検査という合否の出る確認手段と、実行した出力の提示をセットで求めます。決めた設定は、埋め込みモデルや前処理を変えたときに測り直します。
参照した一次ソース
この記事の記述は、次の一次ソースと照合しています。更新時に参照先が変わった場合は一覧も更新します。