常青翻新|给 RAG 检索层建一份『金标准问答集』:换 embedding、改分块,也别让召回悄悄塌下去
团队做了一次看起来无害的优化:把 embedding 模型换成新版本,顺手把分块从 512 改到 1024,理由是「大段落语义更完整」。上线后抽查了几条问答,回答依旧流畅、语气依旧专业,没人觉得有问题。可两周后客服反馈,一批老问题的答案开始答非所问——问「退货运费谁承担」,答的却是「退货流程怎么走」。
去查生成层,Prompt 没动、模型没换、温度没调。真正变了的,是检索层召回的段落:分块一改,原来能精准命中的那段政策被切进了另一个 chunk,新 embedding 又把它排到了 top-k 之外。回答还是那么流畅,是因为生成模型很擅长「就着召回到的错料,编一段通顺的话」。塌的不是生成层,是检索层——而检索层根本没有一份基线,塌了也没人知道。
这就是 RAG 系统最容易被忽略的回归点。这篇常青翻新,就讲一个具体的工程动作:给检索层建一份「金标准问答集」,让换 embedding、改分块这类改动,变成一条会拦你的回归。
一、先给结论:RAG 最易漏的回归点在检索层,不在生成层
RAG 系统分两段:检索(retrieval)负责从知识库里召回相关段落,生成(generation)负责就着这些段落组织回答。大家测 RAG,眼睛几乎都盯着生成层——回答通不通顺、有没有幻觉、语气对不对。可这两段里,真正会因为「换 embedding / 改分块」而漂移的,是检索层。
原因很简单:换 embedding 模型,等于换了一把「衡量语义相近」的尺子,同样的问题和文档,相似度排序会变;改分块大小,等于换了知识的切法,原来落在一个 chunk 里的关键句,可能被切到两个 chunk、稀释了向量表达。这两个改动都不碰生成层,却直接决定了「喂给生成模型的到底是哪几段料」。
而生成层有个坏特性会掩盖检索层的塌方:它太擅长把错料说圆了。 召回的段落偏了,它照样能生成一段语法通顺、看起来专业的回答,人肉抽查根本看不出问题。等你从用户投诉里发现「答非所问」,往往已经过了一两周、影响了一片问题。所以守住 RAG 的回归,第一现场必须是检索层——在召回结果流进生成模型之前,就把它拦下来比对。
二、金标准问答集是什么:检索层的「预期返回基线」
要能回归检索层,前提是有一份基线。这份基线就是「金标准问答集」(golden set):一批问题,每个问题都人工标注好「它本该召回哪些文档/片段」。
这东西你其实早就用过,只是对象不同。它就是接口测试里的「预期返回基线」:测一个订单接口,你会存一份「正确返回长这样」的预期,之后每次改动都拿真实返回跟它比。金标准问答集做的是同一件事,只不过「预期返回」从一个 JSON 结构,变成了「召回的文档 id 集合」——问题 Q001 本该召回 policy_07#3 和 faq_12#1,这就是它的基线。
有了这份基线,换 embedding、改分块的性质就变了:它们相当于换了检索的实现,而基线(哪个问题该召回哪些文档)不动。实现一换,把新版本召回的文档 id 集合跟金标准一比,召回有没有塌、塌在哪几个问题上,一目了然。这跟接口重构后拿旧基线回归是同一个套路——实现可以随便换,基线是那块不动的参照物。
金标准集怎么落盘?用最朴素的 jsonl,一行一个 case,版本化进 Git:
{"qid": "Q001", "query": "退货运费谁承担", "expected_ids": ["policy_07#3", "faq_12#1"], "min_recall_at_5": 1.0}
{"qid": "Q002", "query": "发票多久能开出来", "expected_ids": ["faq_03#2"], "min_recall_at_5": 1.0}
{"qid": "Q003", "query": "会员积分怎么兑换", "expected_ids": ["member_05#1", "member_05#4"], "min_recall_at_5": 0.5}
为什么这么存、踩过什么坑。 第一,expected_ids 存的是文档片段的稳定 id(文件名#chunk序号),不是段落原文——因为改分块后原文会变,但「这个问题该命中哪块知识」这个基线不该跟着分块方式变,用 id 才能锚住。第二,每个 case 带一个 min_recall_at_5 阈值而不是要求全命中,是因为有些问题本来就该召回多个相关片段、命中一半就算守住底线(像 Q003),有些则是单一答案、必须全中(像 Q001)——阈值按问题性质分别设,一刀切要么太松要么太紧。第三,最大的坑是拿生成出来的回答文本当基线:那样每次换模型答案措辞都会变,基线天天假红,团队很快就把它注掉了。基线只锚「召回了哪些文档」,不锚「回答怎么说」。
三、把金标准集跑成一条会红的回归:pytest 参数化 + recall@k
有了 jsonl 基线,接下来把它接进 pytest,让每次换 embedding / 改分块都自动跑一遍、召回低于基线就红。
# test_retrieval_regression.py —— 换 embedding / 改分块时,守住检索层的召回基线
import json
import pytest
def load_golden(path="datasets/golden_set.jsonl"):
with open(path, encoding="utf-8") as f:
return [json.loads(line) for line in f if line.strip()]
def recall_at_k(retrieved_ids, expected_ids, k):
"""召回率:期望命中的文档里,有多少出现在 top-k 检索结果中"""
topk = set(retrieved_ids[:k])
hit = topk & set(expected_ids)
return len(hit) / len(expected_ids)
@pytest.fixture
def retriever():
# 换成你真实的检索器:内部用哪个 embedding、哪种分块,都由它决定
from rag import build_retriever
return build_retriever()
@pytest.mark.parametrize("case", load_golden(), ids=lambda c: c["qid"])
def test_recall_not_below_baseline(case, retriever):
got = retriever.search(case["query"], k=5) # 返回 top-5 文档片段 id 列表
r = recall_at_k(got, case["expected_ids"], k=5)
assert r >= case["min_recall_at_5"], (
f"{case['qid']}『{case['query']}』召回塌了:"
f"recall@5={r:.2f} < 基线 {case['min_recall_at_5']},实际召回={got}"
)
为什么这么写、踩过什么坑。 第一,用 @pytest.mark.parametrize 把 jsonl 里每个 case 铺成一条独立用例,好处是红灯直接指名到 qid——你不用翻日志猜是哪类问题塌了,报错里就写着「Q001 召回塌了,实际召回=[…]」,一步定位到是检索层、哪个问题、召回成了什么。第二,断言的是 recall@k >= 阈值 而不是「召回结果等于期望集合」,因为检索允许召回额外的相关片段(top-5 里多出无关项不一定要红),我们只守「该命中的有没有命中」这条底线,这跟接口回归里「关键字段必须在、多余字段放过」是同一个分寸。第三,retriever 做成 fixture 是关键——换 embedding、改分块,改的都是 build_retriever() 内部的实现,测试代码一个字不用动,重跑一遍就知道新实现有没有把召回搞塌。这正是「基线不动、实现随便换」的落地形态。第四,把这套挂进 CI:以后任何人提交「换 embedding 版本」或「调分块参数」的改动,这条回归自动跑,召回一塌就拦住合并,而不是等上线两周后从客服投诉里才发现。
四、只看答案像样 vs 检索层金标准回归:四个维度看清差别
把两种做法放到四个维度上对照,为什么「别再只盯生成层」就很清楚了:
| 维度 | 只看生成答案像不像样 | 给检索层建金标准集回归 |
|---|---|---|
| 能否发现召回塌方 | 难,答案流畅就以为没事,塌方被生成层掩盖 | 能,召回一偏离基线立刻红 |
| 可归因到检索还是生成 | 不能,只知道「答得不对」,查不出哪一段的锅 | 能,红灯直接落在具体 qid 的召回结果上 |
| 换 embedding/改分块时的守护 | 无,靠上线后人肉抽查、事后补救 | 有,实现一换自动比对基线,塌了就拦 |
| 维护成本 | 看似低,实则每次改动都要重新人肉抽查 | 前期标注金标准有成本,之后一条命令自动回归 |
这张表最该记住的一行是「可归因」。没有金标准集时,用户投诉「答非所问」,你要在一整条 RAG 链路里猜:是分块切坏了?embedding 排错了?还是 Prompt 没写好?有了金标准集,一条 pytest 就能告诉你——召回基线过了,问题在生成层;召回基线红了,问题在检索层,还指名到具体是哪几个问题。归因从「靠猜」变成「一条命令」,这才是回归基线真正的价值。
五点五、金标准集怎么攒、怎么维护,才不变成一次性工程
很多人认同金标准集的价值,却卡在「标注太累、攒不起来」。务实的做法是别追求一步到位。第一批 case 从哪来?从线上真实用户问得最多的高频问题里挑二三十条,人工标一遍「本该召回哪几段」——这批覆盖了绝大多数流量,性价比最高。之后每次线上出一个「答非所问」的投诉,就把那个问题补进金标准集,标上正确召回——让每一次真实的召回事故,都沉淀成一条永久的回归基线,这跟你把每个线上 bug 补进接口回归集是同一个习惯。
维护上盯两件事:其一,知识库内容大改(文档增删、政策更新)后,要回头校准受影响 case 的 expected_ids,否则基线会指向已经不存在的旧片段,造成假红;其二,金标准集本身要版本化进 Git,跟知识库、跟分块配置的版本对应上,这样「哪一版检索配哪一版基线」永远查得到。做到这两点,金标准集就不是一次性标注的负担,而是一份随系统一起长大、越用越值钱的资产。
写在最后
RAG 系统里,回答流畅从来不等于检索正确。生成模型太擅长把错料说圆,于是换 embedding、改分块这类「只动检索层」的改动,成了最容易被漏掉的回归点——上线时风平浪静,两周后投诉扎堆。
对测试工程师来说,这压根不是新领域。你早就懂「重构后拿旧基线回归」「红灯要能指名到具体用例」「实现可以换、参照物不能动」。金标准问答集只是把这套接口测试的老骨架,搬到了检索层:预期返回从 JSON 变成了「召回的文档 id 集合」,assert == 期望 变成了 assert recall@k >= 基线,而那份不动的参照物,就是你亲手标注、随事故长大的金标准集。
别等用户来告诉你召回塌了——给检索层留一份基线,让换模型、改分块的每一次改动,都先在这条回归面前照一次镜子。
- 点赞
- 收藏
- 关注作者
评论(0)