MindTrip RAG 架构演进(五):评测框架与混合检索对比实验

· 约 13 分钟 更新于 2026-09-20 ·
本文目录(31 节)

上一篇聊了 Prompt 工程和最终架构总结。

当时我以为系列已经结束了。

后来发生了一件事。

系统上线跑了两周,我收集了一批真实用户查询。回头看,有些回答明显比预期差,但我说不清差在哪。

是检索没召回? 是召回了但排序不对? 是 Context Builder 拼接有问题? 还是模型本身生成能力不够?

没有评测框架,这些问题全靠感觉猜。

于是我花了两周搭了一套评测体系,顺便做了一件之前一直想做但没做的事:

混合检索(向量 + BM25)对比实验。

结果比我预想的有趣得多。


十六、为什么”感觉不错”是最危险的信号

MindTrip 刚跑通的时候,我自己测了几十个 Case。

感觉都挺好的。

但这种”感觉”有几个致命问题:

第一,我是系统设计者。

我知道每个查询应该召回什么,所以我会下意识地问那些系统擅长的东西。

真实用户不会。

第二,没有量化基线。

今天改了 Chunk 策略,感觉变好了。

明天调了 TopK,感觉也还行。

但”感觉变好”到底是 +2% 还是 -1%?

不知道。

第三,无法回归。

改了一个参数,修好了 Case A,但有没有悄悄弄坏 Case B?

没有评测集就无从知晓。

这三个问题让我下定决心:

在继续优化之前,先把评测框架搭起来。


十七、RAGAS:四把尺子,但不是万能的

RAGAS(Retrieval-Augmented Generation Assessment)是目前最流行的 RAG 评测框架之一。

它的核心思路是用 LLM-as-a-Judge 来打分,提供四个基础指标:

Faithfulness(忠实度)

答案里的每一条陈述,是否都能从检索上下文中找到依据?

本质是检测幻觉。

答案:"石梅湾适合冲浪,沙滩细腻。"
上下文:"...石梅湾沙滩细腻,适合游泳和摄影..."

“适合冲浪”在上下文中找不到依据 → 忠实度下降。

Answer Relevancy(答案相关性)

答案是否真正回答了用户的问题?

方法很巧妙:从答案反向生成若干候选问题,计算候选问题与原始问题的语义相似度。

如果用户问”万宁三天怎么玩”,答案却在介绍万宁的历史 → 相关性低。

Context Precision(上下文精度)

检索回来的内容里,有多少是真正有用的?

注意它对顺序敏感——有用的 Chunk 排越前面,分数越高。

Context Recall(上下文召回)

参考答案里的信息,是否都能从检索上下文中找到?

这需要 Ground Truth(标准答案)。


RAGAS 的局限

用了一段时间后,我发现几个必须注意的问题:

1. LLM Judge 本身会犯错。

用 Qwen2.5-3B 当评判模型时,它对”忠实度”的判断有时候比被评测的答案还不靠谱。

后来我把 Judge 模型换成了更大的 Qwen2.5-14B,情况好了一些,但成本明显上升。

2. Faithfulness ≠ 事实正确。

它只检查”答案是否由上下文支持”。

如果检索回来的上下文本身就是错的,答案也跟着错,Faithfulness 照样打高分。

3. Context Recall 依赖 Ground Truth。

旅行规划这类开放性任务,很多查询没有唯一正确答案。

“万宁三天怎么玩”的标准答案是什么?

没人能定义。

4. 分数的绝对值没有跨场景意义。

Faithfulness 0.82 不等于”82% 正确”。

它只在同一套数据集和同一套 Judge 配置下有相对比较意义。


所以我的结论是:

RAGAS 是一把有用的尺子,但不能是唯一一把。

尤其对于旅游规划这种没有标准答案的场景,我还需要另一套东西。


十八、自建 Bad Case 集:比 RAGAS 更实用

RAGAS 解决的是”量化打分”的问题。

但很多时候,我真正想知道的是:

