BM25 擅长精确标识符和稀有术语,向量检索擅长语义匹配和跨语言。单独使用任何一种都会在对方擅长的查询类型上表现不佳。本篇把两路召回的结果合并为一个排名——混合检索。
两路召回各有盲区
在第 16 篇的评测集上分别运行 BM25 和向量检索,按查询类型统计 nDCG@10:
| 查询类型 |
BM25 |
向量检索 |
差距 |
| 精确标识符 |
高 |
低 |
BM25 胜出,向量空间对标识符区分度差 |
| 概念查询 |
中 |
高 |
向量胜出,语义匹配覆盖同义表达 |
| 中英混合 |
中 |
中高 |
向量稍好,跨语言 embedding 有优势 |
| 同义改写 |
低 |
高 |
向量大幅胜出 |
| 多条件 |
中 |
中 |
各有优劣 |
| 无答案 |
好 |
差 |
BM25 正确返回零结果,向量总会返回"最近"的 |
没有一路在所有类型上都最优。混合检索的目标:在每种类型上至少达到两路中较好的那个。
Reciprocal Rank Fusion
为什么不直接加分数
BM25 分数通常在 0-30 之间,向量相似度在 0-1 之间。直接加权求和 α × BM25 + β × cosine 有三个问题:
- 量纲不同——需要归一化,但归一化方式(min-max、z-score)会改变分布
- 分数分布不同——BM25 分数的方差远大于向量分数
- α 和 β 难调——最优权重取决于查询类型,静态权重无法适应
RRF 公式
Reciprocal Rank Fusion 完全绕过分数,只用排名:
1
| RRF_score(d) = Σ_i 1 / (k + rank_i(d))
|
k 是平滑常数(标准值 60),防止排名第 1 的文档权重过大。
对于两路召回(BM25 + 向量):
1
| RRF_score(d) = 1/(k + rank_bm25(d)) + 1/(k + rank_vec(d))
|
如果文档 d 只在一路中出现,另一路的贡献为 0(该文档不在另一路的 Top-N 中)。
手算示例
k = 60,BM25 和向量各返回 Top-5:
| 文档 |
BM25 排名 |
向量排名 |
RRF 分数 |
| A |
1 |
- |
1/61 = 0.01639 |
| B |
2 |
3 |
1/62 + 1/63 = 0.03200 |
| C |
3 |
1 |
1/63 + 1/61 = 0.03226 |
| D |
- |
2 |
1/62 = 0.01613 |
| E |
4 |
4 |
1/64 + 1/64 = 0.03125 |
RRF 排序:C > B > E > A > D
文档 C 在两路都靠前(BM25 第 3,向量第 1),融合后排第一。文档 A 虽然 BM25 排第 1,但只在一路出现,融合后排第四。
实现
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| record DocScore(String docId, double score) {}
List<DocScore> reciprocalRankFusion(List<DocScore> bm25Results, List<DocScore> vectorResults, int k) { Map<String, Double> rrfScores = new HashMap<>();
for (int i = 0; i < bm25Results.size(); i++) { String docId = bm25Results.get(i).docId(); rrfScores.merge(docId, 1.0 / (k + i + 1), Double::sum); } for (int i = 0; i < vectorResults.size(); i++) { String docId = vectorResults.get(i).docId(); rrfScores.merge(docId, 1.0 / (k + i + 1), Double::sum); }
return rrfScores.entrySet().stream() .map(e -> new DocScore(e.getKey(), e.getValue())) .sorted(Comparator.comparingDouble(DocScore::score).reversed()) .toList(); }
|
k 值的影响
| k |
效果 |
| 1 |
排名第 1 的权重极高(1/2 = 0.5),头部集中 |
| 60 |
标准值,权重平缓衰减 |
| 1000 |
所有排名权重接近,几乎等权 |
k = 60 是原始论文推荐的值,在多数场景下表现稳定,不需要调优。
候选并行获取
并行执行
BM25 搜索和向量搜索没有数据依赖,可以并行:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| List<DocScore> hybridSearch(String queryText, float[] queryVec, int topN) { CompletableFuture<List<DocScore>> bm25Future = CompletableFuture.supplyAsync(() -> bm25Search(queryText, topN)); CompletableFuture<List<DocScore>> vecFuture = CompletableFuture.supplyAsync(() -> vectorSearch(queryVec, topN));
try { List<DocScore> bm25 = bm25Future.get(3, TimeUnit.SECONDS); List<DocScore> vec = vecFuture.get(3, TimeUnit.SECONDS); return reciprocalRankFusion(bm25, vec, 60); } catch (TimeoutException e) { return handlePartialResult(bm25Future, vecFuture); } }
|
单路降级
如果向量搜索超时或 embedding 服务不可用,退化为纯 BM25:
1 2 3 4 5 6 7 8 9 10 11
| List<DocScore> handlePartialResult( CompletableFuture<List<DocScore>> bm25Future, CompletableFuture<List<DocScore>> vecFuture) { if (bm25Future.isDone() && !bm25Future.isCompletedExceptionally()) { return bm25Future.join(); } if (vecFuture.isDone() && !vecFuture.isCompletedExceptionally()) { return vecFuture.join(); } return List.of(); }
|
搜索服务的可用性不能依赖 embedding 模型服务。模型挂了,搜索退化但不中断。
Chunk 到 Document 粒度对齐
问题
BM25 索引的粒度是 document(一篇完整网页),向量索引的粒度是 chunk(一个段落)。融合前必须统一。
方案
向量搜索返回 chunk 后,先聚合为 document 级别排名,再与 BM25 做 RRF:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| List<DocScore> hybridSearch(String queryText, float[] queryVec, int topN) { List<DocScore> bm25Results = bm25Search(queryText, topN);
List<ScoredChunk> chunkResults = vectorSearch(queryVec, topN * 3); List<DocScore> vecDocResults = aggregateToDoc(chunkResults, topN);
return reciprocalRankFusion(bm25Results, vecDocResults, 60); }
List<DocScore> aggregateToDoc(List<ScoredChunk> chunks, int topN) { Map<String, Double> docMaxScore = new HashMap<>(); for (ScoredChunk chunk : chunks) { docMaxScore.merge(chunk.docId(), chunk.score(), Math::max); } return docMaxScore.entrySet().stream() .map(e -> new DocScore(e.getKey(), e.getValue())) .sorted(Comparator.comparingDouble(DocScore::score).reversed()) .limit(topN) .toList(); }
|
向量搜索时多召回一些 chunk(topN × 3),确保聚合后有足够的 document 候选。
去重与平分规则
去重
RRF 的 merge 操作天然处理了去重——同一 docId 的分数被累加,不会出现重复。
平分规则
RRF 分数相同时(极少见),用 BM25 排名作为 tiebreaker:
1 2 3 4 5 6 7 8 9 10 11 12 13 14
| List<DocScore> sortWithTiebreaker(Map<String, Double> rrfScores, List<DocScore> bm25Results) { Map<String, Integer> bm25Rank = new HashMap<>(); for (int i = 0; i < bm25Results.size(); i++) { bm25Rank.put(bm25Results.get(i).docId(), i); }
return rrfScores.entrySet().stream() .sorted(Comparator .comparingDouble((Map.Entry<String, Double> e) -> e.getValue()).reversed() .thenComparingInt(e -> bm25Rank.getOrDefault(e.getKey(), Integer.MAX_VALUE))) .map(e -> new DocScore(e.getKey(), e.getValue())) .toList(); }
|
平分时优先 BM25 排名高的——精确匹配优先于语义模糊匹配。
四路对照实验
在开发集上比较四种配置:
1 2 3 4 5 6 7 8 9 10 11
| void compareRetrievalModes(QuerySet devSet) { String[] modes = {"BM25-only", "Dense-only", "RRF", "RRF-k30"}; for (String mode : modes) { double ndcg = evaluate(devSet, mode); Map<String, Double> perType = evaluatePerType(devSet, mode); System.out.printf("%s → nDCG@10=%.4f\n", mode, ndcg); for (var entry : perType.entrySet()) { System.out.printf(" %s: %.4f\n", entry.getKey(), entry.getValue()); } } }
|
预期结果:
- BM25-only 在精确标识符上最强
- Dense-only 在同义改写上最强
- RRF 在整体 nDCG 上最高——取两路的长处
- 无答案查询:RRF 可能返回向量检索的"最近"结果(误召回),需要设置分数阈值
无答案查询的处理
向量检索总是返回"最近"的 K 个向量——即使查询和所有文档都不相关。BM25 在没有匹配 term 时正确返回零结果。
混合检索中需要处理这个问题:
1 2 3 4 5 6 7 8 9
| List<DocScore> filterLowConfidence(List<DocScore> rrfResults, List<DocScore> bm25Results) { if (bm25Results.isEmpty()) { return rrfResults.stream() .filter(d -> d.score() > HIGH_CONFIDENCE_THRESHOLD) .toList(); } return rrfResults; }
|
当前局限
- RRF 对所有查询用相同的 k 值——不同查询类型可能需要不同权重
- 向量搜索的无答案问题没有完美解决方案
- 候选数 topN 是固定的——调大增加召回但也增加融合计算
- 没有学习融合权重——生产系统会用 LambdaMART 等学习到排序模型
练习
- 对 5 个查询分别运行 BM25 和向量搜索,手算 RRF 分数并验证实现
- 在开发集上比较 BM25-only、Dense-only、RRF 三种模式的 nDCG@10
- 将 k 分别设为 1、60、1000,观察融合结果的变化
- 构造一个无答案查询,验证混合检索的处理是否合理
- 测量并行执行 vs 串行执行的延迟差异
延伸阅读
- Cormack, Clarke & Butt, “Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods”, SIGIR 2009
- Elastic 官方指南:Hybrid Search
- Apache Lucene: 混合查询组合方式