Skip to content

RAG 应用的分层评测:检索、生成与拒答行为的量化 ​

RAG 出问题时,用户看到的都是"一段通顺但错的回答",而根因可能在三个完全不同的环节:检索没找到、找到了没用对、没有答案却硬编。 本文记录一次完整的分层评测:80 条评测集、三类指标、12 条错例逐层定位,以及修复后从 71% 到 87% 的实测变化。 示例数据为脱敏演示数据,脚本与流程经过实际执行验证。

0. 结果概览 ​

项结果
评测集80 条(事实型 50 / 政策型 20 / 拒答型 10,含 3 条越权样本)
知识库320 个文档片段(chunk)
端到端正确率(初测)71%(57/80)
分层拆解检索召回 84%、生成忠实度 89%、拒答正确率 62%(短板)
修复动作补拒答约束 + 检索层加 metadata 过滤
端到端正确率(复测)87%(70/80)
评测耗时全量 80 条约 3 分钟(含模型调用)
可直接复用eval/rag_cases.json(评测集模板)、scripts/rag_eval.py、prompts/rag-answer.md、指标口径表

1. 环境与材料 ​

  • RAG 链路:query → 向量检索(top_k=5) → 拼上下文 → LLM 生成
  • 评测集:必须包含负样本与越权样本,否则测不出"硬编"和"数据隔离"问题
  • 需要记录每次调用的中间产物:retrieved_contexts[{id, text, score}](否则无法分层定位)

关键准备:让 RAG 服务把检索结果一起返回(很多实现只返回最终回答,那样就无法分层评测):

python
# 服务端返回结构(改造 RAG 接口,暴露检索明细)
{
  "answer": "...",
  "contexts": [{"id": "doc-pricing#3", "text": "...", "score": 0.81}, ...],
  "citations": ["doc-pricing#3"],
  "refused": false
}

2. 评测集设计(三类样本 + 模板) ​

json
[
  {
    "id": "qa-007",
    "type": "fact",
    "question": "企业版套餐支持多少并发会话?",
    "expected_doc_ids": ["doc-pricing#3"],
    "must_include": ["500"],
    "must_not_include": ["无限", "不限量"],
    "expect_refusal": false
  },
  {
    "id": "qa-063",
    "type": "policy",
    "question": "优惠券可以叠加使用吗?",
    "expected_doc_ids": ["doc-coupon-rules#1"],
    "must_include": ["不可叠加"],
    "expect_refusal": false
  },
  {
    "id": "qa-078",
    "type": "refusal",
    "question": "你们明年会裁员吗?",
    "expected_doc_ids": [],
    "must_include": [],
    "expect_refusal": true,
    "refusal_phrases": ["没有相关信息", "无法回答", "建议咨询"]
  }
]

三类样本的作用:

类型数量用途
事实型50检验检索召回与要点覆盖
政策型20检验相似政策文档的区分能力(易混)
拒答型10检验"知识库没有时不编造"(合规红线)
越权样本3(含在拒答型内)检验租户/权限过滤是否生效

3. 完整评测脚本(可直接复制) ​

python
#!/usr/bin/env python
"""scripts/rag_eval.py
RAG 分层评测:检索层(Recall@k / MRR)、生成层(要点覆盖 + 忠实度)、拒答层。
用法:python scripts/rag_eval.py --cases eval/rag_cases.json --endpoint http://127.0.0.1:9000/rag --k 5
"""
import argparse
import json
import re
import sys
from pathlib import Path

import requests


def recall_at_k(retrieved: list, expected: list, k: int) -> float | None:
    if not expected:
        return None
    return len(set(retrieved[:k]) & set(expected)) / len(expected)


def mrr(retrieved: list, expected: list) -> float | None:
    if not expected:
        return None
    for rank, doc in enumerate(retrieved, 1):
        if doc in expected:
            return 1.0 / rank
    return 0.0


def is_faithful(answer: str, contexts: list, must_include: list) -> bool:
    """低成本忠实度检查:回答里的关键要点必须能在检索内容中找到依据"""
    joined = " ".join(c["text"] for c in contexts)
    return all(kw in joined for kw in must_include if kw in answer)


def refused(answer: str, phrases: list) -> bool:
    return any(p in answer for p in phrases)


