RAG 文档变更后的检索回归清单

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,都会让同一套固定问题命中不同的文档编号,甚至完全命不中。

常见翻车路径:

  1. 只 diff 了 Git,没跑检索评测:提交记录干净,问答却答偏。
  2. 文档指纹清单变了就当"没事":指纹能检出增删改,不等于前三条检索命中率仍达标。
  3. 内容还在,文档编号换了:检索仍找到"支付回调"正文,但评测集里期望命中的文档编号仍写旧值,评测会记为失败。
  4. 检索退了,引用回放仍通过:信封格式没坏,但引用的已是错误文档。

图1. 文档指纹清单与前三条检索命中率是两层闸门,缺一层都可能在上线后暴露。

本篇硬规则:

每次文档入库或修改,必须重跑固定问题集,并与基线对比;超出门槛则输出 REGRESSION_BLOCKED(回归未通过),不得直接发布索引。


2. 先说结论

本篇用到的几个指标,先统一说明(后文不再重复解释):

说法 含义
前三条检索命中率 对每道固定评测问题,看检索返回的前 3 条结果里,是否至少有一条是「应该命中的文档」;全部问题的通过比例,就是这条命中率
文档指纹清单 每个文档编号对应一份 sha256 校验值,用来判断哪些文件新增、删除或改过
退步问题 基线评测时通过、文档变更后同一道题却失败的问题
进步问题 基线失败、变更后反而通过的问题
引用与拒答回放 用固定问法检查:该带文档引用时是否仍输出可核对来源,证据不足时是否仍明确拒答
步骤 动作 通过标准
1 fingerprint_docs.py 生成文档指纹清单 记录每个文档编号的 sha256
2 diff_manifest.py 对比前后 列出新增、删除、改动的文件
3 重新 chunk_docs.pyeval_retrieval.py 得到当前前三条检索命中率
4 compare_eval_reports.py 退步问题数为 0,或落在策略允许范围内
5 replay_answer_cases.py(可选) 引用/拒答信封仍符合《RAG 命中后的引用格式与拒答规则》中的策略
6 regression_gate.py 输出 REGRESSION_ALLOWED

四条落地判断:

  1. 基线 eval_report 要版本化,不要每次覆盖后无处对比。
  2. 期望命中的文档编号随文档编号维护,重命名文档要同步改评测集或做编号映射。
  3. 退步问题列表要可追责,每条记录变更前命中列表与变更后命中列表。
  4. 闸门阈值写进 JSON,由脚本执行,不靠口头"应该没问题"。

图2. 指纹 → 切分 → 评测 → 对比 → 引用回放 → 闸门。


3. 两层检测:指纹清单与检索命中率

图3. 文档指纹清单回答"哪些文件变了";前三条检索命中率回答"固定问题还能不能命中期望文档"。

层级 工具 能发现什么 发现不了什么
文件层 fingerprint_docs + diff_manifest 增删改、重命名(文档编号变化) 语义是否仍覆盖业务问法
检索层 eval_retrieval + compare_eval_reports 固定问题是否仍命中期望文档 未收录在评测集里的新问题
信封层 replay_answer_cases 引用字段与拒答是否仍合规 引用的是否是"正确"业务文档

演示里,docs_afterpayment_callback.md 换成 payment_callback_v2.md,并改了配置路径与字段名。文档指纹清单会报 removed: payment_callbackadded: 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.mddoc_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_callbackadded: 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. 小结

本地知识库上线后仍会随文档演进。可验收的做法是:

  1. 用文档指纹清单记录哪些文件变了
  2. 用固定问题集证明检索没有悄悄退步
  3. 用对比报告列出退步问题明细
  4. 用闸门脚本在 CI 里输出 ALLOWED / BLOCKED
  5. 需要时叠加引用与拒答回放,避免信封层退化

上文已给出策略、指纹、对比、闸门、一键脚本与清单全文。先在 docs_after 上演练一次 REGRESSION_BLOCKED,再在你们真实文档目录接入同一流水线。


17. 相关阅读

上一篇《RAG 命中后的引用格式与拒答规则》回答"命中后如何引用与拒答";本篇回答"文档变更后如何证明检索仍达标"。把前三条检索命中率、引用信封与回归闸门叠在一起,知识库才不容易在改版时静默退化。

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

相关推荐
Hrain-AI13 分钟前
企业 AI 治理运营怎么做:分级授权、Token 用量可观测与模型统一纳管
大数据·人工智能·算法
2401_8652616318 分钟前
亦唐科技:创新驱动下的国产贴片机领航者
人工智能
Mr数据杨18 分钟前
【CanMV K210】硬件基础 面包板连通规则与无焊接电路搭建
人工智能·硬件开发·canmv k210
长江后浪博客25 分钟前
陶瓷浮雕盘印刷视觉定位方案:8K线扫相机 + 暗场光源 + 旋转平台
人工智能·数码相机·机器视觉·视觉定位·线扫相机·暗场光源·陶瓷印刷
江苏赛融科技27 分钟前
数据驱动:能耗管理系统如何将能源数据转化为管理决策资产
大数据·人工智能·能源·智慧园区·企业资产管理·园区智能化
AI智图坊31 分钟前
宠物用品电商视觉内容生产的技术难点与自动化方案分析
大数据·运维·人工智能·ai作画·自动化·aigc
啥都想学点的研究生33 分钟前
一篇文章讲清楚:机器学习所用的数学知识
人工智能·机器学习
七牛云行业应用35 分钟前
国产新模型选型:GLM-5.3、DeepSeek V4、GLM-5.3-Flash、Qwen3.8-Flash-Next、Kimi K3 怎么选
人工智能·大模型·ai编程
Patrick在香港36 分钟前
Claude Agent 进阶编排:循环控制 + 写权限审批闸门 + 幂等重试
python·agent·claude·编排·anthropic api