Recall@3 从 68.5% 到 77.3%:RAG 检索链路消融实验怎么做

28 阅读12分钟

我做的课程答疑 RAG 系统跑通之后,检索召回一直是心里的刺。向量检索、BM25 混合检索、MMR、BGE-Reranker 重排——这四个组件全都堆上去了,看起来该做的都做了,但 30 题测试集上的 Recall@3 只有 68.5%。

于是我做了件当时觉得有点自虐的事:把这四个组件逐个关掉,每种配置完整跑一遍 30 题,记录 Recall@3、HitRate@3、MRR。结果有一组数字把我钉在椅子上——关掉 BM25 混合检索,Recall@3 反而升到 77.3%

这篇写的就是这次消融实验的全过程和它逼我承认的几个事实。

一、先说清楚问题:课程答疑为什么不能直接问大模型

我做的系统是计算机课程答疑。学生复习时问的问题,高度绑定指定教材的表述——同一句"软件质量保证的目标是什么",教材有它的标准答案,老师按它考,而通用大模型会给你一段听起来很对、但和考纲对不上的话。

所以这个场景的需求不是"回答得像专家",而是三条:

  1. 答案必须来自指定资料,不能是模型的固有知识;
  2. 必须能溯源,每一句要能标出出自哪份资料的哪一段;
  3. 覆盖不了就明说,宁可答"根据现有资料无法回答",也不能编。

第三条是这类工具和聊天机器人的分野。一个答疑工具"看起来什么都会"是缺点不是优点——它会让学生背错东西。

这三条决定了架构必须是 RAG(检索增强生成),而不是微调:微调贵、知识更新要重训,而且微进去的知识答出来依然没有出处,第二条直接不满足。

而 RAG 一旦定下来,检索环节就成了新的瓶颈。这一点值得单独强调:检索错了,后面生成得再流畅也是错的,而且错得很难发现——因为答案读起来完全通顺。这就是我后来花大力气做检索评估和消融的原因。

二、我的方案:三层漏斗 + 一条可选支路

链路的整体形态是这样:

用户问题(conversation_id, course_name, use_hybrid)
  → 有历史:LLM 改写指代("它的目标""软件工程的目标")
  → 第一层 召回:Chroma MMR(k=8, fetch_k=20, λ=0.5)
  → 第二层 精排:BGE-Reranker-v2-m3 对 8 条逐一打分,取 Top-3
  → 第三层 生成:GLM(带【资料n】编号 + 近 5 轮历史 + 8 条回答要求)
  → 两阶段幻觉校验 → 推引用来源

离线侧(知识库构建)

PDF / DOCX / PPTX / MD / TXT  →  统一元数据 {source, course_name, chapter, page}
  →  RecursiveCharacterTextSplitter(chunk 500 / overlap 50)
  →  智谱 embedding-2 向量化(每批 ≤ 60 条)
  →  Chroma 持久化(collection: course_qa)+ SQLite 记文档元数据

规模:6 份课程文档、277 个块、Chroma 库 3.2MB。

几个关键取舍:

决策选择理由
向量库Chroma(嵌入式)277 个块 / 3MB,Milvus、Qdrant 要额外起服务,在百万级向量和多副本场景才有意义。选型逻辑是规模适配,不是谁功能多
切片500 / 50500 约等于课程文档一个完整知识点的体量:太短会把定义和解释切开,命中了也答不完整;太长一个块混多个主题,向量表示被稀释
MMR开,λ=0.5普通相似度检索容易召回一堆几乎重复的块;MMR 每次选下一个时兼顾"跟问题相关"和"跟已选结果不重复"
Reranker开,取 Top-3双编码器 vs 交叉编码器的结构差异(下面细说)
BM25 混合检索实现但默认关闭消融实验的数据不支持开——这是这篇的核心

为什么 Reranker 值得放在漏斗末端:

向量检索是双编码器——query 和文档各自独立编码成向量,再算距离。两边的编码互相"看不见",细粒度匹配会失真。交叉编码器把 query 和文档拼在一起送进模型做联合注意力,能判断"这个问题到底在问这段话没有"。

代价是每一对都要跑一次前向,所以它只能放在末端对少量候选精排,不可能全库跑。这就是"召回 8 → 精排取 3"这个漏斗形状的由来,不是拍脑袋定的数字。

三、消融实验怎么设计

做一个模块和证明一个模块有用,是两件事。我一开始只有前者。

设计原则:单变量。 一次只关一个模块,其他保持不动,五组配置跑同一套 30 题测试集:

