RAG评估实践:先测检索,再怪LLM

用Recall@k与MRR单独评估RAG检索环节,先定位失败阶段再调优。
文章主张把RAG评估拆成检索与生成两个独立阶段,避免把答案错误笼统归因于模型。检索评估只需Recall@k和MRR两个指标:前者发现证据缺失,后者区分证据排第一还是被埋没。测试用例需来自真实工作流并经人工审核,chunk与document的粒度必须对齐,不可回答的问题应单独汇报而不计入均值。文中给出仅用Python标准库的确定性示例,演示如何为去重后的结果打分并汇总mean_recall与MRR。失败排查按语料库、标注、chunk切分、元数据过滤、embedding匹配、reranking六个方向逐一进行,每次只改一个变量并保留held-out集合。检索通过后再进入独立的答案评估,最终需记录版本信息以保证可复现。
RAG系统给出错误答案时,很多人第一反应是「模型不行」。但问题往往出在更早的环节:检索器可能根本没找到正确的文档,或者把关键段落排到了很后面。把检索质量和答案质量混在一起评估,只会让你在错误的模块上反复调参。

这篇文章介绍一个仅针对检索环节的评估工具,用最小的代码量回答一个问题:系统有没有检索到预期的证据?
检索评估能告诉你什么,不能告诉你什么
核心指标只有两个:
- Recall@k:前k个结果中,包含已知相关文档的比例。它帮你发现「证据缺失」的问题。
- Reciprocal Rank@k:第一个相关结果的排名倒数。如果排在第一位,得分为1;排在第三位,得分为1/3;完全没有则得0。它帮你区分「证据排第一」和「证据被埋在下面」。
这两个指标都不衡量最终答案是否正确、安全或有据可依。它们只回答检索层面的问题。
粒度对齐是一个容易踩的坑:如果你的检索器返回的是chunk,而标注是针对document的,那在打分前必须把chunk ID映射回document ID,否则指标会失真。
Recall@k 与 Precision@k 的取舍常被混淆。Recall@k 关注「该找的有没有找到」,Precision@k 关注「找到的里有多少是相关的」。检索评估优先看 Recall,因为漏掉证据会让下游生成无从补救,而带回少量噪声文档通常还能被上下文窗口或 reranker 过滤掉。
MRR(Mean Reciprocal Rank) 是 Reciprocal Rank 在所有可回答用例上的平均值,全称 Mean Reciprocal Rank。单个用例的 RR 只取第一个相关结果的位置,因此它天然只奖励「最早命中的那一个」,不关心其余相关文档是否也被召回。当一个问题需要多段证据拼合时,MRR 可能高估检索质量,此时应同时看 Recall@k 或引入 nDCG 等能体现实序整体质量的指标。
粒度(granularity) 指检索返回单元与标注单元的对齐层级——document、chunk、段落或句子。跨层级比较必须先做映射,否则一个 chunk 命中会被算作整个 document 命中,虚高指标。
构建经过审核的测试用例
每个可回答的问题需要一组预期的文档ID。用例要来自真实工作流中的典型问题,并且必须有人工审核环节。
对于不可回答的问题,空的相关文档集合不等于「检索到的任何文档都不相关」。它的含义是:在已批准的语料库中不存在已知答案。这类用例应单独汇报,不纳入recall和排序平均值的计算。
一个可运行的确定性示例
下面的代码只用Python标准库。把fixture检索函数替换成你自己的检索API即可用于实际测试。
from dataclasses import dataclass
from statistics import mean
from typing import Callable, Sequence
@dataclass(frozen=True)
class Case:
case_id: str
question: str
relevant_ids: frozenset[str]
def score(case: Case, retrieved: Sequence[str], k: int) -> dict:
if k < 1:
raise ValueError("k must be positive")
top = list(dict.fromkeys(retrieved))[:k]
relevant = case.relevant_ids
if not relevant:
return {"case_id": case.case_id, "answerable": False,
"retrieved_ids": top, "recall": None, "rr": None}
hits = relevant.intersection(top)
first_hit = next((rank for rank, doc_id in enumerate(top, 1)
if doc_id in relevant), None)
return {"case_id": case.case_id, "answerable": True,
"retrieved_ids": top,
"recall": len(hits) / len(relevant),
"rr": 1 / first_hit if first_hit else 0.0}
evaluate函数遍历所有用例,调用score,然后汇总可回答用例的mean_recall、MRR,以及可回答和不可回答用例的数量。
示例数据设计了三组用例:refund问题期望命中refund-policy,cancel问题期望命中cancel-guide,一个不可回答的问题期望空集。fixture检索结果故意在refund场景把正确文档排在第二位,在cancel场景完全遗漏了正确文档。
运行结果:mean_recall为0.5,MRR为0.25,不可回答用例数为1。这些平均值只描述这两个可回答的fixture用例,不是对生产性能的估计。
关键细节:dict.fromkeys在保留排名的同时去重,因为重复文档不应占用多个排名位置。
修改系统前先检查失败原因
每个失败的用例,按顺序排查:
- 预期文档是否在被索引的语料库中?
- 语料库版本更新后,标注是否仍然正确?
- 相关文本是否被切分到了不同的chunk里?
- 元数据过滤器是否把它排除了?
- 查询是否使用了embedding模型无法匹配的术语?
- reranking是否把证据推出了上下文预算?
每次只改一个组件,用同一份语料快照跑同一组审核过的用例。保留一个held-out集合——反复在评估集上调参,可以让分数好看但不代表真实工作流有改善。
排查清单里的第 5、6 项涉及两个常被低估的组件。Embedding 模型把查询和文档映射到同一向量空间,用余弦相似度等度量匹配;当查询使用领域缩写、内部代号或与训练语料分布差异较大的措辞时,向量距离可能无法反映语义相关,导致正确文档排到很后面甚至不出现在候选集中。
Reranking 是在初步召回之后,用更重但更精确的模型(常见的是 cross-encoder,将查询与文档拼接后联合打分)对候选重新排序。它能显著提升排序质量,但要消耗额外的上下文预算——如果最终送给生成模型的 top-n 太小,reranker 把正确证据推到第 n 位之后,表面看是排序问题,实际是预算配置问题。第 6 项要检查的正是这个边界。
答案评估是独立的第二阶段
检索通过之后,用明确的评分标准测试生成的答案:
- 事实性陈述是否有供应的段落支撑?
- 引用是否指向支持对应陈述的证据?
- 是否回答了用户的真实问题?
- 证据不足时是否请求澄清或拒答?
- 是否遵守访问控制、避免暴露受限数据?
自动化检查和模型评判可以帮助初筛,但它们的判断仍需人工审核验证。检索测试通过不代表答案正确或授权安全。
保持证据可复现
记录用例ID、语料库/索引版本、检索设置、模型版本、prompt版本和检索到的ID。存储段落和输出时注意权限,分享追踪记录前要脱敏。
生产环境对比需要更有代表性的用例、对随机性运行重复测试、报告分母和抽样局限性,并在质量之外追踪延迟和成本。
实践结论:先定位失败发生在哪个阶段。Prompt重写无法找回系统从未检索到的证据。
相关推荐

WorkBuddy全流程教程:从零搭建AI办公自动化智能体
WorkBuddy 全流程教程精讲:从下载安装、三种工作模式到多模态绘本视频实战,再到专家团搭建智能体与工作流配置,帮你用低成本 AI Agent 实现办公自动化。

WorkBuddy智能体全攻略:7天从入门到自动化实战
基于B站256集WorkBuddy全套精讲课程,系统拆解腾讯AI办公工作台的核心能力:Agent原理、Skill封装、连接器跨平台、定时自动化、知识库与零代码开发,附学习路径建议。

8个AI账号翻车7个:我用AI干活的真实复盘
B站8个AI运营账号仅1个跑通。本文拆解"演AI"与"用AI干活"的本质区别,分析5条视频217万播放背后的产能控制、评论区运营和交付节奏策略。