Skip to content

6.5 检索策略:从稀疏到混合

在前一节中,我们详细讨论了向量数据库的选型与使用。通过 HNSW、IVF 等近似最近邻算法,向量数据库能够以毫秒级延迟返回语义相近的文档片段。然而,只有向量检索并不足以覆盖所有检索需求——当你搜索一个函数名 API_KEY_123 或一个版本号 v2.4.1 时,语义相似度往往无能为力。本节将回到检索本身,系统讲解稀疏检索、稠密检索与混合检索三种策略,帮助你在不同场景下做出正确的选择。

理解检索策略,不妨从最熟悉的场景切入:搜索引擎找网页。当你在百度或 Google 输入"Transformer 注意力机制"时,搜索引擎并非简单地把你的查询变成向量去找最近邻。它同时做了两件事——一方面用关键词匹配(找到包含"Transformer""注意力""机制"这些词的网页),另一方面用语义模型理解查询意图(可能还会召回讨论"self-attention"的英文页面)。这就是混合检索的雏形:关键词精确命中 + 语义模糊召回,两条路并行,结果融合后排序呈现给你。RAG 系统中的检索策略,本质上就是把搜索引擎的这套机制搬进你的知识库。

6.5.1 稀疏检索:关键词匹配的老兵

稀疏检索是信息检索领域的经典方法,代表算法是 BM25(Okapi BM25)。它是 TF-IDF 的改进版,至今仍是 Elasticsearch、Lucene 等搜索引擎的默认排序算法。

核心原理:统计查询词在文档中出现的频率(TF),结合词的稀有程度(IDF),计算相关性得分。词频越高、词越稀有,得分越高;同时对长文档做归一化惩罚,避免长文档天然占优。

查询: "Transformer 注意力机制"
    ↓ 分词
["Transformer", "注意力", "机制"]
    ↓ BM25 打分
文档A "Transformer 使用自注意力机制"     → 得分: 8.5
文档B "RNN 和 LSTM 是传统序列模型"      → 得分: 1.2
文档C "注意力机制最早用于神经机器翻译"   → 得分: 6.0

BM25 的得分公式(简化形式):

score(D, Q) = Σ IDF(qi) × f(qi,D) × (k1+1) / (f(qi,D) + k1×(1 - b + b×|D|/avgdl))

其中:
  qi     : 查询中的第 i 个词
  f(qi,D): 词 qi 在文档 D 中的出现频率
  |D|    : 文档 D 的长度(词数)
  avgdl  : 语料库平均文档长度
  k1, b  : 调节参数,通常 k1=1.2, b=0.75

稀疏检索的优势在于"所见即所得"——命中哪个词、得分多少,完全可解释。对专有名词、代码标识符、产品 ID、版本号这类必须精确匹配的内容,BM25 是不可替代的。

它的局限同样明显:没有语义理解能力。"猫"和"猫咪"是两个不同的词,"吃饭"和"进食"互不相干。同义词、近义词、跨语言查询,稀疏检索统统处理不了。

6.5.2 稠密检索:语义相似的新秀

稠密检索(Dense Retrieval)是深度学习时代的产物。它将查询和文档都通过 Embedding 模型映射到同一个高维向量空间,用余弦相似度衡量匹配程度。

查询: "猫喜欢吃什么?"
    → Embedding → [0.12, -0.34, 0.78, ..., 0.05]  (768维)
                        ↓ 余弦相似度
文档: "猫咪爱吃鱼和肉"
    → Embedding → [0.11, -0.33, 0.79, ..., 0.04]  (768维)
                        ↑ 相似度 0.98

稠密检索的核心优势是语义泛化

  • 同义词匹配:"吃饭"能召回"进食""用餐"的文档
  • 跨语言检索:中文查询匹配英文文档,前提是 Embedding 模型支持多语言
  • 概念关联:查询"如何降低血压"能召回讨论"高血压预防"的文档

但它也有自己的短板:对精确字符串不敏感。查询 API_KEY_123,稠密检索可能返回语义相近但内容无关的文档,而 BM25 能精准命中包含这串字符的那一条。此外,稠密检索的效果高度依赖 Embedding 模型质量,模型不行则全盘皆输。

