跳至主要内容
RAG 评测:没有评测集就没有优化

RAG 评测:没有评测集就没有优化

RAG 系统最折磨人的地方是它有太多能调的地方:切分大小、嵌入模型、bm25 参数、融合权重、重排模型、片段数、包装格式、生成指令。改任何一个,都能挑出几个变好的例子,也都能挑出几个变差的。没有评测集的时候,调参基本是在用最近一次印象做梯度下降。

我吃过亏之后定了一条规矩:先有可复跑的评测集,才允许改参数。

本系列第 11/15 篇 · 上一篇:上下文构建:召回之后才刚开始 · 下一篇:Agentic RAG:把检索交给 Agent 调度

评测集比指标重要

一百多条精心构造的评测集,比一千条自动生成的合成问题有用得多。我的构建方式大致固定:

从真实日志按频次分层抽样。只抽高频查询会让整体指标虚高,因为高频问题往往最简单。我会分三层:高频、中频、长尾,再补一批真实但措辞很乱的查询(口语、错别字、带上下文省略)。

手写边界样本。这几类必须专门留组:错误码与编号、口语化改写、含否定、多条件组合、跨文档才能回答、有时效要求、以及需要引用出处的。每组至少十几条,单独统计。

留一组「无答案」问题。语料里根本没写过的东西,用来测拒答。没有这组,系统会一路优化成「什么都答」,指标还挺好看。

标注到 doc_id / chunk_id,而不是标答案文本。检索层评测要的是「前 K 个位置里该出现哪些片段」。答案文本留给生成层评测用。标注规范要写清楚,尤其是「相关」的定义——提到关键词算不算相关、只提供背景算不算相关。我一个人标的时候也会前后不一致,所以会抽一批复查,争议样本单独记,别硬凑共识。

评测集本身要版本化,并且不能拿它调参又拿它汇报。我的做法切成三份:开发集随便跑,验证集用于选型决策,一小份留出集只在季度复盘时看。样本量小的时候这个切分显得奢侈,但它能防住最典型的自欺:把参数调到评测集过拟合,然后宣布系统变好了。

分层指标:先定位是哪一层坏了

第 4 篇给过症状归因表,评测体系要把它量化。我把指标分成三层:

层 指标 回答的问题
召回 Recall@K、MRR 正确材料有没有进候选池
排序 nDCG@K、top-K 精度 正确材料排没排到会被读到的位置
生成 忠实度、答案相关性、引用准确率、可回答率 材料给对了,答案对不对

这个分层有个很实用的推论:生成层的错误不能只靠改 prompt 修。如果 Recall@50 只有 0.6,再怎么优化生成指令,天花板就在那儿。反过来,Recall@50 接近 1 但答案忠实度低,问题在上下文构造或生成约束(第 10 篇),回去调 bm25 参数纯属浪费。

除了质量指标,还要把延迟 P95 和单次成本当成一等指标记进同一张表。一个把 nDCG 提了两个点、但把 P95 从 800ms 拉到 2.5s 的重排模型,在线上未必是改进。每次评测输出一行完整记录,比只看分数列靠谱。

PYTHON17 行
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def run_eval(pipeline, dataset, k=50):
rows = []
for case in dataset:
t0 = time.monotonic()
out = pipeline.run(case["query"], trace=True)
candidates = [h.chunk_id for h in out.trace.candidates[:k]]
final = [c.chunk_id for c in out.trace.context]
rows.append({
"group": case["group"],
"recall@k": bool(set(candidates) & set(case["relevant"])),
"ndcg@5": ndcg(final[:5], case["graded_relevant"]),
"cited_ok": citations_valid(out.answer, out.trace.context),
"answerable": case["answerable"],
"latency_ms": (time.monotonic() - t0) * 1000,
"tokens": out.trace.tokens,
})
return aggregate(rows, by="group") # 分组聚合,别只输出总分

aggregate(by="group") 这行是我最想强调的。整体分数会把「编号类涨了 20 个点、口语类跌了 5 个点」平均成一个不起眼的小涨,然后你就带着一个退化上线了。第 6 篇换嵌入模型踩的就是这个坑。

生成层评测与 LLM-as-judge

