摘要

检索增强生成(Retrieval-Augmented Generation, RAG)已经成为大语言模型应用中不可或缺的技术范式。本文深入探讨RAG系统中的组合检索流程,全面解析稀疏检索(Sparse Retrieval)与密集检索(Dense Retrieval)的技术原理、实现机制以及融合策略。通过对BM25算法、神经稀疏模型SPLADE、向量检索技术、混合搜索架构以及重排序机制的系统性分析,为读者构建高性能RAG系统提供完整的技术指南。


一、检索增强生成(RAG)概述

1.1 RAG的核心价值

检索增强生成代表了一种重要的范式转变。传统大语言模型受限于其训练数据的时效性和范围,往往会产生"幻觉"(Hallucination)现象——即生成看似合理但实际错误的内容。RAG通过引入外部知识库作为"事实来源"(Source of Truth),在生成响应前先检索相关文档,从而显著提升回答的准确性、时效性和可验证性。

1.2 RAG系统的基本工作流程

一个典型的RAG系统包含以下核心阶段:

用户查询
查询处理与编码
检索器
相关文档
重排序
上下文构建
大语言模型
生成响应

其中,检索器(Retriever)是整个系统的关键组件,其质量直接决定了最终生成内容的相关性和准确性。正如研究表明,如果检索器无法找到正确的文档,即使最强大的语言模型也无法生成正确的答案。


二、稀疏检索(Sparse Retrieval):词法匹配的经典方法

2.1 稀疏检索的本质

稀疏检索建立在词袋模型(Bag-of-Words)的基础上,通过统计词项的出现频率来表示文档和查询。其"稀疏性"体现在向量表示中:假设词汇表大小为30,000,而一篇文档可能只包含几百个不同的词,那么其向量表示中绝大部分维度的值都是零。

稀疏向量的形式如下:

文档向量 = [0, 0, 0.85, 0, 0, 1.2, 0, 0, ..., 0]
           |----------------------------------|
                  30,000 维度

2.2 BM25算法详解

2.2.1 BM25的历史背景

BM25(Best Match 25)算法由Stephen E. Robertson等人在20世纪70-80年代基于概率检索框架(Probabilistic Retrieval Framework)开发,作为Okapi信息检索系统的一部分在伦敦城市大学实现。它代表了TF-IDF思想的进化版本,通过**引入文档长度归一化和词项饱和度处理,**成为信息检索领域的黄金标准。

2.2.2 BM25的数学原理

BM25的核心公式如下:

Score ( D , Q ) = ∑ i = 1 n IDF ( q i ) ⋅ f ( q i , D ) ⋅ ( k 1 + 1 ) f ( q i , D ) + k 1 ⋅ ( 1 − b + b ⋅ ∣ D ∣ avgdl ) \text{Score}(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 (1 - b + b \cdot \frac{|D|}{\text{avgdl}})} Score(D,Q)=i=1nIDF(qi)f(qi,D)+k1(1b+bavgdlD)f(qi,D)(k1+1)

其中:

  • D D D 是文档
  • Q Q Q 是查询
  • q i q_i qi 是查询中的第 $ i $ 个词项
  • f ( q i , D ) f(q_i, D) f(qi,D) 是词项 q i q_i qi 在文档 D D D 中的频率(Term Frequency)
  • ∣ D ∣ |D| D 是文档 D D D 的长度
  • avgdl \text{avgdl} avgdl 是语料库中文档的平均长度
  • k 1 k_1 k1 b b b 是可调参数

逆文档频率(IDF) 的计算公式为:

IDF ( q i ) = ln ⁡ ( N − n ( q i ) + 0.5 n ( q i ) + 0.5 + 1 ) \text{IDF}(q_i) = \ln \left( \frac{N - n(q_i) + 0.5}{n(q_i) + 0.5} + 1 \right) IDF(qi)=ln(n(qi)+0.5Nn(qi)+0.5+1)

其中:

  • N N N 是语料库中文档总数
  • n ( q i ) n(q_i) n(qi) 是包含词项 q i q_i qi 的文档数量
2.2.3 BM25的关键参数

参数 k 1 k_1 k1(词项频率饱和参数):

  • 控制词项频率对得分的影响程度
  • 典型值范围:1.2 到 2.0
  • 较高的 k 1 k_1 k1 值意味着词项频率的增加会持续提高得分
  • 较低的 k 1 k_1 k1 值会更快达到饱和,防止高频词过度主导得分

参数 b b b(文档长度归一化参数):

  • 控制文档长度对得分的影响
  • 取值范围:0 到 1
  • b = 1 b = 1 b=1:完全根据文档长度归一化
  • b = 0 b = 0 b=0:忽略文档长度
  • 典型值:0.75,提供平衡的归一化
2.2.4 BM25的工作机制示例

假设我们有以下简单场景:

查询: “quick fox”

文档集合:

  • 文档1 (长度: 8): “The quick brown fox jumps over the lazy”
  • 文档2 (长度: 5): “A brown dog”
  • 文档3 (长度: 12): “The fox is quick and the fox is clever”

参数设置: k 1 = 1.5 k_1 = 1.5 k1=1.5, b = 0.75 b = 0.75 b=0.75, 平均文档长度 = 8.33

计算步骤

  1. 计算IDF:
    • “quick” 出现在 2 个文档中: $ \text{IDF}(\text{quick}) = \ln(\frac{3-2+0.5}{2+0.5}+1) = 0.29 $
    • “fox” 出现在 2 个文档中: $ \text{IDF}(\text{fox}) = 0.29 $
  2. 计算各文档得分:对于文档1:
    • “quick” TF = 1, 文档长度 = 8
    • “fox” TF = 1
    • 长度归一化因子 = ( 1 − 0.75 + 0.75 × 8 8.33 ) = 0.97 (1 - 0.75 + 0.75 \times \frac{8}{8.33}) = 0.97 (10.75+0.75×8.338)=0.97

Score 1 = 0.29 × 1 × 2.5 1 + 1.5 × 0.97 + 0.29 × 1 × 2.5 1 + 1.5 × 0.97 = 0.60 \text{Score}_1 = 0.29 \times \frac{1 \times 2.5}{1 + 1.5 \times 0.97} + 0.29 \times \frac{1 \times 2.5}{1 + 1.5 \times 0.97} = 0.60 Score1=0.29×1+1.5×0.971×2.5+0.29×1+1.5×0.971×2.5=0.60

对于文档2:

  • “quick” TF = 1, 文档长度 = 5
  • “fox” TF = 0

Score 2 = 0.29 × 1 × 2.5 1 + 1.5 × 0.70 = 0.35 \text{Score}_2 = 0.29 \times \frac{1 \times 2.5}{1 + 1.5 \times 0.70} = 0.35 Score2=0.29×1+1.5×0.701×2.5=0.35

对于文档3:

  • “quick” TF = 1, 文档长度 = 12
  • “fox” TF = 2

Score 3 = 0.29 × 1 × 2.5 1 + 1.5 × 1.33 + 0.29 × 2 × 2.5 2 + 1.5 × 1.33 = 0.75 \text{Score}_3 = 0.29 \times \frac{1 \times 2.5}{1 + 1.5 \times 1.33} + 0.29 \times \frac{2 \times 2.5}{2 + 1.5 \times 1.33} = 0.75 Score3=0.29×1+1.5×1.331×2.5+0.29×2+1.5×1.332×2.5=0.75

排序结果: 文档3 > 文档1 > 文档2

2.3 BM25的优势与局限

2.3.1 BM25的核心优势
优势 说明
高效性 基于倒排索引,检索速度极快,可轻松处理百万级文档
可解释性 匹配结果直观可理解,易于调试和优化
精确匹配 擅长处理需要精确词项匹配的查询,如产品代码、技术术语
无需训练 纯统计方法,不依赖训练数据和计算资源
稳定性强 在各种场景下都能提供稳定的基线性能
资源占用低 索引体积小,内存占用少
2.3.2 BM25的主要局限
局限 影响 示例
词汇鸿沟 无法处理同义词和语义相关词 "汽车"与"车辆"无法匹配
语义理解缺失 仅基于字面匹配,不理解上下文 “银行”(金融) vs "河岸"无法区分
拼写敏感 拼写错误会导致完全无法匹配 “teh"无法匹配"the”
语言依赖 需要针对不同语言定制分词和停用词 中文分词质量直接影响效果
无法处理长查询 对复杂的自然语言查询效果不佳 “请告诉我关于…”
缺乏学习能力 无法从用户反馈中改进 固定的统计模型

三、密集检索(Dense Retrieval):语义理解的神经方法

3.1 密集检索的范式转变

密集检索代表了从词法匹配到语义理解的根本性转变。通过深度神经网络(特别是Transformer架构),密集检索将文本编码为稠密的、低维的连续向量,在这个语义空间中,意义相近的文本会有相似的向量表示。

3.2 密集向量的特性

与稀疏向量相比,密集向量具有以下特征:

# 稀疏向量示例 (30,000维)
sparse_vector = {234: 0.85, 567: 1.2, 12890: 0.43, ...}  # 仅存储非零值
# 实际存储: 几百个非零维度

# 密集向量示例 (768维)
dense_vector = [0.234, -0.567, 0.123, ..., 0.891]  # 每个维度都有值
# 实际存储: 所有768个维度

密集向量的关键特性:

  • 维度固定且较低: 通常 384-1536 维,远小于词汇表大小
  • 所有维度有值: 每个维度都是非零的连续值
  • 语义编码: 向量距离反映语义相似度而非词法重叠
  • 上下文感知: 同一个词在不同上下文中可能有不同的向量表示

3.3 双编码器架构(Bi-Encoder)

3.3.1 架构设计

双编码器是密集检索最常用的架构,它包含两个独立的编码器:

文档编码器
查询编码器
BERT/T5/...
文档文本
池化层
文档向量 768维
BERT/T5/...
查询文本
池化层
查询向量 768维
余弦相似度/点积
相关性得分
3.3.2 工作流程

离线阶段(索引构建):

  1. 使用文档编码器处理所有文档
  2. 提取[CLS] token或进行均值池化得到文档向量
  3. 将文档向量存储在向量数据库(如FAISS, Milvus)中

在线阶段(查询检索):

  1. 使用查询编码器处理用户查询
  2. 在向量数据库中执行近似最近邻搜索(ANN)
  3. 返回Top-K最相似的文档
3.3.3 相似度计算方法

1. 点积(Dot Product):

sim ( q , d ) = q ⋅ d = ∑ i = 1 n q i × d i \text{sim}(q, d) = q \cdot d = \sum_{i=1}^{n} q_i \times d_i sim(q,d)=qd=i=1nqi×di

  • 优势:计算最快
  • 劣势:受向量长度影响

2. 余弦相似度(Cosine Similarity):

sim ( q , d ) = q ⋅ d ∣ ∣ q ∣ ∣ × ∣ ∣ d ∣ ∣ = ∑ i = 1 n q i × d i ∑ i = 1 n q i 2 × ∑ i = 1 n d i 2 \text{sim}(q, d) = \frac{q \cdot d}{||q|| \times ||d||} = \frac{\sum_{i=1}^{n} q_i \times d_i}{\sqrt{\sum_{i=1}^{n} q_i^2} \times \sqrt{\sum_{i=1}^{n} d_i^2}} sim(q,d)=∣∣q∣∣×∣∣d∣∣qd=i=1nqi2 ×i=1ndi2 i=1nqi×di

  • 优势:归一化后不受向量长度影响
  • 劣势:需要额外计算向量长度

3. 欧氏距离(Euclidean Distance):

dist ( q , d ) = ∑ i = 1 n ( q i − d i ) 2 \text{dist}(q, d) = \sqrt{\sum_{i=1}^{n} (q_i - d_i)^2} dist(q,d)=i=1n(qidi)2

  • 用途:距离越小表示越相似
  • 常用于某些向量数据库实现

3.4 流行的密集检索模型

3.4.1 DPR (Dense Passage Retrieval)

DPR是Facebook AI在2020年提出的开创性工作,首次证明了纯密集检索可以在开放域问答任务上超越BM25。

核心创新:

  • 使用BERT作为双编码器的基础
  • 负样本采样策略:从同一批次中采样负例(in-batch negatives)
  • 针对开放域QA任务优化

训练目标:
最小化负对数似然损失:

