RAG 检索质量评估完全指南:从一次"向量库明明命中了、答案却驴唇不对马嘴"看懂召回率与评测集

2023 年我做一个企业知识库的 RAG 问答系统用户问一句话系统先去向量库里检索相关的文档片段再把片段塞给大模型生成答案检索这一环怎么做这件事我压根没多想第一版我做得很顺手把文档切成片段灌进向量库用户来一个问题我把问题向量化做一次 top-k 相似度查询取回最相似的 5 段拼进 prompt 就完事了本地测一测真不错我自己想了七八个问题挨个问检索每次都能返回 5 段东西答案看着也像模像样我心里很踏实可等这个系统真正上线面对用户五花八门的真实提问一串问题冒了出来第一种最先把我打懵用户反馈答案驴唇不对马嘴我去查日志发现检索那 5 段里压根没有能回答这个问题的内容可检索成功了返回了 5 段没报任何错第二种最难缠它不是每个问题都错有的问题答得很好有的就崩第三种最头疼我换了个 chunk 大小调了 top-k 感觉好像好了一点可到底好没好好了多少我说不出一个数全靠手感第四种最莫名其妙我在本地用那七八个老问题怎么测都没问题后来才反应过来那几个问题是我照着文档内容编出来的本来就一定能检索到我盯着这一连串问题想了很久才彻底想明白第一版错在一个根本的认知上我以为检索能返回结果不报错就说明检索是对的这句话把检索这段代码没抛异常当成了检索到的内容真的相关可这两件事毫无关系向量检索永远会成功你给它任何一个问题它都会老老实实返回 top-k 个片段因为它的工作只是在向量空间里找出离你最近的 k 个点它一定能找到检索从不报错它只是有时候返回的是相关的内容有时候返回的是一堆垃圾返回了 5 段和这 5 段里有答案是两件完全独立的事检索质量是一个必须被显式度量的东西而我连度量它的尺子都没造真正把 RAG 检索做扎实核心不是让检索代码能跑通而是承认检索质量是一个要被测量的量先构建一个带标注的评测集每个问题标清楚正确答案该来自哪些片段再用 recall@k MRR 这样的指标把检索好不好算成一个确定的数字然后所有的调参所有的我改进了检索的说法都必须用这个数字来证明而不是靠手感本文从头梳理为什么检索没报错不等于检索是对的怎么构建评测集 recall@k 和 MRR 到底怎么算怎么用命中率分桶找出检索在哪类问题上塌方怎么用评测集做参数选型以及把检索评测接进 CI 这些把检索做扎实要避开的坑

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@kMRR

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_sizetop_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=300top_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 回归测试 每次改动自动跑评测集,卡住退化 需设基线与容差,抓无声的质量下滑
评测集反哺 把线上检索失败案例补进评测集 让尺子持续逼近真实用户提问分布

避坑清单

  1. 不要把"检索代码没报错"当成"检索质量好"。向量检索永远返回 top-k、永不失败,有返回和相关是两件无关的事。
  2. 必须构建带标注的评测集,对每个问题标清楚答案该来自哪几个片段,这是评估检索的唯一地基。
  3. 评测问题要来自真实用户日志,不要自己照文档编。自编问题用词和文档重合,会系统性高估检索质量。
  4. relevant_ids 必须人工逐条确认,最好交叉标注。标注错了,后面所有指标都是精确的错误。
  5. 同时盯 hit@k 和 MRR。hit@k 守"相关内容进没进上下文",MRR 看"进得够不够靠前",缺一不可。
  6. 不要只看聚合总分,必须按问题类型分桶。平均值会把一整类问题的塌方掩盖成"整体还行"。
  7. 调参靠评测集跑分,不靠"感觉好像好了"。chunk_size、top_k 用网格搜索,让数字做决定。
  8. 调大 top_k 命中率会涨,但要权衡。无关内容变多会烧 token、干扰模型,结合 MRR 和预算定。
  9. 把检索评测接进 CI 做回归测试。检索退化不报错、不变红,只有专门的指标断言能抓住它。
  10. 评测集要持续生长。把线上每一个真实的检索失败案例补进去,让尺子跟着用户分布一起进化。

总结

回头看,第一版栽的跟头,根子是一个认知误判:我以为检索是个"成功/失败"的二元操作,代码跑通、有返回,就说明检索对了。可向量检索根本没有"失败"这个状态——你问它任何问题,它都返回 top-k 个最近的片段,哪怕这些片段离你的问题远在天边。"返回了 5 段"和"这 5 段里有答案",是两件完全独立的事,而我从头到尾只确认了前者。问题从来不在"检索代码有没有跑通",而在我手里压根没有一把能量"检索到的东西相不相关"的尺子。

真正把 RAG 检索做扎实,工作量不在"把检索代码写得更花哨",而在一次观念的转变:承认检索质量是一个必须被显式测量的量。一旦接受这一点,该做的事就都浮现出来了——构建一个带人工标注的评测集、用 recall@k 和 MRR 把质量算成数字、按问题类型分桶找出塌方的角落、用网格搜索让数字替你选参数、把评测接进 CI 守住不退化。每一步都不复杂,难的是先承认:检索的好坏,不写在代码里、不写在错误日志里,它只写在"检索到的内容和问题相不相关"这个你必须额外去测量的维度里。

我后来常拿招聘来想这件事。招人,你不能因为"简历投过来了、面试也安排上了、流程没卡壳",就以为招对了人——流程顺畅和招到合适的人,是两件毫无关系的事。真正负责的招聘,手里得有一份清清楚楚的岗位要求(这就是评测集里的 relevant_ids:这个位置到底需要什么),得有一套能打分的评估方式(这就是 recall 和 MRR:候选人到底有多匹配),还得分维度去看(这就是分桶:技术过硬但沟通塌方,也是不能要的)。检索就是在给每一个用户问题"招聘"文档片段:你不能因为"向量库返回了 5 段"就觉得招对了,你得拿着标准答案,一段一段去核对它们到底配不配得上这个问题。

这类问题最咬人的地方,在于它在上线前几乎永远是"对"的:你用自己编的几个问题一测,检索每次都命中,因为那些问题本来就是照着文档写的,功能测试全绿,你看着一切正常就上线了。它只在真实用户用他们自己的、五花八门的、和文档措辞对不上的话来提问时,才露出獠牙,而那时错误答案已经发到用户面前了。所以别等用户投诉"答得驴唇不对马嘴"才想起检索质量:做 RAG 的第一天,就该把"检索质量是要被测量的"刻进设计里——先攒评测集、先定指标、先把评测跑起来。把这把尺子在写第一行检索代码时就造好,你才算真正跳出了那个人人都会写、却让人人都栽跟头的"有返回就以为对了"的检索函数。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

Docker 镜像分层缓存完全指南:从一次"改一行代码、构建却重跑 5 分钟 npm install"看懂层缓存

2026-5-22 17:26:15

技术教程

CORS 跨域完全指南:从一次"本地好好的、一上线前端就报跨域错误"看懂浏览器同源策略

2026-5-22 17:38:51

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索