RAG 文档切分长度与检索召回
文章目录
- [RAG 文档切分长度与检索召回](#RAG 文档切分长度与检索召回)
-
- [1. 文档能命中,片段却不够写引用](#1. 文档能命中,片段却不够写引用)
- [2. 先说结论](#2. 先说结论)
- [3. 演示:同一套问题,两个指标会分叉](#3. 演示:同一套问题,两个指标会分叉)
- [4. 切分策略文件](#4. 切分策略文件)
- [5. 切分脚本:chunk_docs.py](#5. 切分脚本:chunk_docs.py)
- [6. 检索与文档命中率评测](#6. 检索与文档命中率评测)
-
- [6.1 retrieve.py](#6.1 retrieve.py)
- [6.2 eval_retrieval.py](#6.2 eval_retrieval.py)
- [7. 片段可用率评测:eval_snippet_coverage.py](#7. 片段可用率评测:eval_snippet_coverage.py)
- [8. 扫描切分长度:sweep_chunk_sizes.py](#8. 扫描切分长度:sweep_chunk_sizes.py)
- [9. 切分闸门:chunk_gate.py](#9. 切分闸门:chunk_gate.py)
- [10. 一键调参:run_chunk_tune.py](#10. 一键调参:run_chunk_tune.py)
- [11. 人工核对清单](#11. 人工核对清单)
- [12. 常见错误](#12. 常见错误)
-
- [12.1 只扫文档命中率,不扫片段可用率](#12.1 只扫文档命中率,不扫片段可用率)
- [12.2 把 must_contain 写得过宽](#12.2 把 must_contain 写得过宽)
- [12.3 切分越长越好](#12.3 切分越长越好)
- [12.4 调完切分不跑文档回归](#12.4 调完切分不跑文档回归)
- [12.5 阈值写死在脚本里](#12.5 阈值写死在脚本里)
- [12.6 用聊天试问代替固定评测集](#12.6 用聊天试问代替固定评测集)
- [13. 术语速查](#13. 术语速查)
- [14. 小结](#14. 小结)
- [15. 相关阅读](#15. 相关阅读)
摘要 :《本地知识库落地顺序》已经能证明"前三条检索命中率达标",《RAG 命中后的引用格式与拒答规则》又要求片段里能写出可核对原文。很多团队卡在这一步:文档明明能命中,引用却缺字段名或路径。本文用同一套支付回调、登录接口、发布手册示例,说明切分长度如何影响检索召回 与片段可用率,给出扫描脚本、双指标对比与切分闸门;本机标准库即可跑通。
说明 :承接 RAG 文档变更后的检索回归清单。上一篇解决"文档改了如何证明检索没退步";本篇解决"切分参数怎么定,才既不丢召回,又能引用"。
承接前文:
建议目录:
bash
mkdir -p ~/kb-chunk-tune/{notes,scripts,configs,fixtures/docs,fixtures/eval,images,logs/chunk_sweep}
cd ~/kb-chunk-tune
| 文件 | 作用 |
|---|---|
fixtures/docs/*.md |
示例业务文档 |
fixtures/eval/questions.jsonl |
评测问题(含 must_contain) |
configs/chunk_policy.example.json |
切分闸门阈值 |
scripts/chunk_docs.py |
按长度切分 |
scripts/eval_snippet_coverage.py |
片段可用率评测 |
scripts/sweep_chunk_sizes.py |
扫描多档 max_chars |
scripts/chunk_gate.py |
允许或阻断切分参数 |
scripts/run_chunk_tune.py |
一键调参 |
notes/chunk_tune_checklist.md |
人工核对清单 |
文中配置与脚本均全文给出。演示仍用标准库关键词检索;换成向量检索后,双指标扫描 + 闸门流程可以保持不变。
1. 文档能命中,片段却不够写引用
《本地知识库落地顺序》里已经用前三条检索命中率 验收检索:对每道固定问题,看返回的前 3 条结果里是否至少有一条来自期望文档。《RAG 命中后的引用格式与拒答规则》又要求对外回答必须带可核对原文片段。
切分长度(max_chars,单段最大字符数)同时影响这两层:
- 切得过碎:字段名、文件路径、动作说明被拆进不同片段,排名第一的片段里凑不齐引用所需短语。
- 切得过大:多个主题挤在同一片段,检索分数被无关句稀释,排名第一的片段可能来自错误文档。
- 只盯文档命中率:前三条里"出现过期望文档"就算通过,但排名第一的片段仍可能无法引用。

图1. 召回(能否找到文档)与片段(能否写出引用)是两层验收。
本篇硬规则:
定切分长度时,必须同时扫描"前三条文档命中率"和"片段可用率",不能只看其中一个。
2. 先说结论
本篇用到的指标,先统一说明:
| 说法 | 含义 |
|---|---|
| 前三条检索命中率 | 固定问题的前 3 条检索结果里,是否至少有一条来自期望文档;6 题全过即为 100% |
| 片段可用率 | 对标注了 must_contain 的问题,排名第 1 的片段正文里是否同时包含这些关键短语 |
| 检索召回 | 本文泛指"问法能否在索引里找回正确文档与可用片段",不单指某一个英文评测缩写 |
| max_chars | 切分脚本里单段最大字符数,是最先要扫的参数 |
| 步骤 | 动作 | 通过标准 |
|---|---|---|
| 1 | sweep_chunk_sizes.py 扫描多档 max_chars |
得到每档的双指标 |
| 2 | 读 snippet_failed_ids |
知道是"拆碎"还是"排错片段" |
| 3 | chunk_gate.py |
输出 CHUNK_ALLOWED |
| 4 | 固化 max_chars 并写入配置 |
后续文档变更走回归流水线 |
四条落地判断:
- 评测问题要加
must_contain,否则切分调参没有引用侧约束。 - 优先提升片段可用率,再追求更短的片段长度。
- 扫描结果要保留失败题号,不要只看一个总百分比。
- 阈值写进 JSON,由闸门脚本执行。

图2. 扫长度 → 看双指标 → 读失败题号 → 过闸门。
3. 演示:同一套问题,两个指标会分叉
示例文档仍是三份:payment_callback、auth_api、deploy_runbook。评测集 6 道题,每道除了 gold_docs(期望命中的文档编号),还加了 must_contain(片段里必须出现的关键短语)。
示例(节选):
json
{"id":"Q5","question":"configs payment.yaml callback_timeout_ms 超时字段在哪个文件","gold_docs":["payment_callback"],"must_contain":["callback_timeout_ms","payment.yaml"]}
{"id":"Q6","question":"Token 过期后应调用哪个刷新接口","gold_docs":["auth_api"],"must_contain":["/api/refresh"]}
对 max_chars 从 50 扫到 400,在本机得到:
| max_chars | 前三条文档命中率 | 片段可用率 | 片段失败题号 |
|---|---|---|---|
| 50 | 100% | 50% | Q1、Q2、Q5 |
| 120 | 100% | 50% | Q1、Q2、Q3 |
| 200 | 100% | 66.7% | Q2、Q3 |
| 240 | 100% | 83.3% | Q2 |
| 320 | 100% | 83.3% | Q2 |
| 400 | 100% | 83.3% | Q2 |

图3.
max_chars=50时文档命中率已是 100%,片段可用率只有 50%。
读失败题号:
- Q5 在 50 字切分时失败 :
payment.yaml与callback_timeout_ms被拆进不同片段,排名第一的片段只有路径或只有字段名。 - Q2 在 240 字切分时仍失败:登录文档能进前三条,但排名第一的是支付文档片段(都含"200""token"等弱相关词),说明片段过大时也会拉低可用率。
因此本篇推荐长度落在 240 字符左右(演示环境),而不是"越短越好"或"越大越好"。
4. 切分策略文件
保存为 configs/chunk_policy.example.json:
json
{
"min_hit_at_k": 1.0,
"min_snippet_coverage": 0.85,
"max_recommended_chars": 320,
"default_overlap_chars": 40,
"sweep_sizes": [60, 120, 180, 240, 320, 480]
}
| 字段 | 含义 |
|---|---|
min_hit_at_k |
前三条文档命中率下限 |
min_snippet_coverage |
片段可用率下限 |
max_recommended_chars |
在满足片段率的前提下,优先选不超过该值的较短切分 |
sweep_sizes |
一键调参时的扫描档位 |
当 min_snippet_coverage=0.85 时,演示数据最好档为 83.3%,闸门输出:
CHUNK_BLOCKED
recommended_max_chars=240
FAIL: no size reaches min_hit_at_k=1.0 and min_snippet_coverage=0.85
把片段率阈值调到 0.8 后,同一数据可通过闸门。这恰好说明:闸门阈值要按业务引用要求定,不能拍脑袋。

图4. 片段可用率随
max_chars变化,存在平台区,不是单调递增。
5. 切分脚本:chunk_docs.py
保存为 scripts/chunk_docs.py:
python
#!/usr/bin/env python3
"""Split markdown docs into retrieval chunks with configurable size."""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
def split_chunks(text: str, max_chars: int = 280, overlap_chars: int = 0) -> list[str]:
parts = re.split(r"\n\s*\n", text.strip())
raw: list[str] = []
buf = ""
for part in parts:
part = part.strip()
if not part:
continue
if len(buf) + len(part) + 2 <= max_chars:
buf = f"{buf}\n\n{part}".strip() if buf else part
else:
if buf:
raw.append(buf)
if len(part) <= max_chars:
buf = part
else:
for i in range(0, len(part), max_chars):
raw.append(part[i : i + max_chars])
buf = ""
if buf:
raw.append(buf)
if overlap_chars <= 0 or len(raw) <= 1:
return raw
merged: list[str] = []
for i, chunk in enumerate(raw):
if i == 0:
merged.append(chunk)
continue
prev = raw[i - 1]
tail = prev[-overlap_chars:] if len(prev) > overlap_chars else prev
merged.append(f"{tail}\n\n{chunk}".strip())
return merged
def main() -> None:
parser = argparse.ArgumentParser(description="Chunk markdown docs for local RAG demo")
parser.add_argument("--docs-dir", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
parser.add_argument("--max-chars", type=int, default=280)
parser.add_argument("--overlap-chars", type=int, default=0)
args = parser.parse_args()
rows = []
for path in sorted(args.docs_dir.glob("*.md")):
doc_id = path.stem
text = path.read_text(encoding="utf-8")
for idx, chunk in enumerate(split_chunks(text, args.max_chars, args.overlap_chars)):
rows.append(
{
"chunk_id": f"{doc_id}#{idx}",
"doc_id": doc_id,
"path": str(path),
"text": chunk,
"max_chars": args.max_chars,
"overlap_chars": args.overlap_chars,
}
)
args.out.parent.mkdir(parents=True, exist_ok=True)
with args.out.open("w", encoding="utf-8") as f:
for row in rows:
f.write(json.dumps(row, ensure_ascii=False) + "\n")
print(
f"CHUNK_OK docs={len({r['doc_id'] for r in rows})} chunks={len(rows)} "
f"max_chars={args.max_chars} overlap={args.overlap_chars} -> {args.out}"
)
if __name__ == "__main__":
main()
用法:
bash
python3 scripts/chunk_docs.py \
--docs-dir fixtures/docs \
--out logs/chunks.jsonl \
--max-chars 240 \
--overlap-chars 0
overlap_chars(相邻片段重叠字符数)用于缓解"句子刚好被拦腰切断"。演示里主要扫 max_chars;上线前可对候选长度再补一轮重叠扫描。
6. 检索与文档命中率评测
6.1 retrieve.py
python
#!/usr/bin/env python3
"""Keyword retrieval over chunked docs (stdlib only)."""
from __future__ import annotations
import argparse
import json
import math
import re
from collections import Counter
from pathlib import Path
WORD_RE = re.compile(r"[A-Za-z0-9_]+|[\u4e00-\u9fff]+", re.UNICODE)
def tokenize(text: str) -> list[str]:
"""English words plus Chinese character bigrams for short local docs."""
tokens: list[str] = []
for piece in WORD_RE.findall(text):
if re.fullmatch(r"[A-Za-z0-9_]+", piece):
if len(piece) > 1:
tokens.append(piece.lower())
continue
# Chinese segment: keep unigrams and bigrams so queries can overlap docs
chars = list(piece)
tokens.extend(chars)
tokens.extend(chars[i] + chars[i + 1] for i in range(len(chars) - 1))
return tokens
def load_chunks(path: Path) -> list[dict]:
rows = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line:
rows.append(json.loads(line))
return rows
def build_index(chunks: list[dict]):
df: Counter[str] = Counter()
tfs: list[Counter[str]] = []
for row in chunks:
tf = Counter(tokenize(row["text"]))
tfs.append(tf)
for term in tf:
df[term] += 1
n = max(len(chunks), 1)
idf = {t: math.log((n + 1) / (df[t] + 1)) + 1.0 for t in df}
return tfs, idf
def score_query(query: str, chunks: list[dict], tfs, idf) -> list[tuple[float, dict]]:
q_tf = Counter(tokenize(query))
scored = []
for i, row in enumerate(chunks):
score = 0.0
for term, q_w in q_tf.items():
if term not in tfs[i]:
continue
score += q_w * tfs[i][term] * idf.get(term, 0.0)
if score > 0:
scored.append((score, row))
scored.sort(key=lambda x: x[0], reverse=True)
return scored
def main() -> None:
parser = argparse.ArgumentParser(description="Retrieve chunks for a question")
parser.add_argument("--chunks", type=Path, required=True)
parser.add_argument("--query", required=True)
parser.add_argument("--top-k", type=int, default=3)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
chunks = load_chunks(args.chunks)
tfs, idf = build_index(chunks)
ranked = score_query(args.query, chunks, tfs, idf)[: args.top_k]
hits = [
{
"rank": i + 1,
"score": round(score, 4),
"doc_id": row["doc_id"],
"chunk_id": row["chunk_id"],
"snippet": row["text"][:160].replace("\n", " "),
}
for i, (score, row) in enumerate(ranked)
]
if args.json:
print(json.dumps({"query": args.query, "hits": hits}, ensure_ascii=False, indent=2))
else:
print(f"QUERY: {args.query}")
if not hits:
print("NO_HIT")
for h in hits:
print(f"#{h['rank']} score={h['score']} doc={h['doc_id']} chunk={h['chunk_id']}")
print(f" {h['snippet']}")
if __name__ == "__main__":
main()
6.2 eval_retrieval.py
脚本日志里的 hit_at_k 字段,对应本文的前三条文档命中率。
python
#!/usr/bin/env python3
"""Evaluate retrieval hit@k against gold doc ids."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from retrieve import build_index, load_chunks, score_query
def load_jsonl(path: Path) -> list[dict]:
rows = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line:
rows.append(json.loads(line))
return rows
def main() -> None:
parser = argparse.ArgumentParser(description="Evaluate local RAG retrieval")
parser.add_argument("--chunks", type=Path, required=True)
parser.add_argument("--questions", type=Path, required=True)
parser.add_argument("--top-k", type=int, default=3)
parser.add_argument("--out", type=Path, default=None)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
chunks = load_chunks(args.chunks)
tfs, idf = build_index(chunks)
questions = load_jsonl(args.questions)
details = []
hits = 0
for q in questions:
ranked = score_query(q["question"], chunks, tfs, idf)[: args.top_k]
got_docs = [row["doc_id"] for _, row in ranked]
gold = set(q.get("gold_docs") or [])
ok = bool(gold & set(got_docs))
if ok:
hits += 1
details.append(
{
"id": q.get("id"),
"ok": ok,
"gold_docs": sorted(gold),
"got_docs": got_docs,
"question": q.get("question"),
}
)
total = len(questions) or 1
hit_at_k = hits / total
report = {
"ok": True,
"total": len(questions),
"hits": hits,
"hit_at_k": round(hit_at_k, 4),
"top_k": args.top_k,
"details": details,
}
if args.out:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"wrote {args.out}")
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
print(f"EVAL hit@{args.top_k}={hit_at_k:.2%} ({hits}/{len(questions)})")
for d in details:
mark = "PASS" if d["ok"] else "FAIL"
print(f"[{mark}] {d['id']}: got={d['got_docs']} gold={d['gold_docs']}")
if __name__ == "__main__":
main()
bash
python3 scripts/eval_retrieval.py \
--chunks logs/chunks.jsonl \
--questions fixtures/eval/questions.jsonl \
--out logs/eval_report.json
7. 片段可用率评测:eval_snippet_coverage.py
保存为 scripts/eval_snippet_coverage.py:
python
#!/usr/bin/env python3
"""Check whether top retrieved chunks contain required answer phrases."""
from __future__ import annotations
import argparse
import json
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).resolve().parent))
from retrieve import build_index, load_chunks, score_query
def load_jsonl(path: Path) -> list[dict]:
rows = []
for line in path.read_text(encoding="utf-8").splitlines():
line = line.strip()
if line:
rows.append(json.loads(line))
return rows
def snippet_ok(text: str, must_contain: list[str]) -> bool:
if not must_contain:
return True
lower = text.lower()
for term in must_contain:
if term.lower() not in lower and term not in text:
return False
return True
def main() -> None:
parser = argparse.ArgumentParser(description="Evaluate snippet phrase coverage in top-k")
parser.add_argument("--chunks", type=Path, required=True)
parser.add_argument("--questions", type=Path, required=True)
parser.add_argument("--top-k", type=int, default=1)
parser.add_argument("--out", type=Path, default=None)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
chunks = load_chunks(args.chunks)
tfs, idf = build_index(chunks)
questions = load_jsonl(args.questions)
details = []
hits = 0
checked = 0
for q in questions:
must = list(q.get("must_contain") or [])
if not must:
continue
checked += 1
ranked = score_query(q["question"], chunks, tfs, idf)[: args.top_k]
texts = [row["text"] for _, row in ranked]
ok = any(snippet_ok(t, must) for t in texts)
if ok:
hits += 1
details.append(
{
"id": q.get("id"),
"ok": ok,
"must_contain": must,
"question": q.get("question"),
"top_snippet": (texts[0] if texts else "")[:200],
}
)
total = checked or 1
rate = hits / total
report = {
"metric": "snippet_coverage",
"checked": checked,
"hits": hits,
"snippet_coverage": round(rate, 4),
"top_k": args.top_k,
"details": details,
}
if args.out:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(report, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"wrote {args.out}")
if args.json:
print(json.dumps(report, ensure_ascii=False, indent=2))
else:
print(f"SNIPPET_COVERAGE={rate:.2%} ({hits}/{checked})")
for d in details:
mark = "PASS" if d["ok"] else "FAIL"
print(f"[{mark}] {d['id']}: need={d['must_contain']}")
if __name__ == "__main__":
main()
bash
python3 scripts/eval_snippet_coverage.py \
--chunks logs/chunks.jsonl \
--questions fixtures/eval/questions.jsonl \
--top-k 1 \
--out logs/snippet_report.json
只检查排名第 1 的片段,与《RAG 命中后的引用格式与拒答规则》里"优先依据首条命中写引用"的习惯一致。若你们实际引用 top-3 合并,可以把 --top-k 改为 3,但要在策略里写清楚。
8. 扫描切分长度:sweep_chunk_sizes.py
保存为 scripts/sweep_chunk_sizes.py:
python
#!/usr/bin/env python3
"""Sweep chunk max_chars and compare retrieval hit rate."""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
def run(cmd: list[str], cwd: Path) -> None:
proc = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True)
if proc.returncode != 0:
raise SystemExit((proc.stdout or "") + (proc.stderr or ""))
def main() -> None:
parser = argparse.ArgumentParser(description="Sweep chunk sizes for retrieval quality")
parser.add_argument("--docs-dir", type=Path, required=True)
parser.add_argument("--questions", type=Path, required=True)
parser.add_argument("--sizes", type=str, default="80,160,280,480")
parser.add_argument("--overlap", type=int, default=0)
parser.add_argument("--top-k", type=int, default=3)
parser.add_argument("--workdir", type=Path, default=Path("."))
parser.add_argument("--out-dir", type=Path, default=Path("logs/chunk_sweep"))
parser.add_argument("--out", type=Path, default=None)
args = parser.parse_args()
workdir = args.workdir.resolve()
out_dir = (workdir / args.out_dir).resolve()
out_dir.mkdir(parents=True, exist_ok=True)
py = sys.executable
sizes = [int(x.strip()) for x in args.sizes.split(",") if x.strip()]
rows = []
for size in sizes:
chunks = out_dir / f"chunks_{size}.jsonl"
report = out_dir / f"eval_{size}.json"
run(
[
py,
str(ROOT / "chunk_docs.py"),
"--docs-dir",
str(args.docs_dir),
"--out",
str(chunks),
"--max-chars",
str(size),
"--overlap-chars",
str(args.overlap),
],
workdir,
)
run(
[
py,
str(ROOT / "eval_retrieval.py"),
"--chunks",
str(chunks),
"--questions",
str(args.questions),
"--top-k",
str(args.top_k),
"--out",
str(report),
],
workdir,
)
snippet_report = out_dir / f"snippet_{size}.json"
run(
[
py,
str(ROOT / "eval_snippet_coverage.py"),
"--chunks",
str(chunks),
"--questions",
str(args.questions),
"--top-k",
"1",
"--out",
str(snippet_report),
],
workdir,
)
data = json.loads(report.read_text(encoding="utf-8"))
snippet = json.loads(snippet_report.read_text(encoding="utf-8"))
failed = [d for d in data.get("details", []) if not d.get("ok")]
rows.append(
{
"max_chars": size,
"overlap_chars": args.overlap,
"chunks_file": str(chunks),
"eval_file": str(report),
"hit_at_k": data.get("hit_at_k"),
"hits": data.get("hits"),
"total": data.get("total"),
"snippet_coverage": snippet.get("snippet_coverage"),
"snippet_hits": snippet.get("hits"),
"snippet_checked": snippet.get("checked"),
"failed_ids": [d.get("id") for d in failed],
"snippet_failed_ids": [d.get("id") for d in snippet.get("details", []) if not d.get("ok")],
}
)
print(
f"SIZE {size}: doc_hit={data.get('hit_at_k'):.2%} "
f"snippet={snippet.get('snippet_coverage'):.2%} "
f"fail_doc={rows[-1]['failed_ids']} fail_snippet={rows[-1]['snippet_failed_ids']}"
)
best = max(rows, key=lambda r: (r.get("snippet_coverage") or 0, r.get("hit_at_k") or 0, -r["max_chars"]))
payload = {
"sizes": rows,
"best_max_chars": best["max_chars"],
"best_hit_at_k": best.get("hit_at_k"),
"best_snippet_coverage": best.get("snippet_coverage"),
}
out_path = args.out or (out_dir / "sweep_report.json")
out_path.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"wrote {out_path}")
print(f"BEST max_chars={best['max_chars']} doc_hit={best.get('hit_at_k'):.2%} snippet={best.get('snippet_coverage'):.2%}")
if __name__ == "__main__":
main()
bash
python3 scripts/sweep_chunk_sizes.py \
--docs-dir fixtures/docs \
--questions fixtures/eval/questions.jsonl \
--sizes 50,120,200,240,320,400 \
--workdir .
输出 logs/chunk_sweep/sweep_report.json,包含每档的 snippet_failed_ids。
9. 切分闸门:chunk_gate.py
保存为 scripts/chunk_gate.py:
python
#!/usr/bin/env python3
"""Gate chunk size choice against policy thresholds."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser(description="Gate recommended chunk size")
parser.add_argument("--policy", type=Path, required=True)
parser.add_argument("--sweep-report", type=Path, required=True)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
policy = json.loads(args.policy.read_text(encoding="utf-8"))
sweep = json.loads(args.sweep_report.read_text(encoding="utf-8"))
rows = sweep.get("sizes") or []
need = float(policy.get("min_hit_at_k", 0.0))
need_snippet = float(policy.get("min_snippet_coverage", 0.0))
max_size = int(policy.get("max_recommended_chars", 99999))
eligible = []
for r in rows:
doc_hit = float(r.get("hit_at_k") or 0)
snippet = float(r.get("snippet_coverage") or 0)
if doc_hit >= need and snippet >= need_snippet:
eligible.append(r)
if not eligible:
allow = False
pick = sweep.get("best_max_chars")
errors = [f"no size reaches min_hit_at_k={need} and min_snippet_coverage={need_snippet}"]
else:
eligible.sort(key=lambda r: (r["max_chars"], -float(r.get("hit_at_k") or 0)))
pick = None
for r in eligible:
if r["max_chars"] <= max_size:
pick = r["max_chars"]
break
if pick is None:
pick = min(r["max_chars"] for r in eligible)
allow = True
errors = []
out = {
"allow": allow,
"recommended_max_chars": pick,
"min_hit_at_k": need,
"errors": errors,
"candidates": eligible,
}
if args.json:
print(json.dumps(out, ensure_ascii=False, indent=2))
else:
print("CHUNK_ALLOWED" if allow else "CHUNK_BLOCKED")
if pick is not None:
print(f"recommended_max_chars={pick}")
for e in errors:
print(f"FAIL: {e}")
raise SystemExit(0 if allow else 2)
if __name__ == "__main__":
main()

图5. 文档命中率与片段可用率同时达标,才输出
CHUNK_ALLOWED。
bash
python3 scripts/chunk_gate.py \
--policy configs/chunk_policy.example.json \
--sweep-report logs/chunk_sweep/sweep_report.json
10. 一键调参:run_chunk_tune.py
保存为 scripts/run_chunk_tune.py:
python
#!/usr/bin/env python3
"""One-shot chunk size tuning pipeline."""
from __future__ import annotations
import argparse
import subprocess
import sys
from pathlib import Path
ROOT = Path(__file__).resolve().parent
def run(cmd: list[str], cwd: Path) -> int:
proc = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True)
print((proc.stdout or "") + (proc.stderr or ""))[-900:]
return proc.returncode
def main() -> None:
parser = argparse.ArgumentParser(description="Run chunk size sweep and gate")
parser.add_argument("--docs-dir", type=Path, required=True)
parser.add_argument("--questions", type=Path, required=True)
parser.add_argument("--policy", type=Path, required=True)
parser.add_argument("--workdir", type=Path, default=Path("."))
parser.add_argument("--sizes", type=str, default=None)
args = parser.parse_args()
policy = __import__("json").loads(args.policy.read_text(encoding="utf-8"))
sizes = args.sizes or ",".join(str(x) for x in policy.get("sweep_sizes", [120, 240, 320]))
py = sys.executable
workdir = args.workdir.resolve()
sweep_out = workdir / "logs/chunk_sweep/sweep_report.json"
code = run(
[
py,
str(ROOT / "sweep_chunk_sizes.py"),
"--docs-dir",
str(args.docs_dir),
"--questions",
str(args.questions),
"--sizes",
sizes,
"--workdir",
str(workdir),
],
workdir,
)
if code != 0:
raise SystemExit(code)
gate = run(
[
py,
str(ROOT / "chunk_gate.py"),
"--policy",
str(args.policy),
"--sweep-report",
str(sweep_out),
],
workdir,
)
print("CHUNK_TUNE_OK" if gate == 0 else "CHUNK_TUNE_FAIL")
raise SystemExit(gate)
if __name__ == "__main__":
main()
bash
python3 scripts/run_chunk_tune.py \
--docs-dir fixtures/docs \
--questions fixtures/eval/questions.jsonl \
--policy configs/chunk_policy.example.json \
--workdir .
11. 人工核对清单
保存为 notes/chunk_tune_checklist.md:
markdown
# RAG 文档切分后检索调参清单
## 切分前
- [ ] 固定评测问题集已准备,且每条写了期望命中的文档编号
- [ ] 需要引用核对的问题,补充 must_contain 关键短语列表
- [ ] 记录当前 max_chars 与 overlap_chars
## 扫描切分长度
- [ ] 用 sweep_chunk_sizes.py 扫描多档 max_chars
- [ ] 记录每档的前三条文档命中率
- [ ] 记录每档的片段可用率(top-1 是否含 must_contain)
- [ ] 对比片段数与索引体积,避免无意义地切得过碎
## 发布前闸门
- [ ] 文档命中率不低于策略阈值
- [ ] 片段可用率不低于策略阈值
- [ ] chunk_gate.py 输出 CHUNK_ALLOWED
- [ ] 选定长度写入配置或 CI 环境变量
## 收尾
- [ ] 把新长度同步到切分脚本默认参数
- [ ] 文档变更后重跑回归(指纹 + 命中率对比)
- [ ] 引用回放仍通过(若已接入引用与拒答信封)
12. 常见错误
12.1 只扫文档命中率,不扫片段可用率
文档能进前三条,不代表排名第一的片段能写引用。Q5 在 50 字切分就是典型。
12.2 把 must_contain 写得过宽
例如只写"配置""接口",几乎所有片段都能过,片段可用率失去约束力。
12.3 切分越长越好
240 与 400 在演示里片段率相同,但片段数更少、索引更轻;过长还会让弱相关文档排到第一名(Q2)。
12.4 调完切分不跑文档回归
切分长度是索引的一部分。定稿后应接《RAG 文档变更后的检索回归清单》里的回归流水线,防止后续文档改动把指标打回去。
12.5 阈值写死在脚本里
应放进 chunk_policy.example.json,演示环境与生产环境可以不同。
12.6 用聊天试问代替固定评测集
切分调参必须可重复。固定问题集 + 双指标扫描,才适合进 CI。
13. 术语速查
| 术语 | 本文用法 |
|---|---|
| 检索召回 | 问法能否找回正确文档与可引用片段 |
| 前三条检索命中率 | 前 3 条结果是否出现期望文档 |
| 片段可用率 | 首条片段是否含 must_contain 全部短语 |
| max_chars | 单段最大字符数 |
| overlap_chars | 相邻片段重叠字符数 |
| CHUNK_ALLOWED | 切分参数通过闸门,可固化上线 |
14. 小结
切分不是"随便按段落拆"。可验收的做法是:
- 在评测集里为引用敏感问题补充
must_contain - 扫描多档
max_chars,同时记录文档命中率 与片段可用率 - 读失败题号,判断是拆碎还是排错片段
- 用
chunk_gate.py在 CI 里输出 ALLOWED / BLOCKED - 定稿后接入文档变更回归,避免后续改动把指标打回去
上文已给出策略、切分、双指标评测、扫描、闸门与清单全文。先在示例文档上扫出一组 CHUNK_ALLOWED 的长度,再接到你们真实手册目录。
15. 相关阅读
《本地知识库落地顺序》解决"先证明检索达标";《RAG 命中后的引用格式与拒答规则》解决"命中后如何引用";《RAG 文档变更后的检索回归清单》解决"文档改了如何验";《RAG 文档切分长度与检索召回》补齐"切分参数怎么定"。四篇叠在一起,本地知识库才不容易在切分这一层悄悄丢召回。
如果本篇对你有帮助,欢迎点赞、收藏,也欢迎关注后续更新。