L = − log ⁡ e sim ( q , d + ) e sim ( q , d + ) + ∑ i = 1 n e sim ( q , d i − ) L = -\log \frac{e^{\text{sim}(q, d^+)}}{e^{\text{sim}(q, d^+)} + \sum_{i=1}^{n} e^{\text{sim}(q, d_i^-)}} L=logesim(q,d+)+i=1nesim(q,di)esim(q,d+)

其中 d + d^+ d+ 是正例文档, d i − d_i^- di 是负例文档。

3.4.2 BGE (BAAI General Embedding)

BGE系列由北京智源人工智能研究院开发,在MTEB排行榜上表现优异。

模型变体:

  • BGE-small: 33M参数,384维向量
  • BGE-base: 109M参数,768维向量
  • BGE-large: 335M参数,1024维向量

特点:

  • 在大规模语料上训练
  • 支持多语言
  • 在检索、聚类、重排序等任务上表现均衡
3.4.3 E5 系列

微软开发的E5(Embeddings from bidirectional Encoder rEpresentations)模型以其训练方法著称。

训练策略:

  • E5-base: 对比学习 + 负样本挖掘
  • E5-large: 蒸馏学习 + 数据增强
  • E5-mistral-7b: 基于Mistral-7B的大规模嵌入模型

性能特点:

  • 在MTEB基准上表现出色
  • 特别擅长零样本迁移
  • 支持长文本(最多512 tokens)
3.4.4 模型对比表
模型 参数量 向量维度 MTEB得分 优势 适用场景
DPR 110M 768 ~52 经典基线,易于理解 开放域QA
BGE-base 109M 768 63.5 均衡性能,中文友好 通用检索
E5-base 109M 768 61.5 零样本能力强 跨域检索
E5-mistral-7b 7B 4096 66.5 最强性能 高质量要求
GTE-large 335M 1024 63.7 长文本理解 文档检索

3.5 密集检索的优势与挑战

3.5.1 核心优势
优势 说明 价值
语义理解 能够匹配意义相同但表达不同的文本 解决词汇鸿沟问题
鲁棒性 对拼写错误、同义词等具有容错能力 提升用户体验
跨语言能力 多语言模型可以跨语言检索 全球化应用
上下文感知 理解词的多义性和上下文依赖 精确语义匹配
端到端学习 可以通过任务数据微调优化 持续改进性能
3.5.2 主要挑战
挑战 影响 缓解策略
计算成本高 编码和索引需要GPU资源 模型蒸馏,量化
索引体积大 向量存储需求大(每个文档768-1536维) 降维技术,Matryoshka表示
领域适应性 通用模型在特定领域可能表现不佳 领域微调
精确匹配弱 对于需要精确词匹配的查询(如ID、代码)效果差 混合检索
黑盒特性 难以解释为什么两个文本相似 可解释性研究

四、学习型稀疏检索:SPLADE模型家族

4.1 SPLADE的动机与创新

SPLADE(Sparse Lexical and Expansion Model)代表了稀疏检索和密集检索的中间地带——它试图结合两者的优势:

  • 保持稀疏性: 兼容传统倒排索引,检索高效
  • 引入学习: 利用神经网络学习词项权重和扩展
  • 可解释性: 稀疏向量的每个维度对应词汇表中的具体词项

4.2 SPLADE的核心机制

4.2.1 架构设计
输入文本
BERT编码器
MLM头: 词汇表投影
对数饱和激活
FLOPS正则化
稀疏向量: 词项权重映射

关键组件:

  1. BERT编码器: 提供上下文化的token表示
  2. MLM(Masked Language Model)头: 将每个token映射到词汇表维度
  3. 对数饱和: 应用log(1 + ReLU(x))激活函数,控制权重分布
  4. FLOPS正则化: 惩罚非零值数量,确保稀疏性
4.2.2 词项扩展机制

SPLADE的一个关键创新是自动词项扩展(Term Expansion)。

传统方法的词项扩展:

  • 基于同义词词典(手工维护)
  • 查询重写规则(专家设计)
  • 伪相关反馈(基于初次检索结果)

SPLADE的学习型扩展:

输入查询: "深度学习"

# SPLADE扩展后的表示(简化):
{
    "深度": 0.95,
    "学习": 0.92,
    "神经网络": 0.78,    # 扩展词
    "机器学习": 0.65,    # 扩展词  
    "人工智能": 0.52,    # 扩展词
    "训练": 0.48,        # 扩展词
    ...
}

扩展的优势:

  • 自动发现语义相关词
  • 基于大规模预训练学到的知识
  • 保持可解释性(可以看到扩展了哪些词)
4.2.3 数学形式化

对于输入序列 x = [ x 1 , x 2 , . . . , x n ] x = [x_1, x_2, ..., x_n] x=[x1,x2,...,xn],SPLADE计算稀疏表示如下:

  1. 编码: h i = BERT ( x ) i h_i = \text{BERT}(x)_i hi=BERT(x)i 得到每个token的隐藏状态
  2. MLM投影: l i = MLM-head ( h i ) ∈ R ∣ V ∣ l_i = \text{MLM-head}(h_i) \in \mathbb{R}^{|V|} li=MLM-head(hi)RV,其中 ∣ V ∣ |V| V 是词汇表大小
  3. 激活与正则化:

w j = max ⁡ i log ⁡ ( 1 + ReLU ( l i , j ) ) w_j = \max_i \log(1 + \text{ReLU}(l_{i,j})) wj=maxilog(1+ReLU(li,j))

其中 j j j 遍历词汇表中的所有词项

  1. FLOPS正则化:

L FLOPS = ∑ j 1 [ w j > 0 ] L_{\text{FLOPS}} = \sum_{j} \mathbb{1}[w_j > 0] LFLOPS=j1[wj>0]

4.3 SPLADE模型演进

4.3.1 SPLADE v1 (2021)

核心贡献:

  • 引入MLM头用于稀疏向量生成
  • 提出log(1+ReLU)激活函数
  • FLOPS正则化保证稀疏性

性能:

  • 在MS MARCO上达到与DPR相当的效果
  • 推理速度接近BM25
4.3.2 SPLADE v2 (2021)

改进点:

  • 修改池化机制(从mean pooling到max pooling)
  • 引入知识蒸馏训练
  • 优化文档扩展策略

性能提升:

  • NDCG@10提升9%以上
  • 在BEIR benchmark上达到SOTA
4.3.3 SPLADE++ (2022)

主要创新:

  • 查询和文档双向扩展(之前只扩展文档)
  • 困难负样本挖掘(Hard Negative Mining)
  • 更高效的训练策略

benchmark结果:

  • MS MARCO Dev MRR@10: 0.384
  • BEIR平均NDCG@10: 0.501
4.3.4 DistilSPLADE

动机: 减小模型体积,提升推理速度

方法:

  • 从SPLADE++蒸馏到更小的模型(如DistilBERT)
  • 保持95%以上的性能

效果:

  • 参数量减少40%
  • 推理速度提升2-3倍

4.4 SPLADE的性能特性

4.4.1 与其他方法的对比
指标 BM25 DPR SPLADE++
MS MARCO MRR@10 0.187 0.395 0.384
BEIR Avg NDCG@10 0.401 0.446 0.501
检索延迟(ms) 5-10 50-100 15-30
索引体积
可解释性
4.4.2 稀疏性分析

SPLADE生成的向量虽然理论上是词汇表维度(30,000+),但实际上:

  • 查询向量: 平均50-150个非零值
  • 文档向量: 平均200-500个非零值

这使得SPLADE可以:

  • 存储在传统倒排索引中
  • 使用标准的词项匹配算法
  • 享受倒排索引的压缩和缓存优势

4.5 SPLADE的应用建议

何时使用SPLADE:

适合的场景:

  • 需要可解释的检索结果
  • 有精确词匹配需求(如专业术语、代码)
  • 想平衡语义理解和精确匹配
  • 需要较低的存储和计算成本

不太适合的场景:

  • 纯跨语言检索(不如多语言密集模型)
  • 需要最优语义理解(密集检索可能更好)
  • 实时性要求极高(BM25更快)

实现建议:

# 使用Hugging Face的SPLADE模型
from transformers import AutoTokenizer, AutoModelForMaskedLM
import torch

model_name = "naver/splade-cocondenser-ensembledistil"
tokenizer = AutoTokenizer.from_pretrained(model_name)
model = AutoModelForMaskedLM.from_pretrained(model_name)

def encode_splade(text):
    tokens = tokenizer(text, return_tensors="pt")
    with torch.no_grad():
        output = model(**tokens)
    # 提取MLM logits并应用激活函数
    logits = output.logits
    sparse_vec = torch.log(1 + torch.relu(logits))
    # 取最大值池化
    sparse_vec = torch.max(sparse_vec, dim=1)[0].squeeze()
    return sparse_vec

# 编码查询
query_vec = encode_splade("什么是机器学习?")

# 找出权重最高的词项
top_k = 20
values, indices = torch.topk(query_vec, top_k)
for idx, val in zip(indices, values):
    if val > 0:
        token = tokenizer.decode([idx])
        print(f"{token}: {val:.3f}")

五、混合检索(Hybrid Search):融合的艺术

5.1 混合检索的必要性

研究和实践都表明,单一检索方法往往无法在所有场景下都表现最佳:

  • BM25: 在需要精确词匹配的查询中表现出色
  • 密集检索: 在语义查询和跨语言场景中占优
  • SPLADE: 在两者之间提供平衡

混合检索的核心思想是:让不同方法的优势互补,劣势相互抵消

5.2 混合检索的架构设计

5.2.1 并行检索架构
用户查询
查询副本1
查询副本2
查询副本3
BM25检索器
密集检索器
SPLADE检索器
排序列表1
doc2:0.85, doc5:0.72, doc1:0.68...
排序列表2
doc5:0.92, doc7:0.89, doc2:0.76...
排序列表3
doc5:0.88, doc2:0.81, doc9:0.75...
融合算法
RRF/Weighted Fusion
合并排序列表
Top-K结果
5.2.2 工作流程
  1. 并行检索: 同时向多个检索器发送查询
  2. 结果收集: 每个检索器返回独立的排序列表
  3. 去重: 识别在多个列表中出现的文档
  4. 融合: 使用融合算法计算综合得分
  5. 重排序: 按照综合得分生成最终排序

5.3 融合算法详解

5.3.1 简单加权融合(Weighted Score Fusion)

最直接的方法是对不同检索器的得分进行加权求和:

score final ( d ) = ∑ i = 1 n w i ⋅ score i ( d ) \text{score}_{\text{final}}(d) = \sum_{i=1}^{n} w_i \cdot \text{score}_i(d) scorefinal(d)=i=1nwiscorei(d)

其中:

  • w i w_i wi 是第 $ i $ 个检索器的权重
  • score i ( d ) \text{score}_i(d) scorei(d) 是文档 d d d 在第 i i i 个检索器中的得分
  • ∑ i = 1 n w i = 1 \sum_{i=1}^{n} w_i = 1 i=1nwi=1 (归一化约束)

挑战:

  • ❌ 不同检索器的得分量纲和分布可能不同
  • ❌ 需要手动调整权重
  • ❌ 对于未出现在某个列表中的文档,得分为0

改进:归一化加权融合

先对每个检索器的得分进行归一化:

score i ′ ( d ) = score i ( d ) − min ⁡ i max ⁡ i − min ⁡ i \text{score}'_i(d) = \frac{\text{score}_i(d) - \min_i}{\max_i - \min_i} scorei(d)=maximiniscorei(d)mini

然后进行加权融合:

score final ( d ) = ∑ i = 1 n w i ⋅ score i ′ ( d ) \text{score}_{\text{final}}(d) = \sum_{i=1}^{n} w_i \cdot \text{score}'_i(d) scorefinal(d)=i=1nwiscorei(d)

5.3.2 互易排名融合(Reciprocal Rank Fusion, RRF)

RRF是混合检索中最受欢迎的融合算法,因其简单、鲁棒且无需参数调优。

核心公式:

RRF ( d ) = ∑ r ∈ R 1 k + rank r ( d ) \text{RRF}(d) = \sum_{r \in R} \frac{1}{k + \text{rank}_r(d)} RRF(d)=rRk+rankr(d)1

其中:

  • R R R 是所有检索器的集合
  • rank r ( d ) \text{rank}_r(d) rankr(d) 是文档 d d d 在检索器 r r r 的排序列表中的位置(从1开始)
  • k k k 是一个常数,通常设为60

