Java转AI Agent教程(第十三周)混合检索与重排

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。

相关推荐
爱码少年25 分钟前
Springboot下如何将CSV文件数据导入H2、Mysql数据库表
数据库·mysql·csv
.痞老板.1 小时前
java ee--多线程(进阶)
java·java-ee
可乐ea1 小时前
智能体记忆该拆成几层:笔记、记忆表示与注入上下文
服务器·数据库·笔记·ai智能体·提示词工程·上下文工程·记忆系统
老金带你玩AI2 小时前
惊人的一致性!Seedream 5.5实测!
人工智能
数据技术说2 小时前
在 KES AI 数据库上搭一条 NL2SQL 链路:语义、召回、权限各放哪
人工智能·sql·代码规范
深入云栈3 小时前
Netty 4.2.x 源码深度解析 (二十三):ByteBuf 核心设计 —— 索引、类型与零拷贝
java·后端·架构
jay神3 小时前
基于 YOLOv8 的手势识别系统(Web 版)
前端·人工智能·python·深度学习·yolo·计算机视觉·计算机毕业设计
阿义有问题3 小时前
MySQL 迁移达梦全流程实战:DTS 七步图解 + 对象名全大写适配(附踩坑清单)
数据库
奶油话梅糖3 小时前
用 iPhone 扫一间房,AI 帮你在浏览器里 1:1 重建它:Wes Bos 的 scan-to-threejs 工作流
人工智能·ios·iphone