RAG 文档变更后的检索回归清单
文章目录
- [RAG 文档变更后的检索回归清单](#RAG 文档变更后的检索回归清单)
-
- [1. 文档改了,固定评测却没人重跑](#1. 文档改了,固定评测却没人重跑)
- [2. 先说结论](#2. 先说结论)
- [3. 两层检测:指纹清单与检索命中率](#3. 两层检测:指纹清单与检索命中率)
- [4. 演示场景:支付回调文档改版](#4. 演示场景:支付回调文档改版)
- [5. 回归策略文件](#5. 回归策略文件)
- [6. 文档指纹:fingerprint_docs.py](#6. 文档指纹:fingerprint_docs.py)
- [7. 文档指纹清单对比:diff_manifest.py](#7. 文档指纹清单对比:diff_manifest.py)
- [8. 切分与固定问题检索评测](#8. 切分与固定问题检索评测)
-
- [8.1 chunk_docs.py](#8.1 chunk_docs.py)
- [8.2 retrieve.py](#8.2 retrieve.py)
- [8.3 eval_retrieval.py](#8.3 eval_retrieval.py)
- [9. 对比基线:compare_eval_reports.py](#9. 对比基线:compare_eval_reports.py)
- [10. 发布闸门:regression_gate.py](#10. 发布闸门:regression_gate.py)
- [11. 引用与拒答回放](#11. 引用与拒答回放)
-
- [11.1 build_answer_envelope.py](#11.1 build_answer_envelope.py)
- [11.2 replay_answer_cases.py](#11.2 replay_answer_cases.py)
- [12. 一键回归:run_doc_regression.py](#12. 一键回归:run_doc_regression.py)
- [13. 人工核对清单](#13. 人工核对清单)
- [14. 常见错误](#14. 常见错误)
-
- [14.1 只跑文档指纹,不重跑前三条检索命中率评测](#14.1 只跑文档指纹,不重跑前三条检索命中率评测)
- [14.2 重命名文档编号却不更新评测集里的期望编号](#14.2 重命名文档编号却不更新评测集里的期望编号)
- [14.3 基线 eval_report 每次被覆盖](#14.3 基线 eval_report 每次被覆盖)
- [14.4 闸门阈值写死在脚本里](#14.4 闸门阈值写死在脚本里)
- [14.5 检索退了仍更新 baseline](#14.5 检索退了仍更新 baseline)
- [14.6 省略引用回放](#14.6 省略引用回放)
- [15. 术语速查](#15. 术语速查)
- [16. 小结](#16. 小结)
- [17. 相关阅读](#17. 相关阅读)
摘要 :本地知识库把前三条检索命中率与引用信封跑通之后,文档一改又容易悄悄退化:文件名换了、字段名改了,文档指纹清单能检出变更,但没人重跑固定问题集,上线后才发现旧问法命不中。本文给出 RAG 文档变更后的检索回归清单:文档指纹清单、固定评测集对比、退步问题列表与发布闸门;以支付回调文档改版为例,本机用标准库脚本可一键跑通,并在闸门处拦住
REGRESSION_BLOCKED(回归未通过,不得发布索引)。
说明 :承接 RAG 命中后的引用格式与拒答规则。上一篇解决"命中后如何引用、证据不足如何拒答";本篇解决"文档入库或修改后,如何证明检索没有退步"。
承接前文:
建议目录:
bash
mkdir -p ~/kb-doc-regression/{notes,scripts,configs,fixtures/docs_before,fixtures/docs_after,fixtures/eval,fixtures/baseline,fixtures/cases,images,logs/regression}
cd ~/kb-doc-regression
| 文件 | 作用 |
|---|---|
fixtures/docs_before/*.md |
变更前文档快照 |
fixtures/docs_after/*.md |
变更后文档(演示回归) |
fixtures/eval/questions.jsonl |
固定检索评测问题 |
fixtures/baseline/eval_report.json |
变更前三条检索命中率基线 |
configs/regression_policy.example.json |
发布闸门阈值 |
scripts/fingerprint_docs.py |
文档 sha256 指纹 |
scripts/diff_manifest.py |
对比变更前后的文档指纹清单 |
scripts/compare_eval_reports.py |
列出退步问题与进步问题 |
scripts/regression_gate.py |
允许或阻断发布 |
scripts/run_doc_regression.py |
一键回归流水线 |
notes/regression_checklist.md |
人工核对清单 |
文中配置与脚本均全文给出。演示检索仍用标准库关键词打分;换成向量检索后,文档指纹清单对比与前三条检索命中率闸门逻辑可以保持不变。
1. 文档改了,固定评测却没人重跑
本地知识库落地顺序 里已经用前三条检索命中率证明"能找对文档";RAG 命中后的引用格式与拒答规则 又锁住了对外信封。但知识库不是一次工程:接口字段改名、手册拆页、文件名从 payment_callback.md 换成 payment_callback_v2.md,都会让同一套固定问题命中不同的文档编号,甚至完全命不中。
常见翻车路径:
- 只 diff 了 Git,没跑检索评测:提交记录干净,问答却答偏。
- 文档指纹清单变了就当"没事":指纹能检出增删改,不等于前三条检索命中率仍达标。
- 内容还在,文档编号换了:检索仍找到"支付回调"正文,但评测集里期望命中的文档编号仍写旧值,评测会记为失败。
- 检索退了,引用回放仍通过:信封格式没坏,但引用的已是错误文档。

图1. 文档指纹清单与前三条检索命中率是两层闸门,缺一层都可能在上线后暴露。
本篇硬规则:
每次文档入库或修改,必须重跑固定问题集,并与基线对比;超出门槛则输出 REGRESSION_BLOCKED(回归未通过),不得直接发布索引。
2. 先说结论
本篇用到的几个指标,先统一说明(后文不再重复解释):
| 说法 | 含义 |
|---|---|
| 前三条检索命中率 | 对每道固定评测问题,看检索返回的前 3 条结果里,是否至少有一条是「应该命中的文档」;全部问题的通过比例,就是这条命中率 |
| 文档指纹清单 | 每个文档编号对应一份 sha256 校验值,用来判断哪些文件新增、删除或改过 |
| 退步问题 | 基线评测时通过、文档变更后同一道题却失败的问题 |
| 进步问题 | 基线失败、变更后反而通过的问题 |
| 引用与拒答回放 | 用固定问法检查:该带文档引用时是否仍输出可核对来源,证据不足时是否仍明确拒答 |
| 步骤 | 动作 | 通过标准 |
|---|---|---|
| 1 | fingerprint_docs.py 生成文档指纹清单 |
记录每个文档编号的 sha256 |
| 2 | diff_manifest.py 对比前后 |
列出新增、删除、改动的文件 |
| 3 | 重新 chunk_docs.py 并 eval_retrieval.py |
得到当前前三条检索命中率 |
| 4 | compare_eval_reports.py |
退步问题数为 0,或落在策略允许范围内 |
| 5 | replay_answer_cases.py(可选) |
引用/拒答信封仍符合《RAG 命中后的引用格式与拒答规则》中的策略 |
| 6 | regression_gate.py |
输出 REGRESSION_ALLOWED |
四条落地判断:
- 基线 eval_report 要版本化,不要每次覆盖后无处对比。
- 期望命中的文档编号随文档编号维护,重命名文档要同步改评测集或做编号映射。
- 退步问题列表要可追责,每条记录变更前命中列表与变更后命中列表。
- 闸门阈值写进 JSON,由脚本执行,不靠口头"应该没问题"。

图2. 指纹 → 切分 → 评测 → 对比 → 引用回放 → 闸门。
3. 两层检测:指纹清单与检索命中率

图3. 文档指纹清单回答"哪些文件变了";前三条检索命中率回答"固定问题还能不能命中期望文档"。
| 层级 | 工具 | 能发现什么 | 发现不了什么 |
|---|---|---|---|
| 文件层 | fingerprint_docs + diff_manifest |
增删改、重命名(文档编号变化) | 语义是否仍覆盖业务问法 |
| 检索层 | eval_retrieval + compare_eval_reports |
固定问题是否仍命中期望文档 | 未收录在评测集里的新问题 |
| 信封层 | replay_answer_cases |
引用字段与拒答是否仍合规 | 引用的是否是"正确"业务文档 |
演示里,docs_after 把 payment_callback.md 换成 payment_callback_v2.md,并改了配置路径与字段名。文档指纹清单会报 removed: payment_callback、added: payment_callback_v2;检索层则因评测集仍期望命中 payment_callback,Q1、Q4、Q5 全部记为退步,前三条检索命中率从 100% 降到 40%。
这是刻意设计的"可验收失败":提醒团队重命名文档必须同步维护评测集,不能只看正文是否还在。
4. 演示场景:支付回调文档改版
变更前 fixtures/docs_before/payment_callback.md(节选):
markdown
配置文件路径:configs/payment.yaml,字段名为 callback_timeout_ms。
变更后 fixtures/docs_after/payment_callback_v2.md(节选):
markdown
配置文件路径:configs/payment_v2.yaml,字段名为 payment_callback_timeout。
同时文件名从 payment_callback.md 改为 payment_callback_v2.md,doc_id(文档编号) 随之变化。
固定评测问题 fixtures/eval/questions.jsonl:
json
{"id":"Q1","question":"支付回调超时先查哪几个配置字段","gold_docs":["payment_callback"]}
{"id":"Q2","question":"登录成功时 HTTP 状态码和 token 字段","gold_docs":["auth_api"]}
{"id":"Q3","question":"Orin 现场发布失败如何回滚模型软链","gold_docs":["deploy_runbook"]}
{"id":"Q4","question":"回调签名校验失败常见原因","gold_docs":["payment_callback"]}
{"id":"Q5","question":"configs payment.yaml callback_timeout_ms 超时字段在哪个文件","gold_docs":["payment_callback"]}
变更前基线 fixtures/baseline/eval_report.json 已由 eval_retrieval.py 生成,前三条检索命中率为 100%。对 docs_after 跑回归后,compare_report.json 典型片段如下(字段名保留英文,含义见第 2 节指标表):
json
{
"baseline_hit_at_k": 1.0,
"current_hit_at_k": 0.4,
"hit_drop": 0.6,
"regressed_count": 3,
"regressed": [
{
"id": "Q1",
"before_got": ["payment_callback", "payment_callback", "deploy_runbook"],
"after_got": ["payment_callback_v2", "payment_callback_v2", "deploy_runbook"]
}
]
}
注意:检索其实仍找到了"支付回调"相关片段,但文档编号与评测集里期望命中的编号不一致 ,评测仍判失败。修复路径要么是保留稳定文档编号(只改内容不改文件名),要么在改版时同步把评测集里的期望编号改成 payment_callback_v2,并人工确认问法仍合理。
5. 回归策略文件
保存为 configs/regression_policy.example.json:
json
{
"min_hit_at_3": 0.75,
"max_regressed_questions": 0,
"max_hit_drop": 0.25,
"require_answer_replay": true,
"fingerprint_algorithm": "sha256"
}
| 字段 | 含义 |
|---|---|
min_hit_at_3 |
当前前三条检索命中率下限 |
max_regressed_questions |
允许退步的问题条数(演示为 0) |
max_hit_drop |
相对基线允许的最大命中率跌幅 |
require_answer_replay |
是否要求引用与拒答回放也通过 |
演示场景下闸门输出(hit_at_k 即脚本里的命中率字段,对应前三条检索命中率):
REGRESSION_BLOCKED
FAIL: 命中率 0.4 低于下限 0.75(min_hit_at_3)
FAIL: 退步问题 3 条,超过允许值 0(max_regressed_questions)
FAIL: 命中率跌幅 0.6,超过允许值 0.25(max_hit_drop)

图4. 对比报告要保留变更前、变更后各自命中的文档列表,方便判断是文档编号问题还是正文内容问题。
6. 文档指纹:fingerprint_docs.py
保存为 scripts/fingerprint_docs.py:
python
#!/usr/bin/env python3
"""Fingerprint markdown docs to detect changes."""
from __future__ import annotations
import argparse
import hashlib
import json
from pathlib import Path
def fingerprint_file(path: Path) -> str:
data = path.read_bytes()
return hashlib.sha256(data).hexdigest()
def build_manifest(docs_dir: Path) -> dict:
files = []
for path in sorted(docs_dir.glob("*.md")):
files.append(
{
"doc_id": path.stem,
"path": str(path),
"sha256": fingerprint_file(path),
"size": path.stat().st_size,
}
)
return {"docs_dir": str(docs_dir), "count": len(files), "files": files}
def main() -> None:
parser = argparse.ArgumentParser(description="Fingerprint docs directory")
parser.add_argument("--docs-dir", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
manifest = build_manifest(args.docs_dir)
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(manifest, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
if args.json:
print(json.dumps(manifest, ensure_ascii=False, indent=2))
else:
print(f"FINGERPRINT_OK docs={manifest['count']} -> {args.out}")
for row in manifest["files"]:
print(f" {row['doc_id']}: {row['sha256'][:12]}...")
if __name__ == "__main__":
main()
用法:
bash
python3 scripts/fingerprint_docs.py \
--docs-dir fixtures/docs_before \
--out fixtures/baseline/manifest_before.json
python3 scripts/fingerprint_docs.py \
--docs-dir fixtures/docs_after \
--out fixtures/baseline/manifest_after.json
7. 文档指纹清单对比:diff_manifest.py
保存为 scripts/diff_manifest.py:
python
#!/usr/bin/env python3
"""Compare two doc manifests and list changed files."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
def load_manifest(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def diff_manifests(before: dict, after: dict) -> dict:
bmap = {f["doc_id"]: f for f in before.get("files", [])}
amap = {f["doc_id"]: f for f in after.get("files", [])}
added = sorted(set(amap) - set(bmap))
removed = sorted(set(bmap) - set(amap))
changed = []
for doc_id in sorted(set(bmap) & set(amap)):
if bmap[doc_id].get("sha256") != amap[doc_id].get("sha256"):
changed.append(doc_id)
return {
"changed": changed,
"added": added,
"removed": removed,
"has_changes": bool(changed or added or removed),
}
def main() -> None:
parser = argparse.ArgumentParser(description="Diff doc manifests")
parser.add_argument("--before", type=Path, required=True)
parser.add_argument("--after", type=Path, required=True)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
result = diff_manifests(load_manifest(args.before), load_manifest(args.after))
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print("MANIFEST_CHANGED" if result["has_changes"] else "MANIFEST_SAME")
if result["changed"]:
print("changed:", ", ".join(result["changed"]))
if result["added"]:
print("added:", ", ".join(result["added"]))
if result["removed"]:
print("removed:", ", ".join(result["removed"]))
raise SystemExit(0 if result["has_changes"] else 1)
if __name__ == "__main__":
main()
bash
python3 scripts/diff_manifest.py \
--before fixtures/baseline/manifest_before.json \
--after fixtures/baseline/manifest_after.json
演示输出含 removed: payment_callback 与 added: payment_callback_v2。
8. 切分与固定问题检索评测
8.1 chunk_docs.py
python
#!/usr/bin/env python3
"""Split local markdown docs into retrieval chunks."""
from __future__ import annotations
import argparse
import json
import re
from pathlib import Path
def split_chunks(text: str, max_chars: int = 280) -> list[str]:
parts = re.split(r"\n\s*\n", text.strip())
chunks: 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:
chunks.append(buf)
if len(part) <= max_chars:
buf = part
else:
for i in range(0, len(part), max_chars):
chunks.append(part[i : i + max_chars])
buf = ""
if buf:
chunks.append(buf)
return chunks
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)
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)):
rows.append(
{
"chunk_id": f"{doc_id}#{idx}",
"doc_id": doc_id,
"path": str(path),
"text": chunk,
}
)
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)} -> {args.out}")
if __name__ == "__main__":
main()
8.2 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()
8.3 eval_retrieval.py
脚本日志里仍会出现 hit_at_k 等英文字段名,对应第 2 节表格里的前三条检索命中率;正文叙述统一用中文指标名,配置与 JSON 里保留英文键名便于脚本读取。
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/chunk_docs.py \
--docs-dir fixtures/docs_after \
--out logs/regression/chunks.jsonl \
--max-chars 360
python3 scripts/eval_retrieval.py \
--chunks logs/regression/chunks.jsonl \
--questions fixtures/eval/questions.jsonl \
--out logs/regression/eval_report.json
9. 对比基线:compare_eval_reports.py
保存为 scripts/compare_eval_reports.py:
python
#!/usr/bin/env python3
"""Compare retrieval eval reports and list regressions."""
from __future__ import annotations
import argparse
import json
from pathlib import Path
def index_details(report: dict) -> dict[str, dict]:
return {d.get("id"): d for d in report.get("details", []) if d.get("id")}
def compare(baseline: dict, current: dict) -> dict:
b = index_details(baseline)
c = index_details(current)
regressed = []
improved = []
for qid in sorted(set(b) | set(c)):
b_ok = bool(b.get(qid, {}).get("ok"))
c_ok = bool(c.get(qid, {}).get("ok"))
if b_ok and not c_ok:
regressed.append(
{
"id": qid,
"question": c.get(qid, b.get(qid, {})).get("question"),
"before_got": b.get(qid, {}).get("got_docs"),
"after_got": c.get(qid, {}).get("got_docs"),
}
)
if not b_ok and c_ok:
improved.append({"id": qid, "question": c.get(qid, {}).get("question")})
b_hit = float(baseline.get("hit_at_k") or 0.0)
c_hit = float(current.get("hit_at_k") or 0.0)
return {
"baseline_hit_at_k": b_hit,
"current_hit_at_k": c_hit,
"hit_drop": round(b_hit - c_hit, 4),
"regressed": regressed,
"improved": improved,
"regressed_count": len(regressed),
"improved_count": len(improved),
}
def main() -> None:
parser = argparse.ArgumentParser(description="Compare eval reports for regressions")
parser.add_argument("--baseline", type=Path, required=True)
parser.add_argument("--current", type=Path, required=True)
parser.add_argument("--out", type=Path, default=None)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
baseline = json.loads(args.baseline.read_text(encoding="utf-8"))
current = json.loads(args.current.read_text(encoding="utf-8"))
result = compare(baseline, current)
if args.out:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(result, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"wrote {args.out}")
if args.json:
print(json.dumps(result, ensure_ascii=False, indent=2))
else:
print(
f"COMPARE hit {result['baseline_hit_at_k']:.2%} -> {result['current_hit_at_k']:.2%} "
f"drop={result['hit_drop']:.2%}"
)
print(f"regressed={result['regressed_count']} improved={result['improved_count']}")
for row in result["regressed"]:
print(f" REGRESS {row['id']}: got={row.get('after_got')} (was {row.get('before_got')})")
if __name__ == "__main__":
main()
bash
python3 scripts/compare_eval_reports.py \
--baseline fixtures/baseline/eval_report.json \
--current logs/regression/eval_report.json \
--out logs/regression/compare_report.json
只关心"之前通过、现在失败"的退步问题;进步问题用于确认优化是否意外修好旧问题。
10. 发布闸门:regression_gate.py
保存为 scripts/regression_gate.py:
python
#!/usr/bin/env python3
"""Gate release when doc-change regression exceeds policy."""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
def main() -> None:
parser = argparse.ArgumentParser(description="Regression gate for doc changes")
parser.add_argument("--policy", type=Path, required=True)
parser.add_argument("--compare-report", type=Path, required=True)
parser.add_argument("--current-eval", type=Path, required=True)
parser.add_argument("--answer-replay-exit", type=int, default=0)
parser.add_argument("--json", action="store_true")
args = parser.parse_args()
policy = json.loads(args.policy.read_text(encoding="utf-8"))
compare = json.loads(args.compare_report.read_text(encoding="utf-8"))
current = json.loads(args.current_eval.read_text(encoding="utf-8"))
errors: list[str] = []
hit = float(current.get("hit_at_k") or 0.0)
need = float(policy.get("min_hit_at_3", 0.0))
if hit < need:
errors.append(f"hit_at_k={hit} < min_hit_at_3={need}")
max_regressed = int(policy.get("max_regressed_questions", 0))
regressed = int(compare.get("regressed_count") or 0)
if regressed > max_regressed:
errors.append(f"regressed_count={regressed} > max_regressed_questions={max_regressed}")
max_drop = float(policy.get("max_hit_drop", 1.0))
drop = float(compare.get("hit_drop") or 0.0)
if drop > max_drop:
errors.append(f"hit_drop={drop} > max_hit_drop={max_drop}")
if policy.get("require_answer_replay") and args.answer_replay_exit != 0:
errors.append(f"answer replay exit={args.answer_replay_exit}")
allow = not errors
out = {
"allow_release": allow,
"errors": errors,
"hit_at_k": hit,
"regressed_count": regressed,
"hit_drop": drop,
}
if args.json:
print(json.dumps(out, ensure_ascii=False, indent=2))
else:
print("REGRESSION_ALLOWED" if allow else "REGRESSION_BLOCKED")
for e in errors:
print(f"FAIL: {e}")
if allow:
print(f"hit_at_k={hit} regressed={regressed} hit_drop={drop}")
raise SystemExit(0 if allow else 2)
if __name__ == "__main__":
main()

图5. 任一 FAIL 即
REGRESSION_BLOCKED,CI 应以退出码 2 失败。
11. 引用与拒答回放
文档变更后,除了前三条检索命中率,还应回放引用与拒答用例,防止阈值或切分改动把拒答弄丢。
configs/answer_policy.example.json 与《RAG 命中后的引用格式与拒答规则》中的配置相同。fixtures/cases/answer_cases.jsonl 里每条用例标注期望结果:ANSWER 表示应输出带文档引用的回答,REFUSE 表示证据不足时应拒答。
11.1 build_answer_envelope.py
python
#!/usr/bin/env python3
"""Build a cited answer or a refuse envelope from retrieval hits."""
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_policy(path: Path) -> dict:
return json.loads(path.read_text(encoding="utf-8"))
def decide(query: str, ranked: list[tuple[float, dict]], policy: dict) -> dict:
if not ranked:
return {
"status": "REFUSE",
"reason": "NO_HIT",
"query": query,
"answer": "知识库未检索到相关片段,不能给出可核对的结论。请补充文档或换一种问法。",
"citations": [],
}
top_score, top = ranked[0]
min_score = float(policy.get("min_top_score", 0.0))
if top_score < min_score:
return {
"status": "REFUSE",
"reason": "LOW_SCORE",
"query": query,
"answer": (
f"检索最高分 {top_score:.2f} 低于阈值 {min_score},"
"证据不够,暂不回答。请补充更接近业务原话的文档,或降低阈值前先人工核对。"
),
"citations": [],
"top_score": round(top_score, 4),
"top_doc_id": top.get("doc_id"),
}
max_chars = int(policy.get("max_snippet_chars", 280))
citations = []
for score, row in ranked[: int(policy.get("min_top_k", 1)) + 2]:
snippet = (row.get("text") or "").strip().replace("\n", " ")
if not snippet:
continue
citations.append(
{
"doc_id": row.get("doc_id"),
"chunk_id": row.get("chunk_id"),
"score": round(score, 4),
"snippet": snippet[:max_chars],
}
)
if policy.get("require_doc_id") and any(not c.get("doc_id") for c in citations):
return {
"status": "REFUSE",
"reason": "MISSING_CITATION",
"query": query,
"answer": "命中片段缺少 doc_id,不能对外输出。请先修复切分脚本。",
"citations": [],
}
if policy.get("require_chunk_id") and any(not c.get("chunk_id") for c in citations):
return {
"status": "REFUSE",
"reason": "MISSING_CITATION",
"query": query,
"answer": "命中片段缺少 chunk_id,不能对外输出。请先修复切分脚本。",
"citations": [],
}
if policy.get("require_snippet") and not citations:
return {
"status": "REFUSE",
"reason": "EMPTY_SNIPPET",
"query": query,
"answer": "命中结果没有可用原文片段,不能对外输出。",
"citations": [],
}
top_c = citations[0]
claim = (
f"优先依据 {top_c['doc_id']} 中的说明处理。"
f"关键原文:{top_c['snippet']}"
)
tmpl = policy.get("cite_template") or "根据文档 {doc_id}(片段 {chunk_id}):{claim}"
answer = tmpl.format(
doc_id=top_c["doc_id"],
chunk_id=top_c["chunk_id"],
claim=claim,
)
return {
"status": "ANSWER",
"reason": "OK",
"query": query,
"answer": answer,
"citations": citations,
"top_score": round(top_score, 4),
}
def main() -> None:
parser = argparse.ArgumentParser(description="Build cited answer or refuse envelope")
parser.add_argument("--chunks", type=Path, required=True)
parser.add_argument("--policy", type=Path, required=True)
parser.add_argument("--query", 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()
policy = load_policy(args.policy)
chunks = load_chunks(args.chunks)
tfs, idf = build_index(chunks)
ranked = score_query(args.query, chunks, tfs, idf)[: args.top_k]
envelope = decide(args.query, ranked, policy)
if args.out:
args.out.parent.mkdir(parents=True, exist_ok=True)
args.out.write_text(json.dumps(envelope, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"wrote {args.out}")
if args.json:
print(json.dumps(envelope, ensure_ascii=False, indent=2))
else:
print(envelope["status"], envelope.get("reason", ""))
print(envelope["answer"])
for c in envelope.get("citations") or []:
print(f"- {c['doc_id']} / {c['chunk_id']} score={c['score']}")
raise SystemExit(0 if envelope["status"] == "ANSWER" else 2)
if __name__ == "__main__":
main()
11.2 replay_answer_cases.py
python
#!/usr/bin/env python3
"""Replay answer/refuse cases against expected status."""
from __future__ import annotations
import argparse
import json
import subprocess
import sys
from pathlib import Path
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="Replay citation/refuse cases")
parser.add_argument("--cases", type=Path, required=True)
parser.add_argument("--chunks", type=Path, required=True)
parser.add_argument("--policy", type=Path, required=True)
parser.add_argument("--builder", type=Path, required=True)
parser.add_argument("--workdir", type=Path, default=Path("."))
args = parser.parse_args()
failed = []
for case in load_jsonl(args.cases):
proc = subprocess.run(
[
sys.executable,
str(args.builder),
"--chunks",
str(args.chunks),
"--policy",
str(args.policy),
"--query",
case["query"],
"--json",
],
cwd=str(args.workdir),
capture_output=True,
text=True,
)
try:
data = json.loads(proc.stdout)
except json.JSONDecodeError:
data = {"status": "PARSE_ERROR", "raw": (proc.stdout or "")[:300]}
expect = case.get("expect_status")
got = data.get("status")
mark = "PASS" if got == expect else "FAIL"
print(f"[{mark}] {case.get('case_id')}: expect={expect} got={got} reason={data.get('reason')}")
if got != expect:
failed.append(case.get("case_id"))
print("REPLAY_OK" if not failed else "REPLAY_FAIL")
if failed:
print("failed:", ", ".join(str(x) for x in failed))
raise SystemExit(0 if not failed else 2)
if __name__ == "__main__":
main()
演示里即使检索退步,支付与登录类问题仍能生成 ANSWER 信封;库外问题仍 REFUSE。闸门在 require_answer_replay: true 时会把回放退出码一并计入。
12. 一键回归:run_doc_regression.py
保存为 scripts/run_doc_regression.py:
python
#!/usr/bin/env python3
"""Run retrieval regression after document changes."""
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) -> int:
proc = subprocess.run(cmd, cwd=str(cwd), capture_output=True, text=True)
tail = ((proc.stdout or "") + (proc.stderr or ""))[-800:]
print(tail)
return proc.returncode
def main() -> None:
parser = argparse.ArgumentParser(description="Run doc-change retrieval regression")
parser.add_argument("--docs-dir", type=Path, required=True)
parser.add_argument("--baseline-eval", type=Path, required=True)
parser.add_argument("--questions", type=Path, required=True)
parser.add_argument("--policy", type=Path, required=True)
parser.add_argument("--answer-policy", type=Path, required=True)
parser.add_argument("--answer-cases", type=Path, required=True)
parser.add_argument("--workdir", type=Path, default=Path("."))
parser.add_argument("--out-dir", type=Path, default=Path("logs/regression"))
args = parser.parse_args()
workdir = args.workdir.resolve()
out_dir = (workdir / args.out_dir).resolve()
out_dir.mkdir(parents=True, exist_ok=True)
manifest = out_dir / "manifest.json"
chunks = out_dir / "chunks.jsonl"
eval_report = out_dir / "eval_report.json"
compare_report = out_dir / "compare_report.json"
summary = out_dir / "regression_summary.json"
py = sys.executable
steps = [
[py, str(ROOT / "fingerprint_docs.py"), "--docs-dir", str(args.docs_dir), "--out", str(manifest)],
[py, str(ROOT / "chunk_docs.py"), "--docs-dir", str(args.docs_dir), "--out", str(chunks), "--max-chars", "360"],
[
py,
str(ROOT / "eval_retrieval.py"),
"--chunks",
str(chunks),
"--questions",
str(args.questions),
"--out",
str(eval_report),
],
[
py,
str(ROOT / "compare_eval_reports.py"),
"--baseline",
str(args.baseline_eval),
"--current",
str(eval_report),
"--out",
str(compare_report),
],
]
for cmd in steps:
code = run(cmd, workdir)
if code != 0 and "compare_eval_reports" not in cmd[1]:
raise SystemExit(code)
answer_exit = run(
[
py,
str(ROOT / "replay_answer_cases.py"),
"--cases",
str(args.answer_cases),
"--chunks",
str(chunks),
"--policy",
str(args.answer_policy),
"--builder",
str(ROOT / "build_answer_envelope.py"),
"--workdir",
str(workdir),
],
workdir,
)
gate_exit = run(
[
py,
str(ROOT / "regression_gate.py"),
"--policy",
str(args.policy),
"--compare-report",
str(compare_report),
"--current-eval",
str(eval_report),
"--answer-replay-exit",
str(answer_exit),
],
workdir,
)
payload = {
"manifest": str(manifest),
"chunks": str(chunks),
"eval_report": str(eval_report),
"compare_report": str(compare_report),
"answer_replay_exit": answer_exit,
"gate_exit": gate_exit,
}
summary.write_text(json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
print(f"wrote {summary}")
print("REGRESSION_OK" if gate_exit == 0 else "REGRESSION_FAIL")
raise SystemExit(gate_exit)
if __name__ == "__main__":
main()
在仓库根目录执行:
bash
python3 scripts/run_doc_regression.py \
--docs-dir fixtures/docs_after \
--baseline-eval fixtures/baseline/eval_report.json \
--questions fixtures/eval/questions.jsonl \
--policy configs/regression_policy.example.json \
--answer-policy configs/answer_policy.example.json \
--answer-cases fixtures/cases/answer_cases.jsonl \
--workdir .
预期:对比报告报 3 条退步问题,终端打印 REGRESSION_FAIL,退出码为 2。更新评测集里的期望文档编号,或恢复稳定文档编号后,应能回到 REGRESSION_OK。
13. 人工核对清单
保存为 notes/regression_checklist.md:
markdown
# 文档变更后检索回归清单
## 文档入库或修改时
- [ ] 记录变更的文档编号列表
- [ ] 生成变更前后的文档指纹清单(sha256)
- [ ] 重新切分并生成 chunks.jsonl
- [ ] 跑固定评测问题集 eval_retrieval.py
## 与基线对比
- [ ] 对比基线与当前的前三条检索命中率
- [ ] 列出退步问题(之前通过、现在失败)
- [ ] 列出进步问题(之前失败、现在通过)
- [ ] 回放引用/拒答用例(若已接入引用与拒答信封)
## 发布前闸门
- [ ] 前三条检索命中率不低于策略阈值
- [ ] 退步问题数不超过策略允许值
- [ ] 命中率跌幅不超过策略允许值
- [ ] regression_gate.py 输出 REGRESSION_ALLOWED
## 收尾
- [ ] 若通过:更新基线 eval_report 与文档指纹清单
- [ ] 若失败:修文档/切分/问法,不要直接上线
- [ ] 在变更记录里写明失败问题编号与处理结果
14. 常见错误
14.1 只跑文档指纹,不重跑前三条检索命中率评测
文件指纹是必要步骤,不能代替检索评测。内容微调、同义词替换也可能让排序变化。
14.2 重命名文档编号却不更新评测集里的期望编号
本篇演示的 3 条退步问题就是这种情况。正文还在,评测仍失败。
14.3 基线 eval_report 每次被覆盖
应把"上次上线通过"的报告存进 fixtures/baseline/ 或版本库 tag,对比才有意义。
14.4 闸门阈值写死在脚本里
策略应进 JSON,方便按环境调整演示阈值与生产阈值。
14.5 检索退了仍更新 baseline
REGRESSION_BLOCKED 时应先修文档或评测集,通过后再刷新基线,不要用失败结果当新基线。
14.6 省略引用回放
前三条检索命中率与信封是两条线。只守命中率,可能出现"命中文档但拒答逻辑坏了"的漏网。
15. 术语速查
| 术语 | 本文用法 |
|---|---|
| 文档指纹清单(manifest) | 每个文档编号对应 sha256 的清单 |
| 退步问题(regressed) | 基线通过、当前失败的评测问题 |
| 命中率跌幅(hit_drop) | 基线前三条检索命中率减去当前值 |
| 期望命中的文档编号(gold_docs) | 评测问题标准答案里应出现的文档编号 |
| REGRESSION_BLOCKED | 回归闸门拒绝发布 |
16. 小结
本地知识库上线后仍会随文档演进。可验收的做法是:
- 用文档指纹清单记录哪些文件变了
- 用固定问题集证明检索没有悄悄退步
- 用对比报告列出退步问题明细
- 用闸门脚本在 CI 里输出 ALLOWED / BLOCKED
- 需要时叠加引用与拒答回放,避免信封层退化
上文已给出策略、指纹、对比、闸门、一键脚本与清单全文。先在 docs_after 上演练一次 REGRESSION_BLOCKED,再在你们真实文档目录接入同一流水线。
17. 相关阅读
上一篇《RAG 命中后的引用格式与拒答规则》回答"命中后如何引用与拒答";本篇回答"文档变更后如何证明检索仍达标"。把前三条检索命中率、引用信封与回归闸门叠在一起,知识库才不容易在改版时静默退化。
如果本篇对你有帮助,欢迎点赞、收藏,也欢迎关注后续更新。