多路召回与融合排序-RAG检索质量提升
> **[进阶选读]** 本篇适合已掌握基础混合检索(第 05 篇)、想深入理解 RRF 融合算法原理、或者在生产环境中对检索质量有更高要求的读者。
多路召回与融合排序:RAG 检索质量提升
[进阶选读] 本篇适合已掌握基础混合检索(第 05 篇)、想深入理解 RRF 融合算法原理、或者在生产环境中对检索质量有更高要求的读者。
1.1 单路检索的本质局限:两种不同的"相关性"
要理解为什么需要多路召回,需要先理解向量检索和关键词检索各自在"相关性"这个概念上的不同假设。
向量检索(Dense Retrieval)的假设:相关性 = 语义接近。把查询和文档都编码成高维向量,向量距离近的视为相关。这个方法的优势是能处理语义变换:用户说"苹果手机多贵",它能找到说"iPhone 售价"的文档,因为两者在向量空间里距离很近。
但向量检索有一个盲区:它对精确词汇匹配不敏感。用户说"订单号 ORD-20240315-8823 的状态",这串数字对向量化来说几乎没有语义意义。模型会把整句话的"语义"理解为"查询订单状态",然后检索出所有关于"订单状态查询"的文档——但包含那个具体订单号的文档未必排在最前面。
关键词检索(BM25/Sparse Retrieval)的假设:相关性 = 关键词重叠,特别是稀有词的重叠。IDF(逆文档频率)机制使得罕见词(如特定订单号、专有名词、缩写)的权重极高——如果一个词只出现在少数文档里,它的出现就是强相关信号。
BM25 的盲区是:它无法处理语义变换。用户说"苹果手机",它检索不到只说"iPhone"的文档(除非两者恰好都出现)。
互补性的直觉理解
可以这样理解两者的互补:
- 向量检索擅长找"意思相近的文档",即使用词完全不同
- BM25 擅长找"用了相同关键词的文档",特别是罕见的精确词汇
现实中的查询往往两种情况都有:用户的问题既包含需要语义理解的模糊部分,也包含需要精确匹配的专有名词部分。只用一路,就会在另一路的强项场景上失效。
多路召回的思路就是:并行运行两种(或更多)检索方法,取各自的优势,再合并结果。合并的方法就是 RRF(倒数排名融合)——它不比较两种方法的分数(两者分数量纲完全不同),只比较排名,用排名的倒数累加来得到综合得分。
RAG 系统的天花板由检索质量决定。LLM 再强,给它看的上下文不对,答案也不会好。检索这一关没过,后面的一切都是徒劳。
本文系统梳理从单路向量检索到生产级混合检索的完整升级路径,包括 RRF 融合算法、Reranker 重排序和 HyDE 查询改写的原理与实现。
1.2 单路向量检索的问题
向量检索(Dense Retrieval)能理解语义,但在以下场景表现差:
精确字符串匹配:"订单号 ORD-20240315-8823 的状态"——这个查询向量化后,和"订单状态查询指南"的向量相似度可能比包含该订单号的文档还高,因为向量空间不能区分具体的数字串。
罕见词和专业术语:技术文档里的缩写(如"OKR"、"SDK"、"API 429")、产品专有名词、人名,在 embedding 模型的训练数据中频率低,向量表示不稳定,检索质量差。
关键词权重问题:"Python 如何读取 CSV 文件"中,"Python"和"CSV"是最重要的关键词,但向量化时整句话的语义被整体压缩,关键词的权重被平均掉了。而 BM25 这类关键词方法天然对高频关键词赋予更高权重。
单纯依赖向量检索,对上述场景的召回率有明显缺口。
1.3 多路召回策略
解决方案是多路召回:用多种检索方法并行检索,各取所长,再融合结果。
| 检索方法 | 原理 | 擅长 | 不擅长 |
|---|---|---|---|
| 向量检索(Dense) | Embedding 相似度 | 语义相似,改述,同义词 | 精确字符串,稀疏词 |
| BM25(Sparse) | TF-IDF 改进,词频统计 | 精确关键词,稀疏词 | 语义变换,同义词 |
| 元数据过滤 | 结构化字段精确匹配 | 时间范围、分类、作者 | 非结构化内容查询 |
三路并行,互为补充。
1.3.1 BM25 算法原理
BM25(Best Match 25)是经典 TF-IDF(Term Frequency-Inverse Document Frequency,词频-逆文档频率:TF衡量一个词在文档中出现多频繁,IDF衡量这个词在所有文档中有多稀有)的改进版,是信息检索领域几十年来最稳健的基准算法。
BM25 对文档 $D$ 与查询 $Q$ 的相关性评分:
$$\text{BM25}(D, Q) = \sum_{i=1}^{n} \text{IDF}(q_i) \cdot \frac{f(q_i, D) \cdot (k_1 + 1)}{f(q_i, D) + k_1 \cdot \left(1 - b + b \cdot \frac{|D|}{\text{avgdl}}\right)}$$
- $f(q_i, D)$:词 $q_i$ 在文档 $D$ 中的频率(词频,TF)
- $\text{IDF}(q_i)$:词 $q_i$ 的逆文档频率,越罕见的词 IDF 越高
- $|D|$:文档长度,$\text{avgdl}$:语料平均文档长度
- $k_1$(通常取 1.2-2.0)和 $b$(通常取 0.75)是调参参数
关键改进点:用参数 $k_1$ 控制词频饱和(词出现 100 次不应该比出现 10 次好 10 倍),用参数 $b$ 做文档长度归一化(长文档不应该因为更长而得分更高)。
1.4 RRF 融合算法
多路召回后,每路有自己的排名列表,需要合并成一个。RRF(Reciprocal Rank Fusion,倒数排名融合) 是最常用的融合算法:
$$\text{RRF}(d) = \sum_{r \in R} \frac{1}{k + \text{rank}_r(d)}$$
- $d$:文档
- $R$:所有检索系统的集合
- $\text{rank}_r(d)$:文档 $d$ 在检索系统 $r$ 中的排名(从 1 开始)
- $k$:平滑常数,通常取 60
RRF 的优点:
不需要归一化分数:向量相似度和 BM25 分数的量纲完全不同(一个是 0-1 的余弦值,一个是基于词频的无界值),直接相加没有意义。RRF 只用排名,完全不依赖分数的绝对值。
稳健,不需要调权重:传统的线性加权融合(
0.7 * dense_score + 0.3 * sparse_score)需要为每个数据集手动调参。RRF 几乎不需要调参,在各种数据集上都表现稳健。头部文档优势:分母是
k + rank,排名第 1 的文档得分是1/61,排名第 2 是1/62,差距很小;但排名第 100 是1/160,差距就很明显了。RRF 天然放大了排名靠前文档的优势,压制了长尾噪声。
1.5 Reranker 重排序
召回解决的是"把相关文档找出来",重排序解决的是"让最相关的排在最前面"。
1.5.1 Bi-Encoder vs Cross-Encoder
向量检索使用的 Embedding 模型是 Bi-Encoder(双编码器)架构:查询和文档分别独立编码,然后计算向量距离。优点是文档向量可以预计算存储,查询时只需计算查询向量,然后做快速的向量搜索。缺点是查询和文档之间没有直接的注意力交互,相关性判断不够精准。
Cross-Encoder(交叉编码器)把查询和文档拼接在一起输入同一个 Transformer,让两者充分做注意力交互,直接输出相关性分数。判断精度高得多,但无法预计算——每个 (查询, 文档) 对都要跑一次完整前向计算,速度慢,不能用于第一阶段大规模检索。
这就是为什么检索架构分两阶段:Bi-Encoder 做快速初筛(几毫秒内检索几十万文档),Cross-Encoder 对初筛结果做精排(对 20 个候选文档重新打分)。
常用 Reranker 对比:
| Reranker | 类型 | 中文支持 | 部署方式 | 适用场景 |
|---|---|---|---|---|
| BGE Reranker Large | 开源 Cross-Encoder | 优秀 | 本地 GPU | 国内场景首选 |
| Cohere Rerank | 云服务 API | 良好 | API 调用 | 无 GPU 资源时 |
| JinaAI Reranker | 开源 | 良好 | 本地/API | 轻量化部署 |
| ms-marco-MiniLM | 开源 | 弱 | 本地 CPU | 英文场景,低资源 |
1.6 HyDE:假设文档嵌入
HyDE(Hypothetical Document Embeddings,假设文档嵌入) 是查询改写的一个有趣变体。
问题背景:用户的查询通常是简短的问句("Python 怎么读 CSV"),而知识库里的文档是完整的陈述性文本("Python 的 csv 模块提供了 reader() 函数...")。问句和文档在向量空间的分布不同,即使语义相关,向量距离也可能较远。
HyDE 的思路:不用问句直接检索,而是先让 LLM 生成一个假设性的答案文档,用这个假设答案去检索。假设答案的文本风格和真实文档更接近,向量距离更近,检索效果更好。
1.7 完整代码:LangChain(一个构建 AI 应用的 Python 框架,提供检索器、向量库等现成组件)混合检索实现
验证目的:通过对比单路向量检索、多路召回+RRF 融合、加入 Reranker 精排三种方案的检索结果,在"语义查询"和"精确字符串查询"两种典型场景下,验证多路召回对检索质量的提升效果。
# hybrid_retrieval_complete.py
# 完整的多路召回 + RRF 融合 + Reranker 重排序 + HyDE 实现
import math
from typing import List, Dict, Tuple, Optional
from dataclasses import dataclass
@dataclass
class RetrievedDoc:
"""检索结果的统一数据结构。"""
doc_id: str
content: str
score: float
source: str # "dense" | "bm25" | "metadata"
# ============================================================
# 1. BM25 实现(轻量版,不依赖额外库)
# ============================================================
class BM25:
"""
BM25 实现。
生产环境推荐使用:
- rank_bm25 库(pip install rank-bm25)
- Elasticsearch 内置的 BM25(更适合大规模)
"""
def __init__(self, k1: float = 1.5, b: float = 0.75):
# k1:词频饱和参数,越大词频的影响越持久
# b:文档长度归一化参数,0=不归一化,1=完全归一化
self.k1 = k1
self.b = b
self.docs: List[List[str]] = []
self.doc_contents: List[str] = []
self.doc_freq: Dict[str, int] = {} # 每个词出现在多少文档中
self.avgdl: float = 0.0
def fit(self, documents: List[str]):
"""建立 BM25 索引。"""
self.doc_contents = documents
self.docs = [doc.split() for doc in documents]
# 计算文档频率(df)
for doc_tokens in self.docs:
for token in set(doc_tokens):
self.doc_freq[token] = self.doc_freq.get(token, 0) + 1
# 计算平均文档长度
self.avgdl = sum(len(d) for d in self.docs) / len(self.docs)
def get_scores(self, query: str) -> List[float]:
"""计算查询对所有文档的 BM25 分数。"""
query_tokens = query.split()
n = len(self.docs)
scores = []
for doc_tokens in self.docs:
score = 0.0
doc_len = len(doc_tokens)
token_freq = {}
for token in doc_tokens:
token_freq[token] = token_freq.get(token, 0) + 1
for token in query_tokens:
if token not in self.doc_freq:
continue
df = self.doc_freq[token]
# IDF:文档频率越低,词越重要
idf = math.log((n - df + 0.5) / (df + 0.5) + 1)
tf = token_freq.get(token, 0)
# BM25 词频计算(含饱和和长度归一化)
tf_score = (tf * (self.k1 + 1)) / (
tf + self.k1 * (1 - self.b + self.b * doc_len / self.avgdl)
)
score += idf * tf_score
scores.append(score)
return scores
def retrieve(self, query: str, top_k: int = 10) -> List[RetrievedDoc]:
"""检索 top_k 个最相关文档。"""
scores = self.get_scores(query)
ranked = sorted(enumerate(scores), key=lambda x: x[1], reverse=True)
return [
RetrievedDoc(
doc_id=str(idx),
content=self.doc_contents[idx],
score=score,
source="bm25"
)
for idx, score in ranked[:top_k]
if score > 0
]
# ============================================================
# 2. RRF 融合算法
# ============================================================
def reciprocal_rank_fusion(
result_lists: List[List[RetrievedDoc]],
k: int = 60
) -> List[RetrievedDoc]:
"""
RRF 融合多路检索结果。
k=60 是经验最优值,来自原始论文的实验结论。
通常不需要调整这个参数。
"""
# 记录每个文档的 RRF 分数(累加各路的倒数排名分)
rrf_scores: Dict[str, float] = {}
# 保留文档内容(用第一次出现的结果)
doc_contents: Dict[str, str] = {}
doc_sources: Dict[str, List[str]] = {}
for result_list in result_lists:
for rank, doc in enumerate(result_list, start=1):
doc_id = doc.doc_id
# RRF 核心公式:1 / (k + rank)
rrf_scores[doc_id] = rrf_scores.get(doc_id, 0) + 1.0 / (k + rank)
if doc_id not in doc_contents:
doc_contents[doc_id] = doc.content
doc_sources[doc_id] = []
doc_sources[doc_id].append(doc.source)
# 按 RRF 分数降序排列
sorted_docs = sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)
return [
RetrievedDoc(
doc_id=doc_id,
content=doc_contents[doc_id],
score=score,
source="+".join(doc_sources[doc_id]) # 标记来自哪几路
)
for doc_id, score in sorted_docs
]
# ============================================================
# 3. Reranker 重排序
# ============================================================
class BGEReranker:
"""
BGE Reranker 封装。
安装:pip install sentence-transformers
首次运行会下载模型(约 1GB):BAAI/bge-reranker-base
"""
def __init__(self, model_name: str = "BAAI/bge-reranker-base"):
self.model_name = model_name
self._model = None
def _load_model(self):
"""惰性加载模型,避免不使用时占用资源。"""
if self._model is None:
try:
from sentence_transformers import CrossEncoder
self._model = CrossEncoder(self.model_name)
except ImportError:
raise ImportError("请安装:pip install sentence-transformers")
def rerank(self, query: str, docs: List[RetrievedDoc], top_k: int = 5) -> List[RetrievedDoc]:
"""
用 Cross-Encoder 对候选文档重新打分排序。
输入:第一阶段召回的 top-20 候选文档
输出:精排后的 top-k 文档
"""
self._load_model()
# Cross-Encoder 输入格式:(query, document) 对
pairs = [(query, doc.content) for doc in docs]
scores = self._model.predict(pairs)
# 按分数降序排列
reranked = sorted(zip(docs, scores), key=lambda x: x[1], reverse=True)
return [
RetrievedDoc(
doc_id=doc.doc_id,
content=doc.content,
score=float(score),
source=f"reranked({doc.source})"
)
for doc, score in reranked[:top_k]
]
class MockReranker:
"""
模拟 Reranker(不需要安装模型)。
用于演示流程,实际使用时替换为 BGEReranker。
"""
def rerank(self, query: str, docs: List[RetrievedDoc], top_k: int = 5) -> List[RetrievedDoc]:
"""模拟重排序:给包含查询关键词的文档更高分。"""
query_words = set(query.lower().split())
scored = []
for doc in docs:
# 简单的关键词重叠打分(模拟 Cross-Encoder 的行为)
doc_words = set(doc.content.lower().split())
overlap = len(query_words & doc_words)
scored.append((doc, overlap + doc.score * 0.1))
scored.sort(key=lambda x: x[1], reverse=True)
return [doc for doc, _ in scored[:top_k]]
# ============================================================
# 4. HyDE 查询改写
# ============================================================
class HyDEQueryExpander:
"""
HyDE(Hypothetical Document Embeddings)实现。
核心思路:用 LLM 生成假设答案,用假设答案代替原始问题做检索。
"""
def __init__(self, llm=None):
self.llm = llm
def generate_hypothetical_document(self, query: str) -> str:
"""
生成假设答案文档。
实际场景接入真实 LLM:
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
prompt = f"请为以下问题生成一段可能的答案文档(100-150字,风格像技术文档):\n{query}"
return llm.invoke(prompt).content
"""
# 模拟生成(演示用)
return (
f"关于「{query}」的技术文档:该功能通过以下步骤实现:"
f"首先需要安装相关依赖包,然后导入所需模块,"
f"接着按照标准流程配置参数,最后调用核心函数完成操作。"
f"常见问题包括环境配置错误和参数格式不匹配,"
f"解决方案是检查版本兼容性并参考官方文档。"
)
def expand_query(self, query: str) -> Tuple[str, str]:
"""
返回 (原始查询, 假设文档)。
检索时用假设文档,回答时用原始查询。
"""
hypothetical_doc = self.generate_hypothetical_document(query)
return query, hypothetical_doc
# ============================================================
# 5. 完整的混合检索管道(LangChain EnsembleRetriever 版本)
# ============================================================
def build_hybrid_retrieval_with_langchain(documents: List[str]):
"""
用 LangChain 的 EnsembleRetriever 实现混合检索。
需要安装:pip install langchain langchain-community langchain-openai chromadb
"""
try:
from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain.schema import Document
docs = [Document(page_content=d) for d in documents]
# 稀疏检索:BM25
bm25_retriever = BM25Retriever.from_documents(docs)
bm25_retriever.k = 10 # 每路初始召回 10 个
# 稠密检索:向量相似度
embeddings = OpenAIEmbeddings()
vectorstore = Chroma.from_documents(docs, embeddings)
dense_retriever = vectorstore.as_retriever(search_kwargs={"k": 10})
# 融合:EnsembleRetriever 内部使用 RRF
# weights 对应每路的权重(影响 RRF 中的排名加权方式)
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, dense_retriever],
weights=[0.5, 0.5] # 等权融合;关键词密集的场景可以调高 BM25 的权重
)
return ensemble_retriever
except ImportError as e:
print(f"缺少依赖:{e}")
print("安装:pip install langchain langchain-community langchain-openai chromadb rank-bm25")
return None
# ============================================================
# 6. 独立运行演示(不需要任何外部依赖)
# ============================================================
def run_hybrid_demo():
"""完整演示:多路召回 + RRF + 重排序。"""
print("=" * 60)
print("多路召回与融合排序演示")
print("=" * 60)
# 测试文档集
documents = [
("doc_1", "Python csv 模块提供了 reader() 和 writer() 函数,用于读写 CSV 文件。"),
("doc_2", "订单号 ORD-2024-8823 于 2024 年 3 月 15 日创建,状态:已发货。"),
("doc_3", "机器学习中的梯度下降算法通过迭代优化模型参数,最小化损失函数。"),
("doc_4", "CSV(逗号分隔值)是一种通用的数据交换格式,Python 有原生支持。"),
("doc_5", "退货政策:购买 7 天内可申请退货,商品需保持原包装完整。"),
("doc_6", "如何用 pandas 读取 CSV 文件:pd.read_csv('filename.csv'),支持编码设置。"),
("doc_7", "NumPy 提供了 genfromtxt() 函数,可以从 CSV 文件加载数组数据。"),
]
contents = [content for _, content in documents]
doc_ids = [doc_id for doc_id, _ in documents]
# 初始化 BM25
bm25 = BM25()
bm25.fit(contents)
# 模拟向量检索(演示用:用简单的关键词重叠代替真实向量检索)
def mock_dense_retrieve(query: str, top_k: int = 5) -> List[RetrievedDoc]:
"""模拟向量检索(演示用,实际替换为真实向量库调用)。"""
query_words = set(query.lower().split())
scored = []
for i, content in enumerate(contents):
content_words = set(content.lower().split())
# 模拟语义相似度(用词重叠近似)
overlap = len(query_words & content_words)
if overlap > 0:
scored.append(RetrievedDoc(
doc_id=doc_ids[i],
content=content,
score=overlap / len(query_words),
source="dense"
))
scored.sort(key=lambda x: x.score, reverse=True)
return scored[:top_k]
# 测试查询
test_queries = [
"Python 如何读取 CSV 文件", # 语义查询,应该找到 doc_1, doc_4, doc_6, doc_7
"ORD-2024-8823 订单状态", # 精确字符串,应该找到 doc_2
]
reranker = MockReranker()
hyde = HyDEQueryExpander()
for query in test_queries:
print(f"\n{'=' * 50}")
print(f"查询:{query}")
print("-" * 50)
# 方案一:单纯向量检索
dense_only = mock_dense_retrieve(query, top_k=3)
print("\n[单路向量检索 Top-3]:")
for i, doc in enumerate(dense_only, 1):
print(f" {i}. [{doc.score:.3f}] {doc.content[:60]}...")
# 方案二:多路召回 + RRF
dense_results = mock_dense_retrieve(query, top_k=5)
bm25_results = bm25.retrieve(query, top_k=5)
# 修正 bm25 结果的 doc_id(BM25 用的是数字索引,映射回原始 id)
for doc in bm25_results:
doc.doc_id = doc_ids[int(doc.doc_id)]
fused = reciprocal_rank_fusion([dense_results, bm25_results])
print(f"\n[多路召回 + RRF Top-3](密集检索 + BM25 融合):")
for i, doc in enumerate(fused[:3], 1):
print(f" {i}. [{doc.score:.4f}] [{doc.source}] {doc.content[:60]}...")
# 方案三:融合 + Reranker
reranked = reranker.rerank(query, fused[:6], top_k=3)
print(f"\n[多路召回 + RRF + Reranker Top-3](最终精排):")
for i, doc in enumerate(reranked, 1):
print(f" {i}. [{doc.score:.4f}] {doc.content[:60]}...")
# HyDE 展示
original_q, hypothetical_doc = hyde.expand_query(query)
print(f"\n[HyDE] 生成的假设文档:")
print(f" {hypothetical_doc[:100]}...")
if __name__ == "__main__":
run_hybrid_demo()
1.8 各策略效果对比
| 检索策略 | 语义查询召回率 | 精确字符串召回率 | 延迟 | 实现复杂度 |
|---|---|---|---|---|
| 纯向量检索 | 高 | 低 | 低 | 低 |
| 纯 BM25 | 中 | 高 | 很低 | 低 |
| 向量 + BM25 + RRF | 高 | 高 | 低 | 中 |
| 混合召回 + Reranker | 高 | 高 | 中(+50-200ms) | 中高 |
| 混合召回 + Reranker + HyDE | 高 | 高 | 高(+LLM 调用延迟) | 高 |
HyDE 每次查询都要额外调用一次 LLM 生成假设文档,增加约 500-2000ms 延迟(取决于 LLM 响应时间)。成本和延迟的代价较大,适合对质量要求极高、对延迟不敏感的场景(如离线报告生成),不适合实时对话场景。
推荐的实施顺序:
- 首先加入 BM25 做混合检索(成本极低,几行代码,收益稳定)
- 加入 BGE Reranker 精排(需要 GPU 或 API,但效果提升显著)
- 根据实际评估结果决定是否引入 HyDE(评估后再决定,不要盲目加)
向量 + BM25 + RRF 是混合检索的标准组合,几乎不需要调参,直接用。Reranker 是第二阶段的精排,Cross-Encoder 能做的事情 Bi-Encoder 做不到,有 GPU 资源就加上。HyDE 适合离线高质量场景,实时对话不建议用(每次多一次 LLM 调用)。
把这些模块拼在一起,加上前面的 Agent 编排能力,就够搭一个生产级 AI 应用了。