RAG 文档切分长度与检索召回

RAG 文档切分长度与检索召回

文章目录

  • [RAG 文档切分长度与检索召回](#RAG 文档切分长度与检索召回)
    • [1. 文档能命中,片段却不够写引用](#1. 文档能命中,片段却不够写引用)
    • [2. 先说结论](#2. 先说结论)
    • [3. 演示:同一套问题,两个指标会分叉](#3. 演示:同一套问题,两个指标会分叉)
    • [4. 切分策略文件](#4. 切分策略文件)
    • [5. 切分脚本:chunk_docs.py](#5. 切分脚本:chunk_docs.py)
    • [6. 检索与文档命中率评测](#6. 检索与文档命中率评测)
    • [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. 只盯文档命中率:前三条里"出现过期望文档"就算通过,但排名第一的片段仍可能无法引用。

图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 并写入配置 后续文档变更走回归流水线

四条落地判断:

  1. 评测问题要加 must_contain,否则切分调参没有引用侧约束。
  2. 优先提升片段可用率,再追求更短的片段长度。
  3. 扫描结果要保留失败题号,不要只看一个总百分比。
  4. 阈值写进 JSON,由闸门脚本执行。

图2. 扫长度 → 看双指标 → 读失败题号 → 过闸门。


3. 演示:同一套问题,两个指标会分叉

示例文档仍是三份:payment_callbackauth_apideploy_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.yamlcallback_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. 小结

切分不是"随便按段落拆"。可验收的做法是:

  1. 在评测集里为引用敏感问题补充 must_contain
  2. 扫描多档 max_chars,同时记录文档命中率片段可用率
  3. 读失败题号,判断是拆碎还是排错片段
  4. chunk_gate.py 在 CI 里输出 ALLOWED / BLOCKED
  5. 定稿后接入文档变更回归,避免后续改动把指标打回去

上文已给出策略、切分、双指标评测、扫描、闸门与清单全文。先在示例文档上扫出一组 CHUNK_ALLOWED 的长度,再接到你们真实手册目录。


15. 相关阅读

《本地知识库落地顺序》解决"先证明检索达标";《RAG 命中后的引用格式与拒答规则》解决"命中后如何引用";《RAG 文档变更后的检索回归清单》解决"文档改了如何验";《RAG 文档切分长度与检索召回》补齐"切分参数怎么定"。四篇叠在一起,本地知识库才不容易在切分这一层悄悄丢召回。

如果本篇对你有帮助,欢迎点赞、收藏,也欢迎关注后续更新。

相关推荐
DsirNg9 小时前
让流程会停下:AI 自动化的交班设计
自动化·可观测性·幂等性·智能体·ai 工作流·人工在环·工单分流
DeepAgent10 小时前
AI Agent 工程实践(35):我的 AI Engineering OS 最终架构
大数据·人工智能·agent
cczixun12 小时前
从工具交互到自主进化:2026智能体平台产业发展全景解析
人工智能·智能体
粥里有勺糖12 小时前
视野修炼-技术周刊第131期 | Bun 与 pnpm Rust 化
前端·github·agent
像云~14 小时前
DeepSeek Harness Cordis理解
agent·harness·crodis
魔术师Grace16 小时前
调用一次大模型,就算 Agent 吗?
aigc·agent·ai编程
桃西西呀16 小时前
GPT-5.6 的 ultra 模式凭什么开 4 个 Agent 并行跑?——多智能体协作,是把"一个聪明人"换成"一个团队"
人工智能·llm·agent
怕浪猫16 小时前
用好这 5 把钥匙,你就能拦截 AI Agent 的一切行为
数据分析·agent·产品
澄怀16 小时前
Agent 说「我已经改好文件了」,这句话到底能不能信?
llm·agent