课程0基础Agent开发课 / RAG与向量数据库 / 多路召回与融合排序-RAG检索质量提升
— 28 min read

多路召回与融合排序-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 的优点:

  1. 不需要归一化分数:向量相似度和 BM25 分数的量纲完全不同(一个是 0-1 的余弦值,一个是基于词频的无界值),直接相加没有意义。RRF 只用排名,完全不依赖分数的绝对值。

  2. 稳健,不需要调权重:传统的线性加权融合(0.7 * dense_score + 0.3 * sparse_score)需要为每个数据集手动调参。RRF 几乎不需要调参,在各种数据集上都表现稳健。

  3. 头部文档优势:分母是 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 个候选文档重新打分)。

Top-20 初筛

Top-5 精排结果

用户查询

Bi-Encoder
向量化查询

向量数据库

Cross-Encoder
Reranker 精排

拼装 Prompt

LLM 生成答案

常用 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 生成一个假设性的答案文档,用这个假设答案去检索。假设答案的文本风格和真实文档更接近,向量距离更近,检索效果更好。

Python csv模块提供reader()...

相似文档

用户问题
\Python如何读CSV?\

LLM 生成
假设答案

向量化
假设答案

向量数据库

拼装 Prompt

LLM 生成
最终答案


1.7 完整代码:LangChain(一个构建 AI 应用的 Python 框架,提供检索器、向量库等现成组件)混合检索实现

验证目的:通过对比单路向量检索、多路召回+RRF 融合、加入 Reranker 精排三种方案的检索结果,在"语义查询"和"精确字符串查询"两种典型场景下,验证多路召回对检索质量的提升效果。

python
# 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 响应时间)。成本和延迟的代价较大,适合对质量要求极高、对延迟不敏感的场景(如离线报告生成),不适合实时对话场景。

推荐的实施顺序

  1. 首先加入 BM25 做混合检索(成本极低,几行代码,收益稳定)
  2. 加入 BGE Reranker 精排(需要 GPU 或 API,但效果提升显著)
  3. 根据实际评估结果决定是否引入 HyDE(评估后再决定,不要盲目加)

向量 + BM25 + RRF 是混合检索的标准组合,几乎不需要调参,直接用。Reranker 是第二阶段的精排,Cross-Encoder 能做的事情 Bi-Encoder 做不到,有 GPU 资源就加上。HyDE 适合离线高质量场景,实时对话不建议用(每次多一次 LLM 调用)。

把这些模块拼在一起,加上前面的 Agent 编排能力,就够搭一个生产级 AI 应用了。

本页目录