0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

RAGのチャンク設計は文書ヒット率で決めない:必要根拠と取得予算で比べる

0
Posted at

必要根拠と取得予算でチャンク設計を比較する幾何学イメージ

2026-10-18確認。RAGの分割方法を変更するエンジニア・テックリードに向け、同じ文書と質問で「回答に必要な条件が取得できたか」を比較します。

結論:文書に当たっても、必要な条件が揃うとは限らない

チャンク設計の採否は、対象文書へのヒットに加えて、必要な根拠が取得予算内に揃うかで判断します。「返品できる日数」と「開封済みの例外」のように、条件が複数ある質問を評価へ入れます。

今回の小さなローカル検証では、固定長分割は対象文書に6/6問でヒットしましたが、指定した全根拠が揃ったのは5/6問でした。重複を付けた分割は6/6問でヒットしても、全根拠が揃うのは4/6問でした。

これは方式の一般的な優劣を示すベンチマークではありません。分割境界・取得枠・評価の分母を確認するための、架空英語文書による説明用実験です。埋め込み検索、回答生成、本番効果は検証していません。

今回の条件では全方式に根拠不足が残るため、本番採用の判断は保留します。分割方法だけで解決しようとせず、検索語、関連箇所の追加取得、評価セットを次に確認します。

前提:分割方法以外の条件を固定する

Microsoftの公式資料は、固定長、文書構造に沿う分割、重複などの選択肢を説明しています。これは候補を考える根拠であり、その推奨値が自分の文書で最適だという証拠ではありません。

今回は製品やモデルを使わず、Python 3.11.8の標準ライブラリだけで次を固定しました。

比較条件 今回の設定
文書 返品・権限・ログ・一括取込の架空英語4文書
質問 全方式で共通の6問。Q2・Q3・Q5は基本条件と例外の2根拠を要求
検索 英数字の単語集合の一致数。大小文字を区別しない
同点時 文書ID、チャンク開始位置の昇順
取得上限 正のスコアの上位2候補、合計240文字以内
予算超過時 その候補で打切り。切詰めや後順位の繰上げはしない
変更するもの 固定長120文字/120文字・重複40文字/空行で分かれた段落単位

文字数はPythonのlen(str)で数えます。トークン数、API料金、モデルの文脈上限を測った値ではありません。 単語の途中でも切る単純な固定長分割なので、検索語が分断される影響も含みます。実製品の分割器の再現ではありません。

文書・質問・根拠・設定は実行前に固定し、結果を見て書き換えていません。ただし独立した未見の評価セットは用意していません。

何と比較し、どこまで自作するか

選択肢 比較する理由 採用を見送る条件
現行の固定長を維持 変更せずに品質を満たせれば移行が不要 重要な例外を取得できない
固定長に重複を追加 境界付近の文脈を残す候補 類似した断片が取得枠を使い、別の根拠を押し出す
段落・見出しを利用 文書の意味のまとまりを使う候補 長い段落や表が予算を超える、構造が壊れている
既製の分割器 自作を避け、構造認識や上限処理を利用する候補 自分の評価セットで挙動を追えない。今回は実行未検証
手動で根拠を確認 小規模な比較基準や重要質問の正解付けに使う 継続処理量が人の確認能力を超える。工数は今回未計測

以下のコードは評価の説明用です。本番用の分割器を新しく作る提案ではありません。

文書IDではなく、元文書の根拠位置を正解にする

チャンクIDは分割方法を変えると変わります。そこで正解を「文書ID+元文書内の文字範囲」で指定します。Text Split skillの公式仕様にも位置・長さの出力があります。実際に連携する場合は、UTF-8・UTF-16・CodePointなどの単位を揃える必要があります。

今回は固有の根拠フレーズから開始・終了位置を求め、次の2つを別々に数えます。

  • 文書ヒット:取得した候補に、指定した文書の断片が1つ以上ある。
  • 全根拠取得:その質問で指定した根拠範囲のすべてが、それぞれ少なくとも1つの取得チャンクに丸ごと含まれる。複数根拠は別チャンクにあってもよい。

これは独自の機械的な包含判定です。同義の表現や隣接断片の結合は評価せず、LLMが正しく回答できるかも測りません。候補数を返しただけで正解にせず、逆に取得なしを「安全に回答拒否できた」とも扱いません。

再現コードと実行結果

次のコード全体をchunk_eval.pyへ保存し、python3 chunk_eval.pyで実行できます。追加ライブラリ・ネットワーク接続・APIキーは不要です。英語fixtureは評価用に作った架空の規則です。

import hashlib
import json
import platform
import re