工作原理:

假设我们有两个检索器,查询"深度学习":

BM25排序:

  1. doc_A (score: 3.2)
  2. doc_B (score: 2.8)
  3. doc_C (score: 2.1)
  4. doc_D (score: 1.9)

密集检索排序:

  1. doc_B (score: 0.92)
  2. doc_E (score: 0.89)
  3. doc_A (score: 0.85)
  4. doc_F (score: 0.78)

RRF计算 (k=60):

对于 doc_A:

RRF ( A ) = 1 60 + 1 + 1 60 + 3 = 0.0164 + 0.0159 = 0.0323 \text{RRF}(A) = \frac{1}{60+1} + \frac{1}{60+3} = 0.0164 + 0.0159 = 0.0323 RRF(A)=60+11+60+31=0.0164+0.0159=0.0323

对于 doc_B:

RRF ( B ) = 1 60 + 2 + 1 60 + 1 = 0.0161 + 0.0164 = 0.0325 \text{RRF}(B) = \frac{1}{60+2} + \frac{1}{60+1} = 0.0161 + 0.0164 = 0.0325 RRF(B)=60+21+60+11=0.0161+0.0164=0.0325

对于 doc_C:

RRF ( C ) = 1 60 + 3 + 0 = 0.0159 \text{RRF}(C) = \frac{1}{60+3} + 0 = 0.0159 RRF(C)=60+31+0=0.0159

对于 doc_D:

RRF ( D ) = 1 60 + 4 + 0 = 0.0156 \text{RRF}(D) = \frac{1}{60+4} + 0 = 0.0156 RRF(D)=60+41+0=0.0156

对于 doc_E:

RRF ( E ) = 0 + 1 60 + 2 = 0.0161 \text{RRF}(E) = 0 + \frac{1}{60+2} = 0.0161 RRF(E)=0+60+21=0.0161

对于 doc_F:

RRF ( F ) = 0 + 1 60 + 4 = 0.0156 \text{RRF}(F) = 0 + \frac{1}{60+4} = 0.0156 RRF(F)=0+60+41=0.0156

最终排序:

  1. doc_B (0.0325) ← 在两个列表中都排名靠前
  2. doc_A (0.0323)
  3. doc_E (0.0161)
  4. doc_C (0.0159)
  5. doc_D (0.0156)
  6. doc_F (0.0156)

RRF的优势:

优势 说明
无需归一化 直接使用排名而非得分,避免了得分量纲不一致的问题
鲁棒性强 对单个检索器的异常排名不敏感
参数少 只有一个参数k,且对大多数场景默认值60就很好
数学基础 基于概率论的互易排名理论
多列表融合 可轻松扩展到3个或更多检索器

k参数的选择:

k值的作用是平衡高排名和低排名文档的影响:

  • k较小(如k=10): 高排名文档主导结果
  • k较大(如k=100): 低排名文档也有较大影响
  • 推荐值: k=60 是通过大量实验得出的经验最优值
5.3.3 带权重的RRF

在某些场景下,我们知道某个检索器更可靠,可以引入权重:

RRF w ( d ) = ∑ r ∈ R w r k + rank r ( d ) \text{RRF}_w(d) = \sum_{r \in R} \frac{w_r}{k + \text{rank}_r(d)} RRFw(d)=rRk+rankr(d)wr

其中 w r w_r wr 是检索器 r r r 的权重。

示例配置:

# 语义查询场景,提升密集检索权重
weights = {
    "bm25": 1.0,
    "dense": 2.0,      # 密集检索权重加倍
    "splade": 1.5
}

# 精确匹配场景,提升BM25权重  
weights = {
    "bm25": 2.0,       # BM25权重加倍
    "dense": 1.0,
    "splade": 1.5
}
5.3.4 其他融合算法

Borda Count:
基于排名的简单计数方法,每个文档得分等于其在各列表中排名的倒数总和。

CombMNZ:
考虑文档出现在多少个列表中:

CombMNZ ( d ) = NumLists ( d ) × ∑ i = 1 n score i ( d ) \text{CombMNZ}(d) = \text{NumLists}(d) \times \sum_{i=1}^{n} \text{score}_i(d) CombMNZ(d)=NumLists(d)×i=1nscorei(d)

Learning to Rank:
使用机器学习模型(如LambdaMART, XGBoost)学习如何融合:

  • 输入特征:各检索器的得分、排名、检索器置信度等
  • 训练目标:真实的相关性标签
  • 优势:理论上可达到最优融合
  • 劣势:需要标注数据和训练成本

5.4 混合检索的最佳实践

5.4.1 检索器选择策略
场景类型 推荐组合 理由
通用知识库 BM25 + 密集检索 平衡精确和语义匹配
技术文档 BM25 + SPLADE 重视术语匹配和可解释性
多语言应用 密集检索 + SPLADE 语义理解 + 词项扩展
高质量要求 BM25 + 密集检索 + SPLADE 三路检索,全面覆盖
实时系统 BM25 + 轻量级密集模型 平衡性能和速度
5.4.2 Window参数配置

在混合检索中,通常不是对每个检索器的所有结果进行融合,而是使用"窗口"(Window)机制:

# 典型配置示例
config = {
    "retrieval_k": 100,      # 每个检索器返回top-100
    "fusion_window": 80,     # 融合算法处理top-80
    "rerank_window": 50,     # 重排序处理top-50  
    "final_k": 20            # 最终返回top-20
}

为什么使用窗口?

  1. 提高召回率: 文档在单个列表中排名较低,但在多个列表中都出现,融合后可能排名靠前
  2. 增加多样性: 更大的窗口可以引入更多样化的候选文档
  3. 平衡性能: 避免处理所有结果带来的计算开销
5.4.3 融合权重调优

启发式调优:

# 步骤1: 在验证集上测试单一检索器
bm25_score = evaluate(bm25_retriever, val_set)        # 假设: 0.65
dense_score = evaluate(dense_retriever, val_set)      # 假设: 0.72
splade_score = evaluate(splade_retriever, val_set)    # 假设: 0.68

# 步骤2: 基于性能设置初始权重
weights = {
    "bm25": bm25_score / total_score,      # 0.32
    "dense": dense_score / total_score,    # 0.35  
    "splade": splade_score / total_score   # 0.33
}

# 步骤3: 网格搜索微调
best_weights = grid_search(
    weight_ranges={
        "bm25": [0.2, 0.3, 0.4],
        "dense": [0.3, 0.4, 0.5],
        "splade": [0.2, 0.3, 0.4]
    },
    val_set=val_set
)

自动权重学习:
使用少量标注数据训练一个轻量级模型来学习最优权重:

from sklearn.linear_model import LogisticRegression

# 特征: 各检索器的得分/排名
X_train = extract_features(queries, documents, retrievers)
y_train = relevance_labels  # 0/1标签

# 训练融合模型
fusion_model = LogisticRegression()
fusion_model.fit(X_train, y_train)

# 推理时使用学到的权重
fusion_weights = fusion_model.coef_

5.5 混合检索的性能收益

5.5.1 Benchmark结果对比

基于多个公开数据集的实验结果:

数据集 BM25 Dense SPLADE BM25+Dense(RRF) 三路混合
MS MARCO 0.187 0.395 0.384 0.412 (+4.3%) 0.425 (+7.6%)
Natural Questions 0.621 0.789 0.742 0.811 (+2.8%) 0.823 (+4.3%)
HotpotQA 0.583 0.712 0.687 0.734 (+3.1%) 0.748 (+5.1%)
BEIR Avg 0.401 0.446 0.501 0.489 (+9.7%) 0.523 (+17.2%)

关键观察:

  • 混合检索在绝大多数场景下优于单一方法
  • 在Out-of-Domain任务上改进最为显著(BEIR benchmark)
  • 三路混合比两路混合带来额外但较小的提升
5.5.2 真实案例分析

案例1: 电商产品搜索

  • 场景: 用户搜索"适合小户型的北欧风格沙发"
  • BM25结果: 侧重"小户型"、"沙发"等关键词,可能返回尺寸合适但风格不符的产品
  • 密集检索: 理解"北欧风格"的语义,但可能忽略"小户型"的尺寸约束
  • 混合检索: 同时满足风格语义和尺寸约束,用户满意度提升23%

案例2: 技术文档检索

  • 场景: 开发者搜索"如何在Python中处理JSON解析错误"
  • BM25结果: 精确匹配"Python"、“JSON”、"错误"等词项
  • 密集检索: 理解异常处理的语义但可能返回JavaScript相关内容
  • 混合检索: 既有技术栈精确匹配,又有异常处理的语义理解,问题解决率提升31%

六、重排序(Reranking):检索质量的最后一公里

6.1 重排序的必要性

即使使用了混合检索,Top-K结果中仍可能存在一些不够相关的文档。重排序(Reranking)通过更精细的模型对候选文档进行二次排序,进一步提升结果质量。

双阶段检索架构的优势:

  1. 第一阶段(检索): 使用高效但相对粗糙的方法快速筛选候选集
  2. 第二阶段(重排序): 使用精确但昂贵的方法对候选集精排
速度优先,覆盖广
质量优先,精度高
文档库
1M文档
混合检索
Top-100
候选文档
重排序
Top-20
精排结果

6.2 交叉编码器(Cross-Encoder)

6.2.1 与双编码器的对比

双编码器(Bi-Encoder):

  • 查询和文档独立编码
  • 可以预先计算文档向量
  • 检索时只需编码查询
  • 速度快但语义交互有限

交叉编码器(Cross-Encoder):

  • 查询和文档拼接后联合编码
  • 无法预先计算
  • 每个(查询,文档)对都需要编码
  • 速度慢但语义交互充分
交叉编码器
双编码器
拼接
查询
文档
联合编码器
CLS token
分类层
得分
编码器1
查询
编码器2
文档
向量q
向量d
点积
得分
6.2.2 交叉编码器的架构

输入构造:

[CLS] 查询文本 [SEP] 文档文本 [SEP]

# 例如:
[CLS] 什么是量子计算 [SEP] 量子计算是一种利用量子力学原理进行信息处理的计算范式... [SEP]

模型结构:

  1. Transformer编码器: BERT, RoBERTa, DeBERTa等
  2. 分类头: 通常是[CLS] token的表示经过线性层
  3. 输出: 相关性得分(0-1之间)或分类标签

训练目标:

# 点对损失(Pairwise Loss)
loss = max(0, margin - score(q, d_pos) + score(q, d_neg))

# 交叉熵损失(分类)
loss = -[y * log(score) + (1-y) * log(1-score)]
6.2.3 流行的重排序模型
模型 基座 参数量 特点 适用场景
monoBERT BERT-base 110M 经典基线 英文场景
BGE-reranker-base XLM-RoBERTa 278M 多语言支持 通用重排序
BGE-reranker-large XLM-RoBERTa 560M 最强性能 高质量要求
Cross-Encoder ms-marco MiniLM 33M 轻量级 资源受限场景
bge-reranker-v2-m3 BGE-M3 278M 多语言,多模态 复杂场景

性能对比 (MS MARCO Dev Set):

模型 MRR@10 NDCG@10 延迟(ms/对)
无重排序 0.395 0.621 -
monoBERT 0.414 0.652 45
BGE-reranker-base 0.432 0.668 38
BGE-reranker-large 0.447 0.681 72

6.3 重排序的实现策略

6.3.1 基本实现
from transformers import AutoTokenizer, AutoModelForSequenceClassification
import torch

class CrossEncoderReranker:
    def __init__(self, model_name="BAAI/bge-reranker-base"):
        self.tokenizer = AutoTokenizer.from_pretrained(model_name)
        self.model = AutoModelForSequenceClassification.from_pretrained(model_name)
        self.model.eval()
        
        # 使用GPU加速
        if torch.cuda.is_available():
            self.model = self.model.cuda()
    
    def rerank(self, query: str, documents: list, top_k: int = 20):
        """
        对文档进行重排序
        
        Args:
            query: 查询文本
            documents: 文档列表
            top_k: 返回top-k个文档
        """
        # 构造输入对
        pairs = [[query, doc] for doc in documents]
        
        # 批量编码
        with torch.no_grad():
            inputs = self.tokenizer(
                pairs,
                padding=True,
                truncation=True,
                max_length=512,
                return_tensors="pt"
            )
            
            if torch.cuda.is_available():
                inputs = {k: v.cuda() for k, v in inputs.items()}
            
            # 前向传播
            scores = self.model(**inputs).logits.squeeze(-1)
            
        # 排序
        scores = scores.cpu().numpy()
        sorted_indices = scores.argsort()[::-1][:top_k]
        
        # 返回重排序后的文档和得分
        reranked_docs = [documents[i] for i in sorted_indices]
        reranked_scores = [scores[i] for i in sorted_indices]
        
        return reranked_docs, reranked_scores

