混合检索:向量 + BM25 + RRF 融合

一句话:向量检索找”意思相近”,BM25 找”字面对得上”,RRF 把两路结果拼成一份靠谱清单——三剑合璧,召回率和精准率同时起飞。


一、为什么一种检索不够?——“盲人摸象” analogy

想象你和两个朋友一起摸一头大象:

  • BM25(关键词检索) 像摸到象鼻的朋友:“这有个长长的管子,表面粗糙有纹路” → 精确描述局部特征
  • 向量检索(语义检索) 像摸到象腿的朋友:“这是个粗壮的柱子,支撑着庞大身躯” → 理解整体语义

如果只信一个人的描述,你对大象的认知是残缺的。混合检索就是让两个人同时描述,再综合成完整画面。

两类查询的”翻车”现场:

查询类型只用 BM25只用向量检索结果
”报销制度”(标题原词)✅ 标题精确命中❌ 语义漂移(可能匹配”费用审批""差旅标准”)BM25 稳
”如何推销保险产品”(口语改写)❌ “推销”和”销售”没对上✅ 语义相近匹配到”保险销售技巧”向量稳
”404 状态码”(专业术语)✅ 精确命中错误码❌ 可能匹配”HTTP 错误""页面不存在”等泛泛内容BM25 稳
”现金价值”(保险术语)❌ 通用语境下被日常财务内容淹没⚠️ 可能正确,也可能漂移都不稳,需要混合

BM25 和向量检索解决的是不同类型的’漏找’问题。BM25 对标题、编号和领域术语这类原词明确的内容更稳定,向量检索更适合用户换了一种说法的情况。


2.1 核心原理

把查询和文档都转换成高维向量(Embedding),在向量空间里计算相似度。语义相近的内容,向量距离就近。

用户问:"如何推销保险产品?"
↓ Embedding 模型编码
Query Vector: [0.12, -0.34, 0.89, ...]  (768/1024/1536 维)
 
文档库:
  Doc1: "保险销售技巧" → Vector1: [0.15, -0.30, 0.85, ...] → cos_sim = 0.92 ✅
  Doc2: "保险产品介绍" → Vector2: [0.08, -0.20, 0.60, ...] → cos_sim = 0.75
  Doc3: "保险理赔流程" → Vector3: [-0.10, 0.40, 0.20, ...] → cos_sim = 0.30

2.2 向量检索的优缺点

维度评价
优势理解语义和同义词;不受关键词变形影响(“推销”≈“销售”);能处理口语化查询
劣势对专业术语/错误码/编号等精确匹配弱;短查询容易语义漂移;依赖 Embedding 模型质量
适用概念搜索、语义相似性查询、用户换说法的情况
不适用精确术语匹配、短标题查询、需要原词命中的场景

2.3 常用 Embedding 模型

模型维度特点适用场景
text-embedding-3-small1536OpenAI 出品,性价比高通用场景
text-embedding-3-large3072精度更高,成本也高高精度需求
bge-m31024北京智源,开源可本地部署中文场景优选
Jina Embeddings768/1024长上下文支持好长文档检索
all-MiniLM-L6-v2384轻量快速,本地运行资源受限场景

3.1 什么是 BM25?

BM25(Best Match 25)是信息检索领域最经典的关键词检索算法,基于概率模型,计算查询关键词与文档的相关性分数。

💡 通俗理解:BM25 就像一个”关键词匹配专家”,它判断文档中包含查询关键词的程度,并给出一个相关性分数。

3.2 BM25 的核心思想(不用背公式,理解即可)

BM25 公式虽然看起来复杂,但核心就三个要素:

相关性分数 = TF 因子 × IDF 因子 × 长度归一化因子
因子通俗解释作用
TF(词频)关键词在文档中出现次数越多,越相关但会”饱和”——出现 10 次和 100 次差别不大
IDF(逆文档频率)关键词在整个文档集合中越稀有,区分度越高”发票”比”的”更有区分价值
长度归一化短文档中出现关键词比长文档中更重要避免 1000 页的书因为词多而获得不公平优势

与 TF-IDF 的区别:

对比维度TF-IDFBM25
词频处理线性增长(词越多分越高)饱和函数(超过一定次数不再大幅增长)
文档长度简单归一化更精细的概率模型归一化
效果基础版本工业标准,效果更稳定

3.3 BM25 的优缺点