两种检索策略的核心对比如下:

维度稀疏检索 (BM25)稠密检索 (Dense)
匹配方式关键词精确匹配语义相似度匹配
"猫" ↔ "猫咪"❌ 视为不同词✅ 语义相近可匹配
"API_KEY_123"✅ 精确命中❌ 可能漏掉
计算成本纯 CPU,极快需 Embedding 推理
可解释性白盒,知道哪个词命中黑盒,得分无直观含义
冷启动零依赖,建索引即用需加载 Embedding 模型

正因为两者各有长短,把它们组合起来就成了业界共识——这就是混合检索。

6.5.3 混合检索:取长补短的工程实践

混合检索的核心思想很朴素:同时跑稠密和稀疏两路检索,再把结果融合排序。两路召回的候选集有重叠也有互补,融合后既能抓住语义关联,又不漏掉精确匹配。

查询: "Transformer 的注意力机制是什么?"

    ├──→ 稠密检索 (Embedding)  → 语义相似文档 [D1, D2, D3, D4, D5]

    └──→ 稀疏检索 (BM25)      → 关键词匹配文档 [D2, D4, D6, D7, D8]


                           结果融合 (RRF / 加权求和)


                           最终排序: [D2, D4, D1, D3, D6, ...]

关键在于融合策略。常见的有两种:加权求和与 RRF。

方法一:加权求和融合

将两路得分归一化后按权重相加。α 控制稠密权重,1-α 控制稀疏权重:

python
def weighted_fusion(dense_results, sparse_results, alpha=0.7):
    """
    加权求和融合
    dense_results : 稠密检索返回的 {doc_id: score}
    sparse_results: 稀疏检索返回的 {doc_id: score}
    alpha         : 稠密权重,取值 0~1,1-alpha 即为稀疏权重
    """
    # 分别对两路得分做 min-max 归一化,消除量纲差异
    dense_norm  = normalize_scores(dense_results)   # 映射到 [0, 1]
    sparse_norm = normalize_scores(sparse_results)  # 映射到 [0, 1]

    fused = {}                                       # 融合得分字典
    for doc_id, score in dense_norm.items():         # 遍历稠密结果
        fused[doc_id] = alpha * score               # 乘以稠密权重
    for doc_id, score in sparse_norm.items():        # 遍历稀疏结果
        if doc_id in fused:                         # 若该文档已被稠密召回
            fused[doc_id] += (1 - alpha) * score     # 叠加稀疏权重得分
        else:                                        # 若仅稀疏召回
            fused[doc_id] = (1 - alpha) * score     # 直接赋值

    # 按融合得分降序排列,返回 [(doc_id, score), ...]
    return sorted(fused.items(), key=lambda x: x[1], reverse=True)

加权求和的麻烦之处在于归一化:稠密得分是 0~1 的余弦相似度,BM25 得分可能是 0~20 的无界分数,两者量纲不同,直接相加毫无意义。归一化方式(min-max、z-score 等)本身也会引入偏差。

方法二:RRF(Reciprocal Rank Fusion)

RRF 是目前最推荐的融合方法。它不看得分,只看排名:一个文档在多路检索中排名越靠前,得分越高。公式为:

RRF_score(d) = Σ 1 / (k + rank_i(d))

其中:
  d        : 文档
  rank_i(d): 文档 d 在第 i 路检索结果中的排名(从 1 开始)
  k        : 平滑常数,通常取 60,防止排名第 1 的文档权重过大
python
def reciprocal_rank_fusion(results_list, k=60):
    """
    RRF 融合多路检索结果
    results_list: 多路检索结果列表,每个元素是 [(doc_id, score), ...]
    k           : 平滑参数,通常取 60
    """
    rrf_scores = {}                                # 文档 → RRF 累计得分

    for results in results_list:                   # 遍历每一路检索结果
        for rank, (doc_id, _) in enumerate(results):  # rank 从 0 开始
            if doc_id not in rrf_scores:           # 首次出现的文档
                rrf_scores[doc_id] = 0             # 初始化为 0
            # rank+1 转为 1-based 排名;k 越大,排名差异的影响越平缓
            rrf_scores[doc_id] += 1.0 / (k + rank + 1)

    # 按 RRF 得分降序排列
    return sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)