# 使用示例
reranker = CrossEncoderReranker()

query = "深度学习的最新进展"
candidates = [
    "深度学习在计算机视觉领域取得了突破性进展...",
    "机器学习是人工智能的一个分支...",
    "神经网络的训练需要大量数据...",
    # ... 更多候选文档
]

reranked_docs, scores = reranker.rerank(query, candidates, top_k=10)

for i, (doc, score) in enumerate(zip(reranked_docs, scores), 1):
    print(f"{i}. Score: {score:.4f}")
    print(f"   {doc[:100]}...")
    print()
6.3.2 批处理优化

对于大量文档,批处理可以显著提升效率:

def rerank_batched(self, query: str, documents: list, 
                   top_k: int = 20, batch_size: int = 32):
    """
    批量重排序,适用于大量文档
    """
    all_scores = []
    
    # 分批处理
    for i in range(0, len(documents), batch_size):
        batch_docs = documents[i:i+batch_size]
        pairs = [[query, doc] for doc in batch_docs]
        
        with torch.no_grad():
            inputs = self.tokenizer(
                pairs,
                padding=True,
                truncation=True,
                max_length=512,
                return_tensors="pt"
            )
            
            if torch.cuda.is_available():
                inputs = {k: v.cuda() for k, v in inputs.items()}
            
            batch_scores = self.model(**inputs).logits.squeeze(-1)
            all_scores.extend(batch_scores.cpu().numpy())
    
    # 排序
    all_scores = np.array(all_scores)
    sorted_indices = all_scores.argsort()[::-1][:top_k]
    
    reranked_docs = [documents[i] for i in sorted_indices]
    reranked_scores = [all_scores[i] for i in sorted_indices]
    
    return reranked_docs, reranked_scores
6.3.3 候选窗口策略

重排序的性能和候选窗口大小密切相关:

候选数量 重排序时间 Recall@20 NDCG@20
20 0.2s 0.72 0.68
50 0.5s 0.83 0.74
100 1.0s 0.89 0.78
200 2.0s 0.92 0.80

最佳实践:

  • 对于实时系统:候选50,重排序top-20
  • 对于高质量要求:候选100-200,重排序top-50
  • 对于批处理任务:候选500+,充分利用GPU

6.4 重排序的性能考量

6.4.1 延迟分析

假设处理100个文档,各阶段耗时:

阶段 操作 延迟
混合检索 BM25 + 密集检索 50ms
RRF融合 合并排序 5ms
重排序 Cross-Encoder(batch=32) 150ms
总计 ~205ms

优化策略:

  1. 模型选择: 使用轻量级模型(如MiniLM)减少延迟
  2. 批处理: 最大化GPU利用率
  3. 模型量化: INT8量化可减少50%延迟
  4. 候选数控制: 根据延迟需求调整候选窗口
6.4.2 成本效益分析
指标 无重排序 轻量级重排序 重量级重排序
NDCG@10 0.489 0.523 (+7%) 0.541 (+11%)
GPU推理成本/千次 $0 $0.05 $0.12
P95延迟 60ms 220ms 380ms
推荐场景 低延迟 平衡 高质量

6.5 重排序的进阶技术

6.5.1 ColBERT重排序

ColBERT(Contextualized Late Interaction over BERT)是一种介于双编码器和交叉编码器之间的方法:

核心思想:

  • 查询和文档仍然独立编码
  • 但保留所有token的向量表示(而非只用[CLS])
  • 在检索时进行token级别的交互

相似度计算:

Score ( Q , D ) = ∑ i = 1 ∣ Q ∣ max ⁡ j = 1 ∣ D ∣ E Q i ⋅ E D j \text{Score}(Q, D) = \sum_{i=1}^{|Q|} \max_{j=1}^{|D|} E_Q^i \cdot E_D^j Score(Q,D)=i=1Qmaxj=1DEQiEDj

其中 E Q i E_Q^i EQi 是查询第i个token的向量, E D j E_D^j EDj 是文档第j个token的向量。

优势:

  • 比交叉编码器快10-100倍
  • 比双编码器准确,因为有token级交互
  • 适合作为高效的重排序方案
6.5.2 LLM作为重排序器

最近的研究探索使用大语言模型(如GPT-4, Claude)进行重排序:

Zero-shot Prompting:

给定查询: {query}

以下是候选文档:
1. {doc_1}
2. {doc_2}
3. {doc_3}
...

请按照与查询的相关性,从高到低重新排序这些文档。
只输出文档编号,用逗号分隔。

Few-shot Prompting:
提供示例后效果更好,但token消耗更大。

ListWise Ranking:
同时考虑所有候选文档的排列,而非两两比较。

性能:

  • GPT-4重排序: NDCG@10可达0.68-0.72
  • 成本: $0.5-2.0/千次查询
  • 延迟: 2-5秒/查询

适用场景:

  • 离线批处理任务
  • 对成本不敏感的高价值查询
  • 作为标注工具生成训练数据

七、代码实现解析:RAG检索器

7.1 代码结构概览

# 文件: retriever.py

from .utils import get_reranker
from .retriever.utils import load_corpus, load_docs
from .retriever.encoder import Encoder, STEncoder

7.2 装饰器模式实现缓存和重排序

7.2.1 缓存管理器
def cache_manager(func):
    """
    装饰器用于检索文档缓存。
    使用装饰器后,检索器可以将每个检索到的文档存储为文件并重用。
    """
    @functools.wraps(func)
    def wrapper(self, query_list, num=None, return_score=False):
        if num is None:
            num = self.topk
        
        if self.use_cache:
            # 处理缓存逻辑
            # 1. 检查查询是否在缓存中
            # 2. 对于缓存缺失,执行实际检索
            # 3. 返回缓存和新检索结果的组合
            
            no_cache_query = []
            cache_results = []
            
            for query in new_query_list:
                if query in self.cache:
                    # 缓存命中
                    cache_res = self.cache[query]
                    if len(cache_res) < num:
                        warnings.warn(f"缓存结果数量少于topk ({num})")
                    cache_res = cache_res[:num]
                    doc_scores = [item.pop('score') for item in cache_res]
                    cache_results.append((cache_res, doc_scores))
                else:
                    # 缓存未命中
                    cache_results.append(None)
                    no_cache_query.append(query)
            
            if no_cache_query:
                # 对缓存未命中的查询执行检索
                no_cache_results, no_cache_scores = self._batch_search_with_rerank(
                    no_cache_query, num, True
                )
                # 填充结果
                # ...
        
        if self.save_cache:
            # 保存新检索的结果到缓存
            for query, doc_items, doc_scores in zip(query_list, results, scores):
                for item, score in zip(doc_items, doc_scores):
                    item['score'] = score
                self.cache[query] = doc_items
        
        return (results, scores) if return_score else results
    
    return wrapper

设计优势:

  • ✅ 减少重复检索,提升响应速度
  • ✅ 装饰器模式保持代码整洁
  • ✅ 支持增量缓存更新
7.2.2 重排序管理器
def rerank_manager(func):
    """
    装饰器用于重排序检索到的文档。
    """
    @functools.wraps(func)
    def wrapper(self, query_list, num=None, return_score=False):
        # 先执行原始检索
        results, scores = func(self, query_list, num, True)
        
        if self.use_reranker:
            # 应用重排序
            results, scores = self.reranker.rerank(query_list, results)
            
            # 处理批次和单一查询的不同情况
            if 'batch' not in func.__name__:
                results = results[0]
                scores = scores[0]
        
        return (results, scores) if return_score else results
    
    return wrapper

工作流程:

命中
未命中
查询
cache_manager装饰器
缓存?
返回缓存
执行检索
rerank_manager装饰器
使用重排序?
重排序结果
直接返回
更新缓存
返回结果

7.3 基类设计

class BaseRetriever:
    """构建检索器的基类"""
    
    def __init__(self, config):
        self.config = config
        self.retrieval_method = config['retrieval_method']
        self.topk = config['retrieval_topk']
        
        # 索引和语料库路径
        self.index_path = config['index_path']
        self.corpus_path = config['corpus_path']
        
        # 缓存配置
        self.save_cache = config['save_retrieval_cache']
        self.use_cache = config['use_retrieval_cache']
        self.cache_path = config['retrieval_cache_path']
        
        # 重排序配置
        self.use_reranker = config['use_reranker']
        if self.use_reranker:
            self.reranker = get_reranker(config)
        
        # 初始化缓存
        if self.save_cache:
            self.cache_save_path = os.path.join(
                config['save_dir'], 'retrieval_cache.json'
            )
            self.cache = {}
        
        if self.use_cache:
            assert self.cache_path is not None
            with open(self.cache_path, "r") as f:
                self.cache = json.load(f)
    
    @cache_manager
    @rerank_manager
    def search(self, *args, **kwargs):
        """单查询检索接口"""
        return self._search(*args, **kwargs)
    
    @cache_manager
    @rerank_manager
    def batch_search(self, *args, **kwargs):
        """批量检索接口"""
        return self._batch_search(*args, **kwargs)

设计模式:

  • 模板方法模式: 基类定义流程,子类实现具体检索
  • 装饰器模式: 横切关注点(缓存、重排序)与核心逻辑分离
  • 策略模式: 可插拔的重排序器

7.4 BM25检索器实现

class BM25Retriever(BaseRetriever):
    """基于预构建的pyserini索引的BM25检索器"""
    
    def __init__(self, config):
        super().__init__(config)
        
        # 初始化Lucene搜索器
        from pyserini.search.lucene import LuceneSearcher
        self.searcher = LuceneSearcher(self.index_path)
        
        # 检查索引是否包含文档内容
        self.contain_doc = self._check_contain_doc()
        
        if not self.contain_doc:
            # 如果索引不包含文档,需要单独加载语料库
            self.corpus = load_corpus(self.corpus_path)
    
    def _check_contain_doc(self):
        """检查索引是否包含文档内容"""
        return self.searcher.doc(0).raw() is not None
    
    def _search(self, query: str, num: int = None, 
                return_score=False) -> List[Dict[str, str]]:
        """
        单查询检索
        
        返回:
            list: 包含文档相关信息的列表,包括:
                - contents: 用于构建索引的内容
                - title: 文档标题(如果提供)
                - text: 文档正文(如果提供)
        """
        if num is None:
            num = self.topk
        
        # 执行BM25搜索
        hits = self.searcher.search(query, num)
        
        if len(hits) < 1:
            return ([], []) if return_score else []
        
        # 提取得分
        scores = [hit.score for hit in hits]
        
        if len(hits) < num:
            warnings.warn('检索到的文档数量不足!')
        else:
            hits = hits[:num]
        
        if self.contain_doc:
            # 从索引中直接获取文档内容
            all_contents = [
                json.loads(self.searcher.doc(hit.docid).raw())['contents']
                for hit in hits
            ]
            
            # 解析标题和正文
            results = [
                {
                    'title': content.split("\n")[0].strip("\""),
                    'text': "\n".join(content.split("\n")[1:]),
                    'contents': content
                }
                for content in all_contents
            ]
        else:
            # 从外部语料库加载文档
            results = load_docs(self.corpus, [hit.docid for hit in hits])
        
        return (results, scores) if return_score else results
    
    def _batch_search(self, query_list, num: int = None, 
                      return_score=False):
        """
        批量检索
        
        注意: 当前实现为简单循环,可以优化为真正的批处理
        """
        results = []
        scores = []
        
        for query in query_list:
            item_result, item_score = self._search(query, num, True)
            results.append(item_result)
            scores.append(item_score)
        
        return (results, scores) if return_score else results

实现要点:

  1. pyserini集成: 利用成熟的Lucene索引实现
  2. 灵活的文档存储: 支持索引内嵌和外部语料库两种模式
  3. 结构化输出: 统一的文档格式便于下游处理

