混合检索¶
一句话:混合检索 = 稠密向量(语义)+ 稀疏 BM25(关键词)两路召回,再用 RRF 融合排序,互补各自的召回盲区——这是把单一向量检索召回天花板拉高的关键一跃,许峰百万级文档案例正是"余弦 + BM25 → ReRank"。
概念¶
为什么不能只靠向量检索?因为稠密向量和 BM25(稀疏检索)是互补的,各有盲区:
- 向量检索(稠密):把语义压成向量,擅长语义相似——用户问"怎么设置熔断",能召回到写着"配置限流降级"的文档(同义改写)。但弱点是专有名词、编号、人名、型号等精确符号容易失真("SLA-2024-001"被 Embedding 后语义就糊了)。
- BM25(稀疏):本质是改进的 TF-IDF,基于词项匹配打分,擅长精确关键词命中——搜"许峰"能精准命中含"许峰"的文档。但弱点是不懂同义词、改写("熔断"和"限流降级"BM25 视为完全不同的词)。
两者盲区几乎不重叠:向量救回"语义改写但无关键词命中"的文档,BM25 救回"有精确关键词但语义不近"的文档。混合检索就是把两路 Top-K 各自召回,再融合成一个最终排序列表。这能显著拉高召回上限,是单一向量检索做不到的。
许峰案例(报告 §5.3):百万级文档知识管理,采用"余弦(向量)+ BM25(稀疏)→ ReRank"链路,正是混合检索的典型范式。
原理¶
两路独立召回¶
- 稀疏路(BM25):对 query 和文档做分词、词频统计,按 BM25 公式打分,取 Top-K(如 K=20)。BM25 公式核心:词项在文档中出现越多、越稀有(IDF 高)、文档越短,得分越高。
- 稠密路(向量):query 经 Embedding → 在向量库用余弦相似度(HNSW)取 Top-K。
- 两路结果合并去重,进入融合阶段。
RRF:融合公式¶
两路打分体系完全不同(BM25 是 TF-IDF 分数,向量是 0~1 的余弦值),直接加权求和不可行——尺度不同,且不同 query 下两路分数分布差异巨大,权重根本调不准。
RRF(Reciprocal Rank Fusion,倒数排名融合) 的解法是:不看分数,只看排名。把每路结果按排名转换,再求和:
rank_i(d)是文档 d 在第 i 路检索结果中的排名(第 1 名 rank=1,第 2 名 rank=2…)。k是平滑常数(通常取 60),作用是削弱头部排名的绝对优势、避免单路第一名垄断,让多路都靠前的文档胜出。- 每路 Top-K 都参与,文档在多路中排名越靠前、出现的路数越多,RRF 总分越高。
举例:文档 D 在 BM25 路排第 2、在向量路排第 1,k=60:
而只在 BM25 排第 1、向量路未命中的文档 E:RRF(E) = 1/(60+1) + 0 ≈ 0.0164。D 因两路都靠前而胜出,这正是混合检索的价值——多路共识优先。
RRF 相比加权求和的优势¶
| 维度 | 加权求和(α·BM25 + β·向量) | RRF |
|---|---|---|
| 尺度 | 两路分数尺度不同,必须先归一化,调参敏感 | 只看排名,天然消除尺度差异 |
| 权重 | α/β 需对每个数据集反复调,不同 query 最优权重不同 | k 是近乎固定的常量(60),几乎不用调 |
| 鲁棒性 | 某路分数分布异常(如全 0.9)会污染结果 | 排名受分数绝对值影响小,更稳 |
| 实现 | 需归一化 + 调权 | 公式极简,工程友好 |
正因如此,RRF 是混合检索融合的事实标准(Elasticsearch、Weaviate、Qdrant 的混合检索内置都是 RRF)。
import java.util.*;
// RRF 融合实现:每路是 List<检索结果>,按相关性已排好序
static final int K = 60; // RRF 平滑常数
// 输入:多路检索结果(每路按相关性降序),输出按 RRF 分数降序
static List<String> rrfFuse(Map<String, List<String>> perRouteResults, int topN) {
Map<String, Double> scores = new HashMap<>();
for (List<String> ranked : perRouteResults.values()) {
for (int i = 0; i < ranked.size(); i++) {
// rank 从 1 开始;排名越靠前贡献越大
double contribution = 1.0 / (K + (i + 1));
scores.merge(ranked.get(i), contribution, Double::sum);
}
}
return scores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(topN)
.map(Map.Entry::getKey)
.toList();
}
// 调用:bm25Hits、vectorHits 各自是 Top-20 排序列表
// Map.of("bm25", bm25Hits, "vector", vectorHits) → 融合出 Top-N
# RRF 融合实现:每路结果按相关性降序排列
K = 60 # RRF 平滑常数
def rrf_fuse(per_route_results: dict[str, list[str]], top_n: int) -> list[str]:
"""per_route_results: {'bm25': [...排好序], 'vector': [...排好序]}"""
scores: dict[str, float] = {}
for ranked in per_route_results.values():
for i, doc in enumerate(ranked):
# rank 从 1 开始;i=0 即第 1 名
scores[doc] = scores.get(doc, 0.0) + 1.0 / (K + (i + 1))
return sorted(scores, key=scores.get, reverse=True)[:top_n]
# 调用
fused = rrf_fuse({"bm25": bm25_hits, "vector": vector_hits}, top_n=20)
# ES 内置 RRF:直接在查询 DSL 用 rank_feature / rrf,无需手写
# LangChain 的 EnsembleRetriever 默认也用 RRF 融合 BM25Retriever + 向量检索
实战要点¶
-
混合检索是"合格 RAG"与"前沿 RAG"的分水岭:报告 §5.4 明确把"RAG 只有现成框架调用、无混合检索"列为已落后红旗。能用单一向量库跑通只是入门,生产级必须两路互补。
-
两路 Top-K 取大一点(如各 20~50),再交给 RRF + Rerank 收敛:召回阶段宁多勿漏,精排阶段再去噪。把召回的"宽"和精排的"准"分开,是全链路优化的核心思路。
-
RRF 的 k 用 60,别花时间调:k 对最终排序影响远小于 chunk_size、Embedding 选型这些,60 是社区共识默认值。把精力投在召回质量和 Rerank 上回报更高。
-
ES 一套库天然适合做混合检索:ES 的 kNN 向量字段 + BM25 全文检索同库,RRF 内置支持,省一套基础设施。已有 ES 的团队,混合检索落地成本最低。许峰百万级文档若基础设施已是 ES,"余弦 + BM25"可同库完成。
-
稀疏路别忘了中文分词:BM25 依赖分词,中文必须装 IK / jieba 分词器,否则"配置熔断"会被切成单字,BM25 退化成字面匹配,效果大打折扣。
本节相关题目¶
| 难度 | 题目 | 链接 |
|---|---|---|
| 基础 | BM25 vs 向量检索各擅长什么 | → 题库 |
| 进阶 | 为什么单一向量检索不够、混合检索互补在哪 | → 题库 |
| 进阶 | RRF 融合公式与原理、相比加权求和优势 | → 题库 |