RAG 全链路排错清单:打印 TopK、算 hit@k、做引用校验(含 Python 最小脚手架)

30 阅读4分钟

RAG(知识库问答)最难受的不是“模型答错”,而是:

资料明明有,但模型总答非所问。

我先给一个工程结论:
RAG 失败 80% 在检索链路,不在生成。

所以这篇不讲玄学 prompt,直接给你一套“工程排错打法”:

  • 固定失败样本
  • 打印 top-k(看证据是否命中)
  • 用 hit@k/cite_acc/ans_acc 把迭代量化
  • 给一个最小评测脚手架(能跑起来就行)

0)TL;DR

  • 先评检索,再评生成:hit@k 不行别调 prompt
  • 打印 top-k 是第一生产力:没有 top-k 你只能猜
  • 一次只改一个变量:关重排/调 k/换分块/改提示词,别全改

1)RAG 链路图(你要知道自己在调哪一段)

docs -> clean -> chunk -> embed -> index
   -> retrieve(top-k) -> rerank(optional) -> build_context
   -> prompt_guardrails -> generate -> citations/postprocess

2)最常见翻车点(按优先级)

2.1 top-k 没命中正确证据(hit@k 低)

优先检查:

  • 文档是否真的入库?索引是否更新?
  • chunk 是否切断了关键句?(太碎/边界断)
  • top_k 是否太小?(先试 5→10→20)
  • filter 是否误伤?(版本/部门/权限)

2.2 命中了但答错(context 污染/提示词带跑)

优先检查:

  • context 是否太长、噪音太多?
  • 资料顺序是否合理?(定义→例外→边界)
  • 是否缺少“只基于资料/资料不足就拒答”的强约束?

2.3 引用错(看起来像,但不是证据)

优先检查:

  • 是否需要 rerank(召回多但排序乱时)
  • 引用格式是否强约束(必须引用片段编号)
  • 是否要做引用校验(引用片段是否包含关键事实)

3)排错流程(照着做,最快定位)

Step 1:固定失败样本(四字段)

  • query
  • expected_keypoints(期望关键点)
  • expected_source_ids(正确证据段落 id)
  • observed(线上输出)

Step 2:打印 top-k(必须做)

看两件事:

  1. top-k 里有没有 expected_source_ids
  2. 如果有,排第几,上方噪音多不多

Step 3:消融实验(一次只改一个变量)

建议顺序:

  1. 关 rerank
  2. 调 top_k
  3. 调 chunk_size/overlap
  4. 调 prompt_guardrails

4)最小指标体系(让迭代可量化)

hit@k(检索命中率)

top-k 里是否命中正确证据段落。
这个指标低,你就别纠结生成。

cite_acc(引用正确率)

答案引用的片段是否真的支持结论(制度/合同类尤其重要)。

ans_acc(答案正确率)

关键点是否覆盖且无严重错误。


5)最小评测脚手架(Python)

5.1 评测集格式(jsonl)

{"id":"q001","query":"退款规则是什么?","expected_keypoints":["7天无理由","特殊商品除外"],"expected_source_ids":["refund_v2025#p12","refund_v2025#p13"]}

5.2 检索评测:hit@k

import json
from typing import List

def load_jsonl(path: str):
    with open(path, "r", encoding="utf-8") as f:
        for line in f:
            yield json.loads(line)

def hit_at_k(retrieved_ids: List[str], expected_ids: List[str], k: int) -> int:
    topk = set(retrieved_ids[:k])
    return 1 if any(eid in topk for eid in expected_ids) else 0

def evaluate_retrieval(cases_path: str, retrieve):
    ks = [3, 5, 10]
    totals = {k: 0 for k in ks}
    hits = {k: 0 for k in ks}

    for c in load_jsonl(cases_path):
        results = retrieve(c["query"])  # -> [{"id": "...", "text": "..."}]
        retrieved_ids = [r["id"] for r in results]
        for k in ks:
            hits[k] += hit_at_k(retrieved_ids, c["expected_source_ids"], k)
            totals[k] += 1

    return {f"hit@{k}": hits[k] / max(1, totals[k]) for k in ks}

5.3 打印 top-k(排错必备)

def debug_topk(query: str, retrieve, k: int = 5):
    results = retrieve(query)[:k]
    for i, r in enumerate(results, 1):
        print(f"#{i} id={r['id']}")
        print(r["text"][:400])
        print("-" * 40)

6)默认参数起点(先从可用开始)

参数起点什么时候调
chunk_size300–800 字/Token 等价缺上下文→调大;噪音大→调小
overlap10%–20%关键句被切断→调大
top_k5–10找不到资料→调大;噪音污染→调小
rerank可选召回多但排序乱→开;成本敏感→先关

7)工程化建议(上线前一定要补)

  • 版本化:文档/向量/embedding/prompt/rerank 都要可追溯
  • 可观测:request_id、retrieved_ids、latency、token、retries
  • 兜底:资料不足就拒答并说明缺什么;检索失败可返回资料列表/转人工

资源区

如果你要做多模型 A/B 或评测,建议先把接入层统一(OpenAI 兼容协议多数情况下只改 base_url/api_key)。
我自己用的是大模型中转平台 147ai 官网(参数以其文档与控制台为准)。