7.5 密集检索器实现

class DenseRetriever(BaseRetriever):
    """基于预建FAISS索引的密集检索器"""
    
    def __init__(self, config: dict):
        super().__init__(config)
        
        # 加载FAISS索引
        self.index = faiss.read_index(self.index_path)
        
        # GPU加速
        if config['faiss_gpu']:
            co = faiss.GpuMultipleClonerOptions()
            co.useFloat16 = True  # 使用FP16减少内存
            co.shard = True       # 多GPU分片
            self.index = faiss.index_cpu_to_all_gpus(self.index, co=co)
        
        # 加载语料库
        self.corpus = load_corpus(self.corpus_path)
        
        # 初始化编码器
        if config['use_sentence_transformer']:
            self.encoder = STEncoder(
                model_name=self.retrieval_method,
                model_path=config['retrieval_model_path'],
                max_length=config['retrieval_query_max_length'],
                use_fp16=config['retrieval_use_fp16']
            )
        else:
            self.encoder = Encoder(
                model_name=self.retrieval_method,
                model_path=config['retrieval_model_path'],
                pooling_method=config['retrieval_pooling_method'],
                max_length=config['retrieval_query_max_length'],
                use_fp16=config['retrieval_use_fp16']
            )
        
        self.topk = config['retrieval_topk']
        self.batch_size = config['retrieval_batch_size']
    
    def _search(self, query: str, num: int = None, 
                return_score=False):
        """单查询检索"""
        if num is None:
            num = self.topk
        
        # 编码查询
        query_emb = self.encoder.encode(query)
        
        # FAISS搜索
        scores, idxs = self.index.search(query_emb, k=num)
        idxs = idxs[0]
        scores = scores[0]
        
        # 加载文档
        results = load_docs(self.corpus, idxs)
        
        return (results, scores) if return_score else results
    
    def _batch_search(self, query_list: List[str], 
                      num: int = None, return_score=False):
        """批量检索,使用进度条"""
        if isinstance(query_list, str):
            query_list = [query_list]
        
        if num is None:
            num = self.topk
        
        batch_size = self.batch_size
        results = []
        scores = []
        
        # 使用tqdm显示进度
        for start_idx in tqdm(
            range(0, len(query_list), batch_size),
            desc='检索进度: '
        ):
            # 批量编码
            query_batch = query_list[start_idx:start_idx + batch_size]
            batch_emb = self.encoder.encode(query_batch)
            
            # 批量搜索
            batch_scores, batch_idxs = self.index.search(batch_emb, k=num)
            
            # 转换为列表
            batch_scores = batch_scores.tolist()
            batch_idxs = batch_idxs.tolist()
            
            # 展平索引并批量加载文档
            flat_idxs = sum(batch_idxs, [])
            batch_results = load_docs(self.corpus, flat_idxs)
            
            # 重新分组
            batch_results = [
                batch_results[i * num: (i + 1) * num]
                for i in range(len(batch_idxs))
            ]
            
            scores.extend(batch_scores)
            results.extend(batch_results)
        
        return (results, scores) if return_score else results

优化技术:

  1. 批处理: 最大化GPU利用率
  2. FP16推理: 2倍速度提升,更少内存占用
  3. 多GPU支持: 大规模索引的分片处理
  4. 进度监控: tqdm集成便于长任务跟踪

7.6 混合检索实现示例

class HybridRetriever:
    """混合检索器: BM25 + Dense + SPLADE"""
    
    def __init__(self, config):
        self.bm25 = BM25Retriever(config)
        self.dense = DenseRetriever(config)
        # 可选: SPLADE检索器
        
        self.fusion_method = config.get('fusion_method', 'rrf')
        self.fusion_k = config.get('fusion_k', 60)
        
        # 检索器权重
        self.weights = config.get('retrieval_weights', {
            'bm25': 1.0,
            'dense': 1.0
        })
        
        # 窗口配置
        self.retrieval_window = config.get('retrieval_window', 100)
        self.fusion_window = config.get('fusion_window', 50)
    
    def search(self, query: str, num: int = None):
        """混合检索"""
        if num is None:
            num = 20
        
        # 并行检索
        bm25_results, bm25_scores = self.bm25.search(
            query, self.retrieval_window, return_score=True
        )
        dense_results, dense_scores = self.dense.search(
            query, self.retrieval_window, return_score=True
        )
        
        # 融合
        if self.fusion_method == 'rrf':
            merged = self._rrf_fusion(
                [bm25_results, dense_results],
                [bm25_scores, dense_scores],
                ['bm25', 'dense']
            )
        else:
            merged = self._weighted_fusion(
                [bm25_results, dense_results],
                [bm25_scores, dense_scores],
                ['bm25', 'dense']
            )
        
        # 取top-K
        return merged[:num]
    
    def _rrf_fusion(self, results_list, scores_list, methods):
        """RRF融合"""
        # 构建文档ID到文档的映射
        doc_map = {}
        rrf_scores = {}
        
        for results, method in zip(results_list, methods):
            weight = self.weights.get(method, 1.0)
            
            for rank, doc in enumerate(results, 1):
                doc_id = doc.get('id', doc.get('contents', '')[:50])
                
                if doc_id not in doc_map:
                    doc_map[doc_id] = doc
                    rrf_scores[doc_id] = 0
                
                # RRF公式
                rrf_scores[doc_id] += weight / (self.fusion_k + rank)
        
        # 排序
        sorted_docs = sorted(
            rrf_scores.items(),
            key=lambda x: x[1],
            reverse=True
        )
        
        # 返回文档列表
        return [doc_map[doc_id] for doc_id, _ in sorted_docs]
    
    def _weighted_fusion(self, results_list, scores_list, methods):
        """加权融合"""
        doc_scores = {}
        doc_map = {}
        
        for results, scores, method in zip(
            results_list, scores_list, methods
        ):
            weight = self.weights.get(method, 1.0)
            
            # 归一化得分
            if len(scores) > 0:
                max_score = max(scores)
                min_score = min(scores)
                score_range = max_score - min_score
                
                if score_range > 0:
                    norm_scores = [
                        (s - min_score) / score_range
                        for s in scores
                    ]
                else:
                    norm_scores = [1.0] * len(scores)
            else:
                norm_scores = []
            
            # 累加加权得分
            for doc, norm_score in zip(results, norm_scores):
                doc_id = doc.get('id', doc.get('contents', '')[:50])
                
                if doc_id not in doc_map:
                    doc_map[doc_id] = doc
                    doc_scores[doc_id] = 0
                
                doc_scores[doc_id] += weight * norm_score
        
        # 排序
        sorted_docs = sorted(
            doc_scores.items(),
            key=lambda x: x[1],
            reverse=True
        )
        
        return [doc_map[doc_id] for doc_id, _ in sorted_docs]

八、Elasticsearch在RAG中的部署与优化

8.1 Elasticsearch概述

Elasticsearch是一个基于Lucene的分布式搜索和分析引擎,在RAG系统中可以同时支持:

  • 全文检索(BM25)
  • 向量检索(kNN search)
  • 混合检索(Hybrid search)
  • 分布式扩展

8.2 Elasticsearch部署架构

8.2.1 单机开发环境
# 使用Docker快速启动
docker run -d \
  --name elasticsearch \
  -p 9200:9200 \
  -p 9300:9300 \
  -e "discovery.type=single-node" \
  -e "xpack.security.enabled=false" \
  -e "ES_JAVA_OPTS=-Xms2g -Xmx2g" \
  docker.elastic.co/elasticsearch/elasticsearch:8.11.0

# 验证安装
curl http://localhost:9200
8.2.2 生产集群架构
ML节点
数据节点
主节点
协调节点
负载均衡层
ML Node 1
向量化模型
ML Node 2
向量化模型
Data Node 1
Shard 0,2,4
Data Node 2
Shard 1,3,5
Data Node 3
Replica 0,2,4
Data Node 4
Replica 1,3,5
Master Node 1
Master Node 2
Master Node 3
Coordinating Node 1
Coordinating Node 2
Nginx/HAProxy

节点角色:

  • 协调节点: 路由请求,聚合结果
  • 主节点: 集群管理,索引元数据
  • 数据节点: 存储数据,执行检索
  • ML节点: 运行机器学习模型(如ELSER)

8.3 索引设计与映射