维度评价
优势精确匹配关键词;对标题/编号/术语非常稳定;计算速度快;可解释性强
劣势无法理解语义和同义词;用户换说法就找不到;对长尾查询效果差
适用精确关键词搜索、文档检索、标题匹配、错误码查询
不适用语义搜索、同义词查询、口语化表达

3.4 BM25 在 Python 中的实现

from rank_bm25 import BM25Okapi
import jieba
 
# 1. 准备文档集
documents = [
    "保险销售技巧与实战",
    "报销制度与费用审批流程",
    "HTTP 404 错误码排查指南",
    "现金价值的计算方法"
]
 
# 2. 分词(中文用 jieba,英文直接 split)
tokenized_docs = [list(jieba.cut(doc)) for doc in documents]
 
# 3. 构建 BM25 索引
bm25 = BM25Okapi(tokenized_docs)
 
# 4. 查询
query = "如何推销保险"
query_tokens = list(jieba.cut(query))
scores = bm25.get_scores(query_tokens)
 
# 5. 获取 Top-K
import numpy as np
top_indices = np.argsort(scores)[::-1][:3]
for idx in top_indices:
    print(f"Doc {idx}: {documents[idx]} (score: {scores[idx]:.4f})")

4.1 五步流程

flowchart TD
    Q[用户 Query] --> P1[Step 1: Query 预处理<br/>指代消解 / HyDE / 分词]
    P1 --> P2[Step 2: 并行检索]
    P2 --> BM25[BM25 路<br/>jieba 分词 + 关键词匹配]
    P2 --> VEC[向量路<br/>Embedding + 余弦相似度]
    BM25 --> CAND[两路候选 Top-50]
    VEC --> CAND
    CAND --> P3[Step 3: 范围过滤<br/>知识库/权限/时效性]
    P3 --> P4[Step 4: RRF 融合<br/>1/k+rank 合并排名]
    P4 --> P5[Step 5: Cross-Encoder Rerank<br/>精排 Top-20 → Top-5]
    P5 --> LLM[Top-5 上下文 → LLM 生成]

4.2 关键设计点

┌─────────────────────────────────────────────────────────────────┐
│                      混合检索五步流程                            │
├─────────────────────────────────────────────────────────────────┤
│  Step 1: 接收用户问题                                            │
│       ↓                                                         │
│  Step 2: 并行检索 ──┬──→ BM25 路线(关键词匹配)                 │
│                    └──→ 向量路线(语义相似度)                    │
│       ↓                                                         │
│  Step 3: 限定资料范围(知识库/文档集过滤)                        │
│       ↓                                                         │
│  Step 4: 融合(RRF / 加权)→ 统一候选清单                        │
│       ↓                                                         │
│  Step 5: 重排序(ReRank)→ 截取 Top-K 交给 LLM                   │
└─────────────────────────────────────────────────────────────────┘

4.2 关键设计点

为什么初次检索要多取一些候选?

因为重排只能处理已经找回的内容。如果一开始只拿最终要展示的几条,正确原文排在稍后位置,重排根本看不到它,也就没有机会把它提到前面。

融合 ≠ 重排:

环节职责解决的问题
融合(Fusion)把多路结果放进同一份候选清单”有没有把正确证据带回来”
重排(ReRank)重新比较用户问题和每段候选,把更相关的放到前面”找到后排得好不好”

💡 注意:“重排能不能解决漏召回?” → 不能。如果正确原文没进入候选集,重排模型再强也找不回来。


5.1 为什么要用 RRF?

两路检索返回的分数不是同一种数值:

  • BM25 分数:可能是 0~30 的浮点数
  • 向量相似度:可能是 -1~1 的余弦相似度

它们像”百分制成绩”和”五分制评价”——直接相加没有任何意义。

RRF 的聪明之处:完全抛弃原始分数,只看排名位置

5.2 RRF 核心公式

RRF_score(d) = Σ 1 / (k + rank_r(d))
符号含义
d某个文档
rank_r(d)文档 d 在第 r 个检索结果列表中的排名(从 1 开始)
k平滑常数(通常 = 60)
Σ对所有检索系统的贡献求和

5.3 RRF 计算示例

假设同一个文档在两路检索中的排名:

文档BM25 排名向量排名RRF 计算总分
DocA第 1 名第 3 名1/(60+1) + 1/(60+3)0.0323
DocB第 2 名第 1 名1/(60+2) + 1/(60+1)0.0325
DocC第 3 名第 2 名1/(60+3) + 1/(60+2)0.0321
DocD未进入 Top第 4 名0 + 1/(60+4)0.0156

结果:DocB 在两路中都靠前,RRF 分数最高,排在第一位!

5.4 k 值的作用——“平滑因子”

k 值效果适用场景
k = 10高排名文档优势极大,低排名几乎被忽略强调头部精准度
k = 60平衡头部和尾部,工业标准值通用场景(推荐)
k = 100更保守,低排名文档也有机会数据量大、强调召回

💡 实践建议:“k 通常设为 60,这是经过大量实验验证的经验值。k 越大越保守,低排名的文档贡献不会被过度压制;k 越小头部效应越强。“

5.5 RRF 的优缺点

维度评价
优势不依赖分数归一化(跨系统兼容性强);简单高效;通过”多系统共识”提升稳健性;无需训练
劣势忽略原始相关性分数(可能损失细粒度信号);需要多次检索(额外延迟);需处理文档去重
适用混合检索、多查询检索、多模态检索融合

5.6 RRF Python 实现

from collections import defaultdict
 
def reciprocal_rank_fusion(result_lists, k=60):
    """
    RRF 融合算法
 
    result_lists: 多个检索系统的结果列表,每个列表已按相关性排序
                  如 [bm25_results, vector_results]
    k: 平滑常数,默认 60
    """
    scores = defaultdict(float)
 
    for result_list in result_lists:
        for rank, doc_id in enumerate(result_list, start=1):
            # 核心公式:1 / (k + rank)
            scores[doc_id] += 1.0 / (k + rank)
 
    # 按 RRF 分数降序排序
    sorted_results = sorted(
        scores.items(),
        key=lambda x: x[1],
        reverse=True
    )
 
    return sorted_results
 
# 示例
bm25_results = ["doc_A", "doc_B", "doc_C", "doc_D"]
vector_results = ["doc_C", "doc_A", "doc_E", "doc_B"]
 
fused = reciprocal_rank_fusion([bm25_results, vector_results], k=60)
print(fused)
# 输出: [('doc_A', 0.0325), ('doc_C', 0.0325), ('doc_B', 0.0323), ...]

6.1 加权融合(Weighted Score Fusion)

把各路的原始分数归一化后加权求和:

final_score = α × normalize(BM25_score) + β × normalize(vector_score)
维度说明
优点保留了原始分数的细粒度信息;可以灵活调整权重
缺点需要分数归一化(Z-score / Min-Max);权重调参困难;不同系统的分数范围差异大
适用检索系统少(2~3 路)、分数分布稳定、有调参资源

6.2 RRF 融合

只看排名,不看分数:

RRF_score = Σ 1 / (k + rank_i)
维度说明
优点无需归一化;对分数分布不敏感;实现简单;跨系统兼容性强
缺点丢失原始分数信息;k 值需要一定调参
适用检索系统多、分数分布差异大、追求快速落地

6.3 对比总结

维度加权融合RRF
是否需归一化✅ 需要❌ 不需要
是否保留原始分数✅ 保留❌ 只看排名
调参复杂度高(权重 + 归一化方法)低(只有一个 k)
跨系统兼容性
工业界偏好部分场景主流选择

RRF 是工业界的主流选择,因为它简单、稳定、无需调参。加权融合在特定场景下可能更精准,但需要大量实验确定权重,维护成本高。


7.1 Milvus 2.5 原生混合检索(推荐方案)

Milvus 2.5 内置了 Sparse-BM25,支持原生混合检索:

from pymilvus import MilvusClient, AnnSearchRequest, RRFRanker
 
client = MilvusClient(uri="http://localhost:19530")
 
# 1. 构建稠密向量检索请求
dense_request = AnnSearchRequest(
    data=[query_embedding],      # 查询向量
    anns_field="dense",          # 稠密向量字段
    param={"metric_type": "IP", "params": {"nprobe": 10}},
    limit=50
)
 
# 2. 构建 BM25 稀疏向量检索请求
bm25_request = AnnSearchRequest(
    data=[query_text],           # 原始查询文本
    anns_field="sparse_bm25",    # BM25 稀疏向量字段
    param={"metric_type": "BM25"},
    limit=50
)
 
# 3. 使用 RRF 融合
ranker = RRFRanker(k=100)
 
