主题
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 5text
端到端正确率: 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 |
| 检索精度不足(召回了相似但错误的文档) | 6 | MRR 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@5 | 0.84 | 0.91 |
| 检索 MRR | 0.62 | 0.71 |
| 生成忠实度 | 45/50 | 49/50 |
| 拒答正确率 | 8/13 | 13/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 节) | 团队对齐用 | — |
常态化建议:
- 评测集纳入版本管理,每次改提示词/换模型/改分块策略都跑一遍对比(本案例就是靠它判断"补约束"是否有效)
- 把"越权样本"设为常驻红线用例,失败即阻断发布
- 观察
端到端正确率 ≤ 检索召回率的关系:先修召回,再修生成(本例召回 84% 是上限)
参考资料
- RAG 评测指标口径(faithfulness / answer relevancy / context precision):RAGAS 项目文档
- RAG 投毒、敏感信息泄露与向量检索弱点的风险说明:OWASP Top 10 for LLM Applications
- 分层评估思路参考:SpecOps(步级离散化评估)
本文由 曲奇 整理,内容由 AI 辅助整理,示例数据为脱敏演示数据;仅供学习参考,转载请注明出处。