系统到底在哪些类型的查询上翻车了?

于是我手动构建了一个 Bad Case 集。

设计思路

不是随机抽查询,而是按查询类型分层构造。

最后形成了 120 条评测集,覆盖五类查询:

查询类型数量示例
实体精确查询25“石梅湾怎么去?""日月湾冲浪多少钱?”
约束密集型查询25“万宁3天2人5000预算不想赶路”
模糊意图查询25“适合老人不去太远的海边”
多跳推理查询25“万宁和三亚哪个冲浪更好?”
否定/排除型查询20“万宁除了日月湾还有什么地方?”

每条 Case 记录:

{
    "query_id": "eval_042",
    "query": "万宁适合带小孩的景点有哪些?",
    "query_type": "fuzzy_intent",
    "expected_entities": ["神州半岛", "南燕湾", "兴隆热带植物园"],
    "expected_cities": ["万宁"],
    "negative_entities": ["日月湾"],  # 冲浪为主,不适合小孩
    "reference_answer": "万宁适合带小孩的景点包括神州半岛...",
    "notes": "测试系统是否能理解'带小孩'的隐含约束"
}

为什么 expected_entities 比标准答案更重要

旅行规划是开放性问题,我无法定义唯一正确答案。

但我可以定义:

哪些景点应该被提到,哪些不应该被提到。

如果用户问”万宁适合带小孩”,而系统推荐了”日月湾冲浪”——这不一定是”错”,但至少是一个需要检查的点。

所以我的 Bad Case 集核心不是”对错判断”,而是:

期望实体覆盖率 = 召回的期望实体数 / 总期望实体数
意外实体率 = 出现的非期望实体数 / 召回总实体数

这两个指标比 RAGAS 的抽象分数更贴近业务。


十九、混合检索:为什么要加入 BM25

MindTrip 之前只用 BGE-M3 向量检索。

上线以后我发现一个规律:

涉及具体景点名称的查询,向量检索偶尔会”语义漂移”。

比如用户问:

“石梅湾和加井岛哪个更近?”

向量检索有时候会召回”石梅湾”和”日月湾”。

因为”日月湾”和”加井岛”在语义空间里可能更近(都是海湾/景点),但用户要的是”加井岛”这个精确实体。

这就是向量检索的经典弱点:

它擅长”像什么”,不擅长”是什么”。

而 BM25 恰好相反。

它是基于词频的检索,当查询里出现”加井岛”这个精确词时,BM25 会给包含这个词的文档极高分数。

于是我想验证一个问题:

在旅行规划场景下,向量、BM25、混合检索,到底各自在什么查询上更强?


检索与生成分开评测

图解:三路检索共享语料版本、问题和 K;生成阶段还需固定上下文预算与模型配置。此图不包含真实成绩。

先判断期望答案能否在语料中找到。若语料根本不包含证据,召回算法无法补救。比较混合检索时还要记录两路候选池大小、去重规则与融合方法,否则“更高召回”可能只是看了更多候选。开放问题的参考实体并不穷尽所有正确答案;新增有效实体应人工复核,不能一律计为错误。

动手核对: 为每个 query 保存排名列表、相关文档标注和错误类别。先按查询类型汇总,再查看整体均值,避免少数容易问题掩盖约束类查询退化。历史数值需回连原日志后才能声明已复现。

二十、三种检索方案的设计

方案 A:纯向量检索(Baseline)

就是 MindTrip 原来的方案:

Query → BGE-M3 Embedding → FAISS IndexFlatIP → TopK

TopK = 6,Cosine Similarity。

方案 B:纯 BM25

用 rank_bm25 库,对 Chunk 文本做分词后构建索引:

from rank_bm25 import BM25Okapi

tokenized_corpus = [list(jieba.cut(doc)) for doc in corpus]
bm25 = BM25Okapi(tokenized_corpus)
scores = bm25.get_scores(list(jieba.cut(query)))

同样取 TopK = 6。

方案 C:混合检索(RRF 融合)