生成质量没有简单的字符串指标能覆盖,所以常用一个较强的模型作评判。RAGAS 一类框架的核心思路不是「让 LLM 打个分」,而是把模糊判断拆成可判定的子问题:

  • 忠实度:把答案拆成若干条独立陈述,逐条问「这条陈述能否由给定材料直接推出」,统计被支持的比例。这比问「答案忠实吗」稳定得多。
  • 答案相关性:问「这个答案是否回应了问题,有没有答非所问或跑题」。
  • 上下文精度:问「送进去的材料里,有多少条对回答这个问题是真正必要的」。

拆成子问题之后,评判模型的输出变成了一组布尔或枚举,可复跑、可抽样人工复核、也能定位到具体是哪条陈述没被支持。

但 LLM 作评判有自己的偏差,得主动管:

位置与篇幅偏好:倾向给更长、更工整的答案高分。规避办法是让评判只看「陈述 vs 材料」的对应关系,不看文风。

自我偏好:用同一个模型生成又评判,会系统性地偏袒自己的输出。我尽量让评判模型与生成模型不同家族,至少不同档位。

漂移:评判 prompt 改一个词,分数整体就跳。所以要固定评判模板与版本,把「评判结果」也当成需要回归测试的东西。做法是先人工标 30–50 条 golden,定期跑一遍,看评判模型与人工的一致率;一致率掉了就别信这段时间的分数。

成本:逐陈述调用会让评测成本翻几倍。开发阶段可以全量跑,日常回归只跑一个固定子集。

我从不把 LLM 评判的绝对分数写进对外汇报,只用它的相对变化和分层归因,并且重要的结论一定回到人工抽样复核。

消融与回归防线

评测不只是打分,更是归因工具。改动生效之后,我按层做消融,一次只动一个变量:关掉重排、关掉 BM25、换回旧的切分、退回不带前缀的包装。看清「这一步到底贡献了多少」,比看总分更有价值。有些组件上线时很有效,语料变了之后贡献接近零,甚至变负——没有消融就永远发现不了,只会不断往上堆东西。

把评测接进 CI 是最后一步,也是最容易半途而废的一步。我的最小可行版本:

  1. 每次索引重建 / 模型 / prompt 变更,跑一遍固定的小回归集(几十条,几分钟内跑完);
  2. 设分层阈值:总指标允许小幅波动,但任何一组的召回率下降超过设定幅度就红灯;
  3. 「无答案」组的误答率单独设阈值,这组退化几乎总是严重问题;
  4. 把本次与上次的完整分层结果存档,趋势比单点更重要;
  5. 红灯不一定阻断发布,但必须有人显式确认并记录原因。

在线侧再补三个信号,用来发现评测集没覆盖到的问题:拒答率突然变化、引用校验失败率、以及用户「重新提问」或点踩的比例。评测集是抽样,线上分布会漂,两者都要看。

优劣与边界

收益:把「感觉变好了」变成可复跑的数字;能定位到具体是哪一层坏了,避免在错误的层上反复调参;让换模型、重建索引、改 prompt 这类高风险动作有回滚依据;团队讨论时有一个共同的事实基础。

代价:标注和维护评测集是持续的人力投入,语料一变,标注就会过期;LLM 评判自身需要校准,否则只是把不确定性挪了个地方;指标永远不完整——礼貌程度、回答风格、对模糊问题的处理,很难量化。还有一个更根本的限制:评测集来自过去的查询,对全新类型的提问没有预测力,所以必须定期从新日志补充样本。

不适用:一次性原型、语料只有几页文档、或者系统还在剧烈改结构时,先别建正式评测体系,攒二十条真实问题和期望答案就够用。我见过一上来就设计几十个指标、结果一条都没长期跑下去的项目,那不如先把第 4 篇的 trace 补齐。

评测体系建起来之后,一个更根本的问题会浮出来:整条链路是固定顺序的,可有些问题根本不是一次检索能答的。「对比 A 和 B 两个版本的性能差异,再查一下当时为什么改」需要分解、检索、判断证据够不够、再补一轮检索——这时候检索该是一个流水线阶段,还是 Agent 手里的一件工具?下一篇进入系列的融合部分。你现在改一个参数,多久能知道它在「编号类」查询上是涨还是跌?

关于本文

由 Lu's Blog 撰写,采用 CC BY-NC 4.0 许可协议。

#RAG #工程 #评测

评论