8.3.1 混合检索索引设计
PUT /rag_documents
{
  "settings": {
    "number_of_shards": 3,
    "number_of_replicas": 1,
    "index": {
      "similarity": {
        "default": {
          "type": "BM25",
          "b": 0.75,
          "k1": 1.2
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "doc_id": {
        "type": "keyword"
      },
      "title": {
        "type": "text",
        "analyzer": "standard",
        "fields": {
          "keyword": {
            "type": "keyword"
          }
        }
      },
      "content": {
        "type": "text",
        "analyzer": "standard"
      },
      "content_embedding": {
        "type": "dense_vector",
        "dims": 768,
        "index": true,
        "similarity": "cosine"
      },
      "sparse_embedding": {
        "type": "sparse_vector"
      },
      "metadata": {
        "type": "object",
        "properties": {
          "source": {"type": "keyword"},
          "timestamp": {"type": "date"},
          "category": {"type": "keyword"}
        }
      }
    }
  }
}

关键配置说明:

配置项 说明 推荐值
number_of_shards 主分片数量 小数据集: 1-3 大数据集: 5-10
number_of_replicas 副本数量 生产环境: 1-2
dense_vector.dims 向量维度 取决于嵌入模型
dense_vector.similarity 相似度度量 cosine/dot_product/l2_norm
BM25.b 长度归一化 0.75 (标准值)
BM25.k1 词频饱和度 1.2 (标准值)
8.3.2 使用semantic_text简化向量检索

Elasticsearch 8.15引入的semantic_text字段类型可以自动处理向量化:

PUT /rag_documents_v2
{
  "mappings": {
    "properties": {
      "content": {
        "type": "semantic_text",
        "inference_id": "my_embedding_model"
      }
    }
  }
}

优势:

  • ✅ 自动向量化: 索引时自动生成embeddings
  • ✅ 简化查询: 查询时自动编码
  • ✅ 统一接口: 与普通文本字段使用方式一致

8.4 Elasticsearch检索API

8.4.1 BM25全文检索
from elasticsearch import Elasticsearch

es = Elasticsearch(['http://localhost:9200'])

def bm25_search(query: str, index: str = "rag_documents", 
                size: int = 20):
    """BM25全文检索"""
    response = es.search(
        index=index,
        body={
            "query": {
                "multi_match": {
                    "query": query,
                    "fields": ["title^2", "content"],  # title权重加倍
                    "type": "best_fields",
                    "operator": "or"
                }
            },
            "size": size
        }
    )
    
    results = []
    for hit in response['hits']['hits']:
        results.append({
            'id': hit['_id'],
            'score': hit['_score'],
            'title': hit['_source']['title'],
            'content': hit['_source']['content']
        })
    
    return results
8.4.2 向量检索(kNN)
def vector_search(query_vector: list, index: str = "rag_documents",
                  size: int = 20):
    """密集向量检索"""
    response = es.search(
        index=index,
        body={
            "knn": {
                "field": "content_embedding",
                "query_vector": query_vector,
                "k": size,
                "num_candidates": size * 10  # 候选数量
            },
            "_source": ["title", "content", "metadata"]
        }
    )
    
    results = []
    for hit in response['hits']['hits']:
        results.append({
            'id': hit['_id'],
            'score': hit['_score'],
            'title': hit['_source']['title'],
            'content': hit['_source']['content']
        })
    
    return results
8.4.3 混合检索(RRF)

Elasticsearch原生支持RRF融合:

def hybrid_search(query: str, query_vector: list,
                  index: str = "rag_documents", size: int = 20):
    """混合检索: BM25 + 向量检索 + RRF融合"""
    response = es.search(
        index=index,
        body={
            "retriever": {
                "rrf": {
                    "retrievers": [
                        {
                            # BM25检索器
                            "standard": {
                                "query": {
                                    "multi_match": {
                                        "query": query,
                                        "fields": ["title^2", "content"]
                                    }
                                }
                            }
                        },
                        {
                            # 向量检索器
                            "knn": {
                                "field": "content_embedding",
                                "query_vector": query_vector,
                                "k": 50,
                                "num_candidates": 100
                            }
                        }
                    ],
                    "rank_window_size": 50,  # 融合窗口
                    "rank_constant": 60      # RRF的k参数
                }
            },
            "size": size,
            "_source": ["title", "content", "metadata"]
        }
    )
    
    results = []
    for hit in response['hits']['hits']:
        results.append({
            'id': hit['_id'],
            'score': hit['_score'],
            'title': hit['_source']['title'],
            'content': hit['_source']['content']
        })
    
    return results
8.4.4 使用ELSER进行学习型稀疏检索

ELSER(Elastic Learned Sparse EncodeR)是Elasticsearch的神经稀疏检索模型:

def elser_search(query: str, index: str = "rag_documents", 
                 size: int = 20):
    """使用ELSER进行检索"""
    response = es.search(
        index=index,
        body={
            "query": {
                "text_expansion": {
                    "ml.tokens": {
                        "model_id": ".elser_model_2",
                        "model_text": query
                    }
                }
            },
            "size": size
        }
    )
    
    return parse_results(response)

8.5 性能优化策略

8.5.1 向量索引优化

HNSW参数调优:

{
  "settings": {
    "index": {
      "knn": true,
      "knn.algo_param.ef_construction": 200,  # 构建质量vs速度
      "knn.algo_param.m": 16                   # 图连接数
    }
  }
}
参数 低值影响 高值影响 推荐范围
ef_construction 构建快,质量低 构建慢,质量高 100-300
m 内存少,查询慢 内存多,查询快 16-64

查询时参数:

{
    "knn": {
        "field": "content_embedding",
        "query_vector": query_vector,
        "k": 20,
        "num_candidates": 200,  # 候选数量,影响召回vs延迟
        "filter": {             # 预过滤,减少搜索空间
            "term": {"category": "technology"}
        }
    }
}
8.5.2 分片策略

分片数量选择:

最优分片数 ≈ 索引大小(GB) 20 − 50 GB/分片 \text{最优分片数} \approx \frac{\text{索引大小(GB)}}{20-50 \text{GB/分片}} 最优分片数2050GB/分片索引大小(GB)

示例:

  • 100GB索引 → 3-5个分片
  • 1TB索引 → 20-50个分片

过度分片的危害:

  • 每个分片都有开销(内存、文件句柄)
  • 查询需要遍历所有分片
  • 主节点压力增大
8.5.3 缓存策略

Elasticsearch提供多层缓存:

  1. Query Cache: 缓存查询结果(仅filter context)
  2. Request Cache: 缓存聚合结果
  3. Field Data Cache: 缓存字段数据(用于排序、聚合)
  4. OS Page Cache: 操作系统级文件缓存

推荐配置:

{
  "settings": {
    "index.queries.cache.enabled": true,
    "index.requests.cache.enable": true
  }
}
8.5.4 批量索引优化
from elasticsearch.helpers import bulk

def bulk_index_documents(documents, index_name, batch_size=500):
    """批量索引文档"""
    actions = []
    
    for doc in documents:
        action = {
            "_index": index_name,
            "_id": doc['id'],
            "_source": {
                "title": doc['title'],
                "content": doc['content'],
                "content_embedding": doc['embedding'],
                "metadata": doc.get('metadata', {})
            }
        }
        actions.append(action)
        
        # 分批提交
        if len(actions) >= batch_size:
            success, failed = bulk(es, actions, raise_on_error=False)
            print(f"成功: {success}, 失败: {failed}")
            actions = []
    
    # 提交剩余文档
    if actions:
        bulk(es, actions, raise_on_error=False)

优化建议:

  • 批量大小: 500-1000个文档
  • 禁用refresh: 索引时设置refresh=false
  • 增加bulk线程池: thread_pool.bulk.queue_size: 1000

8.6 监控与维护

8.6.1 关键性能指标
# 集群健康状态
health = es.cluster.health()
print(f"状态: {health['status']}")
print(f"节点数: {health['number_of_nodes']}")
print(f"活跃分片: {health['active_shards']}")

# 索引统计
stats = es.indices.stats(index='rag_documents')
print(f"文档数: {stats['_all']['primaries']['docs']['count']}")
print(f"存储大小: {stats['_all']['primaries']['store']['size_in_bytes'] / 1e9:.2f} GB")

# 查询性能
search_stats = es.indices.stats(index='rag_documents')['_all']['primaries']['search']
print(f"查询总数: {search_stats['query_total']}")
print(f"平均查询时间: {search_stats['query_time_in_millis'] / search_stats['query_total']:.2f} ms")
8.6.2 常见问题诊断
问题 可能原因 解决方案
查询慢 分片过多,堆内存不足 合并分片,增加内存
索引慢 刷新频率高,副本多 降低refresh rate,减少副本
集群黄色 副本未分配 增加节点或减少副本数
OOM错误 字段数据缓存过大 限制fielddata cache大小
磁盘满 日志、快照占用 清理旧数据,增加存储

九、嵌入模型与生成模型的配对策略

9.1 模型配对的重要性

在RAG系统中,嵌入模型(Embedding Model)负责将文本转换为向量进行检索,而生成模型(Generative Model)负责根据检索到的上下文生成最终答案。这两个模型的配对会影响系统的整体性能。

9.2 是否应该使用同系列模型?

9.2.1 同系列模型的潜在优势

假设: 如果嵌入模型和生成模型来自同一系列(如都基于LLaMA),它们可能共享:

  1. 词汇表: 相同的tokenizer,避免token不匹配
  2. 预训练数据: 类似的数据分布,更好的语义对齐
  3. 架构特性: 相似的表示空间

实验证据:

某些研究显示,使用同系列模型可能带来轻微的性能提升(1-3%),但这种提升主要来自于tokenizer的一致性而非模型架构本身。

9.2.2 跨系列配对的实际表现

实践发现: 跨系列配对通常表现良好甚至更优,因为:

  1. 专业化分工:
    • 嵌入模型优化目标: 检索性能(NDCG, Recall)
    • 生成模型优化目标: 生成质量(流畅性, 准确性)
    • 两者的优化目标不同,不一定需要来自同一系列
  2. 模型选择灵活性:
    • 可以选择各自领域的最佳模型
    • 例如: BGE嵌入 + GPT-4生成
  3. 实验结果: 基于BEIR和MS MARCO的实验表明,配对的影响很小(<2%)
9.2.3 配对策略推荐
场景 嵌入模型 生成模型 理由
通用RAG BGE-large / E5-large GPT-4 / Claude 各取所长
中文场景 BGE-zh / m3e Qwen / GLM 语言匹配
代码生成 Voyage-code GPT-4 / Claude 专业化模型
多语言 Multilingual-E5 GPT-4 跨语言能力
资源受限 all-MiniLM-L6 Llama-7B 平衡性能成本
本地部署 BGE-base Mistral-7B 开源方案

9.3 真正重要的配对因素

9.3.1 Token长度匹配

关键问题: 嵌入模型和生成模型的上下文窗口应该兼容。

嵌入模型上下文 生成模型上下文 配对结果
512 tokens 2048 tokens ✅ 良好
512 tokens 128k tokens ✅ 良好(浪费)
8k tokens 4k tokens ⚠️ 需要截断
32k tokens 8k tokens ⚠️ 需要截断

最佳实践:

  • 嵌入模型的max_length应≤生成模型的上下文窗口
  • 考虑到prompt + 检索文档 + 生成空间的总长度
9.3.2 语言对齐

单语言场景:

  • 中文RAG: 使用中文或多语言嵌入模型
  • 英文RAG: 任何强大的模型都可以

多语言场景:

  • 跨语言检索: 需要多语言嵌入模型(如multilingual-E5, LaBSE)
  • 生成: 多语言LLM(如GPT-4, Claude)
9.3.3 领域对齐

通用领域: 任意组合都可以工作良好

特定领域:

  • 生物医学: PubMedBERT嵌入 + Med-PaLM生成
  • 法律: Legal-BERT嵌入 + LegalGPT生成
  • 金融: FinBERT嵌入 + BloombergGPT生成

微调策略:

  • 如果有领域数据,微调嵌入模型通常比微调生成模型更高效
  • 检索性能的提升可以直接惠及生成质量

9.4 配对实验与评估

9.4.1 实验设置
# 配对实验框架
embedding_models = [
    "BAAI/bge-large-en-v1.5",
    "intfloat/e5-large-v2",
    "text-embedding-3-large"  # OpenAI
]

generation_models = [
    "gpt-4-turbo",
    "claude-3-opus",
    "llama-3-70b"
]

# 评估指标
metrics = {
    "retrieval": ["Recall@10", "NDCG@10", "MRR"],
    "generation": ["ROUGE-L", "BERTScore", "Answer Accuracy"]
}

# 运行实验
for embed_model in embedding_models:
    for gen_model in generation_models:
        results = evaluate_rag_pipeline(
            embedding_model=embed_model,
            generation_model=gen_model,
            test_set=rag_benchmark
        )
        print(f"{embed_model} + {gen_model}: {results}")
9.4.2 实验结果示例

基于Natural Questions数据集的实验(简化):

嵌入模型 生成模型 Recall@10 Answer Acc 总评分
BGE-large GPT-4 0.82 0.76 0.79
BGE-large Llama-3-70B 0.82 0.72 0.77
E5-large GPT-4 0.80 0.76 0.78
E5-large Llama-3-70B 0.80 0.72 0.76
OpenAI-embed GPT-4 0.79 0.77 0.78
OpenAI-embed Llama-3-70B 0.79 0.72 0.755

关键观察:

  • 检索性能主要由嵌入模型决定
  • 生成质量主要由生成模型决定
  • 同系列配对(OpenAI-embed + GPT-4)没有显著优势
  • 最佳配对: 最强嵌入模型 + 最强生成模型

9.5 实际部署建议

9.5.1 开发阶段
  1. 快速原型: 使用API服务快速验证
# OpenAI嵌入 + GPT-4
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
llm = ChatOpenAI(model="gpt-4-turbo")
  1. 基准测试: 在小样本上测试多种配对
  2. 成本估算: 计算不同配对的成本
9.5.2 生产部署

高性能方案:

嵌入: BGE-large (自托管GPU)
生成: GPT-4-turbo (API)
成本: 中等,性能最佳

平衡方案:

嵌入: BGE-base (自托管CPU/GPU)
生成: GPT-3.5-turbo (API)
成本: 低,性能良好

完全自托管:

嵌入: BGE-base (CPU)
生成: Llama-3-8B (GPU)
成本: 最低,需要运维
9.5.3 混合方案

对于大规模应用,可以采用分层策略:

class TieredRAG:
    def __init__(self):
        # 嵌入: 统一使用高质量模型
        self.embedder = BGEEmbedder("large")
        
        # 生成: 根据查询复杂度分层
        self.llm_fast = ChatGPT("3.5-turbo")  # 简单查询
        self.llm_smart = ChatGPT("4-turbo")   # 复杂查询
        self.llm_expert = Claude("opus")      # 关键查询
    
    def query(self, question, importance="normal"):
        # 检索
        docs = self.retrieve(question)
        
        # 选择生成模型
        if importance == "low":
            llm = self.llm_fast
        elif importance == "high":
            llm = self.llm_expert
        else:
            llm = self.llm_smart
        
        # 生成
        return llm.generate(question, docs)

十、完整的混合检索流程实现

10.1 端到端RAG系统架构

让我们构建一个完整的、生产级的混合检索RAG系统:

在线阶段
离线阶段
查询处理
用户查询
BM25检索
向量检索
SPLADE检索
RRF融合
重排序
上下文构建
LLM生成
最终答案
文档预处理
原始文档
分块Chunking
BM25索引
向量索引
SPLADE索引

10.2 完整代码实现

import numpy as np
from typing import List, Dict, Optional
from dataclasses import dataclass
import faiss
from elasticsearch import Elasticsearch
from transformers import AutoTokenizer, AutoModel, AutoModelForSequenceClassification
import torch

@dataclass
class SearchResult:
    """检索结果"""
    doc_id: str
    content: str
    score: float
    metadata: Dict = None

class HybridRAGRetriever:
    """混合检索RAG系统"""
    
    def __init__(
        self,
        es_client: Elasticsearch,
        faiss_index_path: str,
        embedding_model_name: str = "BAAI/bge-large-en-v1.5",
        reranker_model_name: str = "BAAI/bge-reranker-base",
        use_gpu: bool = True
    ):
        """
        初始化混合检索器
        
        Args:
            es_client: Elasticsearch客户端
            faiss_index_path: FAISS索引路径
            embedding_model_name: 嵌入模型名称
            reranker_model_name: 重排序模型名称
            use_gpu: 是否使用GPU
        """
        self.es = es_client
        self.device = "cuda" if use_gpu and torch.cuda.is_available() else "cpu"
        
        # 加载FAISS索引
        self.faiss_index = faiss.read_index(faiss_index_path)
        if use_gpu and torch.cuda.is_available():
            res = faiss.StandardGpuResources()
            self.faiss_index = faiss.index_cpu_to_gpu(res, 0, self.faiss_index)
        
        # 加载嵌入模型
        self.tokenizer = AutoTokenizer.from_pretrained(embedding_model_name)
        self.embedding_model = AutoModel.from_pretrained(embedding_model_name)
        self.embedding_model.to(self.device)
        self.embedding_model.eval()
        
        # 加载重排序模型
        self.reranker_tokenizer = AutoTokenizer.from_pretrained(reranker_model_name)
        self.reranker_model = AutoModelForSequenceClassification.from_pretrained(
            reranker_model_name
        )
        self.reranker_model.to(self.device)
        self.reranker_model.eval()
        
        print(f"混合检索器初始化完成 (设备: {self.device})")
    
    def encode_query(self, query: str) -> np.ndarray:
        """编码查询为向量"""
        with torch.no_grad():
            inputs = self.tokenizer(
                query,
                padding=True,
                truncation=True,
                max_length=512,
                return_tensors="pt"
            )
            inputs = {k: v.to(self.device) for k, v in inputs.items()}
            
            outputs = self.embedding_model(**inputs)
            # 平均池化
            embeddings = outputs.last_hidden_state.mean(dim=1)
            # L2归一化
            embeddings = torch.nn.functional.normalize(embeddings, p=2, dim=1)
            
            return embeddings.cpu().numpy()
    
    def bm25_search(
        self,
        query: str,
        index: str = "rag_documents",
        k: int = 100
    ) -> List[SearchResult]:
        """BM25检索"""
        response = self.es.search(
            index=index,
            body={
                "query": {
                    "multi_match": {
                        "query": query,
                        "fields": ["title^2", "content"],
                        "type": "best_fields"
                    }
                },
                "size": k
            }
        )
        
        results = []
        for hit in response['hits']['hits']:
            results.append(SearchResult(
                doc_id=hit['_id'],
                content=hit['_source']['content'],
                score=hit['_score'],
                metadata=hit['_source'].get('metadata', {})
            ))
        
        return results
    
    def dense_search(
        self,
        query: str,
        k: int = 100
    ) -> List[SearchResult]:
        """密集向量检索"""
        query_vector = self.encode_query(query)
        
        # FAISS搜索
        distances, indices = self.faiss_index.search(query_vector, k)
        distances = distances[0]
        indices = indices[0]
        
        # 转换距离为相似度得分(cosine similarity)
        scores = 1 - distances / 2  # 因为使用了L2归一化
        
        # 加载文档内容(实际应用中需要从数据库加载)
        results = []
        for idx, score in zip(indices, scores):
            if idx != -1:  # FAISS用-1表示无效结果
                # 这里简化处理,实际需要查询文档存储
                results.append(SearchResult(
                    doc_id=str(idx),
                    content=self._load_document(idx),
                    score=float(score)
                ))
        
        return results
    
    def _load_document(self, doc_id: int) -> str:
        """从存储加载文档(占位符)"""
        # 实际实现应该查询文档数据库
        return f"Document content for ID {doc_id}"
    
    def rrf_fusion(
        self,
        results_list: List[List[SearchResult]],
        k: int = 60,
        weights: Optional[List[float]] = None
    ) -> List[SearchResult]:
        """
        RRF融合多个检索结果
        
        Args:
            results_list: 多个检索结果列表
            k: RRF常数
            weights: 各检索器权重
        """
        if weights is None:
            weights = [1.0] * len(results_list)
        
        # 计算RRF得分
        doc_scores = {}
        doc_map = {}
        
        for results, weight in zip(results_list, weights):
            for rank, result in enumerate(results, start=1):
                doc_id = result.doc_id
                
                if doc_id not in doc_map:
                    doc_map[doc_id] = result
                    doc_scores[doc_id] = 0.0
                
                # RRF公式
                doc_scores[doc_id] += weight / (k + rank)
        
        # 排序
        sorted_docs = sorted(
            doc_scores.items(),
            key=lambda x: x[1],
            reverse=True
        )
        
        # 构建结果列表
        fused_results = []
        for doc_id, score in sorted_docs:
            result = doc_map[doc_id]
            result.score = score  # 更新为融合后的得分
            fused_results.append(result)
        
        return fused_results
    
    def rerank(
        self,
        query: str,
        documents: List[SearchResult],
        top_k: int = 20,
        batch_size: int = 32
    ) -> List[SearchResult]:
        """
        使用交叉编码器重排序
        
        Args:
            query: 查询文本
            documents: 候选文档列表
            top_k: 返回top-k结果
            batch_size: 批处理大小
        """
        if len(documents) == 0:
            return []
        
        all_scores = []
        
        # 分批处理
        for i in range(0, len(documents), batch_size):
            batch_docs = documents[i:i+batch_size]
            
            # 构造输入对
            pairs = [[query, doc.content] for doc in batch_docs]
            
            with torch.no_grad():
                inputs = self.reranker_tokenizer(
                    pairs,
                    padding=True,
                    truncation=True,
                    max_length=512,
                    return_tensors="pt"
                )
                inputs = {k: v.to(self.device) for k, v in inputs.items()}
                
                # 前向传播
                scores = self.reranker_model(**inputs).logits.squeeze(-1)
                all_scores.extend(scores.cpu().numpy())
        
        # 更新得分并排序
        for doc, score in zip(documents, all_scores):
            doc.score = float(score)
        
        # 排序并返回top-k
        reranked = sorted(documents, key=lambda x: x.score, reverse=True)
        return reranked[:top_k]
    
    def hybrid_retrieve(
        self,
        query: str,
        retrieval_k: int = 100,
        fusion_k: int = 50,
        rerank_k: int = 20,
        use_reranker: bool = True,
        weights: Optional[Dict[str, float]] = None
    ) -> List[SearchResult]:
        """
        完整的混合检索流程
        
        Args:
            query: 查询文本
            retrieval_k: 每个检索器返回的文档数
            fusion_k: 融合后保留的文档数
            rerank_k: 重排序后返回的文档数
            use_reranker: 是否使用重排序
            weights: 检索器权重字典
        
        Returns:
            最终的排序文档列表
        """
        print(f"查询: {query}")
        
        # 1. 并行检索
        print("执行BM25检索...")
        bm25_results = self.bm25_search(query, k=retrieval_k)
        
        print("执行密集检索...")
        dense_results = self.dense_search(query, k=retrieval_k)
        
        print(f"BM25检索到 {len(bm25_results)} 个文档")
        print(f"密集检索到 {len(dense_results)} 个文档")
        
        # 2. RRF融合
        print("执行RRF融合...")
        if weights is None:
            weights_list = [1.0, 1.0]  # BM25和密集检索的默认权重
        else:
            weights_list = [
                weights.get('bm25', 1.0),
                weights.get('dense', 1.0)
            ]
        
        fused_results = self.rrf_fusion(
            [bm25_results, dense_results],
            weights=weights_list
        )
        
        # 保留top融合结果
        fused_results = fused_results[:fusion_k]
        print(f"融合后保留 {len(fused_results)} 个文档")
        
        # 3. 重排序(可选)
        if use_reranker:
            print("执行重排序...")
            final_results = self.rerank(query, fused_results, top_k=rerank_k)
            print(f"重排序后返回 {len(final_results)} 个文档")
        else:
            final_results = fused_results[:rerank_k]
        
        return final_results
    
    def format_context(
        self,
        results: List[SearchResult],
        max_length: int = 3000
    ) -> str:
        """
        格式化检索结果为上下文
        
        Args:
            results: 检索结果列表
            max_length: 最大字符数
        """
        context_parts = []
        current_length = 0
        
        for i, result in enumerate(results, 1):
            part = f"[文档 {i}]\n{result.content}\n\n"
            part_length = len(part)
            
            if current_length + part_length > max_length:
                # 截断最后一个文档
                remaining = max_length - current_length
                if remaining > 100:  # 至少保留100字符
                    context_parts.append(part[:remaining] + "...\n\n")
                break
            
            context_parts.append(part)
            current_length += part_length
        
        return "".join(context_parts)

# 使用示例
def main():
    # 初始化
    es = Elasticsearch(['http://localhost:9200'])
    
    retriever = HybridRAGRetriever(
        es_client=es,
        faiss_index_path="./indices/documents.faiss",
        use_gpu=True
    )
    
    # 执行检索
    query = "什么是深度学习?"
    
    results = retriever.hybrid_retrieve(
        query=query,
        retrieval_k=100,  # 每个检索器返回100个
        fusion_k=50,      # 融合后保留50个
        rerank_k=20,      # 重排序后返回20个
        use_reranker=True,
        weights={'bm25': 1.0, 'dense': 1.5}  # 提升密集检索权重
    )
    
    # 格式化上下文
    context = retriever.format_context(results, max_length=3000)
    
    # 构建LLM提示
    prompt = f"""基于以下上下文回答问题。

上下文:
{context}

问题: {query}

答案:"""
    
    print("\n" + "="*50)
    print("生成的提示:")
    print(prompt)
    
    # 这里接入LLM生成答案
    # answer = llm.generate(prompt)

if __name__ == "__main__":
    main()

10.3 性能优化版本

对于高并发场景,可以使用异步版本:

import asyncio
from typing import List
import aiohttp

class AsyncHybridRetriever(HybridRAGRetriever):
    """异步混合检索器"""
    
    async def async_bm25_search(self, query: str, k: int = 100):
        """异步BM25检索"""
        # 使用异步Elasticsearch客户端
        async with aiohttp.ClientSession() as session:
            async with session.post(
                f"http://localhost:9200/rag_documents/_search",
                json={
                    "query": {"multi_match": {"query": query, "fields": ["content"]}},
                    "size": k
                }
            ) as response:
                data = await response.json()
                # 解析结果
                return self._parse_es_response(data)
    
    async def async_hybrid_retrieve(self, query: str, **kwargs):
        """异步混合检索"""
        # 并发执行多个检索
        bm25_task = asyncio.create_task(
            self.async_bm25_search(query, k=kwargs.get('retrieval_k', 100))
        )
        dense_task = asyncio.create_task(
            asyncio.to_thread(self.dense_search, query, k=kwargs.get('retrieval_k', 100))
        )
        
        # 等待所有检索完成
        bm25_results, dense_results = await asyncio.gather(bm25_task, dense_task)
        
        # 同步执行融合和重排序
        fused = self.rrf_fusion([bm25_results, dense_results])
        
        if kwargs.get('use_reranker', True):
            reranked = await asyncio.to_thread(
                self.rerank,
                query,
                fused[:kwargs.get('fusion_k', 50)],
                top_k=kwargs.get('rerank_k', 20)
            )
            return reranked
        
        return fused[:kwargs.get('rerank_k', 20)]

# 使用异步版本
async def async_main():
    retriever = AsyncHybridRetriever(
        es_client=es,
        faiss_index_path="./indices/documents.faiss"
    )
    
    queries = [
        "什么是深度学习?",
        "如何训练神经网络?",
        "Transformer架构是什么?"
    ]
    
    # 并发处理多个查询
    tasks = [
        retriever.async_hybrid_retrieve(query)
        for query in queries
    ]
    
    results_list = await asyncio.gather(*tasks)
    
    for query, results in zip(queries, results_list):
        print(f"\n查询: {query}")
        print(f"检索到 {len(results)} 个文档")

# 运行
asyncio.run(async_main())

十一、评估与优化

11.1 检索评估指标

11.1.1 基础指标

Recall@K (召回率):

Recall@K = 检索到的相关文档数 所有相关文档数 \text{Recall@K} = \frac{\text{检索到的相关文档数}}{\text{所有相关文档数}} Recall@K=所有相关文档数检索到的相关文档数

Precision@K (精确率):

Precision@K = 检索到的相关文档数 K \text{Precision@K} = \frac{\text{检索到的相关文档数}}{K} Precision@K=K检索到的相关文档数

Mean Reciprocal Rank (MRR):

MRR = 1 ∣ Q ∣ ∑ i = 1 ∣ Q ∣ 1 rank i \text{MRR} = \frac{1}{|Q|} \sum_{i=1}^{|Q|} \frac{1}{\text{rank}_i} MRR=Q1i=1Qranki1

其中 $ \text{rank}_i $ 是第一个相关文档的排名。

Normalized Discounted Cumulative Gain (NDCG@K):

NDCG@K = DCG@K IDCG@K \text{NDCG@K} = \frac{\text{DCG@K}}{\text{IDCG@K}} NDCG@K=IDCG@KDCG@K

DCG@K = ∑ i = 1 K 2 rel i − 1 log ⁡ 2 ( i + 1 ) \text{DCG@K} = \sum_{i=1}^{K} \frac{2^{\text{rel}_i} - 1}{\log_2(i+1)} DCG@K=i=1Klog2(i+1)2reli1

11.1.2 实现评估代码
import numpy as np
from typing import List, Set

class RetrievalEvaluator:
    """检索评估器"""
    
    @staticmethod
    def recall_at_k(retrieved: List[str], relevant: Set[str], k: int) -> float:
        """计算Recall@K"""
        retrieved_k = set(retrieved[:k])
        hits = len(retrieved_k & relevant)
        return hits / len(relevant) if len(relevant) > 0 else 0.0
    
    @staticmethod
    def precision_at_k(retrieved: List[str], relevant: Set[str], k: int) -> float:
        """计算Precision@K"""
        retrieved_k = set(retrieved[:k])
        hits = len(retrieved_k & relevant)
        return hits / k
    
    @staticmethod
    def mrr(retrieved: List[str], relevant: Set[str]) -> float:
        """计算MRR"""
        for i, doc_id in enumerate(retrieved, 1):
            if doc_id in relevant:
                return 1.0 / i
        return 0.0
    
    @staticmethod
    def ndcg_at_k(retrieved: List[str], relevance_scores: Dict[str, int], k: int) -> float:
        """
        计算NDCG@K
        
        Args:
            retrieved: 检索结果文档ID列表
            relevance_scores: 文档相关性得分字典 {doc_id: relevance}
            k: 截断位置
        """
        def dcg(scores, k):
            scores = np.array(scores[:k])
            return np.sum((2**scores - 1) / np.log2(np.arange(2, len(scores) + 2)))
        
        # 实际DCG
        actual_scores = [relevance_scores.get(doc_id, 0) for doc_id in retrieved]
        actual_dcg = dcg(actual_scores, k)
        
        # 理想DCG
        ideal_scores = sorted(relevance_scores.values(), reverse=True)
        ideal_dcg = dcg(ideal_scores, k)
        
        return actual_dcg / ideal_dcg if ideal_dcg > 0 else 0.0
    
    def evaluate(
        self,
        retriever,
        test_queries: List[Dict],
        k_values: List[int] = [5, 10, 20]
    ) -> Dict:
        """
        完整评估
        
        Args:
            retriever: 检索器实例
            test_queries: 测试查询列表,每个包含:
                - query: 查询文本
                - relevant_docs: 相关文档集合
                - relevance_scores: 相关性得分字典(可选)
            k_values: 评估的K值列表
        """
        results = {f"recall@{k}": [] for k in k_values}
        results.update({f"precision@{k}": [] for k in k_values})
        results.update({f"ndcg@{k}": [] for k in k_values})
        results["mrr"] = []
        
        for query_data in test_queries:
            query = query_data["query"]
            relevant = set(query_data["relevant_docs"])
            
            # 执行检索
            retrieved_results = retriever.retrieve(query, top_k=max(k_values))
            retrieved_ids = [r.doc_id for r in retrieved_results]
            
            # 计算各项指标
            results["mrr"].append(self.mrr(retrieved_ids, relevant))
            
            for k in k_values:
                results[f"recall@{k}"].append(
                    self.recall_at_k(retrieved_ids, relevant, k)
                )
                results[f"precision@{k}"].append(
                    self.precision_at_k(retrieved_ids, relevant, k)
                )
                
                if "relevance_scores" in query_data:
                    results[f"ndcg@{k}"].append(
                        self.ndcg_at_k(
                            retrieved_ids,
                            query_data["relevance_scores"],
                            k
                        )
                    )
        
        # 计算平均值
        avg_results = {
            metric: np.mean(scores)
            for metric, scores in results.items()
        }
        
        return avg_results

# 使用示例
evaluator = RetrievalEvaluator()

test_data = [
    {
        "query": "什么是深度学习?",
        "relevant_docs": {"doc_123", "doc_456", "doc_789"},
        "relevance_scores": {"doc_123": 3, "doc_456": 2, "doc_789": 1}
    },
    # ... 更多测试查询
]

metrics = evaluator.evaluate(retriever, test_data)
print("评估结果:")
for metric, value in metrics.items():
    print(f"  {metric}: {value:.4f}")

11.2 端到端RAG评估

除了检索质量,还需要评估最终生成答案的质量:

from rouge import Rouge
from bert_score import score as bert_score

class RAGEvaluator:
    """端到端RAG评估"""
    
    def __init__(self):
        self.rouge = Rouge()
    
    def evaluate_generation(
        self,
        predictions: List[str],
        references: List[str]
    ) -> Dict:
        """
        评估生成质量
        
        Args:
            predictions: 生成的答案列表
            references: 参考答案列表
        """
        # ROUGE得分
        rouge_scores = self.rouge.get_scores(predictions, references, avg=True)
        
        # BERTScore
        P, R, F1 = bert_score(predictions, references, lang="zh")
        
        results = {
            "rouge-1": rouge_scores["rouge-1"]["f"],
            "rouge-2": rouge_scores["rouge-2"]["f"],
            "rouge-l": rouge_scores["rouge-l"]["f"],
            "bert_score_precision": P.mean().item(),
            "bert_score_recall": R.mean().item(),
            "bert_score_f1": F1.mean().item()
        }
        
        return results
    
    def answer_accuracy(
        self,
        predictions: List[str],
        references: List[str]
    ) -> float:
        """
        计算答案准确率(完全匹配)
        """
        correct = sum(
            pred.strip().lower() == ref.strip().lower()
            for pred, ref in zip(predictions, references)
        )
        return correct / len(predictions)

11.3 A/B测试框架

class ABTestFramework:
    """A/B测试框架"""
    
    def __init__(self):
        self.variants = {}
        self.results = {}
    
    def add_variant(self, name: str, retriever):
        """添加测试变体"""
        self.variants[name] = retriever
        self.results[name] = []
    
    def run_test(
        self,
        queries: List[str],
        ground_truth: List[Set[str]]
    ):
        """运行A/B测试"""
        evaluator = RetrievalEvaluator()
        
        for name, retriever in self.variants.items():
            print(f"\n测试变体: {name}")
            
            test_data = [
                {"query": q, "relevant_docs": gt}
                for q, gt in zip(queries, ground_truth)
            ]
            
            metrics = evaluator.evaluate(retriever, test_data)
            self.results[name] = metrics
            
            print(f"  Recall@10: {metrics['recall@10']:.4f}")
            print(f"  NDCG@10: {metrics['ndcg@10']:.4f}")
    
    def compare_results(self):
        """比较结果"""
        import pandas as pd
        
        df = pd.DataFrame(self.results).T
        print("\n比较结果:")
        print(df)
        
        # 找出最佳变体
        best_variant = df["recall@10"].idxmax()
        print(f"\n最佳变体: {best_variant}")
        
        return df

# 使用示例
ab_test = ABTestFramework()

# 变体A: BM25 only
ab_test.add_variant("bm25_only", BM25Retriever(config))

# 变体B: Dense only
ab_test.add_variant("dense_only", DenseRetriever(config))

# 变体C: Hybrid (BM25 + Dense)
ab_test.add_variant("hybrid", HybridRetriever(config))

# 运行测试
ab_test.run_test(test_queries, ground_truth)
comparison = ab_test.compare_results()

十二、总结与展望

12.1 核心要点回顾

本文全面探讨了RAG系统中的组合检索技术,核心要点包括:

  1. 稀疏检索(BM25):
    • 基于词法匹配,高效且可解释
    • 擅长精确词项匹配
    • 受限于词汇鸿沟问题
  2. 密集检索(Neural):
    • 基于语义理解,跨越词汇鸿沟
    • 需要更多计算资源
    • 在语义查询上表现优异
  3. 学习型稀疏检索(SPLADE):
    • 结合两者优势
    • 保持稀疏性和可解释性
    • 引入神经网络的学习能力
  4. 混合检索:
    • 通过RRF等融合算法组合多个检索器
    • 显著提升检索质量
    • 在跨域任务上尤为有效
  5. 重排序:
    • 使用交叉编码器精排候选文档
    • 大幅提升最终结果质量
    • 需要权衡延迟和性能

12.2 最佳实践总结

维度 推荐方案
基础配置 BM25 + 密集检索 + RRF融合
高质量场景 三路检索(BM25+Dense+SPLADE) + 重排序
实时系统 BM25 + 轻量级密集模型
资源受限 BM25 + 小型嵌入模型(如MiniLM)
多语言 多语言密集模型 + 重排序
特定领域 领域微调的嵌入模型 + BM25

12.3 性能优化Checklist

检索阶段:

  • 使用批处理最大化GPU利用率
  • 启用FP16推理减少延迟
  • 合理设置检索窗口大小
  • 使用缓存避免重复检索
  • 对高频查询预计算结果

索引优化:

  • 调优FAISS/HNSW参数
  • 合理设置Elasticsearch分片数
  • 启用适当的缓存策略
  • 定期压缩和优化索引

融合与重排序:

  • 根据场景调整RRF权重
  • 使用候选窗口策略
  • 批处理重排序请求
  • 考虑使用ColBERT等高效重排序器

12.4 未来趋势

12.4.1 大模型时代的检索

随着大语言模型上下文窗口的扩大(如Claude 100K, GPT-4 Turbo 128K),检索的角色正在演变:

从"检索Top-K"到"检索All-Relevant":

  • 不再严格限制文档数量
  • 尽可能提供所有相关信息
  • 让LLM自己筛选和综合

LLM作为检索器:

  • 使用LLM理解查询意图并改写
  • 生成多个检索查询(Query Expansion)
  • 迭代检索与生成
12.4.2 多模态RAG

文本检索扩展到多模态:

  • 图文联合检索
  • 视频片段检索
  • 音频检索
  • 跨模态RAG
12.4.3 自适应检索

根据查询动态选择检索策略:

  • 简单事实查询 → 快速BM25
  • 复杂语义查询 → 密集检索+重排序
  • 多跳推理查询 → 迭代检索
12.4.4 端到端优化

从检索到生成的联合优化:

  • 检索器和生成器联合训练
  • 强化学习优化整个流程
  • 自动学习最优融合权重

12.5 实践建议

对于正在构建RAG系统的开发者:

阶段1: MVP (最小可行产品)

  • 使用单一检索方法(推荐: BM25或现成的向量检索服务)
  • 快速验证业务价值
  • 收集用户反馈和查询日志

阶段2: 优化

  • 引入混合检索(BM25 + 密集检索)
  • 添加重排序
  • 基于真实查询数据评估和调优

阶段3: 规模化

  • 优化索引和检索性能
  • 实现缓存和负载均衡
  • 监控和持续优化

阶段4: 精细化

  • 针对不同查询类型使用不同策略
  • 领域微调嵌入模型
  • 实现自适应检索

参考文献

学术论文

  1. BM25与概率检索
  2. 密集检索
  3. SPLADE系列
  4. 混合检索与RRF
  5. 重排序

技术文档与教程

  1. Pyserini (BM25实现)
  2. Elasticsearch官方文档
  3. FAISS (向量检索)
  4. Sentence Transformers

博客文章

  1. Pinecone: Hybrid Search Explained
  2. Anthropic: Contextual Retrieval
  3. OpenAI Cookbook
  4. Hugging Face: MTEB Leaderboard

开源项目

  1. LangChain
  2. LlamaIndex
  3. Weaviate
  4. Qdrant

研究机构与公司博客

  1. Google Research
  2. Meta AI Research
  3. Elastic Labs
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