2023 年我做一个企业知识库的 RAG 问答系统:用户问一句话,系统先去向量库里检索相关的文档片段,再把片段塞给大模型生成答案。检索这一环怎么做?这件事我压根没多想。第一版我做得很顺手:把文档切成片段、灌进向量库,用户来一个问题,我把问题向量化,做一次 top-k 相似度查询,取回最相似的 5 段,拼进 prompt。就完事了。本地测一测——真不错:我自己想了七八个问题,挨个问,检索每次都能返回 5 段东西,答案看着也像模像样。我心里很踏实:"检索嘛,向量库查一下,能返回结果、不报错,不就说明检索对了?"可等这个系统真正上线、面对用户五花八门的真实提问,一串问题冒了出来。第一种最先把我打懵:用户反馈答案驴唇不对马嘴,我去查日志,发现检索那 5 段里压根没有能回答这个问题的内容——可检索"成功"了,返回了 5 段,没报任何错。第二种最难缠:它不是每个问题都错,有的问题答得很好、有的就崩,我盯着代码怎么看都看不出规律。第三种最头疼:我换了个 chunk 大小、调了 top-k,感觉好像好了一点,可到底好没好、好了多少,我说不出一个数,全靠手感。第四种最莫名其妙:我在本地用那七八个老问题怎么测都没问题——后来才反应过来,那几个问题是我照着文档内容编出来的,本来就一定能检索到。我盯着这一连串问题想了很久才彻底想明白,第一版错在一个根本的认知上:我以为"检索能返回结果、不报错,就说明检索是对的"。这句话把"检索这段代码没抛异常"当成了"检索到的内容真的相关"。可这两件事毫无关系。我脑子里,检索是一个"成功/失败"的二元操作:查到了就是成功,查不到或报错就是失败。可向量检索根本不是这样——它永远会"成功"。你给它任何一个问题,它都会老老实实返回 top-k 个片段,因为它的工作只是"在向量空间里找出离你最近的 k 个点",它一定能找到 k 个最近的点,哪怕这 k 个点离你都远得离谱。检索从不报错,它只是有时候返回的是相关的内容、有时候返回的是一堆最不相关里相对没那么不相关的垃圾。"返回了 5 段"和"这 5 段里有答案",是两件完全独立的事。我的第一版,从头到尾只确认了前者——代码跑通了、返回了 5 段——却从来没有任何一行代码、任何一个数字,去回答后者:这 5 段里,到底有没有能回答用户问题的那一段?检索质量,是一个必须被显式度量的东西,而我连度量它的尺子都没造。我没有评测集,不知道每个问题"正确答案应该来自哪几段";我没有指标,不知道检索的命中率是 90% 还是 40%;我调参全靠"感觉好像好了",因为我手里根本没有一个能告诉我"好了还是坏了"的数。真正把 RAG 检索做扎实,核心不是"让检索代码能跑通",而是承认检索质量是一个要被测量的量:先构建一个带标注的评测集(每个问题,标清楚正确答案该来自哪些片段),再用 recall@k、MRR 这样的指标,把"检索好不好"算成一个确定的数字,然后所有的调参、所有的"我改进了检索"的说法,都必须用这个数字来证明,而不是靠手感。这篇文章就把 RAG 检索质量评估这个坑梳理一遍:为什么"检索没报错"不等于"检索是对的"、怎么构建评测集、recall@k 和 MRR 到底怎么算、怎么用命中率分桶找出检索在哪类问题上塌方、怎么用评测集做参数选型,以及把检索评测接进 CI 这些把检索做扎实要避开的坑。
问题背景
这个坑普遍,是因为"代码不报错就是对的"这个直觉,在大多数功能开发里大体成立——一个登录接口,跑通了、返回了 token,基本就是对的。可检索是一类特殊的功能:它的输出不是"对/错",而是"相关程度",而相关程度这个东西,代码层面永远看不出来。它错得隐蔽,是因为第一版功能上完全正常:向量库连得上、查询有返回、答案能生成,所有"功能测试"都是绿的。它只在"检索质量"这个没有报错信号的维度上塌方,而你自己编的测试问题又恰好都能命中,于是你在上线前永远测不出来。
把这个现象拆开,错误认知和真相是这样对应的:
- 现象:答案驴唇不对马嘴,查日志发现检索片段里根本没有答案;有的问题答得好有的崩、毫无规律;调参"感觉好了一点"但说不出具体数字;本地用自编问题怎么测都正常。
- 错误认知一:以为检索是个"成功/失败"的操作,有返回就是成功。真相是向量检索永远"成功",它总能返回 top-k 个最近的点,哪怕它们都不相关。
- 错误认知二:以为"代码没报错"等于"检索到的内容相关"。真相是这两件事毫无关系——检索质量是相关性,代码层面完全不可见。
- 错误认知三:以为调参可以靠"感觉好像好了"来判断。真相是没有评测集和指标,你手里就没有判断好坏的尺子,所有"改进"都是猜测。
- 真相:检索质量必须被显式度量。你需要一个带标注的评测集,需要 recall@k、MRR 这样的指标把质量算成数字,之后每一次调参、每一个"我改好了"的结论,都用这个数字来证明。
一、为什么"检索没报错"不等于"检索是对的"
先把第一版那个检索函数摆出来。它的逻辑就是字面意思:问题向量化,查 top-k,返回。
def retrieve(vector_store, question: str, top_k: int = 5) -> list:
"""第一版检索:把问题向量化,查 top-k,返回片段(反面教材)。"""
query_vec = embed(question)
# 向量库里找出离 query_vec 最近的 top_k 个片段
hits = vector_store.search(query_vec, top_k=top_k)
return [h.chunk_text for h in hits]
# 用起来是这样:
chunks = retrieve(store, "公司年假有几天")
print(f"检索返回了 {len(chunks)} 段") # 永远打印 "检索返回了 5 段"
这段代码功能上挑不出毛病。它的问题在于:那行 print 永远打印"返回了 5 段"。无论用户问的是"公司年假有几天",还是"今天午饭吃什么"这种知识库里根本没有的问题,vector_store.search 都会忠实地返回 5 个片段。因为它的任务定义就是"找出离查询向量最近的 5 个点"——空间里总有 5 个点是相对最近的,它一定能找到。检索这个操作没有"失败"这个状态。
所以问题来了:返回的这 5 段,到底是"问年假、给了年假制度",还是"问年假、给了 5 段毫不相干的报销流程"?这两种情况,retrieve 函数的返回值长得一模一样——都是一个长度为 5 的字符串列表,都不报错。你光看代码、看返回值的结构,永远区分不出来。能区分它们的,只有一样东西:这 5 段的内容和问题相不相关——而相关性,代码不认识。
这里要建立的第一个、也是最重要的认知是:向量检索是一个"永不失败"的操作,这意味着它的输出里,不存在任何一个能告诉你"这次检索糟糕透了"的信号。普通的函数,出错会抛异常、会返回 None、会有错误码,你的代码能"感知"到失败;可检索不会,它对"问年假返回了 5 段年假制度"和"问年假返回了 5 段无关报销流程",给出的是结构完全相同、同样不报错的返回值。这就解释了为什么第一版的 bug 如此隐蔽:它不在任何错误日志里,不会触发任何告警,功能测试全绿——因为从"代码有没有正确执行"这个维度看,它确实毫无问题。它烂在"检索到的东西相不相关"这个维度,而这个维度,代码自己看不见,只有把片段内容和问题摆在一起做语义判断才看得见。一旦你接受"检索质量是代码不可见的、必须额外测量的东西",解决方向就清楚了:你不能再依赖"它跑通了吗"来判断检索好坏,你必须在代码之外,造一把专门量"相关性"的尺子。下面就来造这把尺子。
二、检索质量要被度量:构建评测集
要量"检索到的片段相不相关",你得先有一个标准答案——对每一个测试问题,事先标清楚:它的答案本应来自哪几个文档片段。这份"问题 + 该命中的片段 id"的集合,就是评测集。它是整个检索评估的地基。
from dataclasses import dataclass, field
@dataclass
class EvalCase:
"""一条评测样本:一个问题,以及它的答案本应来自哪些片段。"""
question: str
# relevant_ids:人工标注的、能回答这个问题的片段 id 集合
relevant_ids: set = field(default_factory=set)
category: str = "general" # 问题类型,用于后面分桶分析
# 评测集就是一组 EvalCase。注意 relevant_ids 必须是人工确认过的
eval_set = [
EvalCase("公司年假有几天",
relevant_ids={"hr_leave_03", "hr_leave_04"},
category="制度查询"),
EvalCase("报销发票丢了怎么办",
relevant_ids={"finance_invoice_11"},
category="流程查询"),
EvalCase("试用期可以请年假吗",
relevant_ids={"hr_leave_03", "hr_leave_07"},
category="制度查询"),
]
构建评测集有几条务必守住的纪律。第一,问题必须来自真实用户,从线上日志里捞,绝不能像我第一版那样自己照着文档编——自编的问题用词和文档高度重合,检索本来就一定命中,测不出任何问题。第二,relevant_ids 必须人工逐条确认:由懂业务的人读问题、读文档,亲手标注"这个问题的答案在这几段里"。第三,评测集要覆盖各种问题类型,所以这里给每条样本加了个 category 字段,后面分桶要用。
这里要建立的认知是:评测集是你给检索系统造的那把"尺子",而这把尺子本身的质量,直接决定了你后面所有测量数字的可信度。一把刻度是错的尺子,比没有尺子更危险——它会给你一个精确的、却是错误的数字,让你信心十足地走错方向。所以构建评测集这件事,看着只是"整理一个列表",实则是整个评估体系里最该较真的一步。它有三个最容易塌方的地方:一是问题的来源,自己编的问题会系统性地高估检索质量,因为你编问题时无意识地"抄"了文档的措辞,而真实用户不会;二是标注的可信度,relevant_ids 是"标准答案",它必须由人来定,而且最好交叉标注、对不齐的地方讨论清楚,绝不能用某种自动化手段糊弄;三是覆盖度,如果评测集里全是"制度查询"类问题,你测出来的高分只能代表检索在这类问题上还行,对其他类型一无所知。把这三件事做扎实,你这把尺子才量得准。评测集一旦建好,它就是你这个 RAG 系统最宝贵的资产之一——它会在你后面每一次调参、每一次升级时,反复地、客观地告诉你到底是进步了还是退步了。
三、recall@k 与 MRR:把检索质量算成数字
有了评测集,就能算指标了。检索评估最核心的两个指标是 recall@k 和 MRR。
recall@k(命中率/召回率)回答的问题是:在检索返回的前 k 个片段里,"该被命中的相关片段"有没有被捞回来。最常用的是一个简化版——只要相关片段里至少有一个出现在 top-k 里,就算这次检索命中。因为 RAG 后面是大模型,只要相关内容进了上下文,模型大概率就能用上。
def recall_at_k(retrieved_ids: list, relevant_ids: set, k: int) -> float:
"""recall@k:前 k 个检索结果里,命中了多少比例的相关片段。"""
if not relevant_ids:
return 0.0
top_k = retrieved_ids[:k]
hit = sum(1 for rid in relevant_ids if rid in top_k)
return hit / len(relevant_ids)
def hit_at_k(retrieved_ids: list, relevant_ids: set, k: int) -> int:
"""hit@k:前 k 个里只要有任意一个相关片段,就算命中(1/0)。"""
top_k = set(retrieved_ids[:k])
return 1 if (top_k & relevant_ids) else 0
MRR(Mean Reciprocal Rank,平均倒数排名)回答的是另一个问题:第一个相关片段,排在第几位。它在乎"排名",因为相关内容排在第 1 位和排在第 5 位,对大模型的帮助是不一样的——越靠前越好。某个问题,如果第一个相关片段排在第 r 位,它的倒数排名就是 1/r;排第 1 位得 1 分,排第 5 位只得 0.2 分,完全没命中得 0 分。
def reciprocal_rank(retrieved_ids: list, relevant_ids: set) -> float:
"""单个问题的倒数排名:第一个相关片段排第 r 位,得分 1/r。"""
for rank, rid in enumerate(retrieved_ids, start=1):
if rid in relevant_ids:
return 1.0 / rank
return 0.0 # 整个检索结果里一个相关片段都没有
把这些指标在整个评测集上跑一遍、取平均,就得到了对检索质量的一个完整体检。整个评估流程是这样跑的:
def evaluate(retrieve_fn, eval_set: list, k: int = 5) -> dict:
"""在整个评测集上评估一个检索函数,返回聚合指标。"""
recalls, hits, rrs = [], [], []
for case in eval_set:
# 用真实检索函数跑这个问题,拿到片段 id 列表
retrieved_ids = retrieve_fn(case.question)
recalls.append(recall_at_k(retrieved_ids, case.relevant_ids, k))
hits.append(hit_at_k(retrieved_ids, case.relevant_ids, k))
rrs.append(reciprocal_rank(retrieved_ids, case.relevant_ids))
n = len(eval_set)
return {
"recall@k": round(sum(recalls) / n, 4),
"hit@k": round(sum(hits) / n, 4), # 有多少比例的问题至少命中一段
"MRR": round(sum(rrs) / n, 4),
"样本数": n,
}
这里要建立的认知是:recall@k 和 MRR 不是两个可以二选一的指标,它们量的是检索质量的两个不同侧面,你得同时盯着。recall@k(尤其是 hit@k)回答的是"相关内容到底进没进上下文"——这是 RAG 的生死线,相关片段连进都没进,后面的大模型再强也是无米之炊,这个问题它一定答不对。MRR 回答的是"相关内容进得够不够靠前"——相关片段排在第 1 位还是第 5 位,虽然都进了上下文,但越靠前,大模型越容易抓住它、越不容易被前面的无关片段带偏。一个常见的误判是:hit@k 看着很高(比如 95%),就以为检索很好了;可如果 MRR 只有 0.4,说明相关片段虽然大多进了 top-k,却经常排在三四位,前面压着两三段无关内容——这种"勉强命中"会让大模型的答案质量打折。反过来,光看 MRR 也不行,它对"压根没命中"的样本只记 0 分,体现不出"差多少"。所以正确的姿势是:先用 hit@k 守住"相关内容必须进上下文"这条底线,再用 MRR 和 recall@k 去优化"进得够不够多、够不够靠前"。两个数字一起看,你才真正知道检索处在什么水平。
四、命中率分桶:找出检索在哪类问题上塌方
一个聚合的总分,比如"hit@5 = 0.78",是个好的开始,但它会掩盖问题。0.78 这个数,可能是"所有问题都七成命中",也可能是"制度查询 100% 命中、但某类问题 30% 命中,平均下来 78%"。后一种情况,平均分会让你误以为"整体还行",而实际上有一整类问题已经塌方了。所以总分之外,必须按问题类型分桶,看每一桶各自的分数。
from collections import defaultdict
def evaluate_by_category(retrieve_fn, eval_set: list, k: int = 5) -> dict:
"""按 category 分桶评估,暴露出检索在哪一类问题上塌方。"""
buckets = defaultdict(list)
for case in eval_set:
buckets[case.category].append(case)
report = {}
for category, cases in buckets.items():
hits = [hit_at_k(retrieve_fn(c.question), c.relevant_ids, k)
for c in cases]
report[category] = {
"hit@k": round(sum(hits) / len(cases), 4),
"样本数": len(cases),
}
return report
# 跑出来可能是这样,问题一目了然:
# {
# "制度查询": {"hit@k": 0.96, "样本数": 50},
# "流程查询": {"hit@k": 0.88, "样本数": 40},
# "口语化提问": {"hit@k": 0.31, "样本数": 35}, # 这一类塌了
# }
上面这个跑出来的结果就很说明问题:总分看着不差,可"口语化提问"这一桶只有 0.31。这立刻给了你一个明确的、可行动的方向——不是泛泛地"优化检索",而是"去解决口语化提问检索不到的问题",可能是用户口语和文档书面语用词差太多,需要做查询改写或者换个 embedding 模型。把崩在哪里定位到具体一类问题,优化才有的放矢。
这里要建立的认知是:平均值是一个善于撒谎的数字,它最擅长的就是把"一部分极好、一部分极烂"伪装成"整体还行"。在检索评估里,这种伪装是危险的——因为对那 31% 命中率的"口语化提问"用户来说,系统不是"还行",而是"基本不可用",他们三次提问有两次拿到错误答案,但他们的痛苦被平均进了那个体面的总分里,你在数据上看不到他们。分桶的本质,是拒绝让平均值替你做判断,坚持把用户群体拆成有意义的子集,逐一审视每个子集的真实体验。怎么分桶,取决于你的业务——可以按问题类型(制度/流程/口语化)、按问题长度、按是否包含专有名词、按用户角色。分桶分得好,评测报告就从一个"还行/不行"的模糊总分,变成一张清晰的地图,直接标出"检索在这片区域是坏的"。这是一个很通用的度量思想:任何时候你用一个聚合指标概括一个系统,都要警惕它在掩盖什么,主动地把它拆开、分组再看。检索如此,延迟、错误率、转化率,无一不是如此。先有分桶,你的优化才不会是"哪疼治哪"的瞎猜,而是"哪坏修哪"的精准打击。
五、用评测集做参数选型:让数字替你做决定
有了能算出数字的评测集,第一版那个"调个 chunk 大小、感觉好像好了"的噩梦就终结了。chunk_size、top_k、用哪个 embedding 模型——这些参数到底怎么选,不再靠手感,而是把候选参数排成一个网格,每一组都在评测集上跑一遍,看谁的分数高。
def grid_search(build_retriever, eval_set: list,
chunk_sizes: list, top_ks: list) -> list:
"""对 chunk_size 和 top_k 做网格搜索,用评测集分数选最优组合。"""
results = []
for cs in chunk_sizes:
# build_retriever:按指定 chunk_size 重建一套检索器
retriever = build_retriever(chunk_size=cs)
for k in top_ks:
metrics = evaluate(lambda q: retriever.search(q, top_k=k),
eval_set, k=k)
results.append({
"chunk_size": cs, "top_k": k,
"hit@k": metrics["hit@k"], "MRR": metrics["MRR"],
})
# 按 hit@k 从高到低排,第一名就是评测集选出来的最优解
results.sort(key=lambda r: r["hit@k"], reverse=True)
return results
# 输出会是一张清清楚楚的表,而不是"我感觉 chunk=500 好像好点":
# chunk_size=300 top_k=8 -> hit@k=0.91 MRR=0.74
# chunk_size=500 top_k=5 -> hit@k=0.86 MRR=0.71
# chunk_size=800 top_k=5 -> hit@k=0.79 MRR=0.63
这张表的价值,在于它把"选参数"这个动作,从一场各说各话的口水仗,变成了一个有客观裁判的比赛。"我觉得 chunk 切大点上下文更完整""我觉得切小点更精准"——这种争论永远吵不出结果,而评测集一跑,数字直接告诉你 chunk_size=300 配 top_k=8 命中率最高。注意一个权衡:top_k 调大,命中率几乎一定会上升(捞得多,自然更容易把相关的捞进来),但塞进 prompt 的无关内容也变多了,既烧 token 又可能干扰大模型。所以选 top_k 不能只看 hit@k 单调往上,要结合 MRR 和你的 token 预算一起判断。
这里要建立的认知是:评测集真正改变的,是团队做检索决策的方式——它把决策的依据,从"谁的资历深、谁的嗓门大、谁的直觉听起来更顺",换成了"谁的数字高"。在没有评测集的时候,RAG 调参是一件非常消耗人的事:每个人都有一套关于 chunk 该多大、top_k 该多少的直觉,这些直觉都有道理、又都无法证明,于是讨论变成了立场之争,最后往往是资历最高的人拍板,而对错要等上线后用户投诉才知道。评测集把这一切短路了:任何关于"这样改检索更好"的主张,都必须先在评测集上证明自己——跑出来分数高,就采纳;分数没变甚至降了,这个主张再动听也直接出局。这不仅让你选对参数,更重要的是,它让整个团队对检索的改进形成了一个可累积的、不退步的共识:每一次被评测集认可的改动,都是一块踏实的垫脚石。但要始终记得第二节那个前提——这一切的可信度,百分之百建立在评测集本身的质量上。评测集如果有偏、覆盖不全,grid_search 选出来的"最优解",就只是"在那个有偏样本上的最优解"。数字能替你做决定,前提是产生数字的那把尺子是准的。
六、把检索评测接进 CI:别让检索悄悄退化
检索质量还有一个反直觉的特点:它会悄悄退化。你升级了 embedding 模型、改了文档分块逻辑、调整了清洗规则——这些改动都不会报错,但完全可能让检索质量掉一截。功能测试一律绿灯,你浑然不觉,直到用户投诉。所以评测集不能只在你手动调参时用一下,它必须变成一道自动化的回归测试,接进 CI,每次相关改动都自动跑、自动卡。
import json
# 基线:上一次被认可的检索质量,存进版本库
BASELINE = {"hit@k": 0.88, "MRR": 0.71}
TOLERANCE = 0.03 # 允许的波动:指标抖动很正常,留一点容差
def test_retrieval_not_regressed():
"""CI 回归测试:检索质量不允许比基线明显退化。"""
eval_set = load_eval_set("eval/retrieval_eval.json")
metrics = evaluate(production_retrieve, eval_set, k=5)
# 核心断言:新的 hit@k 不能比基线低出容差之外
assert metrics["hit@k"] >= BASELINE["hit@k"] - TOLERANCE, (
f"检索命中率退化!基线 {BASELINE['hit@k']},"
f"当前 {metrics['hit@k']}"
)
assert metrics["MRR"] >= BASELINE["MRR"] - TOLERANCE, (
f"MRR 退化!基线 {BASELINE['MRR']},当前 {metrics['MRR']}"
)
print(f"检索质量校验通过:{metrics}")
评测集也不是建好就一劳永逸的,它要持续生长。最好的养料就是线上那些真实答错的问题:每当用户反馈一个答案不对、你排查发现是检索没召回,就把这个问题连同正确的 relevant_ids 补进评测集。这样评测集会越来越逼近真实用户的提问分布,你的尺子会越来越准。
def add_case_from_incident(eval_path: str, question: str,
relevant_ids: list, category: str):
"""把一个线上检索失败的真实案例,补进评测集。"""
eval_set = json.load(open(eval_path, encoding="utf-8"))
eval_set.append({
"question": question,
"relevant_ids": relevant_ids, # 人工确认过的正确片段
"category": category,
})
json.dump(eval_set, open(eval_path, "w", encoding="utf-8"),
ensure_ascii=False, indent=2)
# 从此,这个曾经检索失败的问题,会被每一次 CI 反复守护
这里要建立的认知是:检索质量是一个会"无声退化"的东西,而对付无声的退化,唯一的办法是给它装一个会出声的警报——这就是把评测接进 CI 的全部意义。普通代码 bug 会以异常、报错、测试变红的形式"喊出来",你想忽略都难;但检索退化不喊,它只是让命中率从 0.88 滑到 0.79,代码照常运行、测试照常通过,这种退化只能靠一道专门盯着指标的回归测试来抓。这道测试的关键设计有两点:一是要有基线,把上一次被认可的分数固化进版本库,新的改动必须和这个基线比,而不是和"感觉"比;二是要有容差,指标会因为随机性小幅抖动,卡得太死会天天误报,所以留一个合理的 TOLERANCE,只拦真正的明显退化。还有更深的一层:评测集是有生命周期的,它必须跟着真实用户的提问分布一起进化。一个建好就再不更新的评测集,会慢慢和现实脱节——用户问的问题变了、文档库扩充了,而你的尺子还停在过去。所以要建立一条把"线上检索失败案例"反哺回评测集的通道,让每一次真实的失败,都变成评测集里一条新的、永久的守护。把这套东西配齐——带标注的评测集、recall 和 MRR 指标、分桶报告、CI 回归测试、失败案例反哺——你的 RAG 检索才算真正从"跑通了"升级成了"被持续度量、不会偷偷变坏"。
关键概念速查
| 概念 | 说明 | 关键点 |
|---|---|---|
| 检索永不失败 | 向量检索总能返回 top-k 个最近片段 | 有返回不代表相关,代码层面看不出检索质量 |
| 评测集 | 一组问题加人工标注的相关片段 id | 检索评估的地基,质量决定所有数字的可信度 |
| relevant_ids 标注 | 每个问题答案本应来自哪些片段 | 必须人工逐条确认,不能自动化糊弄 |
| recall@k | 前 k 个结果命中了多少比例的相关片段 | 守住相关内容必须进上下文这条生死线 |
| hit@k | 前 k 个里只要有一个相关片段就算命中 | RAG 最实用的底线指标,看比例 |
| MRR | 第一个相关片段排名的倒数的平均 | 衡量相关内容排得够不够靠前 |
| 分桶评估 | 按问题类型分组,看每桶各自的分数 | 避免平均值掩盖某一类问题的塌方 |
| 网格搜索选参 | candidate 参数逐组在评测集上跑分 | 用数字选 chunk_size 与 top_k,不靠手感 |
| CI 回归测试 | 每次改动自动跑评测集,卡住退化 | 需设基线与容差,抓无声的质量下滑 |
| 评测集反哺 | 把线上检索失败案例补进评测集 | 让尺子持续逼近真实用户提问分布 |
避坑清单
- 不要把"检索代码没报错"当成"检索质量好"。向量检索永远返回 top-k、永不失败,有返回和相关是两件无关的事。
- 必须构建带标注的评测集,对每个问题标清楚答案该来自哪几个片段,这是评估检索的唯一地基。
- 评测问题要来自真实用户日志,不要自己照文档编。自编问题用词和文档重合,会系统性高估检索质量。
- relevant_ids 必须人工逐条确认,最好交叉标注。标注错了,后面所有指标都是精确的错误。
- 同时盯 hit@k 和 MRR。hit@k 守"相关内容进没进上下文",MRR 看"进得够不够靠前",缺一不可。
- 不要只看聚合总分,必须按问题类型分桶。平均值会把一整类问题的塌方掩盖成"整体还行"。
- 调参靠评测集跑分,不靠"感觉好像好了"。chunk_size、top_k 用网格搜索,让数字做决定。
- 调大 top_k 命中率会涨,但要权衡。无关内容变多会烧 token、干扰模型,结合 MRR 和预算定。
- 把检索评测接进 CI 做回归测试。检索退化不报错、不变红,只有专门的指标断言能抓住它。
- 评测集要持续生长。把线上每一个真实的检索失败案例补进去,让尺子跟着用户分布一起进化。
总结
回头看,第一版栽的跟头,根子是一个认知误判:我以为检索是个"成功/失败"的二元操作,代码跑通、有返回,就说明检索对了。可向量检索根本没有"失败"这个状态——你问它任何问题,它都返回 top-k 个最近的片段,哪怕这些片段离你的问题远在天边。"返回了 5 段"和"这 5 段里有答案",是两件完全独立的事,而我从头到尾只确认了前者。问题从来不在"检索代码有没有跑通",而在我手里压根没有一把能量"检索到的东西相不相关"的尺子。
真正把 RAG 检索做扎实,工作量不在"把检索代码写得更花哨",而在一次观念的转变:承认检索质量是一个必须被显式测量的量。一旦接受这一点,该做的事就都浮现出来了——构建一个带人工标注的评测集、用 recall@k 和 MRR 把质量算成数字、按问题类型分桶找出塌方的角落、用网格搜索让数字替你选参数、把评测接进 CI 守住不退化。每一步都不复杂,难的是先承认:检索的好坏,不写在代码里、不写在错误日志里,它只写在"检索到的内容和问题相不相关"这个你必须额外去测量的维度里。
我后来常拿招聘来想这件事。招人,你不能因为"简历投过来了、面试也安排上了、流程没卡壳",就以为招对了人——流程顺畅和招到合适的人,是两件毫无关系的事。真正负责的招聘,手里得有一份清清楚楚的岗位要求(这就是评测集里的 relevant_ids:这个位置到底需要什么),得有一套能打分的评估方式(这就是 recall 和 MRR:候选人到底有多匹配),还得分维度去看(这就是分桶:技术过硬但沟通塌方,也是不能要的)。检索就是在给每一个用户问题"招聘"文档片段:你不能因为"向量库返回了 5 段"就觉得招对了,你得拿着标准答案,一段一段去核对它们到底配不配得上这个问题。
这类问题最咬人的地方,在于它在上线前几乎永远是"对"的:你用自己编的几个问题一测,检索每次都命中,因为那些问题本来就是照着文档写的,功能测试全绿,你看着一切正常就上线了。它只在真实用户用他们自己的、五花八门的、和文档措辞对不上的话来提问时,才露出獠牙,而那时错误答案已经发到用户面前了。所以别等用户投诉"答得驴唇不对马嘴"才想起检索质量:做 RAG 的第一天,就该把"检索质量是要被测量的"刻进设计里——先攒评测集、先定指标、先把评测跑起来。把这把尺子在写第一行检索代码时就造好,你才算真正跳出了那个人人都会写、却让人人都栽跟头的"有返回就以为对了"的检索函数。
—— 别看了 · 2026