上一篇聊了 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 |
| TopK | 6 |
| LLM | Qwen2.5-3B-Instruct(LoRA SFT) |
| Judge 模型 | Qwen2.5-14B-Instruct |
| RRF k | 60 |
| 评测集 | 120 条 Bad Case |
| 城市过滤 | 开启(三种方案均先做 Metadata Filter) |
三种方案在检索之前的 Query Parsing 和 Metadata Filtering 完全一致。
差异只在检索阶段。
评测指标
| 指标 | 计算方式 | 说明 |
|---|---|---|
| Recall@6 | 召回的期望实体数 / 总期望实体数 | 检索层核心指标 |
| 意外实体率 | 非期望实体数 / 召回总实体数 | 越低越好 |
| Faithfulness | RAGAS 指标 | 生成层忠实度 |
| Answer Relevancy | RAGAS 指标 | 生成层相关性 |
| 端到端满意度 | 人工 1-5 分 | 每类抽样 10 条人工评分 |
二十二、实验结果:按查询类型分析胜负
这是整篇文章最重要的部分。
先看总览:
| 指标 | 纯向量 | 纯 BM25 | 混合检索(RRF) |
|---|---|---|---|
| Recall@6(总体) | 0.833 | 0.742 | 0.917 |
| 意外实体率(总体) | 0.121 | 0.083 | 0.069 |
| Faithfulness(总体) | 0.79 | 0.74 | 0.85 |
| Answer Relevancy(总体) | 0.81 | 0.72 | 0.86 |
| 端到端满意度(总体) | 3.8/5 | 3.2/5 | 4.3/5 |
总体看,混合检索全面领先。
但这只是平均。
真正有价值的是分查询类型看胜负。
1. 实体精确查询
示例:“石梅湾怎么去?""加井岛门票多少钱?”
| 指标 | 纯向量 | 纯 BM25 | 混合检索 |
|---|---|---|---|
| Recall@6 | 0.760 | 0.960 | 0.920 |
| 意外实体率 | 0.150 | 0.040 | 0.060 |
BM25 完胜。
原因非常直观:用户直接说了”石梅湾”和”加井岛”这两个精确词,BM25 的 TF-IDF 机制会给包含这些词的 Chunk 极高权重。
向量检索的问题在于:
“石梅湾”和”日月湾”在语义空间里太近了。
它们都是万宁的海湾景点,Embedding 向量几乎挨着。当 TopK = 6 时,很容易把”日月湾”的 Chunk 也召回,挤掉真正的”石梅湾”。
混合检索在这里不是最优,但比纯向量好很多——因为 BM25 的高排名把”石梅湾”拉了上来。
结论:精确实体查询,BM25 是主力,向量是补充。
2. 约束密集型查询
示例:“万宁3天2人5000预算不想赶路希望海边”
| 指标 | 纯向量 | 纯 BM25 | 混合检索 |
|---|---|---|---|
| Recall@6 | 0.880 | 0.640 | 0.880 |
| 意外实体率 | 0.110 | 0.200 | 0.080 |
向量检索和混合检索并列,BM25 明显落后。
原因:这种查询里没有太多”精确景点名称”。
用户说的是”3天""2人""5000预算""海边”——这些是约束条件,不是实体名。
BM25 对”海边”这个词的区分度很低(太多景点都提到”海边”),而向量检索能理解”不想赶路”≈“休闲""轻松""半天”的语义关联。
混合检索和纯向量持平,是因为在这种查询上 BM25 的贡献很小,RRF 融合后基本还是向量说了算。
结论:约束密集型查询,向量检索是绝对主力。
3. 模糊意图查询
示例:“适合老人不去太远的海边""有没有那种很安静的地方”
| 指标 | 纯向量 | 纯 BM25 | 混合检索 |
|---|---|---|---|
| Recall@6 | 0.840 | 0.520 | 0.840 |
| 意外实体率 | 0.160 | 0.280 | 0.100 |
向量检索和混合检索并列领先,BM25 大幅落后。
这是向量检索的主场。
“适合老人""很安静”这些词在文档里可能根本不出现。景点描述里写的是”徒步难度低""人流量少""适合家庭出游”。
BM25 做不到”适合老人”→“徒步难度低”的语义映射。
向量检索可以。
混合检索在这里没有额外加成,但也没有拖后腿。
结论:模糊意图查询,纯向量足够,混合检索不亏。
4. 多跳推理查询
示例:“万宁和三亚哪个冲浪更好?""石梅湾和日月湾距离多远?”
| 指标 | 纯向量 | 纯 BM25 | 混合检索 |
|---|---|---|---|
| Recall@6 | 0.720 | 0.680 | 0.920 |
| 意外实体率 | 0.200 | 0.160 | 0.080 |
混合检索明显胜出。
这是最有意思的一类。
多跳查询需要同时召回两个或多个实体的信息。
比如”万宁和三亚哪个冲浪更好”需要同时召回:
- 万宁的冲浪信息(日月湾)
- 三亚的冲浪信息(后海村)
纯向量检索的问题:它倾向于召回语义集中的内容。万宁的冲浪和三亚的冲浪在语义空间里可能离得远,TopK = 6 的预算很容易被一边占满。
纯 BM25 的问题:它会匹配到”万宁""三亚""冲浪”这些词,但如果某个 Chunk 同时提到”万宁”和”冲浪”,它的分数会非常高,可能挤掉”三亚”的 Chunk。
混合检索为什么赢?
RRF 的共识奖励机制。
万宁相关的 Chunk 在向量检索中排名靠前(语义匹配”冲浪”),在 BM25 中也排名靠前(精确匹配”万宁”)。
三亚相关的 Chunk 同理。
两个检索器都给高排名的 Chunk 会在 RRF 中获得最高分。
而只被一个检索器找到的 Chunk(比如某个泛泛而谈”冲浪很好玩”的 Chunk)会被降权。
这就天然实现了:
同时被两种方式认可的内容优先。
结论:多跳推理查询,混合检索是唯一可靠方案。
5. 否定/排除型查询
示例:“万宁除了日月湾还有什么地方?""不要海边的景点”
| 指标 | 纯向量 | 纯 BM25 | 混合检索 |
|---|---|---|---|
| Recall@6 | 0.680 | 0.600 | 0.720 |
| 意外实体率 | 0.350 | 0.150 | 0.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 和生产系统之间另一条看不见的分界线。
系列回顾:
- 第一篇:数据层与实体级 Chunk 设计
- 第二篇:从语义检索到约束感知
- 第三篇:模型选型与 SSE 流式部署
- 第四篇:Prompt 工程与最终架构总结
- 第五篇:评测框架与混合检索对比实验(本文)
💬 评论
这套方案在哪一步卡住了?也欢迎补充你的实现方式或踩坑经验。