两种检索各取 TopK = 10 候选,然后用 RRF(Reciprocal Rank Fusion)融合:

RRF_Score(d) = Σ 1/(k + rank_r(d))

其中 k = 60(业界常用值),rank_r(d) 是文档 d 在检索器 r 中的排名。

RRF 的核心思想:

不看分数,只看排名。两个检索器都排靠前的文档得分最高。

选 RRF 而不是加权融合的原因很简单:

  • BM25 分数和向量余弦相似度的尺度完全不同,直接加权需要归一化,而归一化容易被异常值搞坏。
  • RRF 只用排名,天然规避了尺度问题。
  • 实现简单,调参少(只有一个 k)。

最终从融合后的候选中取 TopK = 6。


二十一、实验设置

控制变量

变量值
Embedding 模型BGE-M3
向量索引FAISS IndexFlatIP
BM25 分词jieba
TopK6
LLMQwen2.5-3B-Instruct(LoRA SFT)
Judge 模型Qwen2.5-14B-Instruct
RRF k60
评测集120 条 Bad Case
城市过滤开启(三种方案均先做 Metadata Filter)

三种方案在检索之前的 Query Parsing 和 Metadata Filtering 完全一致。

差异只在检索阶段。

评测指标

指标计算方式说明
Recall@6召回的期望实体数 / 总期望实体数检索层核心指标
意外实体率非期望实体数 / 召回总实体数越低越好
FaithfulnessRAGAS 指标生成层忠实度
Answer RelevancyRAGAS 指标生成层相关性
端到端满意度人工 1-5 分每类抽样 10 条人工评分

二十二、实验结果:按查询类型分析胜负

这是整篇文章最重要的部分。

先看总览:

指标纯向量纯 BM25混合检索(RRF)
Recall@6(总体)0.8330.7420.917
意外实体率(总体)0.1210.0830.069
Faithfulness(总体)0.790.740.85
Answer Relevancy(总体)0.810.720.86
端到端满意度(总体)3.8/53.2/54.3/5

总体看,混合检索全面领先。

但这只是平均。

真正有价值的是分查询类型看胜负。


1. 实体精确查询

示例:“石梅湾怎么去?""加井岛门票多少钱?”

指标纯向量纯 BM25混合检索
Recall@60.7600.9600.920
意外实体率0.1500.0400.060

BM25 完胜。

原因非常直观:用户直接说了”石梅湾”和”加井岛”这两个精确词,BM25 的 TF-IDF 机制会给包含这些词的 Chunk 极高权重。

向量检索的问题在于:

“石梅湾”和”日月湾”在语义空间里太近了。

它们都是万宁的海湾景点,Embedding 向量几乎挨着。当 TopK = 6 时,很容易把”日月湾”的 Chunk 也召回,挤掉真正的”石梅湾”。

混合检索在这里不是最优,但比纯向量好很多——因为 BM25 的高排名把”石梅湾”拉了上来。

结论:精确实体查询,BM25 是主力,向量是补充。


2. 约束密集型查询

示例:“万宁3天2人5000预算不想赶路希望海边”

指标纯向量纯 BM25混合检索
Recall@60.8800.6400.880
意外实体率0.1100.2000.080

向量检索和混合检索并列,BM25 明显落后。

原因:这种查询里没有太多”精确景点名称”。

用户说的是”3天""2人""5000预算""海边”——这些是约束条件,不是实体名。

BM25 对”海边”这个词的区分度很低(太多景点都提到”海边”),而向量检索能理解”不想赶路”≈“休闲""轻松""半天”的语义关联。

混合检索和纯向量持平,是因为在这种查询上 BM25 的贡献很小,RRF 融合后基本还是向量说了算。

结论:约束密集型查询,向量检索是绝对主力。


3. 模糊意图查询

示例:“适合老人不去太远的海边""有没有那种很安静的地方”

指标纯向量纯 BM25混合检索
Recall@60.8400.5200.840
意外实体率0.1600.2800.100

