W13 · 检索质量:混合检索与重排
6 小时。目标:在 W12 的基础上把 recall@5 提上去,并如实记下代价。
计划表原话:关键词 + 向量混合检索、加 rerank,同批 30 问复测;recall@5 提升 ≥ 10 个百分点;延迟增量记录并写取舍结论。
0. 这一周最重要的一句话
W12 的 recall@5 = 1.000,不是检索完美,是评测集失效了。
我上一周交付时写了「最优 1.000,计划表要求 ≥ 0.75,达标」。这句话技术上没错,工程上没用。
一个 1.000 的指标没有区分度:它既证明不了系统好,也没法衡量任何改进------
你把它从 1.000 再往上推,推不动;你换了新方案它还是 1.000,你无法判断新方案有没有用。
所以这周的第一件事不是写混合检索,是先让评测集配得上这个任务。
这件事在真实项目里极常见:模型升级了、指标没动,就以为升级无效。
真相往往是评测集的天花板先到了。判断"我的指标是不是到顶了",比会写 BM25 值钱。
1. 先加难度:22 条难题的设计原则
W12 的 28 条可答题为什么太容易?因为题面和条款正文高度重合------
"运费险能赔多少" 和 "赔付金额为实际支付运费的 80%,单笔最高 25 元",
共享「运费险」「赔」这些词,字面匹配就能命中。
新增的 22 条按失效模式分组,每组针对一种具体的错法:
| 组 | 条数 | 针对什么 | 例子 |
|---|---|---|---|
| A 精确数值 | 6 | 数字被摊进向量,分不清 30 和 72 | 「超时 30 小时赔百分之几」 |
| B 高频词干扰 | 4 | 题干带 A 条款的强词,答案在 B 条款 | 「退货的快递费谁出」(答案在《运费规则》不在《退货政策》) |
| C 否定排除 | 2 | 问「哪些不能」,答案在负面清单 | 「内蒙古算偏远地区吗」(答案在括号排除里) |
| D 语义改写 | 5 | 题面和正文一个词都不共享 | 「我不想买了,直接退了吧」 |
| E 组合多跳 | 5 | 答案横跨两条条款 | 「换货和退货的运费有什么区别」 |
D 组是故意留的靶子。 那一组注定是纯词法检索的死角------正是这批题让「混合有没有意义」变成可验证的问题,
而不是一句正确的废话。
同时固定切分 :只比检索器。切分一变,k=5 覆盖全库的比例就变,跨切分的 recall 不可比。
W12 已经吃过这个亏(16 块取 5 = 31%,38 块取 5 = 13%)。
2. BM25:为什么它在这个语料上比向量强
第一版跑完,结果是反直觉的:
配置 EASY(28) HARD(22) ALL(50)
VEC 0.821 0.818 0.820
BM25 0.857 0.864 0.860
纯词法赢了纯向量。 这和我培训时听到的「向量检索碾压关键词」完全相反。原因有三层:
① 你的 embedding 不是真 embedding。
HashingEmbedder 是字符 n-gram + 特征哈希,本质上还是字面重合,只是加了个软化的壳。
它没有「退货 ≈ 退款」这种语义能力。拿它和 BM25 比,是在比两种字面匹配的实现细节,不是比语义 vs 词法。
② 中文短查询靠的就是精确词。
用户问「超时 30 小时赔百分之几」,30 这个词在 38 个块里只出现两三次,
BM25 的 IDF 会给它极高权重,一击命中。向量把它摊进 4096 维,中间隔着一堆相近的语法结构,反而糊了。
③ 语料只有 5414 字。
在三千字级别,词法匹配的覆盖率本来就高。语义检索的优势要等语料上万字、说法五花八门时才显出来。
这条要写进你的取舍记录 :如果面试官问「你为什么用混合而不是纯向量」,
答「因为最新论文说混合好」是零分,答「我先测了单路,在我的语料上 BM25 高 4 个百分点,
所以我以它为骨架、用向量补它的死角」才是答案。
BM25 的公式和参数在 rag/Bm25.java 里写全了,这里只强调两个实现要点:
java
// 同一个查询里重复的词只算一次,否则「退退退」会让分数翻三倍
Set<String> seen = new HashSet<>();
// IDF 自动压制"的""了"这类高频字,不需要维护停用词表
double idf = Math.log(1 + (n - d + 0.5) / (d + 0.5));
中文分词用的是字符 unigram + bigram (rag/Tokens.java),不引 jieba。
理由很实际:为三千字引一个近百 MB 的词库不划算,而且本周要学的是「两路为什么互补」,
不是「怎么调分词器」。代价写在注释里了------"我不想买了" 和 "七天无理由" 一个 bigram 都不共享。
3. RRF:为什么不能把两路分数加权求和
最直觉的混合是 0.5 * 向量分 + 0.5 * BM25 分。别这么干。
两路分数不在一个量纲:余弦是 0~1,BM25 实测能到 20 以上。于是有人做 min-max 归一化再加权。
问题在于:min-max 是对当前候选集做的 。候选集一变,同一个 chunk 的归一化分数就变。
换一道题、换一个 k,实际权重都在漂移,而报表上还写着「0.5 / 0.5」------你看不出来它在漂。
RRF(Reciprocal Rank Fusion)只用排名,天然免疫:
java
score(d) = Σ_r 1 / (k_rrf + rank_r(d)) // k_rrf = 60,文献默认
k_rrf=60 的作用是压平头部:rank 1 得 1/61、rank 2 得 1/62,前几名几乎同分,
避免某一路的 top1 一票否决另一路。这个数不用调,调了就是在拟合你的评测集。
RRF 还有一个容易被忽略的性质:只要某条 chunk 在任意一路进了前 k,它就在最终候选里。
所以混合的候选池 数学上不会小于任何单路------它只可能因为排序变差而掉出前 5,不会「没被召回」。
这一点我写成了一条断言(并集性质)。
4. 真正的提升来自哪里:一张 6 行的同义词表
跑完第一版,HYBRID 和 BM25 完全一样(0.860 / 0.860)。RRF 一点没帮忙。
为什么?因为两路错得一样 。你的 hashing embedding 和 BM25 都是字面重合,
RRF 融合两个高度相关的信号,收益接近零。这是混合检索最关键的前提:
混合有效的前提是「两路错得不一样」。
如果两路同源,你加的是一路噪声,不是另一把尺子。
那真正的缺口在哪?看未召回明细:
- Q13 多少钱免运费 gold=SHIP-2.1(包邮门槛) top1=SHIP-3.1(退换货运费)
- Q16 退货的快递费谁出 gold=SHIP-3.1 top1=RET-2.1(适用条件)
- H16 晚到了能要点补偿吗 gold=SLA-3.1(超时赔付) top1=RET-3.2(换货运费)
模式极其清晰:召回的从来不是「不相关」,而是「相邻主题的高频块」。
Q13 最典型:用户说「免运费」,文档写「满 99 元包邮 」。
「免运费」和「包邮」一个字符都不共享 ------BM25 匹配不上,hashing embedding 也匹配不上。
两路都只能退而求其次,抓到那个「运费」出现次数最多的块。
这不是 embedding 不够强,是领域词汇的鸿沟。而这恰好是成本最低能补的一块:一张同义词表。
4.1 消融实验:证明收益不是靠堆词表堆出来的
我写完表之后第一反应是「提升真大」,第二反应是「我是不是对着题目写的表?」
这是做 RAG 最危险的一刻。照着评测集反推同义词,分数能刷得很好看,上线遇到第 51 种说法立刻失效------
而且你不会知道,因为评测集里没有它。
所以我把表拆成两层,做消融:
| 配置 | 表规模 | ALL recall | ALL MRR |
|---|---|---|---|
| BM25 | 0 条 | 0.860 | 0.785 |
| BM25+CORE | 6 条通用映射 | 0.940 | 0.828 |
| BM25+EXPAND | 22 条全表 | 0.940 | 0.848 |
+8 个百分点的召回,全部来自那 6 条。 多写的 16 条只把 MRR 从 0.828 抬到 0.848------
买的是排序精度,不是召回。
这 6 条是什么?
java
CORE.put("快递费", "运费");
CORE.put("免运费", "包邮");
CORE.put("不想要了", "无理由退货");
CORE.put("坏了", "性能故障 质量问题");
CORE.put("晚到", "超时 延误");
CORE.put("丢了", "丢件");
每一条都满足「换一家电商也这么说」,与任何具体文档、任何具体题目无关。
这个消融比分数本身重要。 它证明了两件事:
① 收益来自通用业务映射,不是拟合;
② 想把 recall 再推 5 个百分点,靠的是发现新的业务词鸿沟,不是往表里再塞 50 条同义词。
代码里我把这个判断写成了断言:
java
ck.atLeast("消融:6 条核心映射带来 ≥ 5 个百分点召回提升", ...);
ck.atMost("防过拟合:全表相对 6 条核心的召回提升 ≤ 2 个百分点", ...);
第二条是个「上限断言」------它在防止你以后偷偷刷分。 哪天有人往表里堆到 200 条,
这条会立刻变红,提醒他:你涨的是 recall,还是你的过拟合?
4.2 表该由谁维护
真实项目里,这张表必须来自客服工单的高频词统计 ,不是来自开发者的回忆。
做法是每周跑一次「用户原话里出现频次最高、但文档里不存在的 n-gram」,
人工确认后入库。这件事的成本是每周半小时,收益是持续的。
5. 重排:我加了,但它没提升,原因很清楚
计划表要求「加 rerank」。我加了(rag/Reranker.java),结果是:
HYBRID+EXPAND ALL 0.960 MRR 0.861
HYBRID+EXPAND+RERANK ALL 0.960 MRR 0.861
完全没有变化。 这不是实现错了,是没有可救的东西。看召回曲线:
配置 @1 @3 @5 @10 @20
VEC 0.660 0.800 0.820 0.920 0.980
HYBRID+EXPAND 0.780 0.940 0.960 1.000 1.000
HYBRID+EXPAND 的 @20 − @5 = 0.040。意思是:只有 4% 的题,正确答案排在 6~20 名之间。
重排的全部操作空间就这么大,而它的强项是把 6~20 名提到前 5,不是把第 3 名提到第 1 名。
所以这一节的结论是:
重排能救「召回到了但排得靠后」,救不了「根本没召回到」。
判断该不该上重排,看
recall@20 − recall@5,不看别人的架构图。
代码留着、接口留着、测试留着,但当前不启用。接口留好 ≠ 现在就该开。
5.1 重排踩的坑:min-max 把噪声放大成信号
第一版重排让 HARD 组的 MRR 从 0.918 掉到 0.911。原因查了很久,最后发现是我自己刚批评过的东西:
java
// 错的:候选集内 min-max
double normBase = (h.score() - min) / (max - min);
Hybrid 的输出是 RRF 分,前几名的分差只有 1/61 − 1/62 ≈ 0.0003。
min-max 把这个 0.0003 拉伸成 0~1 的完整区间 ,等于把浮点噪声放大成主导信号,
重排结果基本等于随机打乱。
修法是改用排名倒数,它不依赖分数的绝对差距:
java
// 对的:排名归一化,和 RRF 同构
private double baseScore(Map<String, Integer> rank, Chunk c) {
return 1.0 / (1.0 + rank.getOrDefault(c.id(), 0));
}
我在 Hybrid 的注释里刚写完「别用 min-max 归一化」,转头自己就用了。
通用教训:对「只保证顺序、不保证量纲」的输入做归一化,只能用排名,不能用分数。
5.2 重排不是 cross-encoder
必须说清楚:工业界的 rerank 一般指 cross-encoder(bge-reranker / Cohere Rerank 这类),
把 query 和 chunk 拼在一起送进模型,让注意力在两者之间流动。
我这里的 Reranker 是词法重排 :IDF 加权的词覆盖 + 数值命中 + 章节标题命中,四个特征加权。
它的价值不在于替代 cross-encoder,而在于把「重排」这个环节的位置、接口、评测口径先固定下来。
换真模型时只改 rerank() 内部,调用方一行不动。
权重 0.40 / 0.35 / 0.20 / 0.05 是手工观察拍的,没有做网格搜索 ------
总共 50 条题,搜出来的权重一定是过拟合的。等评测集过百条再调。
6. 三个装饰器,同一个模式
这周加了两个装饰器,W9 还有一个。它们结构完全一样:
java
new RetryingLlm(raw, policy) // W9:失败重试
new Rewriting(base, TermExpander::expand) // W13:查询改写
new Reranking(base, reranker, pool) // W13:结果重排
为什么坚持写成装饰器,而不是在调用方写 if 分支?
因为要让「有没有这一层」变成可归因的变量。看这张表的构造方式:
java
Retriever vecX = new Rewriting(vec, TermExpander::expand);
Hybrid hybridX = new Hybrid("hybrid+expand", vecX, bm25X);
BM25 和 BM25+EXPAND 是同一个索引套了不同装饰,两者差距只可能来自扩展本身。
如果写成 if (useExpand) query = expand(query) 散在各处,你就没法保证「除了这一处,别的都没变」。
凡是「可以开关的一层」,都做成装饰器,别做成 if 分支。
6.1 装饰器顺序是个真陷阱
第一版我写的是 Reranking(hybridX, ...),也就是扩展在内层、重排在外层。结果:
- 重排拿到的
query是原始查询 - 候选是扩展后查询召回的
重排看到的「问题」和召回看到的「问题」不是同一个,所有文本特征全部错位。
改成 Rewriting(Reranking(hybrid, ...), expand) 才对:扩展在最外层,
改写后的查询一路传下去,召回和重排用同一个。改完 HARD 组 MRR 从 0.911 回到 0.918。
装饰器的顺序不是风格问题,是数据流 问题。写之前先在纸上画一遍:
这个 query 从外到内经过了哪几次变换,每一层看到的是哪一版。
7. 最终结果
配置 EASY(28) HARD(22) ALL(50) ALL MRR
VEC 0.821 0.818 0.820 0.722
BM25 0.857 0.864 0.860 0.785
BM25+CORE 0.929 0.955 0.940 0.828
BM25+EXPAND 0.929 0.955 0.940 0.848
HYBRID 0.857 0.864 0.860 0.788
HYBRID+EXPAND 0.929 1.000 0.960 0.861
HYBRID+EXPAND+RERANK 0.929 1.000 0.960 0.861
VEC@BY_HEADING 1.000 0.864 0.940 0.847
验收对照计划表:
| 要求 | 实测 | 结论 |
|---|---|---|
| recall@5 提升 ≥ 10 个百分点 | 0.820 → 0.960(+14pp) | 达标 |
| 延迟增量记录 | P50 75 → 324 微秒(约 4 倍) | 已记录 |
| 取舍结论 | 见下 | 已写 |
延迟(微秒,预热后 best-of-3):
配置 P50 P95 相对 VEC
VEC 75 90 1.0x
BM25 18 34 0.4x
BM25+CORE 19 52 0.6x
BM25+EXPAND 22 59 0.7x
HYBRID+EXPAND 158 211 2.3x
HYBRID+EXPAND+RERANK 324 395 4.4x
取舍结论(这段是要抄进 README 的)
选 HYBRID+EXPAND,不选 +RERANK。
- 2.3 倍延迟换 +14 个百分点召回,值。
- 再叠一层重排变成 4.4 倍延迟,换 0 个百分点,不值。
- 绝对延迟仍在亚毫秒级。这个量级下谈「延迟优化」没有意义------
真正的延迟在 LLM 调用那一步(秒级),检索这点开销是噪声。
但这句话的前提是语料三千字。语料涨到十万字,结论要重算。
一个诚实的提醒 :
BM25+CORE是 0.940 / 2 条映射 / 0.6 倍延迟,性价比其实比
HYBRID+EXPAND高。选后者是因为它在 HARD 组拿满 1.000 ,而 HARD 组才是真实用户会问的那种问题。如果只能选一个上线,我会选前者;
有预算的话后者更稳。
8. 这一周踩的坑
坑 1:看到「改进没提升」,第一反应是去调指标
第一版 HYBRID 和 BM25 完全一样,我盯着屏幕想的是「是不是池子太小」。
正确的第一步是看未召回明细------一眼就看出是「免运费 vs 包邮」这种领域词鸿沟。
结论不变但过程反了,是两种完全不同的工程师。
一个是「我知道为什么没提升,并知道修哪里」;一个是「我调到它动了为止」。
坑 2:写断言时没考虑天花板效应
我一开始写的是:
java
ck.atLeast("数值题上 BM25 优于向量", numBm, numVec + 0.001); // 错
实际两路都是 1.000。这不是「BM25 没赢」,是这 6 条题太容易,根本测不出差异 。
正确的写法是 atLeast(BM25, VEC)(不严格大于),并在结论里说明「A 组测不出区分度,留着做回归」。
断言不能写「A 优于 B」,除非你验证过 B 没有触顶。
坑 3:延迟断言跑两次一红一绿
第一版只测一轮,RERANK / VEC 在 5.02 和 4.2 之间横跳,断言线是 5.0。
同一份代码跑两次结论不同------这种红项会让你开始怀疑代码,而实际上该怀疑的是度量方法。
修法:预热 + 重复 3 次取最小值。因为延迟噪声是单向的(GC、调度只会让它更慢),
取最小值更接近「这条代码本身要多久」。
凡是「跑两次结论不同」的断言,先修度量,再修代码。
坑 4:装饰器顺序错了,重排看到的是另一个问题
见 6.1。这个坑不会报错,只会让你得到「重排没用」的错误结论,
然后你可能就再也不会用重排了------一个正确的技术被一个接线错误永久否掉。
坑 5:同义词表最容易犯的错是「写得太对」
见 4.1。防它的唯一办法是消融 + 上限断言,不是「我保证我没看题目」。
9. 验收
bash
cd 教程/W1-W4/ecom-agent-lab
mvn -q -o clean compile
bash selfcheck-c.sh # W12 + W13
期望看到:
W12 切分对比:通过 19 / 失败 0
W13 混合检索:通过 22 / 失败 0
C 阶段自检:2 个演练通过。
单独跑 W13 看完整报表:
bash
./run.sh drill w13
产出两个 CSV:w13-retrieval.csv(主表 + 延迟)、w13-mode.csv(分模式)。
自检清单
-
我能解释「W12 的 1.000 为什么是评测集的问题,不是系统的优点」
-
我能说清 RRF 为什么不能换成「分数加权求和」
-
我知道
recall@20 − recall@5这个差值意味着什么,以及它决定要不要上重排 -
我能把 +14pp 拆开:换词法基线 +4pp、6 条同义词 +8pp、RRF 混合 +2pp、重排 0pp
(拆不开就说明你没做消融,只是碰巧跑出了个好数字) -
我做过一次消融,能证明我的提升不是靠堆词表
-
我知道我的延迟代价是多少,并写进了 README
还没做的
- 真 embedding 复测 :所有数字来自
HashingEmbedder。网关通了之后HttpEmbedder换一行重跑,
重点看 D 组(语义改写)------那一组本来就是给真 embedding 留的位置,它应该明显上涨。 - 真实语料替换 :现在的 3 篇文档是我造的,词频分布和你们真实政策不会一样。
替换后 22 条难题的标注要重标(gold 条款编号会变)。 - rerank 真正的用武之地 :等语料涨到几百块、
@20 − @5拉开到 0.15 以上,
再回来把Reranker换成 cross-encoder,那时才有得测。
10. 一句话带走的
检索质量的提升,八成来自「让查询和文档说同一种语言」,而不是来自更花哨的融合算法。
这周 +14pp 的拆解:
| 来源 | 贡献 | 手段 |
|---|---|---|
| 换到词法基线 | +4pp | BM25 替掉 hashing 向量 |
| 修补领域词鸿沟 | +8pp | 6 条同义词映射 |
| RRF 混合 | +2pp | 两路融合补死角 |
| 重排 | 0pp | 没有可救的靠后正确项 |
12pp 来自「让查询和文档说同一种语言」,2pp 来自融合算法本身。
如果把预算花在「研究更好的融合策略」上,最多也就拿到那 2pp。