# ---- 演示:两路检索结果融合 ----
dense_results = [                                   # 稠密检索 Top-4
    ("doc_A", 0.95), ("doc_B", 0.82), ("doc_C", 0.71), ("doc_D", 0.55)
]
sparse_results = [                                  # 稀疏检索 Top-4
    ("doc_C", 8.5), ("doc_B", 7.2), ("doc_E", 6.1), ("doc_A", 4.3)
]

fused = reciprocal_rank_fusion([dense_results, sparse_results])
print("RRF 融合结果:")
for doc_id, score in fused:
    print(f"  {doc_id}: {score:.4f}")

RRF 的优势在于完全不依赖得分本身,只依赖排名,因此对不同检索器的得分分布不敏感——稠密的余弦相似度和 BM25 的绝对分数差异再大也不影响融合结果。这就是它成为首选的原因。

6.5.4 权重调优:场景决定配比

混合检索中 α(稠密权重)的取值没有万能答案,取决于知识库内容和查询特点。以下是实践经验总结:

应用场景稠密权重 α稀疏权重 1-α理由
通用知识问答0.8 ~ 0.90.1 ~ 0.2语义理解重要,关键词多样
代码 / API 文档检索0.3 ~ 0.50.5 ~ 0.7函数名、参数名必须精确命中
法律 / 合同文本0.4 ~ 0.60.4 ~ 0.6术语精确,但条文间有语义关联
多语言混合场景0.9 ~ 1.00.0 ~ 0.1跨语言靠语义,BM25 受分词限制
产品名称 / ID 搜索0.2 ~ 0.30.7 ~ 0.8精确匹配是第一要务

调优建议:先用 α=0.7 作为基线跑一批测试查询,观察漏检和误检的 case,再针对性地微调。如果你的知识库包含大量代码片段、配置文件、日志,稀疏权重一定要拉高

6.5.5 多路召回:不止两路

混合检索是两路(稠密 + 稀疏)的特例。更进一步的策略是多路召回——使用更多种类的检索器,从不同角度召回候选文档,再用 Rerank 精排。

查询: "2024 年 AI 领域有哪些重大突破?"

    ├──→ 向量检索 (Embedding)    → 候选集 A (200 条)  语义相关
    ├──→ BM25 关键词检索         → 候选集 B (200 条)  精确匹配
    ├──→ 知识图谱检索            → 候选集 C (50 条)   实体关联
    └──→ 时间范围检索            → 候选集 D (100 条)  时效过滤


                           去重合并 → 候选集 (约 400 条)


                           Rerank 重排序 → Top-10

多路召回的价值在于互补性:向量检索抓语义,BM25 抓关键词,知识图谱抓实体关系,时间检索保证时效性。每一路单独看都有盲区,合在一起覆盖面就宽得多。最后由 Rerank 模型(下一节详述)做精排,从几百条候选中挑出最相关的 Top-K。

查询重写是多路召回的常见增强手段。用户的原始问题往往表述模糊,通过 LLM 改写成多个变体,再分别检索,能显著提升召回覆盖:

python
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate

# 构造查询重写的提示模板
rewrite_prompt = ChatPromptTemplate.from_template("""
请将以下用户问题改写为 3 个不同角度的检索查询,
以便从知识库中召回更多相关信息。

用户问题:{question}

请用中文输出,每行一个查询:""")

# 初始化 LLM,temperature=0.7 鼓励多样性改写
llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.7)

def rewrite_queries(question):
    """调用 LLM 将单个问题改写为多个查询变体"""
    response = llm.invoke(rewrite_prompt.format(question=question))
    # 按换行符拆分,去掉空行和首尾空格
    queries = [q.strip() for q in response.content.strip().split("\n") if q.strip()]
    return queries

# 演示
question = "如何提高 RAG 系统的检索准确率?"
queries = rewrite_queries(question)
print("原始问题:", question)
print("改写查询:")
for q in queries:
    print(f"  - {q}")