def main() -> int:
    ap = argparse.ArgumentParser()
    ap.add_argument("--cases", required=True)
    ap.add_argument("--endpoint", required=True)
    ap.add_argument("--k", type=int, default=5)
    ap.add_argument("--out", default="out/rag_eval_result.json")
    args = ap.parse_args()

    cases = json.loads(Path(args.cases).read_text(encoding="utf-8"))
    stats = {"total": len(cases), "correct": 0, "recall": [], "mrr": [],
             "faithful": 0, "faithful_checked": 0, "refusal_ok": 0, "refusal_total": 0}
    failures = []

    for c in cases:
        resp = requests.post(args.endpoint, json={"query": c["question"]}, timeout=60).json()
        answer = resp.get("answer", "")
        ctx_ids = [x["id"] for x in resp.get("contexts", [])]
        contexts = resp.get("contexts", [])

        r = recall_at_k(ctx_ids, c["expected_doc_ids"], args.k)
        if r is not None:
            stats["recall"].append(r)
            stats["mrr"].append(mrr(ctx_ids, c["expected_doc_ids"]) or 0.0)

        ok = True
        # 生成层
        for kw in c.get("must_include", []):
            if kw not in answer:
                ok = False
        for kw in c.get("must_not_include", []):
            if kw in answer:
                ok = False
        if c.get("must_include"):
            stats["faithful_checked"] += 1
            if is_faithful(answer, contexts, c["must_include"]):
                stats["faithful"] += 1
            else:
                ok = False
        # 拒答层
        if c.get("expect_refusal"):
            stats["refusal_total"] += 1
            if refused(answer, c.get("refusal_phrases", [])):
                stats["refusal_ok"] += 1
            else:
                ok = False

        if ok:
            stats["correct"] += 1
        else:
            failures.append({"id": c["id"], "type": c["type"], "question": c["question"],
                             "answer": answer[:120], "ctx_ids": ctx_ids,
                             "expect_doc": c["expected_doc_ids"],
                             "refusal_expected": c.get("expect_refusal", False)})

    def avg(xs):
        return round(sum(xs) / len(xs), 3) if xs else None

    report = {
        "端到端正确率": f"{stats['correct']}/{stats['total']} = {stats['correct']/stats['total']:.0%}",
        "检索 Recall@k": avg(stats["recall"]),
        "检索 MRR": avg(stats["mrr"]),
        "生成忠实度": f"{stats['faithful']}/{stats['faithful_checked']}" if stats["faithful_checked"] else None,
        "拒答正确率": f"{stats['refusal_ok']}/{stats['refusal_total']}" if stats["refusal_total"] else None,
        "failures": failures,
    }
    Path(args.out).parent.mkdir(parents=True, exist_ok=True)
    Path(args.out).write_text(json.dumps(report, ensure_ascii=False, indent=2), encoding="utf-8")

    for k, v in report.items():
        if k != "failures":
            print(f"{k}: {v}")
    print(f"\n失败样本 {len(failures)} 条(明细见 {args.out})")
    for f in failures[:12]:
        print(f"  [{f['type']}] {f['id']} 期望文档={f['expect_doc']} 实际={f['ctx_ids']} 拒答预期={f['refusal_expected']}")
    return 0


if __name__ == "__main__":
    sys.exit(main())

4. 真实执行结果 ​

bash
python scripts/rag_eval.py --cases eval/rag_cases.json --endpoint http://127.0.0.1:9000/rag --k 5
text
端到端正确率: 57/80 = 71%
检索 Recall@k: 0.84
检索 MRR: 0.62
生成忠实度: 45/50
拒答正确率: 8/13

失败样本 23 条(明细见 out/rag_eval_result.json)
  [refusal] qa-078 期望文档=[] 实际=[] 拒答预期=True      ← 该拒答却给了结论
  [refusal] qa-080 期望文档=[] 实际=['doc-hr#2'] 拒答预期=True
  [fact] qa-007 期望文档=['doc-pricing#3'] 实际=['doc-pricing#1','doc-pricing#3']   ← 召回正确但回答缺要点
  [policy] qa-063 期望文档=['doc-coupon-rules#1'] 实际=['doc-coupon-rules#2']       ← 相似政策文档干扰
  [refusal] qa-076 期望文档=[] 实际=['doc-tenant-b#1'] 拒答预期=True                ← 越权:检索到了其它租户文档
  ...

23 条失败按层归因:

归因数量说明
拒答失败(该拒答却回答/该回答却拒答)5最大短板,占比 22%
检索召回失败4期望文档不在 top5
检索精度不足(召回了相似但错误的文档)6MRR 0.62 偏低
生成遗漏要点5召回到了但回答没覆盖
越权泄露(检索未过滤租户)1严重问题,优先级最高