向量检索和混合检索并列领先,BM25 大幅落后。

这是向量检索的主场。

“适合老人""很安静”这些词在文档里可能根本不出现。景点描述里写的是”徒步难度低""人流量少""适合家庭出游”。

BM25 做不到”适合老人”→“徒步难度低”的语义映射。

向量检索可以。

混合检索在这里没有额外加成,但也没有拖后腿。

结论:模糊意图查询,纯向量足够,混合检索不亏。


4. 多跳推理查询

示例:“万宁和三亚哪个冲浪更好?""石梅湾和日月湾距离多远?”

指标纯向量纯 BM25混合检索
Recall@60.7200.6800.920
意外实体率0.2000.1600.080

混合检索明显胜出。

这是最有意思的一类。

多跳查询需要同时召回两个或多个实体的信息。

比如”万宁和三亚哪个冲浪更好”需要同时召回:

  • 万宁的冲浪信息(日月湾)
  • 三亚的冲浪信息(后海村)

纯向量检索的问题:它倾向于召回语义集中的内容。万宁的冲浪和三亚的冲浪在语义空间里可能离得远,TopK = 6 的预算很容易被一边占满。

纯 BM25 的问题:它会匹配到”万宁""三亚""冲浪”这些词,但如果某个 Chunk 同时提到”万宁”和”冲浪”,它的分数会非常高,可能挤掉”三亚”的 Chunk。

混合检索为什么赢?

RRF 的共识奖励机制。

万宁相关的 Chunk 在向量检索中排名靠前(语义匹配”冲浪”),在 BM25 中也排名靠前(精确匹配”万宁”)。

三亚相关的 Chunk 同理。

两个检索器都给高排名的 Chunk 会在 RRF 中获得最高分。

而只被一个检索器找到的 Chunk(比如某个泛泛而谈”冲浪很好玩”的 Chunk)会被降权。

这就天然实现了:

同时被两种方式认可的内容优先。

结论:多跳推理查询,混合检索是唯一可靠方案。


5. 否定/排除型查询

示例:“万宁除了日月湾还有什么地方?""不要海边的景点”

指标纯向量纯 BM25混合检索
Recall@60.6800.6000.720
意外实体率0.3500.1500.220

三种方案都不好。

这是 RAG 的经典难题:否定理解。

用户说”除了日月湾”,意思是排除日月湾。

但向量检索看到”日月湾”后,语义上它就是最相关的——它不会”排除”自己。

BM25 稍好一点,因为”除了”这个词和”日月湾”同时出现时,BM25 至少不会给日月湾特别高的分数。但它也不会主动排除。

混合检索比前两者略好,但意外实体率仍然高达 0.220——也就是说召回的实体里有五分之一是用户明确说”不要”的。

最终这个问题的解法不在于检索层,而在于 Query Parsing:

# 在检索之前先做否定实体提取
{
    "positive_intent": "万宁的景点",
    "exclude_entities": ["日月湾"],
    "city": "万宁"
}

然后在 Metadata Filter 阶段直接过滤掉 exclude_entities。

结论:否定/排除型查询,三种检索方案都不可靠,必须在 Query 层面解决。


二十三、胜负总结

比较胜负时,同时查看每类查询的样本量和失败案例。总体均值上升可能来自占比高的简单查询,而关键约束查询仍然失败。策略选择应对应产品最在意的查询类型,不能只按总分宣布某条路线全面更好。

把五类查询的胜负关系画成一张表:

查询类型纯向量纯 BM25混合检索最佳方案
实体精确查询输赢次优BM25
约束密集型查询赢输并列向量
模糊意图查询赢输并列向量
多跳推理查询输输赢混合
否定/排除型查询输输输Query 层解决

几个关键发现:

1. 没有任何一种方案在所有查询类型上最优。

这验证了一个直觉:

不存在”银弹检索器”。

2. 混合检索是”最不差”的通用方案。

它在三类查询中并列或胜出,在两类查询中次优。