配置说明
完整BM25 + MMR + Reranker 全开
去 Reranker保留召回,砍掉精排层
去 MMR用普通相似度检索替代 MMR
去 BM25纯向量 + MMR + Reranker(当前线上默认
只用 BM25反过来只留关键词检索

测试集怎么来的: 30 题人工标注,每题标出相关文档块 ID 和关键词。

判定一条检索结果是否相关: 块 ID 精确命中,内容包含任一标注关键词。双判定是为了抗标注噪声——关键词判定是人工测试集的通病,标宽了虚高、标严了虚低,所以 doc_id 优先、关键词兜底,且所有配置用同一套判定,横向对比不受影响。

三个指标为什么是这三个:

指标定义它回答什么问题
Recall@3前 3 结果里的相关块 ÷ 全部相关块该找出来的,找全了没有
HitRate@3前 3 里有没有至少一个相关块模型有没有"材料可用"
MRR第一个相关块排名倒数的平均最相关的那条,排得够不够前

只看 Recall 会漏掉"排在第 3 和第 8 差不多"这件事,只看 MRR 会漏掉"只找到一条"。三个一起看,才能区分找不到找不齐

跑批的骨架

思路很朴素:配置 × 题目 两层循环,每个配置跑完整 30 题再算指标。核心是把"配置"变成数据,而不是复制五份代码。

CONFIGS = {
    "full":        dict(use_hybrid=True,  use_mmr=True,  use_reranker=True),
    "no_reranker": dict(use_hybrid=True,  use_mmr=True,  use_reranker=False),
    "no_mmr":      dict(use_hybrid=True,  use_mmr=False, use_reranker=True),
    "no_bm25":     dict(use_hybrid=False, use_mmr=True,  use_reranker=True),
    "bm25_only":   dict(use_hybrid="only", use_mmr=False, use_reranker=False),
}

def run_one(cfg, questions, top_k=3):
    recall, hit, rr = [], [], []
    for q in questions:
        retrieved = retriever.search(q["query"], **cfg)   # 返回块 ID 列表
        gold = set(q["gold_doc_ids"])
        kws  = q["keywords"]

        def is_relevant(doc):
            # 双判定:doc_id 精确命中优先,关键词兜底
            return doc["id"] in gold or any(k in doc["text"] for k in kws)

        top = retrieved[:top_k]
        hits = [d for d in top if is_relevant(d)]
        all_rel = [d for d in retrieved if is_relevant(d)]

        recall.append(len(hits) / max(1, len(all_rel)))
        hit.append(1.0 if hits else 0.0)
        rank = next((i + 1 for i, d in enumerate(retrieved) if is_relevant(d)), None)
        rr.append(1.0 / rank if rank else 0.0)
    return dict(recall_at_3=mean(recall), hit_rate_at_3=mean(hit), mrr=mean(rr))

for name, cfg in CONFIGS.items():
    print(name, run_one(cfg, questions))

关键在于:所有配置共用同一个 retriever.search(),开关只是参数。 如果每个配置各写一份检索代码,那测出来的是五份代码的差异,不是五个模块的贡献。

四、数据

跑完 5 组 × 30 题:

配置Recall@3HitRate@3Recall@5Recall@8MRR
完整(BM25+MMR+Reranker)68.5%96.7%84.9%96.7%0.950
去掉 Reranker55.6%96.7%76.9%96.7%0.917
去掉 MMR68.5%96.7%84.9%96.7%0.950
去掉 BM25(当前默认)77.3%100%88.6%100%0.950
只用 BM2555.6%96.7%76.9%96.7%0.917

三个结论

结论一:重排贡献 +12.9pp,是所有模块里最大的。

68.5% − 55.6% = 12.9 个百分点。而且注意 Recall@8 都是 96.7% —— 说明候选池里本来就有正确答案,问题出在排序。这正是交叉编码器该解决的问题,数据对得上。

结论二:关掉 BM25,Recall@3 反升 8.8pp。

68.5% → 77.3%,HitRate@3 从 96.7% 到 100%。这不是小数点波动,这是"我加了个东西,它让结果变差了"。

结论三:MMR 在这套指标上完全没有变化。

去 MMR 那一行和"完整"那一行逐格相同(68.5 / 96.7 / 84.9 / 96.7 / 0.950)。说明我的测试集里,召回前列本身没有严重同质化——去重这件事在这批数据上无事可做。

但我保留了 MMR。理由不是指标,是:它对"一个块讲三点、另两个块各讲一点"这类多主题问题有保险作用,且几乎零成本。这是一个明确说明"指标测不出来的好处,我基于机制判断保留"的例子——我不打算把它包装成实验结论。

五、BM25 为什么会拖后腿

这是整件事最有价值的部分。数据只告诉我"关掉更好",而面试官(或者三个月后的我自己)一定会问:为什么?

我的分析是三条叠加:

① 语料同质化,关键词命中的区分度很低。

课程资料是同一个老师、同一门课写的,术语体系完全一致。"模型""软件""系统"这些词在几乎每个块里都出现。BM25 靠词面命中,这些高频词会把相关但不对的块顶上来——它无法区分"提到了这个概念"和"在讲这个概念的答案"。

② 中文分词粒度的问题。

我的语料是中文,BM25 用 jieba 分词后建索引。分词粒度一旦落下,专有名词和术语就会碎:一个四字术语被切成两个通用词,命中就变成了噪声。英文里 BM25 强是因为空格天然切好了词边界,中文没有这个便宜。

③ RRF 只融合排名,一路的坏结果会挤掉另一路的好结果。

RRF 的公式是 score = Σ 1/(k + rank),k 取 60。它的好处是只看排名不看分数量纲——向量余弦分数和 BM25 分数没有可比性,RRF 天然规避了"怎么调权重"这个问题。

但代价也在这里:排名融合是"零和"的。 BM25 一路塞进来的坏结果,会把向量一路原本排第 4、第 5 的好结果挤到 6、7。而我的链路最终只取 3 条进 Prompt——这个挤压伤害在 Top-3 场景下被放大得最严重。如果最终取 Top-20,这 8.8pp 的损失可能根本不存在。

一个重要限定: 30 题是小样本。我不会说"BM25 没用",只能说"在这套语料 + 这个最终取 3 条的设定下,它的净收益为负"。所以我的处理方式是:

默认关闭,但保留实现、保留开关、前端留复选框,等测试集扩到 100 题再复验。

我认为"实现了、测了、数据说不要、就关掉"比"堆了个听起来厉害的模块"更接近工程。这四个组件里,BM25 是我唯一一个写完又关掉的东西——而它教给我的比另外三个加起来还多。

六、踩过的坑

坑一:embedding 接口单次上限 64,超了报 1214。

入库时我一次传了 123 个块,直接被 API 拒了,错误码 1214。查文档才知道接口单次 input 数组上限是 64 条。改成每批 60 条分批嵌入(留 4 条余量),批大小做成配置项 EMBEDDING_BATCH_SIZE

这个坑的深层含义比错误码本身重要:换 embedding 模型意味着整个向量库和所有评估基线都要重做。 所以 embedding 模型的选型要一次做对——这也是我不轻易换它的原因。

坑二:Reranker 冷启动要加载约 2GB 模型。

BGE-reranker-v2-m3 本地跑(不花钱、不泄漏数据),但模型约 2GB,首次加载有明显延迟。第一次演示时,前面检索都很快,卡在这一步几十秒。

处理方式不是换模型,是把加载时机从"第一次请求"挪到"服务启动"——启动慢一点,但请求路径上不再有冷启动尖刺。

坑三:流式输出和事后校验天生打架。

我的幻觉校验是在生成之后跑的(关键词粗筛 → 命中才调 LLM 三分类:faithful 保留 / minor 换修正版 / unfaithful 拒答)。但我的回答是 SSE 逐字推的——用户已经看到全文了,校验结果才出来。

如果什么都不做,用户会看到一段答案,然后(如果判定 unfaithful)它突然消失。这比慢一点糟糕得多。

我的处理是在协议层加两个事件类型:

  • 校验开始时先推 type=status「校验中」——让用户知道还有一步;
  • 校验有改动时推 type=revise整体替换已显示的内容,而不是逐字改。

同时定了降级规则:校验本身超时或 JSON 解析失败,一律降级为保守拒答。宁可误杀,不放过。

七、下一步

消融实验让我确定了两件事:重排必须留,混合检索先关着,但要把样本量做上去。 接下来三个方向:

  1. 测试集从 30 题扩到 100 题,重跑全部五组配置。30 题的分辨率太粗,"68.5% vs 77.3%"到底是不是稳定的 8.8pp,需要更大样本才能说。
  2. 切片策略:现在是规则切片 + Markdown 标题感知的混合方案,下一步试语义切片和父子块检索(小块召回、大块送进 Prompt)。
  3. Graph RAG:课程知识点之间是有依赖关系的("软件质量保证"依赖"软件测试"),目前的向量检索完全看不到这层结构。

项目仓库:gitee.com/epitome-of-the-sky/jobfit-agent(第二个项目,LangGraph 多智能体岗位匹配,开发中)

系列文章