做 RAG 不评测,等于闭着眼睛调参:一份从原理到落地的完整测评指南
我见过太多做 RAG(检索增强生成)的同学,上线前说得头头是道,一问到“怎么证明你的系统比上个版本好”,立刻支支吾吾。RAG 项目最坑的一点在于:任何一个环节出问题——文档切得不合理、向量检索召回了一堆噪音、大模型没忠实于上下文——最终表现都是“回答不好”。可到底哪里不好、差多少、换参数有没有变好,没有评测手段,你只能靠感觉调 embedding、调 chunk、调 prompt,本质上是闭着眼开车。
这篇文章我打算把 RAG 评测这件事彻底拆开。要先讲清楚评测到底在评什么、为什么这些指标能帮你定位问题,再给出一份可以直接落地的评测数据集构建方法、工具选型思路和完整代码流程,最后把我自己踩过的几个坑和排查经验一并交代。无论是刚入门想把 RAG 做好、还是已经在线上被“效果不稳定”折磨的团队,这篇都值得看完照着抄。
1. 为什么我说 RAG 不评测等于闭眼调参
1.1 三个最常见的尴尬场景
前阵子有个做知识库问答的团队找我,说他们的内部助手最近换了新模型,结果“感觉不如以前好用”。我问具体哪里不好,说法是“有的回答变长了,有的回答开始编了”。
这暴露了三个非常典型的问题:第一,他们没有一个固定的评测集,所有判断都来自开发团队轮流提问,感觉自然飘忽不定;第二,他们分不清变化是检索变差还是生成变差;第三,他们没有任何指标能量化“回答变长了”是好事还是坏事——有时候多给几句背景是好的,有时候就是在注水。
这类场景在 RAG 项目里太常见了。很多人埋头调参数调了一星期,最后发现评测集根本没有,等于在一条没有刻度线的赛道上跑步,跑得快不快全靠裁判喊。更致命的是,RAG 系统本身是“检索模块 + 生成模块”串起来的,两个模块的改动会互相干扰。你换了 embedding 模型,召回结果变了,大模型拿到的上下文变了,最终答案自然变;但你无法判断到底是召回变准带来了提升,还是召回变杂引发了幻觉。没有评测,一切优化都是玄学。
1.2 评测的本质是把“好”变成可测量的量
我理解评测这件事,本质上干三件事:把“回答好不好”拆解成几个可以独立观察的性质,给每个性质定一个打分标准,再用一组固定的输入去反复检验。只有做到这三件事,你才能回答“这次改动到底有没有用”这个工程问题。
RAG 系统的特殊性在于它是两段式结构。检索阶段解决“有没有找对资料”,生成阶段解决“有没有用好资料”。所以评测绝不能只盯着最终答案看,而要把链条拆开来看。这也是为什么业界普遍把指标分成“检索质量”和“生成质量”两组。一组负责量化“资料找得准不准”,一组负责量化“答案写得对不对”,最后还要看两组指标联动下的端到端效果。
说得更直白一点:评测不是做完系统之后补个报告,而是开发过程中用来做决策的依据。哪个模块出问题就往哪个方向修。下面这节我把 RAG 指标体系的原理讲透。
2. 原理拆解:RAG 系统到底该测哪些指标
2.1 检索质量:地基不牢,上面全白搭
检索质量是 RAG 系统的地基。就算大模型再聪明,你喂给它的上下文全是无关文档,它也变不出准确回答。所以第一件事是把“检索好不好”量化。
最常用的两个指标是上下文相关性和上下文召回率。
上下文相关性(Context Relevance)衡量的是:检索回来的这些 chunk,跟用户的原始问题到底有多相关。它检查的不是“答案对不对”,而是“资料相不相干”。试想用户问“今年的营收是多少”,检索系统却召回了一大堆“公司组织架构介绍”的内容,虽然这些内容也来自同一份文档,但对回答当前问题毫无帮助。上下文相关性就会给低分。我通常会让一个评测模型去看“问题 + 检索回来的文本”,然后给一个 1 到 5 的分数,低于 3 分基本可以认定召回环节有噪音。
上下文召回率(Context Recall)更进一步,它衡量的是:在所有“本应该被召回的证据片段”里,你的检索系统实际召回了多少。打个比方,标准答案引用了文档第 3 章和第 5 章的内容,但你的向量检索只把第 3 章捞回来了,第 5 章没捞回来,那召回率就是 50%。这个指标非常依赖标注,因为你需要预先知道每个问题对应的“黄金证据片段”是什么。没有这个标注,你只能在相关性上打转,但永远不知道检索到底漏了什么。这块工作我在 4.1 节会详细讲怎么做。
2.2 生成质量:忠实性和答案相关性缺一不可
检索做得再漂亮,最终用户看到的还是大模型生成的文字。生成质量的评测里,我最看重的两个指标是忠实性和答案相关性。
忠实性(Faithfulness)解决的是“有没有编”的问题。它的逻辑是:把生成的答案拆成若干个独立句子,逐一跟检索回来的上下文比对,判断这句话里的所有事实是否都能从上下文中找到依据。能,则这句话忠实;不能,哪怕只编了一个数字、一个日期,整个句子就算不忠实。然后算“忠实句子数 / 总句子数”作为分数。
为什么这个指标要做得这么细?因为在 RAG 场景下,幻觉往往不是整段凭空捏造,而是“九句真话里夹一句编的”。用户答完问卷说“感觉有点不对”,你根本不知道哪句不对。忠实性指标像探针一样,把每一句话拿到证据面前核验,幻觉率立刻现形。我甚至会单独统计“不忠实句子出现的频率”作为幻觉率报告,比只给一个综合分直观得多。
答案相关性(Answer Relevance)解决的是“有没有答非所问”的问题。它不关心答案是否全对,只关心答案是否针对用户的问题。这里有个经典坑:一个看起来特别长、特别完整的回答,可能只是在自说自话,压根没回应提问里的关键限定词。比如用户问“哪些产品支持离线模式”,模型洋洋洒洒介绍了一遍全部产品线——信息是真实的,但没回答到点子上。这个指标就是在抓这种问题。
2.3 为什么必须把端到端指标也纳入进来
把检索指标和生成指标分开测,是为了定位问题。但在实际版本迭代中,你也需要一个“最终效果分”来做整体对比——这就是端到端评分。端到端评分通常是用评测模型直接给“问题 + 标准答案 + 系统回答”做一个整体质量打分,涵盖正确性、完整性、条理性等多个维度。注意,端到端评分不能替代前面那些细粒度指标,否则你又回到了“知道不好但不知道哪里不好”的状态。正确用法是:先用细粒度指标定位环节,再用端到端分数做回归对比。
我在实际项目里会把指标分成三个层次:第一层是底层模块指标,包括上下文相关性、上下文召回率;第二层是生成侧指标,包括忠实性、答案相关性;第三层是端到端综合分。每一层都有独立用途。底层的坏了修底层,中层的坏了调 prompt,整体分掉了再用分层指标追因。这个体系在我带过的多个 RAG 项目里都验证过,是排查问题速度最快的一套结构。
3. 评测集和评测工具:从哪来、怎么选
3.1 评测集不能只靠开源数据,必须自建最小集
评测指标聊聊容易,真正难的是评测集。我看到很多新手直接拉一个开源问答数据集跑分,结果分数很好看,一上自己的业务数据就露馅。原因很简单:RAG 是强业务绑定的系统,通用数据集测不出你的知识库切片风格、你的文档结构和你的 query 分布。
稳妥的做法是“开源数据集暖场 + 自建评测集把关”。暖场阶段用公开的百科类问答对验证代码链路通不通、指标有没有算反,但上线和调参的决策,最终必须依赖自建评测集。
自建评测集起步不要贪多,50 条先跑起来,后续再扩到 200 到 500 条。关键是结构要完整。每条数据至少包含三要素:用户问题、标准答案、以及标准答案所依赖的证据片段(可以是一个或多个 chunk 的 ID 或者原文引用)。有了“证据片段”才能算回声召回率,没有这层标注,你只能测相关性测不了召回。关于这一点,我用了一个很笨但很稳的办法:先在文档库里检索出每个问题的 Top-20 候选 chunk,然后人工从中挑出真正构成标准答案依据的若干条,存成“黄金证据”。人工挑确实累,但这一步的质量决定了后面所有评测分数的可信度。
3.2 评测框架怎么选:先看清楚是“调一次”还是“盯长期”
RAG 评测框架的选择,我总结下来就三种路线。
第一种是纯手工的在线评测:把几组问题丢给系统,人工看答案打分。零成本、最灵活,但不可重复、费人力,适合项目刚开始摸索方向时用。
第二种是调用大模型 API 做自动化评测:自己写一个评测脚本,把各项指标提示词封装好,批量跑。我推荐初期用这一条,它兼顾落地速度和可控性。所谓“用大模型评大模型”,是用一个评分模型(通常选和主系统不同的模型,避免自评偏差)去扮演评判员。你需要非常重视评分模型的 prompt 稳定性和输出格式,让打分尽量可复现。
第三种是基于专业评测工具的半自动方式:业界已经有不少开源评估套件实现了上下文相关性和忠实性等指标的自动化计算,你只需要准备数据集、配置评测模型、跑出来的报告会自动分维度汇总。我建议在团队需要长期做回归、每次迭代都要出一份几十项指标报告的场景下直接上这类工具。
三种路线不是互斥的,实际项目里我通常先用第二种搭起基线,再逐渐往第三种迁移,纯人工只用来抽查。
3.3 评测成本要提前算明白
自动化评测不是免费的。每次打分调用大模型,都要消耗 token。我来算一笔账:假设你有 300 条评测数据,每条数据要评上下文相关性、忠实性、答案相关性三个指标,每个指标至少调用一次模型。那就是 900 次调用;端到端综合分再来 300 次,共 1200 次调用。如果每条数据的输入输出加起来有 2000 token,一个月跑 5 轮评测,那就是一千多万 token。这个量级放到按量计费的大模型服务上成本不低,放自部署开源模型上则要占不少算力。
所以我的建议是:评测模型和生成模型分开,评测端优先选便宜、速度快、指令遵循稳定的模型;同时把评测集按重要性分成“核心集”和“扩展集”,全量回归只跑核心集(比如 80 条),改动面大时再扩到全量。成本控制要在设计评测流程时就规划好,上线后再压缩往往很被动。
4. 落地实操:一份可以直接照抄的评测流程
4.1 第一步:搭一个最小可用的评测集
我以一个内部文档问答场景为例,说明可行的操作方式。先收集真实用户 query,没有真实数据的情况下也可以让业务同事按他们平时会问的方式写 50 条。每一条 query 后面人工写好标准答案;标准答案最好写成通顺的段落,不要只写关键词,因为后面评测答案相关性时需要一个完整答案作参照。
然后做“证据标注”。用你当前的检索系统把每一条 query 的 Top-20 结果导出,人工从这些结果里圈定哪几条是标准答案真正依赖的内容,存为证据片段列表。做好以后,数据集长这样:
{ "question": "我们公司的年假政策中,司龄满三年后年假天数如何变化?", "reference_answer": "司龄满三年后,年假天数从 10 天提升至 15 天。", "reference_contexts": [ "doc_201_paragraph_4", "doc_201_paragraph_5" ] }如果你觉得 50 条全人工太累,可以把前 20 条做成种子集,后续的 30 条通过“模型生成候选答案 + 人工修正”的半自动方式快速补齐,但证据标注必须人工核对。这个环节偷懒,后面所有召回率的数字都会失真。
4.2 第二步:把评测流程写成脚本
接下来是核心工程步骤。我把自己常用的评测框架流程贴出来,代码做了简化但逻辑是完整的,核心包含四段:加载评测集、跑检索、跑生成、跑指标打分。
import json import random def load_benchmark(path): with open(path, "r", encoding="utf-8") as f: return json.load(f) def retrieve(question, top_k=10): # 这是你自己的检索逻辑,返回 chunk 文本列表 # 示例:先用向量召回,再走一层重排 chunks = vector_search(question, top_k=top_k) return [c["text"] for c in chunks] def generate_answer(question, contexts): # 这是你自己的生成逻辑,把检索到的上下文拼进 prompt context_block = "\n\n".join(contexts) prompt = f"请基于以下资料回答问题:\n\n{context_block}\n\n问题:{question}" return call_llm(prompt) def score_context_relevance(question, contexts): batch_text = "\n\n".join(contexts) prompt = f"""你是一个评测员。请判断下面检索回来的资料与用户问题的相关程度。 用户问题:{question} 检索资料: {batch_text} 请打分:1 到 5 分,只输出数字。""" return int(call_llm(prompt)) def score_faithfulness(question, answer, contexts): context_block = "\n\n".join(contexts) prompt = f"""你是一个评测员。请把下面的回答拆成句子,逐句判断每个句子是否被资料支持。 回答:{answer} 资料: {context_block} 请按以下格式输出: 支持句数:x 总句数:y 不支持的句子:...""" result = call_llm(prompt) # 从结果里解析 x 和 y return parse_faithfulness(result) def score_answer_relevance(question, answer, reference_answer): prompt = f"""你是一个评测员。请判断回答是否回应了用户的问题,可参考标准答案。 用户问题:{question} 标准答案:{reference_answer} 系统回答:{answer} 请打分:1 到 5 分,只输出数字。""" return int(call_llm(prompt))评测主流程如下:
def run_evaluation(benchmark_path): data = load_benchmark(benchmark_path) results = [] for item in data: question = item["question"] contexts = retrieve(question, top_k=10) answer = generate_answer(question, contexts) metrics = { "context_relevance": score_context_relevance(question, contexts), "faithfulness": score_faithfulness(question, answer, contexts), "answer_relevance": score_answer_relevance( question, answer, item["reference_answer"] ), } metrics["answer"] = answer metrics["question"] = question results.append(metrics) print("平均上下文相关性:", round(avg([m["context_relevance"] for m in results]), 2)) print("平均忠实性:", round(avg([m["faithfulness"] for m in results]), 2)) print("平均答案相关性:", round(avg([m["answer_relevance"] for m in results]), 2)) def avg(nums): return sum(nums) / len(nums)这段脚本跑完后,你就有了三组核心指标的平均分和每条样本的分值明细。注意,score_context_relevance 和 score_faithfulness 的 prompt 很长,输出解析要做得稳。我自己早期吃过亏:模型偶尔在数字前后多加解释文字,导致 int() 直接报错。后