RAG系统中的组合检索流程:从稀疏到密集的融合之道
摘要
检索增强生成(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=1∑nIDF(qi)⋅f(qi,D)+k1⋅(1−b+b⋅avgdl∣D∣)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.5N−n(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
计算步骤:
- 计算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 $
- 计算各文档得分:对于文档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 (1−0.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 架构设计
双编码器是密集检索最常用的架构,它包含两个独立的编码器:
3.3.2 工作流程
离线阶段(索引构建):
- 使用文档编码器处理所有文档
- 提取[CLS] token或进行均值池化得到文档向量
- 将文档向量存储在向量数据库(如FAISS, Milvus)中
在线阶段(查询检索):
- 使用查询编码器处理用户查询
- 在向量数据库中执行近似最近邻搜索(ANN)
- 返回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)=q⋅d=∑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∣∣q⋅d=∑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(qi−di)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编码器: 提供上下文化的token表示
- MLM(Masked Language Model)头: 将每个token映射到词汇表维度
- 对数饱和: 应用log(1 + ReLU(x))激活函数,控制权重分布
- 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计算稀疏表示如下:
- 编码: h i = BERT ( x ) i h_i = \text{BERT}(x)_i hi=BERT(x)i 得到每个token的隐藏状态
- MLM投影: l i = MLM-head ( h i ) ∈ R ∣ V ∣ l_i = \text{MLM-head}(h_i) \in \mathbb{R}^{|V|} li=MLM-head(hi)∈R∣V∣,其中 ∣ V ∣ |V| ∣V∣ 是词汇表大小
- 激活与正则化:
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 遍历词汇表中的所有词项
- 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 并行检索架构
5.2.2 工作流程
- 并行检索: 同时向多个检索器发送查询
- 结果收集: 每个检索器返回独立的排序列表
- 去重: 识别在多个列表中出现的文档
- 融合: 使用融合算法计算综合得分
- 重排序: 按照综合得分生成最终排序
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=1nwi⋅scorei(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)=maxi−miniscorei(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=1nwi⋅scorei′(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)=∑r∈Rk+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排序:
- doc_A (score: 3.2)
- doc_B (score: 2.8)
- doc_C (score: 2.1)
- doc_D (score: 1.9)
密集检索排序:
- doc_B (score: 0.92)
- doc_E (score: 0.89)
- doc_A (score: 0.85)
- 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
最终排序:
- doc_B (0.0325) ← 在两个列表中都排名靠前
- doc_A (0.0323)
- doc_E (0.0161)
- doc_C (0.0159)
- doc_D (0.0156)
- 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)=∑r∈Rk+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
}
为什么使用窗口?
- 提高召回率: 文档在单个列表中排名较低,但在多个列表中都出现,融合后可能排名靠前
- 增加多样性: 更大的窗口可以引入更多样化的候选文档
- 平衡性能: 避免处理所有结果带来的计算开销
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)通过更精细的模型对候选文档进行二次排序,进一步提升结果质量。
双阶段检索架构的优势:
- 第一阶段(检索): 使用高效但相对粗糙的方法快速筛选候选集
- 第二阶段(重排序): 使用精确但昂贵的方法对候选集精排
6.2 交叉编码器(Cross-Encoder)
6.2.1 与双编码器的对比
双编码器(Bi-Encoder):
- 查询和文档独立编码
- 可以预先计算文档向量
- 检索时只需编码查询
- 速度快但语义交互有限
交叉编码器(Cross-Encoder):
- 查询和文档拼接后联合编码
- 无法预先计算
- 每个(查询,文档)对都需要编码
- 速度慢但语义交互充分
6.2.2 交叉编码器的架构
输入构造:
[CLS] 查询文本 [SEP] 文档文本 [SEP]
# 例如:
[CLS] 什么是量子计算 [SEP] 量子计算是一种利用量子力学原理进行信息处理的计算范式... [SEP]
模型结构:
- Transformer编码器: BERT, RoBERTa, DeBERTa等
- 分类头: 通常是[CLS] token的表示经过线性层
- 输出: 相关性得分(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 |
优化策略:
- 模型选择: 使用轻量级模型(如MiniLM)减少延迟
- 批处理: 最大化GPU利用率
- 模型量化: INT8量化可减少50%延迟
- 候选数控制: 根据延迟需求调整候选窗口
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=1∣Q∣maxj=1∣D∣EQi⋅EDj
其中 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
工作流程:
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
实现要点:
- pyserini集成: 利用成熟的Lucene索引实现
- 灵活的文档存储: 支持索引内嵌和外部语料库两种模式
- 结构化输出: 统一的文档格式便于下游处理
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
优化技术:
- 批处理: 最大化GPU利用率
- FP16推理: 2倍速度提升,更少内存占用
- 多GPU支持: 大规模索引的分片处理
- 进度监控: 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节点: 运行机器学习模型(如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/分片}} 最优分片数≈20−50GB/分片索引大小(GB)
示例:
- 100GB索引 → 3-5个分片
- 1TB索引 → 20-50个分片
过度分片的危害:
- 每个分片都有开销(内存、文件句柄)
- 查询需要遍历所有分片
- 主节点压力增大
8.5.3 缓存策略
Elasticsearch提供多层缓存:
- Query Cache: 缓存查询结果(仅filter context)
- Request Cache: 缓存聚合结果
- Field Data Cache: 缓存字段数据(用于排序、聚合)
- 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),它们可能共享:
- 词汇表: 相同的tokenizer,避免token不匹配
- 预训练数据: 类似的数据分布,更好的语义对齐
- 架构特性: 相似的表示空间
实验证据:
某些研究显示,使用同系列模型可能带来轻微的性能提升(1-3%),但这种提升主要来自于tokenizer的一致性而非模型架构本身。
9.2.2 跨系列配对的实际表现
实践发现: 跨系列配对通常表现良好甚至更优,因为:
- 专业化分工:
- 嵌入模型优化目标: 检索性能(NDCG, Recall)
- 生成模型优化目标: 生成质量(流畅性, 准确性)
- 两者的优化目标不同,不一定需要来自同一系列
- 模型选择灵活性:
- 可以选择各自领域的最佳模型
- 例如: BGE嵌入 + GPT-4生成
- 实验结果: 基于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 开发阶段
- 快速原型: 使用API服务快速验证
# OpenAI嵌入 + GPT-4
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")
llm = ChatOpenAI(model="gpt-4-turbo")
- 基准测试: 在小样本上测试多种配对
- 成本估算: 计算不同配对的成本
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系统:
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=∣Q∣1∑i=1∣Q∣ranki1
其中 $ \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)2reli−1
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系统中的组合检索技术,核心要点包括:
- 稀疏检索(BM25):
- 基于词法匹配,高效且可解释
- 擅长精确词项匹配
- 受限于词汇鸿沟问题
- 密集检索(Neural):
- 基于语义理解,跨越词汇鸿沟
- 需要更多计算资源
- 在语义查询上表现优异
- 学习型稀疏检索(SPLADE):
- 结合两者优势
- 保持稀疏性和可解释性
- 引入神经网络的学习能力
- 混合检索:
- 通过RRF等融合算法组合多个检索器
- 显著提升检索质量
- 在跨域任务上尤为有效
- 重排序:
- 使用交叉编码器精排候选文档
- 大幅提升最终结果质量
- 需要权衡延迟和性能
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: 精细化
- 针对不同查询类型使用不同策略
- 领域微调嵌入模型
- 实现自适应检索
参考文献
学术论文
- BM25与概率检索
- Robertson, S. E., & Zaragoza, H. (2009). The probabilistic relevance framework: BM25 and beyond. Foundations and Trends in Information Retrieval.
- https://www.staff.city.ac.uk/~sbrp622/papers/foundations_bm25_review.pdf
- 密集检索
- Karpukhin, V., et al. (2020). Dense Passage Retrieval for Open-Domain Question Answering. EMNLP 2020.
- https://arxiv.org/abs/2004.04906
- SPLADE系列
- Formal, T., et al. (2021). SPLADE: Sparse Lexical and Expansion Model for First Stage Ranking. SIGIR 2021.
- https://arxiv.org/abs/2107.05720
- Formal, T., et al. (2021). SPLADE v2: Sparse Lexical and Expansion Model for Information Retrieval. arXiv.
- https://arxiv.org/abs/2109.10086
- 混合检索与RRF
- Cormack, G. V., et al. (2009). Reciprocal rank fusion outperforms condorcet and individual rank learning methods. SIGIR 2009.
- https://plg.uwaterloo.ca/~gvcormac/cormacksigir09-rrf.pdf
- 重排序
- Nogueira, R., & Cho, K. (2019). Passage Re-ranking with BERT. arXiv.
- https://arxiv.org/abs/1901.04085
技术文档与教程
- Pyserini (BM25实现)
- Pyserini: A Python Toolkit for Reproducible Information Retrieval Research
- https://github.com/castorini/pyserini
- Elasticsearch官方文档
- Reciprocal Rank Fusion Reference
- https://www.elastic.co/guide/en/elasticsearch/reference/current/rrf.html
- FAISS (向量检索)
- Facebook AI Similarity Search Library
- https://github.com/facebookresearch/faiss
- Sentence Transformers
- All MiniLM and BGE Models Documentation
- https://www.sbert.net/
博客文章
- Pinecone: Hybrid Search Explained
- Anthropic: Contextual Retrieval
- 提升RAG检索质量的技术
- https://www.anthropic.com/news/contextual-retrieval
- OpenAI Cookbook
- Question Answering using Embeddings
- https://cookbook.openai.com/examples/question_answering_using_embeddings
- Hugging Face: MTEB Leaderboard
开源项目
- LangChain
- LlamaIndex
- 数据框架for LLM应用
- https://github.com/run-llama/llama_index
- Weaviate
- Qdrant
- 向量搜索引擎
- https://qdrant.tech/
研究机构与公司博客
- Google Research
- Dense Retrieval Papers Collection
- https://research.google/pubs/?topic=information-retrieval
- Meta AI Research
- DPR and FAISS Papers
- https://ai.meta.com/research/
- Elastic Labs
- RAG with Elasticsearch Tutorials
- https://www.elastic.co/search-labs
更多推荐



所有评论(0)