BM25 擅长精确标识符和稀有术语,向量检索擅长语义匹配和跨语言。单独使用任何一种都会在对方擅长的查询类型上表现不佳。本篇把两路召回的结果合并为一个排名——混合检索。

两路召回各有盲区

在第 16 篇的评测集上分别运行 BM25 和向量检索,按查询类型统计 nDCG@10:

查询类型 BM25 向量检索 差距
精确标识符 BM25 胜出,向量空间对标识符区分度差
概念查询 向量胜出,语义匹配覆盖同义表达
中英混合 中高 向量稍好,跨语言 embedding 有优势
同义改写 向量大幅胜出
多条件 各有优劣
无答案 BM25 正确返回零结果,向量总会返回"最近"的

没有一路在所有类型上都最优。混合检索的目标:在每种类型上至少达到两路中较好的那个。

Reciprocal Rank Fusion

为什么不直接加分数

BM25 分数通常在 0-30 之间,向量相似度在 0-1 之间。直接加权求和 α × BM25 + β × cosine 有三个问题:

  1. 量纲不同——需要归一化,但归一化方式(min-max、z-score)会改变分布
  2. 分数分布不同——BM25 分数的方差远大于向量分数
  3. α 和 β 难调——最优权重取决于查询类型,静态权重无法适应

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 等学习到排序模型

练习

  1. 对 5 个查询分别运行 BM25 和向量搜索,手算 RRF 分数并验证实现
  2. 在开发集上比较 BM25-only、Dense-only、RRF 三种模式的 nDCG@10
  3. 将 k 分别设为 1、60、1000,观察融合结果的变化
  4. 构造一个无答案查询,验证混合检索的处理是否合理
  5. 测量并行执行 vs 串行执行的延迟差异

延伸阅读

  • Cormack, Clarke & Butt, “Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods”, SIGIR 2009
  • Elastic 官方指南:Hybrid Search
  • Apache Lucene: 混合查询组合方式