# Fictional English fixtures, fixed before running the comparison.
DOCS = {
    "returns": (
        "Return policy. Unused items can be returned within 30 days. Keep the order number and attach the receipt.\n\n"
        "Return exception. Opened items require manual review. Do not promise a refund until the review is complete."
    ),
    "access": (
        "Access policy. Export access is read only by default. A request must name the dataset and explain the reason.\n\n"
        "Access exception. Write access requires owner approval. Approval expires after 24 hours and must be renewed."
    ),
    "logs": (
        "Log policy. Application logs are retained for 14 days. Operators verify deletion using the daily audit report.\n\n"
        "Log exception. Records under an incident hold must not be deleted. The incident owner must release the hold first."
    ),
    "bulk": (
        "Bulk import policy. Each import file is limited to 5 MB. Split larger files before submitting them. "
        "The import process validates headers, records a batch identifier, and reports rows that need correction. "
        "Operators retain the original file, compare accepted rows with the source, and retry only rejected rows after review."
    ),
}
QUESTIONS = [
    ("Q1", "return unused days", "returns", ["within 30 days"]),
    ("Q2", "return opened review", "returns", ["within 30 days", "Opened items require manual review"]),
    ("Q3", "access write approval", "access", ["read only by default", "Write access requires owner approval"]),
    ("Q4", "log retained days", "logs", ["retained for 14 days"]),
    ("Q5", "log incident deleted", "logs", ["retained for 14 days", "Records under an incident hold must not be deleted"]),
    ("Q6", "bulk import file", "bulk", ["Each import file is limited to 5 MB"]),
]
CONFIG = {"size": 120, "overlap": 40, "top_k": 2, "budget": 240}
payload = json.dumps([DOCS, QUESTIONS, CONFIG], sort_keys=True).encode()


def chunks(mode):
    result = []
    for doc, text in sorted(DOCS.items()):
        if mode == "paragraph":
            spans = [(m.start(), m.end()) for m in re.finditer(r"[^\n]+(?:\n[^\n]+)*", text)]
        else:
            step = CONFIG["size"] - (CONFIG["overlap"] if mode == "overlap" else 0)
            spans = [(s, min(s + CONFIG["size"], len(text))) for s in range(0, len(text), step)]
        result.extend((doc, s, e, text[s:e]) for s, e in spans)
    return result


def retrieve(index, query):
    terms = set(re.findall(r"[a-z0-9]+", query.lower()))
    ranked = sorted(index, key=lambda c: (-len(terms & set(re.findall(r"[a-z0-9]+", c[3].lower()))), c[0], c[1]))
    selected, used = [], 0
    for c in ranked[:CONFIG["top_k"]]:
        if not terms & set(re.findall(r"[a-z0-9]+", c[3].lower())):
            break
        if used + len(c[3]) > CONFIG["budget"]:
            break  # Stop at the first overflow; do not truncate or skip ahead.
        selected.append(c)
        used += len(c[3])
    return selected


results = []
for mode in ("fixed", "overlap", "paragraph"):
    index = chunks(mode)
    rows = []
    for qid, query, expected_doc, phrases in QUESTIONS:
        selected = retrieve(index, query)
        required = []
        for phrase in phrases:
            assert DOCS[expected_doc].count(phrase) == 1
            start = DOCS[expected_doc].index(phrase)
            required.append((start, start + len(phrase)))
        covered = [any(d == expected_doc and s <= a and b <= e for d, s, e, _ in selected) for a, b in required]
        sent = sum(len(c[3]) for c in selected)
        unique = {(d, p) for d, s, e, _ in selected for p in range(s, e)}
        rows.append({"id": qid, "doc_hit": any(c[0] == expected_doc for c in selected),
                     "complete": all(covered), "covered": covered, "sent_chars": sent,
                     "duplicate_chars": sent - len(unique),
                     "chunks": [[d, s, e] for d, s, e, _ in selected]})
    results.append({"mode": mode, "index_chunks": len(index),
                    "indexed_chars": sum(len(c[3]) for c in index),
                    "doc_hits": sum(r["doc_hit"] for r in rows),
                    "complete": sum(r["complete"] for r in rows),
                    "sent_chars": sum(r["sent_chars"] for r in rows),
                    "duplicate_chars": sum(r["duplicate_chars"] for r in rows), "rows": rows})
print(json.dumps({"python": platform.python_version(), "fixture_sha256": hashlib.sha256(payload).hexdigest(),
                  "config": CONFIG, "results": results}, indent=2))

結果:重複を増やしても、根拠は増えなかった

方式 文書ヒット 全根拠取得 索引チャンク数 取得文字数合計 そのうち重複文字数
固定長120文字 6/6 5/6 9 1,341 0
120文字・重複40文字 6/6 4/6 14 1,348 160
段落単位 5/6 4/6 7 987 0