改写后的多个查询分别送入检索器,结果合并后再融合,相当于在不增加检索器种类的前提下,扩大了召回的语义空间。

6.5.6 实战:LangChain 混合检索

下面用 LangChain 完整实现一遍混合检索,代码逐行注释,可直接运行:

python
# 安装依赖:
# pip install langchain langchain-openai langchain-chroma chromadb rank-bm25

from langchain_openai import OpenAIEmbeddings              # OpenAI 嵌入模型
from langchain_chroma import Chroma                        # Chroma 向量数据库
from langchain.text_splitter import RecursiveCharacterTextSplitter  # 文本切分器
from langchain.retrievers import EnsembleRetriever          # 混合检索器(内置 RRF)
from langchain_community.retrievers import BM25Retriever    # BM25 稀疏检索器
from langchain_core.documents import Document               # 文档数据结构

# ====== 1. 准备语料:5 篇短文档,模拟知识库 ======
documents = [
    Document(                                              # 文档1:Transformer 论文摘要
        page_content="Transformer 架构由 Vaswani 等人在 2017 年提出。"
                     "它使用自注意力机制替代了传统的 RNN 结构。"
                     "Transformer 在机器翻译任务上取得了突破性成果。",
        metadata={"source": "transformer_paper"}),        # 元数据标记来源
    Document(                                              # 文档2:BERT 论文摘要
        page_content="BERT 是基于 Transformer 编码器的预训练模型。"
                     "它通过掩码语言模型(MLM)任务进行预训练。"
                     "BERT 在 11 项 NLP 基准测试上取得了 SOTA 结果。",
        metadata={"source": "bert_paper"}),
    Document(                                              # 文档3:GPT 系列介绍
        page_content="GPT 系列模型使用 Transformer 解码器。"
                     "GPT-3 拥有 1750 亿参数,展示了强大的少样本学习能力。"
                     "GPT-4 支持多模态输入,可以处理图像和文本。",
        metadata={"source": "gpt_paper"}),
    Document(                                              # 文档4:注意力机制概述
        page_content="注意力机制(Attention Mechanism)是深度学习中的重要概念。"
                     "它允许模型在处理输入时关注重要的部分。"
                     "Bahdanau Attention 最早用于神经机器翻译。",
        metadata={"source": "attention_paper"}),
    Document(                                              # 文档5:KV-Cache 优化技术
        page_content="KV-Cache 是 Transformer 推理优化的关键技术。"
                     "它通过缓存 Key 和 Value 矩阵来避免重复计算。"
                     "KV-Cache 可以显著降低自回归生成的计算复杂度。",
        metadata={"source": "kvcache_paper"}),
]

# ====== 2. 创建稠密检索器 ======
embedding = OpenAIEmbeddings(model="text-embedding-3-small")  # 加载嵌入模型
vectorstore = Chroma.from_documents(documents, embedding)      # 建向量索引
dense_retriever = vectorstore.as_retriever(                    # 转为检索器
    search_kwargs={"k": 5})                                   # 每次返回 Top-5

# ====== 3. 创建稀疏检索器 ======
bm25_retriever = BM25Retriever.from_documents(documents)       # 用全部文档建 BM25 索引
bm25_retriever.k = 5                                           # 每次返回 Top-5

# ====== 4. 组装混合检索器 ======
ensemble_retriever = EnsembleRetriever(
    retrievers=[dense_retriever, bm25_retriever],              # 两路检索器
    weights=[0.7, 0.3],                                        # 稠密 0.7 / 稀疏 0.3
    # EnsembleRetriever 内部默认使用 RRF 融合
)

# ====== 5. 对比测试:同一查询下三种检索策略的差异 ======
queries = [
    "什么是 Transformer?",                                     # 语义+关键词都命中
    "GPT-3 有多少参数?",                                       # 精确数字,BM25 占优
    "KV-Cache 的作用是什么?",                                   # 专有名词,BM25 占优
]