# 4. 执行混合检索
results = client.hybrid_search(
    collection_name="my_collection",
    reqs=[dense_request, bm25_request],
    ranker=ranker,
    limit=10,
    output_fields=["text"]
)

7.2 Elasticsearch 混合检索

ES 8.8+ 支持 RRF 原生融合:

# ES 查询示例
POST /my_index/_search
{
  "retriever": {
    "rrf": {
      "retrievers": [
        {
          "standard": {
            "query": {
              "match": {
                "content": "报销制度"
              }
            }
          }
        },
        {
          "knn": {
            "field": "embedding",
            "query_vector": [0.12, -0.34, ...],
            "k": 50
          }
        }
      ],
      "rank_window_size": 50,
      "rank_constant": 60
    }
  }
}

7.3 纯 Python 实现(教学版)

import numpy as np
from sklearn.metrics.pairwise import cosine_similarity
from rank_bm25 import BM25Okapi
import jieba
 
class HybridRetriever:
    def __init__(self, documents, embeddings):
        """
        documents: 原始文档列表
        embeddings: 对应的向量列表 (numpy array)
        """
        self.documents = documents
        self.embeddings = np.array(embeddings)
 
        # 构建 BM25 索引
        self.tokenized_docs = [list(jieba.cut(doc)) for doc in documents]
        self.bm25 = BM25Okapi(self.tokenized_docs)
 
    def bm25_search(self, query, top_k=50):
        """BM25 关键词检索"""
        tokens = list(jieba.cut(query))
        scores = self.bm25.get_scores(tokens)
        top_indices = np.argsort(scores)[::-1][:top_k]
        return [(idx, scores[idx]) for idx in top_indices if scores[idx] > 0]
 
    def vector_search(self, query_embedding, top_k=50):
        """向量语义检索"""
        similarities = cosine_similarity(
            [query_embedding],
            self.embeddings
        )[0]
        top_indices = np.argsort(similarities)[::-1][:top_k]
        return [(idx, similarities[idx]) for idx in top_indices]
 
    def rrf_fusion(self, bm25_results, vector_results, k=60, final_top_k=10):
        """RRF 融合"""
        scores = {}
 
        # BM25 贡献
        for rank, (doc_idx, _) in enumerate(bm25_results, start=1):
            scores[doc_idx] = scores.get(doc_idx, 0) + 1.0 / (k + rank)
 
        # 向量贡献
        for rank, (doc_idx, _) in enumerate(vector_results, start=1):
            scores[doc_idx] = scores.get(doc_idx, 0) + 1.0 / (k + rank)
 
        # 排序并返回
        sorted_docs = sorted(scores.items(), key=lambda x: x[1], reverse=True)
        return sorted_docs[:final_top_k]
 
    def search(self, query, query_embedding, top_k=10):
        """完整混合检索流程"""
        # 1. 两路并行检索
        bm25_results = self.bm25_search(query, top_k=50)
        vector_results = self.vector_search(query_embedding, top_k=50)
 
        # 2. RRF 融合
        fused = self.rrf_fusion(bm25_results, vector_results, k=60, final_top_k=top_k)
 
        # 3. 返回结果
        return [(self.documents[idx], score) for idx, score in fused]

8.1 各路检索取多少候选?

参数推荐值理由
每路 Top-K20~50太少会漏掉正确证据,太多引入噪声
RRF 最终 Top-K5~10给 LLM 的上下文窗口有限
RRF k 值60经验标准值,10~100 可调

8.2 文档去重

跨结果列表需对”同一文档”稳定去重,建议使用复合键:

# 推荐去重键
doc_key = f"{source}:{page}:{hash(chunk_content)}"

8.3 检索阶段引入重排(ReRank)

RRF 融合后,可以用 Cross-Encoder 做精排:

用户 Query → BM25 Top-50 ──┐
                            ├──→ RRF 融合 Top-20 ──→ Cross-Encoder 重排 ──→ Top-5 给 LLM
用户 Query → 向量 Top-50 ──┘

常用 ReRank 模型:

  • BGE-Reranker / BGE-Reranker-v2
  • Jina Reranker
  • Cohere Rerank


十、总结:混合检索的”黄金 Pipeline”