取得文字数は6問分の合計です。重複文字数は、同じ質問で同じ文書の同じ位置を二度送る分だけを数えます。質問をまたぐ再利用は重複として数えません。索引文字数は順に981・1,343・975文字でした。段落単位は区切りの改行を含めないため、元の全文字数とは一致しません。

Q2:例外が見つかっても、基本条件が欠ける

返品の基本条件within 30 daysと、例外Opened items require manual reviewの両方を必要根拠としました。全方式で例外側は取得できましたが、基本条件は取得できませんでした。

質問はreturn opened reviewです。検索語自体が基本条件を十分に表していません。固定長では、2番目に別文書のreviewを含む断片が入りました。段落単位では、次候補の長さが残り予算を超え、1段落で打ち切られました。

この失敗は「チャンクを大きくすれば解決する」とは言えません。 根拠付けでは基本条件も要求した一方、短い検索語ではそこを探せていないためです。基本条件を要求する評価基準自体が妥当かも含め、人が確認する必要があります。

Q5:重複側の候補が、別の必要根拠を押し出す

ログの保存期間と削除例外を求めたQ5では、固定長・段落単位は2根拠を取得しました。重複ありではlogs[80:200]とlogs[160:226]が上位となり、例外側は含むものの保存期間が欠けました。

重複を付けると境界の文脈を残せる可能性はあります。しかし、この検索器・取得枠では、重なった断片が2枠を使いました。重複排除や親文書の関連箇所取得は次の検証候補です。今回は未実装です。

Q6:段落を壊さなくても、予算には収まらない

一括取込の段落は322文字で、取得予算は240文字でした。段落単位では最上位候補をそのまま入れられず、仕様どおり取得なしになりました。固定長と重複ありは指定根拠を取得しました。

段落単位の取得文字数が少ないことを、効率がよい証拠にしてはいけません。必要な根拠を取得できない質問が含まれるからです。長い段落を上限付きで再分割する案を次に比較します。

採否の条件と運用で残す記録

今回の6問で全方式の採用判断を保留したのは、人工的な評価で未解決の欠落があるためです。6問すべての取得を一般的な本番合格基準とする意味ではありません。

実際の変更では、次の基準を結果を見る前に合意します。

確認すること 条件付きの判断案
重要な例外・禁止条件 重大な根拠欠落を許容しない。重要質問を別枠で確認する
通常質問の品質 現行方式との比較を、合意した評価集合・同じ予算で行う
費用と遅延 実際のトークン・索引量・再取込時間・応答時間を測る。今回の文字数から料金を換算しない
取得予算の超過 切詰め・再分割・打切り等の挙動を明示し、根拠を失う条件を確認する
根拠不足時の動作 自動回答せず確認へ戻す設計案。今回、回答生成・人間確認の連携は未検証

変更ログには、文書版・質問セット版・分割設定・検索設定・取得予算・取得範囲・欠落した根拠・判断者を残します。本文全文をログへ無条件で保存せず、閲覧権限と保存期間を別途設計します。

文書担当は基本条件と例外の正解付け、実装担当は再現手順と取得処理、運用担当は更新反映・監視・切戻し、採用判断者は許容するリスクと費用を確認する、という責任分担を提案します。これは運用設計案であり、導入済みの体制ではありません。

方式変更時は旧設定・旧索引を照合可能な状態で保持し、根拠欠落の増加や予算超過が合意した停止条件に達したら切戻す設計にします。索引の再構築だけで判断ログや評価履歴を上書きしないことが必要です。

次に確認すること

今回の結果から進める順序は、Q2の必要根拠と検索語の妥当性確認、関連箇所取得または重複排除、長い段落の再分割、そして固定した未見質問での再評価です。同じ6問への調整だけで採用を決めません。

その後に、日本語文書、表・見出し、実際に使う検索器と埋め込み、権限、更新、回答品質、費用・遅延を確認します。このコードの一致数検索の結果を、ベクトル検索や商用サービスの性能へ一般化できません。

検証コードの作成・実行・集計はCodexによるAI支援で行いました。著者本人の説明確認、本番運用、業務上の削減効果は未確認です。根拠を説明できることと、実システムでの採用判断は分けて扱います。

参考資料

公式仕様の確認日は2026-10-18です。公式資料の方式説明と、本記事の人工fixtureによる結果・設計案を区別しています。

この記事を書いた人✏️@YushiYamamoto
ITPRODX.com代表。AI × Web × 業務自動化の開発・検証に取り組んでいます。
技術選定・品質・運用の判断材料を、検証範囲と制約を示して発信しています。
AI導入や業務自動化の技術選定・設計整理については、対応できる範囲を確認のうえご相談を承ります。

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?