for query in queries:                                         # 逐个测试
    print(f"\n{'='*50}")
    print(f"查询: {query}")

    print("\n--- 仅稠密检索 ---")                                # 看语义召回效果
    for i, doc in enumerate(dense_retriever.invoke(query)):
        print(f"  {i+1}. {doc.page_content[:80]}...")

    print("\n--- 仅 BM25 检索 ---")                             # 看关键词命中效果
    for i, doc in enumerate(bm25_retriever.invoke(query)):
        print(f"  {i+1}. {doc.page_content[:80]}...")

    print("\n--- 混合检索 (Ensemble) ---")                      # 看融合后效果
    for i, doc in enumerate(ensemble_retriever.invoke(query)):
        print(f"  {i+1}. {doc.page_content[:80]}...")

运行这段代码,你会观察到:对于"GPT-3 有多少参数?"这类包含精确关键词的查询,BM25 的排序往往更准;对于"什么是 Transformer?"这类语义查询,稠密检索更全面;而混合检索综合了两者的长处,既不会漏掉关键词精确匹配的文档,也不会丢失语义相关的候选。

6.5.7 常见误区

在实际工程中,检索策略的选型和调优有几个反复出现的误区:

误区一:只要上了混合检索就够了,不用调权重。

很多人把 α 设成 0.5 就不管了。但前面已经说过,不同场景的权重差异很大——代码检索场景下 α=0.5 会让大量无关的语义文档排在前面,干扰最终生成质量。务必根据知识库特点做 A/B 测试,不能一刀切。

误区二:Embedding 模型随便选一个就行。

稠密检索的天花板由 Embedding 模型决定。text-embedding-3-smallbge-large-zh 在中文场景下的表现可能差出 10 个百分点以上。知识库是中文的,就选中文优化的模型;是多语言的,就选多语言模型。模型选错,混合检索也救不回来。

误区三:BM25 不需要分词,直接用就行。

BM25 依赖分词,中文如果不分词(直接按字切),效果会大打折扣。"注意力机制"会被切成"注""意""力""机""制"五个单字,每个字单独匹配,噪音极大。务必使用 jieba、pkuseg 等中文分词器对文档和查询做预处理,或者选择内置中文分词的 BM25 实现。

误区四:召回的 Top-K 越多越好。

多召回文档不一定提升效果,反而会增加下游 Rerank 和 LLM 的负担。通常两路各召回 20~50 条,融合后取 Top-10 送入 Rerank 即可。过多的候选不仅浪费算力,还可能引入噪音文档,拉低最终答案质量。

误区五:RRF 的 k 参数需要精调。

k=60 是论文给出的经验值,在绝大多数场景下都适用,不需要反复调。RRF 的鲁棒性正是它的优势——如果 k 从 60 改到 50 或 70 对结果影响巨大,说明你的检索系统本身有更根本的问题需要排查(比如分词、Embedding 模型质量),而不是纠结 k 值。

6.5.8 本节小结

本节从信息检索的经典与现代两条线出发,系统讲解了三种检索策略:

策略核心机制擅长短板
稀疏检索 (BM25)词频 + IDF 统计精确关键词匹配、专有名词/代码/ID无语义理解、依赖分词质量
稠密检索 (Dense)向量余弦相似度语义泛化、同义词/跨语言精确字符串不敏感、依赖模型质量
混合检索 (Hybrid)两路召回 + RRF 融合兼顾精确与语义,覆盖面最广工程复杂度略高

核心要点回顾:

  1. 稀疏检索和稠密检索是互补关系,不是替代关系。精确匹配靠 BM25,语义理解靠 Dense,两者各管一摊。
  2. RRF 是首选融合算法——只看排名不看得分,免去归一化的麻烦,对各种检索器都鲁棒。
  3. 权重调优要因场景而异:通用问答偏稠密(α≈0.8),代码/ID 检索偏稀疏(α≈0.3),没有万能参数。
  4. 多路召回是混合检索的扩展,可加入知识图谱、时间范围等检索器,配合查询重写进一步提升召回覆盖。

检索策略决定了"候选池"的质量,但从候选池到最终喂给 LLM 的 Top-K,中间还有一步关键工序——重排序(Rerank)。Rerank 模型能对候选文档做更精细的相关性判断,把真正最相关的排到最前面。这正是下一节 6.6 的主题。