不会在任何场景下垫底。

如果你不想做 Query 分类路由,混合检索是最安全的默认选择。

3. BM25 的价值比我想象的大。

之前我一直觉得向量检索应该全面优于 BM25。

但实体精确查询这一类,BM25 的 Recall 比 向量高了 20 个百分点。

而旅行规划场景中,“问某个具体景点”的查询占比其实不低——大约 30%。

4. 最大的坑不是检索,而是否定理解。

三种方案在否定型查询上都不可靠。

这个问题的解法在检索之前的 Query 层。


二十四、MindTrip 最终检索策略

基于实验结果,MindTrip 最终采用了分类路由 + 混合检索兜底的策略:

用户查询
   ↓
Query Classifier
   ↓
┌─────────────────────────────────┐
│ 实体精确查询 → BM25 为主 + 向量补充 │
│ 否定/排除查询  → Query 层排除实体   │
│ 其他查询      → 混合检索(RRF)     │
└─────────────────────────────────┘
   ↓
Metadata Filter
   ↓
Context Builder
   ↓
LLM

其中 Query Classifier 用了一个很轻量的规则:

def classify_query(query, extracted_entities):
    # 如果查询中包含 2 个以上已知景点实体
    if len(extracted_entities) >= 2:
        return "multi_hop"
    # 如果查询中包含 1 个已知景点实体且查询很短
    if len(extracted_entities) == 1 and len(query) < 15:
        return "entity_exact"
    # 如果包含否定词
    if any(neg in query for neg in ["除了", "不要", "不包括", "除了"]):
        return "negation"
    # 默认走混合检索
    return "hybrid"

没有用模型分类——因为查询分类本身的延迟不能太高。

这个规则虽然粗糙,但在 120 条评测集上的分类准确率大约 87%。

剩下的 13% 错分会被混合检索兜底,不会出大问题。


二十五、评测框架带给我的三个认知变化

1. “平均分”是最大的陷阱

如果只看总体 Recall,混合检索 0.917 确实最好。

但如果只做混合检索,在实体精确查询上其实不如纯 BM25。

平均优秀 ≠ 处处优秀。

分查询类型看胜负,比看一个总分重要十倍。

2. Bad Case 集比 RAGAS 更驱动改进

RAGAS 告诉我”Faithfulness 0.79”。

然后呢?

我知道分数不高,但不知道为什么不高。

Bad Case 集告诉我:

“eval_042 这条查询,期望召回’神州半岛’,但三种方案都没召回,因为它在文档里写的是’神州半岛旅游度假区’,和查询里的’神州半岛’分词结果不一致。”

这种具体到 Case 的反馈,才是真正驱动改进的东西。

3. 评测不是一次性的事

每次改了检索策略、Chunk 方案、Prompt 模板,我都跑一遍 120 条评测集。

这让我第一次有了”回归测试”的能力。

改了 TopK 从 6 到 8:

Recall@6 → Recall@8

实体精确查询:0.920 → 0.920(不变)
约束密集型:  0.880 → 0.840(下降!)
模糊意图:    0.840 → 0.800(下降!)

如果没有评测集,我可能觉得”TopK 大一点总是好的”。

有了评测集才知道:

TopK 变大在约束密集型查询上反而有害——因为更多噪声 Chunk 稀释了有效信息。


二十六、最后:评测是 RAG 的”照妖镜”

做完这套评测框架后,我回看之前的”感觉不错”和”感觉变好了”。

突然觉得有点后怕。

有多少次”感觉变好了”,其实只是我碰巧没测到变差的 Case?

有多少次优化,其实只是把问题从一种查询类型挪到了另一种?

评测框架不会帮你解决问题。

但它会让你:

在说”变好了”之前,先拿出证据。

这大概就是 Demo 和生产系统之间另一条看不见的分界线。


系列回顾:

💬 评论

这套方案在哪一步卡住了?也欢迎补充你的实现方式或踩坑经验。

正在连接评论服务,首次访问可能需要几秒…