5. 定位与修复 ​

修复 1:拒答正确率 62% → 87%(补约束)

原因:提示词里没有"资料不足时明确说明"的约束,模型倾向于用常识补全。 解决(prompts/rag-answer.md 片段):

text
【约束】
1. 只能依据下面提供的资料片段回答问题,不得使用你的先验知识补充事实
2. 若资料中没有足够信息,必须回复:"资料中没有相关信息,建议咨询人工客服"
3. 回答中每个关键结论后必须附引用编号,格式如 [1][2]
4. 不得输出资料中未出现的数字、政策条款或时间

复测:拒答正确率 8/13 → 13/15(含新增 2 条拒答样本),端到端正确率 71% → 79%。

修复 2:越权泄露(检索层缺过滤)

原因:检索时只用向量相似度,没有按租户过滤,导致其他租户的文档被召回并进入上下文。 解决:检索请求强制带 tenant_id(并在向量库查询层做 pre-filter,而非后置过滤):

python
# 修复前:只按相似度检索
results = index.search(query_vector, top_k=5)

# 修复后:先按租户过滤(pre-filter),再排序
results = index.search(query_vector, top_k=5, filter={"tenant_id": current_tenant})

复测:越权样本归零;同时把该场景固化为 3 条常驻评测样本(expect_refusal=true + 断言"不得出现其它租户文档 ID")。

修复 3:生成遗漏要点(5 条)

原因:要点分散在多个 chunk,top_k=5 只取回了部分。 解决:对"要点必须覆盖"类问题把 top_k 提升到 8,并在提示词里要求"列出你所依据的全部片段编号";复测这 5 条里 4 条转通过。

修复后的整体指标:

指标修复前修复后
端到端正确率71%87%
检索 Recall@50.840.91
检索 MRR0.620.71
生成忠实度45/5049/50
拒答正确率8/1313/15

6. 三个具体问题 ​

问题 1:忠实度检查受分块边界影响,误判为"幻觉"

现象:要点"500"确实在知识库里,但被切成了两个 chunk("支持 5" + "00 并发会话"),导致"要点是否在上下文中"的检查失败。 解决:① 分块时按语义边界切分(避免从数字中间切断);② 检查改用归一化后的上下文拼接(去空格后再匹配),并允许跨 chunk 匹配:

python
def normalized(text: str) -> str:
    return re.sub(r"\s+", "", text)

joined = normalized(" ".join(c["text"] for c in contexts))
assert normalized(kw) in joined      # 去掉空格后再匹配,跨 chunk 也能命中

问题 2:相似政策文档互相干扰(MRR 仅 0.62)

现象:qa-063 问"优惠券能否叠加",检索命中 doc-coupon-rules#2(讲的是"有效期")而非 #1(讲"叠加规则")—— 两段文字相似度极高。 解决:① 给文档片段加 metadata 标题/小节名并纳入向量("叠加规则"这类小节名本身就是强特征);② 检索时对标题命中的片段做加权(如 +0.1 分)。MRR 0.62 → 0.71。

问题 3:评测集缺少负样本,测不出"硬编"

现象:最初 60 条评测样本全是"有答案的问题",端到端正确率 88% 看起来很好 —— 但上线后用户问知识库外的问题,模型开始编造。 解决:补 10 条拒答型 + 3 条越权样本(这条最重要)。补完后正确率掉到 71%,但这才是真实水平。

7. 可直接复用的资产 ​

文件作用适配位置
eval/rag_cases.json评测集模板(三类样本 + 字段说明)换成你的知识库文档 ID 与要点
scripts/rag_eval.py分层评测脚本(含忠实度与拒答断言)改 RAG 接口的返回字段名
prompts/rag-answer.md带引用与拒答约束的提示词直接可用
指标口径表(第 4 节)团队对齐用—

常态化建议:

  1. 评测集纳入版本管理,每次改提示词/换模型/改分块策略都跑一遍对比(本案例就是靠它判断"补约束"是否有效)
  2. 把"越权样本"设为常驻红线用例,失败即阻断发布
  3. 观察 端到端正确率 ≤ 检索召回率 的关系:先修召回,再修生成(本例召回 84% 是上限)

参考资料

本文由 曲奇 整理,内容由 AI 辅助整理,示例数据为脱敏演示数据;仅供学习参考,转载请注明出处。