Claude Media
Claude CodeでRAGのチャンクサイズを評価する — recall@kで比べる手順

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つあります。

  1. kを大きくしたときの伸び: recall@10では差が消え、recall@1・3で差が出る設定は、順位づけの質が違います。
  2. 既定判定と--strictの差: 開きが大きい設定は、根拠がチャンク境界をまたいで切れています。オーバーラップを増やす余地があります。
  3. チャンク数: チャンクが短いほど数が増え、埋め込みの呼び出し量と保存量が増えます。

もう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に書かせ、ラベル検査という合否の出る確認手段と、実行した出力の提示をセットで求めます。決めた設定は、埋め込みモデルや前処理を変えたときに測り直します。

参照した一次ソース

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

  1. code.claude.com/docs/en/best-practices
  2. platform.claude.com/docs/en/build-with-claude/embeddings
この記事を共有:XはてブLinkedIn