┌─────────────┐    ┌─────────────┐    ┌─────────────┐    ┌─────────────┐
│  用户 Query  │───→│  双路并行    │───→│  RRF 融合    │───→│  ReRank 精排 │───→ LLM
│             │    │  召回 Top-50 │    │  统一 Top-20 │    │  最终 Top-5  │
└─────────────┘    └──────┬──────┘    └─────────────┘    └─────────────┘

              ┌───────────┴───────────┐
              ↓                       ↓
        ┌─────────┐             ┌─────────┐
        │  BM25   │             │ 向量检索 │
        │ 关键词   │             │ 语义相似 │
        └─────────┘             └─────────┘

混合检索不是堆方法,而是解决一个具体问题——精确词面和语义改写,可能分别把正确原文带进候选集。RRF 用排名而非分数做融合,简单稳定无需训练。重排只能调整已找回的内容,不能救回候选集之外的证据

▶ 对应原理:17-语义搜索与混合检索

相关链接


技术学习路线图 > RAG

速记卡(面试闪卡)

Q1:一句话讲清「混合检索:向量 + BM25 + RRF 融合」到底是什么? A:向量检索找「意思相近」,BM25 找「字面对得上」,RRF(Reciprocal Rank Fusion,倒数排名融合)把两路结果拼成一份靠谱清单——三剑合璧,召回率和精准率同时起飞。

Q2:一、为什么一种检索不够?——「盲人摸象」怎么理解? A:想象你和两个朋友一起摸一头大象:BM25(关键词检索,Sparse Retrieval)像摸到象鼻的朋友,精确描述「长长的管子、粗糙纹路」这类局部特征;向量检索(Dense Retrieval,语义检索)像摸到象腿的朋友,理解「粗壮的柱子支撑庞大身躯」这种整体语义。只信一个人,对大象的认知就是残缺的。比如「报销制度」这种标题原词 BM25 稳稳命中,但「如何推销保险产品」这种口语改写只有向量能懂——两路各有翻车现场,合起来才完整。

Q3:二、向量检索(Dense Retrieval)——「找意思相近的」怎么理解? A:把查询和文档都转成高维向量(Embedding,用模型编码成 768/1024/1536 维的数字串),在向量空间里算余弦相似度(Cosine Similarity),意思越近距离越近。它懂同义词(「推销」≈「销售」)、能处理口语化查询;弱点是专业术语、错误码、编号这类精确匹配容易漂移,而且依赖 Embedding 模型质量。常用模型有 OpenAI 的 text-embedding-3、开源的 bge-m3(中文优选)。

Q4:三、BM25 检索(Sparse Retrieval)——「找字面对得上的」怎么理解? A:BM25(Best Match 25,最佳匹配 25)是信息检索的概率模型打分算法,相关性分数 = TF 因子 × IDF 因子 × 长度归一化因子。TF(Term Frequency,词频)会饱和,出现 10 次和 100 次差别不大;IDF(Inverse Document Frequency,逆文档频率)让稀有词更值钱(「发票」比「的」区分度高);长度归一化避免长文档靠词多占便宜。它和 TF-IDF 的区别是:TF-IDF 词频线性增长,BM25 用饱和函数、长度归一更精细,是工业标准。对标题、编号、术语极稳,但换种说法就找不到。

Q5:四、混合检索架构 + RRF 融合——「双塔并行,合纵连横」怎么理解? A:流程是五步——Query 预处理 → BM25 和向量两路并行检索各取 Top-50 → 范围过滤 → RRF 融合成 Top-20 → Cross-Encoder 重排(ReRank)精排到 Top-5 给 LLM。RRF 聪明在完全抛弃原始分数、只看排名:RRF_score(d) = Σ 1/(k + rank),k 通常取 60(工业标准),只数「两路都排前面的文档更靠前」。关键记牢:融合(Fusion)解决「有没有把证据带回来」,重排(ReRank)解决「带回来的排得好不好」——重排救不回漏召回,所以初次检索要多取候选。

Q6:核心速记主线有哪些? A:一、为什么一种检索不够(盲人摸象)、二、向量检索(Dense Retrieval,找意思)、三、BM25(Sparse Retrieval,找字面)、四、混合检索架构与 RRF 融合(只看排名不看分数)、五、加权融合 vs RRF(要不要归一化)、八、参数调优(每路 Top-K 20~50,RRF k=60)。

口诀 A:向量找意思,BM25 找字面; 两路并行取候选,RRF 只看排第几; 重排救不回漏召回,多取候选是铁律。

相关链接