Skip to content
从朴素 RAG 到多阶段检索:Hybrid Search、Rerank 与流水线设计
1. 概述
RAG(Retrieval-Augmented Generation)将外部文档检索与大型语言模型生成结合在一起。Naive RAG 是最简形态,其流程分为索引构建与查询推理两个阶段。
索引构建阶段:
- 将文档切分为文本块(chunk);
- 用 Embedding 模型将每个文本块编码为向量;
- 将向量写入向量数据库并建立近似最近邻索引。
查询推理阶段:
- 用户输入查询;
- 用同一个 Embedding 模型将查询编码为向量;
- 在向量数据库中检索与查询向量最相似的 k 个文本块;
- 将 k 个文本块作为上下文,与查询一起拼入 prompt;
- 让 LLM 参考上下文生成回答。
Naive RAG 的实现较为简单,但检索质量往往成为瓶颈。例如,查询 How to set database connection timeout?,文档中对应的内容可能是 DB_TIMEOUT 配置项或 ConnectionTimeoutException 异常类名。仅靠语义向量,这类精确 token 的匹配不够稳定。
Naive RAG 的问题可以归结为三点。
第一,向量检索存在词法盲区。 Embedding 模型对“含义相似”的文本表现好,但对代码符号、产品名称、错误码这类精确词元的匹配不稳定。查询 database connection timeout 与文档中的 ConnectionTimeoutException 在语义上相近,但向量空间中的距离未必足够近。
第二,单一召回通道的覆盖率有限。 同一文档库中,不同文档对同一主题的表述风格可能差别很大。语义向量召回容易漏掉“用词不同但含义相关”的内容;词法召回又因查询和文档没有共用词而失败。单独任何一种检索方式都只能覆盖一部分相关文档。
第三,Top-k 截断不可逆。 相关文档一旦在召回阶段被截除,后续生成阶段就完全看不到它。因此,召回阶段的目标不是精确排序,而是尽量让正例出现在前 k 个位置;排序的精细化交给后续阶段。
Advanced RAG 的做法,是把“查询 → 向量检索 → 生成”的紧凑链路,拆成多个可独立设计、替换和调优的阶段。
一个典型的 Advanced RAG 流水线包含以下阶段:
- 查询理解(Query Understanding):对查询进行改写、扩展、分解或路由。
- 多路召回(Hybrid Retrieval):并行运行稀疏检索(BM25)和稠密检索(Embedding),得到多份候选列表。
- 融合(Fusion):将多份候选列表合并为一个有序列表。
- 精排(Reranking):用一个更精确但更慢的模型对融合后的 Top-N 候选重新打分排序。
- 上下文压缩(Context Compression):去除候选文本中与问题无关的内容,减少 prompt 长度和噪声。
- 生成(Generation):将压缩后的上下文与查询拼接,交给 LLM 生成回答。
这条流水线具有级联性质:
- 每加入一个阶段,延迟都会增加,但也会改善后续阶段的输入质量;
- 上游阶段的召回错误无法被下游阶段修正;
- 下游阶段能对上游结果“缩小范围”或“重排序”,但不能找回已丢弃的文档。
因此,多阶段检索设计的核心权衡是:在总延迟预算内,把算力花在哪个阶段最能提升最终回答质量。
2. 基本概念:稀疏检索与稠密检索
2.1 稀疏检索:BM25 与倒排索引
稀疏检索(Sparse Retrieval)基于词项匹配。底层数据结构是倒排索引:从词项(term)指向包含该词项的文档列表的映射。查询时,将查询拆成词项,在倒排索引中找出同时包含这些词项的文档,再按相关性打分 [3]。
BM25(Best Match 25)是最常用的稀疏检索打分算法 [3]。它的思想是:
- 词项在文档中出现次数越多,文档越相关;
- 包含该词项的文档越多(即词项越常见),该词项的作用越小;
- 文档长度越短,词项占文档权重越大,应给予补偿。
BM25 的分数由词项频率(TF)和逆文档频率(IDF)共同决定。基本公式为:
score(D, Q) = Σ_{term∈Q} IDF(term) * f(term, D) * (k1 + 1) /
(f(term, D) + k1 * (1 - b + b * |D| / avgdl))其中:
f(term, D)是词项在文档 D 中的出现次数;|D|是文档长度,avgdl是语料库平均文档长度;k1和b是调节参数,Lucene 实现的默认值取k1 = 1.2、b = 0.75;IDF(term)在 Lucene 实现中通常写为ln(1 + (N - df + 0.5) / (df + 0.5)),其中N是文档总数,df是包含该词项的文档数。
k1 控制词频饱和曲线:词频超过某个阈值后,增加词频对分数的提升趋于平缓。b 控制文档长度惩罚强度:b = 0 表示不惩罚长文档,b = 1 表示完全按文档长度与平均长度的比例惩罚。
以下是一个最小化的 BM25 词项打分计算:
javascript
// bm25-score.js
function bm25TermScore(termFreq, docLen, avgDocLen, docFreq, totalDocs) {
const k1 = 1.2;
const b = 0.75;
const idf = Math.log(1 + (totalDocs - docFreq + 0.5) / (docFreq + 0.5));
const tf =
(termFreq * (k1 + 1)) /
(termFreq + k1 * (1 - b + b * (docLen / avgDocLen)));
return idf * tf;
}
// 短文档中 "timeout" 出现 2 次,文档长度 50,平均文档长度 200,
// 包含 "timeout" 的文档数 10,文档总数 1000。
console.log(bm25TermScore(2, 50, 200, 10, 1000));输出的是一个相对分数。该函数演示了 BM25 的形状:词频高、文档短、词项罕见 → 分数高。实际使用应依赖 Lucene、Elasticsearch 等经过优化的实现,这些实现包含查询归一化、协调因子等额外处理,但其核心得分形式与上述公式一致。
稀疏检索对查询词项通常使用 OR 语义:文档只要包含其中一个词项就进入候选集,只是得分较低。这保证了召回率,但也会引入大量低相关性的候选。
2.2 稠密检索:Embedding 与向量索引
稠密检索(Dense Retrieval)将查询和文档分别编码为高维向量,通过向量之间的相似度衡量语义相关性。与词项匹配不同,向量表示可以捕捉同义关系、上下位关系和跨语言对应关系 [4]。
向量检索的工作流程:
- 文档离线编码。用 Embedding 模型将每个文本块转换为固定维数的向量。
- 建索引。将向量写入向量数据库,建立 ANN(近似最近邻)索引,常用方法如 HNSW、IVF。
- 查询编码。查询输入同一个 Embedding 模型,转换为与文档相同的向量空间。
- 近邻搜索。在向量索引中查找与查询向量最接近的 k 个文档向量。
相似度度量通常有三种 [5][10]:
- 余弦相似度:衡量两个向量的方向差异,不关心向量长度;
- 点积:向量归一化后与余弦相似度等价;
- 欧氏距离:衡量直线距离,数值越小越相似。
大多数 Embedding 模型在训练时使用点积或余弦相似度作为目标函数 [5]。使用前应查看模型文档确认推荐的度量方式,并对查询与文档向量做同样的归一化处理。
向量索引是近似结构。以 HNSW 为例,查询时从上往下搜索,返回的 k 个结果是近似最近邻,而不是全局精确结果。ef_search 参数设置过小或数据分布不均匀时,向量检索本身也会漏召。
2.3 两种检索的互补性与缺陷
| 维度 | 稀疏检索(BM25) | 稠密检索(向量) |
|---|---|---|
| 匹配方式 | 词项精确匹配 | 语义相似度 |
| 擅长场景 | 代码符号、产品名、错误码、专有名词 [2] | 同义改写、自然语言查询、跨语言 |
| 失败模式 | 查询和文档无共用词但含义相同时召回失败 | 精确 token 不同但语义相近时可能混淆 |
| 计算成本 | 倒排索引,延迟较低 | ANN 索引,延迟略高 |
| 可解释性 | 可查看命中了哪些词 | 很难解释为何相似 |
举例来说:
对查询 "authentication middleware",稀疏检索可能找到 auth-middleware.ts,因为 auth 与 authentication 有词干关联,middleware 精确匹配;但它找不到标题为 verify tokens before route handlers 的文档,即使这篇文档讲的正是认证中间件。
对查询 "database connection timeout",向量检索可能找到讨论 how to handle the error when the database reads slowly 的文档,因为语义相近;但用户需要的错误类 ConnectionTimeoutException 枚举值,只有精确匹配的稀疏检索更容易命中 [2]。
两种检索的失败模式互不重叠,组合起来能显著提高召回率。Hybrid Search 就是这一组合的结构化实现 [7]。
3. 工作原理:Hybrid Search
3.1 多路召回架构
Hybrid Search(混合搜索)指同时用稀疏检索和稠密检索查找文档,再对结果做融合的检索架构 [2][7]。基本结构如下:
query
├── BM25Index.search(query) ──→ sparseResults
├── EmbeddingModel.encode(query)
│ └── VectorIndex.search(queryVector) ──→ denseResults
│
└── Fusion(sparseResults, denseResults) ──→ finalResults两个检索通道的候选集大小通常不相等。融合之前需要确定各自取多少候选:如果每路只取 top-10,某通道中排名第 11 但实际相关的内容会被截断;如果取 top-100,融合阶段要承担更大的排序误差。常见的做法是让两个通道各返回较大的候选集(如 top-50 或 top-100),交给后续的融合和重排做精细化处理 [2]。
3.2 融合算法:RRF 与加权融合
将多路召回结果合并成一个列表,需要解决分数不可比的问题。BM25 的分数受文档长度、词频和语料库统计影响;向量相似度的值域取决于 Embedding 模型和归一化方式。直接把两个原始分数相加没有意义。
RRF:倒排排名融合
RRF(Reciprocal Rank Fusion)不使用原始分数,只使用每个文档在各通道中的排名 [1]。公式为:
score(d) = Σ_{通道 r} 1 / (k + rank_r(d))其中 rank_r(d) 是文档 d 在通道 r 结果列表中的排名(从 1 开始),k 是常数,常见实现取 k = 60 [1]。
k 的作用是平滑排名差异。排名第 1 的文档贡献 1/61 ≈ 0.016,排名第 10 的文档贡献 1/70 ≈ 0.014,差距不悬殊。RRF 不偏好某一通道的绝对首位,而是看重文档在多个通道中持续出现。
以下是一个两路结果融合的示例。假设 BM25 通道返回 ['doc-a', 'doc-b', 'doc-c'],向量通道返回 ['doc-c', 'doc-a', 'doc-d']:
javascript
// rrf-fusion.js
function reciprocalRankFusion(resultSets, k = 60) {
const scores = new Map();
for (const docIds of resultSets) {
docIds.forEach((docId, index) => {
const rank = index + 1;
const increment = 1 / (k + rank);
scores.set(docId, (scores.get(docId) || 0) + increment);
});
}
return [...scores.entries()]
.sort((a, b) => b[1] - a[1])
.map(([docId, score]) => ({ docId, score }));
}
const results = reciprocalRankFusion([
['doc-a', 'doc-b', 'doc-c'],
['doc-c', 'doc-a', 'doc-d'],
]);
console.log(results);执行结果如下:
[
{ docId: 'doc-a', score: 0.0323 },
{ docId: 'doc-c', score: 0.0323 },
{ docId: 'doc-b', score: 0.0161 },
{ docId: 'doc-d', score: 0.0159 },
]doc-a 在两个通道中分别排名第 1 和第 2,分数为 1/(60+1) + 1/(60+2) ≈ 0.0323。doc-b 只在 BM25 中排名第 2,向量通道未出现,只得到 1/62 ≈ 0.0161。
这个例子展示了 RRF 的重要特性:每个通道只需返回内部排序,不需要暴露原始分数 [1]。这对集成外部检索系统很有利。
RRF 的局限在于:
- 假设所有通道排名质量相同。若 BM25 通道整体精度高于向量通道,RRF 不会自动给予更高权重;
- 完全丢弃分数信息。分数非常接近与非常悬殊的两种情况,在 RRF 中体现为相同的排名贡献。
加权融合
加权融合需要先做分数归一化,常用方式有:
- min-max 归一化:将原始分数线性缩放到
[0, 1]; - z-score 归一化:按通道分数的均值和标准差标准化。
归一化后,加权融合公式为:
score(d) = w_sparse * norm_score_sparse(d) + w_dense * norm_score_dense(d)权重可以手工设定,也可以通过网格搜索或相对排序回归学习。加权融合的优点是保留分数幅度信息,但代价是归一化可能不稳定:某个通道的分数分布极不均匀时,归一化会放大噪声。相比 RRF,加权融合对分数分布的假设更多,RRF 在实践中更鲁棒 [8]。
归一化与学习式融合
学习式融合从标注数据中学习融合规则。一个常见做法是:对每个候选提取特征——各通道的排序位置、归一化分数、文档长度等,训练一个 LTR 模型输出排序。学习式融合能利用数据信号,但要求有标注数据,并增加了流水线复杂度。
在 RAG 场景中,只有当多种检索信号需要精细调节时,才考虑引入学习式融合。作为过渡,可以先对归一化分数做线性加权组合,用简单回归优化权重。
3.3 融合阶段在 RAG 中的位置
融合阶段在多路召回之后、精排之前。融合的输出应是“足够多的候选文档”,而不是最终送给 LLM 的文档,因为:
- 融合算法的目标是提高召回率,而非精确排序;
- 精排阶段(Rerank)会用精度更高的模型对候选重新评分。
融合阶段还需要去重。同一篇文档可能以不同的 chunk 形式被两个通道同时召回,需要按文档 ID 去重,再保留每个文档的最佳排名或最高融合分数。
4. 工作原理:Rerank 精排阶段
4.1 召回-排序级联中的 Rerank
在经典搜索系统中,“召回-排序”是两个分离的阶段。召回阶段用低成本模型快速取得高召回率,排序阶段对有限候选集做精细打分。RAG 的多阶段检索采用同样的级联结构。
召回阶段的产出一般大于最终送入生成的上下文数量。例如,两路召回各取 50,融合后取 60 个候选,但最终 prompt 中只能放入 5~10 个文档。从候选中选出最关键的几个,就是精排阶段的任务。
Rerank 阶段使用独立排序模型,接收一组查询-文档对,输出每个文档的相关性分数。与召回阶段的模型相比,Rerank 模型通常:
- 更精确:通过模拟查询与文档的深度交互来预测相关性;
- 更慢:对每个查询-文档对都做一次前向计算;
- 部署成本更高:计算量随候选数量线性增长。
因此,Rerank 之前必须“减量”:将多路召回和融合结果截断到 50~100 的量级,再交给 Rerank。Rerank 之后通常只保留最靠前的少数结果,进入上下文压缩或直接进入生成。
4.2 Bi-Encoder 与 Cross-Encoder 评分机制
Rerank 常用模型分为两类:Bi-Encoder 和 Cross-Encoder。
Bi-Encoder 将查询和文档分别编码为向量,计算向量相似度。大多数召回阶段的 Embedding 模型就是 Bi-Encoder(如 Sentence-BERT 类)。它的特点:
- 查询和文档的编码互不干扰,文档向量可预计算并索引;
- 查询与文档之间缺乏 token 级交互,无法捕捉“文档中哪一部分与查询相关”的细粒度信号。
Cross-Encoder 将查询与文档拼接成一个文本序列,输入同一个 Transformer 编码器,在输出端用分类头预测相关性。它不产出向量,而是直接输出一个相关性分数。
评分流程:
输入: [CLS] query [SEP] document [SEP]
↓
Transformer Encoder
↓
输出: 取 [CLS] 位置的表示,接线性层 + sigmoid → 相关性分数因为查询与文档在编码过程中互相可见,Cross-Encoder 能检测到文档中的某句话对查询中的某个词形成精确回答。这种交互能力是 Bi-Encoder 不具备的。
代价是计算复杂度。Cross-Encoder 无法预计算文档表示,必须对每个查询-文档对执行一次完整前向传播。60 个候选文档就需要 60 次前向,比 Bi-Encoder 的 60 次点积昂贵得多。
在 RAG 流水线中,Bi-Encoder 通常放在召回阶段,Cross-Encoder 放在重排阶段。两个阶段的模型可以不同,也无需同族。例如召回用 bge-m3,重排用 bge-reranker 或 Cohere Rerank,二者独立部署,独立升级。
4.3 LLM Reranker 与生成式排序
LLM 本身也可以作为重排器。做法是将候选文档列表和查询放入 prompt,要求 LLM 对文档打分或排序。
直接打分:
你是一个相关性评分器。给定一个问题和一个文档,判断该文档是否有助于回答问题。
输出 0~10 的整数分数。
问题:{query}
文档:{document}
分数:列表排序:
请根据相关性对以下文档排序,输出文档编号:
1. {doc1}
2. {doc2}
...LLM Reranker 的特点:
- 不需要标注数据微调排序模型;
- 对长尾领域和复杂推理问题的相关性判断更强;
- 能根据用户指令调整排序依据,如“是否包含解决方案”“是否最新”。
限制:
- 延迟高,通常比 Cross-Encoder 慢数倍到数十倍;
- 对候选数量敏感,候选过多时可能超出上下文窗口或产生位置偏差;
- 输出的分数不能跨查询直接比较,因为 LLM 对分数尺度的解释不稳定。
因此,LLM Reranker 一般在候选集已缩减到很小(如 10 个以内)且排序质量成为瓶颈时使用。
4.4 Rerank 模型选型与推理注意点
选择 Rerank 模型需要考虑三个因素:候选集大小、延迟预算和质量要求。
模型大小方面,专门的重排模型不追求参数量大,而追求训练数据与目标领域接近。中等规模(1~3 亿参数)的 Cross-Encoder 在大部分 RAG 场景中已能显著提高排序质量。大型模型仅对复杂语义的垂直领域有边际收益。
部署层面,Rerank 的 QPS 应与召回阶段分开评估。常见优化手段:
- 批量推理:对多个候选文档在同一 batch 内执行 Cross-Encoder 前向传播,利用 GPU 并行;
- 候选截断:先按融合分数粗略排序,只对前 30~50 个候选做 Cross-Encoder 打分;
- 分数含义:重排模型输出的原始分数(logit 或 sigmoid 概率)通常只在本查询内部做排序有意义,不同查询之间的分数分布不一致。
这里需要特别强调:不应设置一个全局的绝对分数阈值来过滤文档。Cross-Encoder 的分数分布受训练数据、领域和输入序列长度影响。一个查询中分数为 0.9 的文档,在另一个查询中可能语义明显不足,但分数仍然很高。实际过滤应采用相对策略,例如保留 Rerank 排序后前 N 个,或根据候选分数的分位数做截断。
5. 多阶段检索流水线设计
5.1 查询理解:分析、改写与扩展
多阶段流水线的第一处理对象是查询本身。
查询分析
查询分析解析用户输入,提取关键信息。典型任务:
- 意图分类:事实性问题、意见征求、代码生成、多文档对比等;
- 实体抽取:提取技术名称、产品名、API 名称;
- 语言识别:判断查询是中文、英文还是代码片段。
这些信息用于后续阶段。例如,识别查询包含代码符号时提高 BM25 权重;识别为跨语言查询时,增加查询扩展或翻译步骤。
查询改写
查询改写将原始表述转换为更适合检索的形式。常见方式:
- 去除对话历史中的指代:
"它的费用是多少?"改写为"RAGFlow 产品的价格是多少?"; - 将口语或疑问句改写为关键词短语;
- 从长问题中提取核心实体:
"我想知道如何在不改业务代码的情况下提高搜索的相关性"改写为"提高搜索相关性 不改业务代码"。
查询改写通常用一个小模型或固定提示词模板完成。改写后的查询不一定比原查询好,因此有些实现会保留原查询和多个改写版本,并行送入召回阶段,再在融合阶段合并结果。
查询扩展与 HyDE
查询扩展补充查询中缺失的信息。一种方式是同义词扩展,为关键术语增添同义词或上下位词,再送入稀疏检索。另一种方式是生成多个视角的查询变体,对每个变体执行检索。
HyDE(Hypothetical Document Embeddings)是另一种形式的查询扩展。它先让 LLM 根据查询生成一个“假设文档”——一段模拟真实文档的回答——再将这个假设文档编码为向量用于检索 [11]。
流程示意:
javascript
// hyde-pipeline.js(流程示意)
const { Generator, Embedder } = require('haystack');
// 1. 用 LLM 生成一个假设文档
const mockDoc = await generator.generate(`
Given the question "${query}", write a passage that answers the question.
`);
// output: "To set a connection timeout, use the 'connectTimeout' option..."
// 2. 将假设文档编码为向量
const vector = await embedder.embed(mockDoc);
// 3. 用该向量检索真实文档
const results = await retriever.retrieve(vector, topK = 20);HyDE 的适用条件 [12]:
- 检索的召回率确实不理想(如 Recall@k 偏低);
- 文档领域与 Embedding 模型训练数据差异较大,导致查询和文档的向量空间不一致;
- 下游任务是以“给定查询返回相关文档”为主,检索是有损的。
风险在于假设文档可能与真实文档风格差异过大,检索结果片面向“假设文档风格”偏移。在代码库检索中,先让 LLM 生成示例代码再检索实际实现,效果往往优于直接查询,但需要领域内测试验证。
5.2 多路召回、融合与重排的组合策略
将前述模块组合起来,形成完整检索链。示例:
javascript
const userQuery = "为什么我的 Redis 连接老是在高并发时超时?";
// 阶段1:查询改写
const queryVariants = [
"Redis 高并发 连接超时 原因",
"Redis connection timeout under high concurrency",
];
// 阶段2:多路召回
const bm25Results = bm25Index.search(queryVariants[0]); // top 50
const denseResults = vectorIndex.search(queryVariants[1]); // top 50
// 阶段3:融合
const fused = rrfFusion([bm25Results, denseResults]); // top 60
// 阶段4:重排
const reranked = crossEncoder.rerank(userQuery, fused.slice(0, 50)); // top 10
// 阶段5:上下文压缩
const compactContexts = compressor.compress(userQuery, reranked.slice(0, 5));
// 阶段6:生成
const answer = llm.generate(userQuery, compactContexts);各阶段的数量配置(融合后保留 50、Rerank 后取 10、压缩后留 5)不是固定值。它们取决于:
- 文档库大小和噪声水平;
- 重排模型的速度和成本;
- 上游召回精度(召回精度差则需要更大候选来兜底)。
一个重要原则:召回阶段尽量多取,重排阶段再收敛。在延迟预算允许范围内,多带给重排阶段一些候选,通常比增加召回阶段的精度更划算。重排模型比召回模型能力强,可以修正召回阶段的排序错误;召回阶段一旦过早截断,重排看不到漏掉的文档。
5.3 上下文压缩与自适应检索
检索到的文档块不一定适合直接送给 LLM——它们可能包含冗余信息、多主题混杂,或与查询相关的段落只占一小部分。
上下文压缩有两种手段:
基于抽取的压缩:用轻量模型判断文本块中哪些句子与查询相关,只保留相关句子。适合代码片段、说明书、规范等需要保留准确表述的内容。
基于生成的压缩:让 LLM 根据查询重新组织检索到的内容,生成简洁摘要。引入额外延迟,但可压缩大段冗余,并将多个文档的要点整合到一段上下文中。
压缩在流水线中的位置在 Rerank 之后。重排已确定哪些文档最相关,压缩才有聚焦点。若先压缩再重排,需要对更多文档做生成,计算量更大。
自适应检索指根据查询或中间结果的置信度决定是否执行额外检索。例如:
- 高频问题命中缓存,直接复用回答;
- 融合后候选分数整体偏低,触发一次改写查询或 HyDE 重检;
- 重排后第一名分数显著高于后续候选,直接进入生成;否则扩充候选做二次检索。
自适应分支的判断条件应基于相对信号而非绝对分数。基于 Rerank 分数的判断,应使用当前候选列表中的相对位置或分位数;基于检索覆盖率的判断(如 BM25 命中文档数明显偏少)要结合查询类型和文档库特性设置。
5.4 级联延迟与效果权衡
多阶段检索的实质是用延迟换取输入质量。设计流水线时,需要对每阶段的延迟预算做分配。
各阶段延迟从高到低大致为:
- LLM 调用(查询改写、HyDE、上下文压缩、生成)最高;
- Cross-Encoder 重排次之,与候选数量成线性关系;
- 向量检索取决于索引大小和召回数;
- BM25 检索通常最低;
- 融合接近零成本。
假设总延迟预算为 2 秒,查询改写、重排、压缩各占约 500 ms,剩余时间不支持多通道各取 top-100 再加生成。如果希望 LLM 生成时间尽量充裕,就应该在查询改写和压缩阶段使用更小的模型,或对低优先级通道降低召回数。
流水线不要求每个查询都走完整链路。可以设计旁路:简单查询直接走“向量检索 → 生成”,不经过改写和重排;复杂查询进入完整流水线。旁路策略的关键是“简单”和“复杂”的判断规则,应在评测集上验证。
6. 系统架构与接口设计
6.1 组件划分与数据流
将检索流水线拆分为模块,是保证可测试性和可演进性的基础。建议的组件划分:
┌─────────────────────────────────────────────────────────┐
│ Advanced RAG Pipeline │
│ │
│ ┌────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Query │ │ Hybrid │ │ Fusion │ │
│ │ Analyzer │ → │ Retriever │ → │ Module │ │
│ └────────────┘ └──────────────┘ └───────────────┘ │
│ │ │
│ ┌──────────────┐ │ │
│ │ Reranker │ ←───────────────┘ │
│ └──────────────┘ │
│ │ │
│ ┌──────────────┐ │ │
│ │ Compressor │ ←───────────────┘ │
│ └──────────────┘ │
│ │ │
│ ┌──────────────┐ │ │
│ │ Generator │ ←───────────────┘ │
│ └──────────────┘ │
└─────────────────────────────────────────────────────────┘该划分遵循单一职责原则:
- QueryAnalyzer 只负责产生检索查询(可能多个);
- HybridRetriever 内部管理 BM25 索引和向量索引,对外暴露
search(queryVariants); - FusionModule 将多路结果合并为一个有序列表;
- Reranker 接收候选列表,输出打分排序列表;
- Compressor 将高排名候选压缩为简洁上下文;
- Generator 用上下文生成回答。
6.2 模块接口定义
模块之间使用异步函数和可序列化数据结构。下面用 TypeScript 定义接口:
typescript
// types.ts
type ChunkId = string;
interface ScoredChunk {
chunkId: ChunkId;
score: number;
source: string; // "bm25" | "dense" | "fusion" | "rerank"
}
interface QueryPlan {
variants: string[];
options: {
searchType: 'sparse' | 'dense' | 'hybrid';
from: number;
size: number;
};
}
interface QueryAnalyzer {
analyze(rawQuery: string, context?: string): Promise<QueryPlan>;
}
interface Retriever {
search(plan: QueryPlan): Promise<ScoredChunk[]>;
}
interface Fusion {
fuse(results: ScoredChunk[][]): Promise<ScoredChunk[]>;
}
interface Reranker {
rerank(query: string, candidates: ScoredChunk[]): Promise<ScoredChunk[]>;
}
interface Compressor {
compress(query: string, chunks: ScoredChunk[]): Promise<string[]>;
}接口设计要点:
- 阶段之间只传递 ID 和分数,不传递文档全文。全文由各阶段按
chunkId从存储中获取,避免大对象反复复制; ScoredChunk.source标记分数来源,便于评测时分析每个环节的行为;- QueryAnalyzer 与 Retriever 解耦,检索查询不总是等于用户查询;
- Fusion、Reranker、Compressor 相互独立,可单独替换实现。例如从 RRF 换成加权融合,只需实现同一个
Fusion接口。
6.3 混合索引架构与存储
两个检索通道的数据可以分开存储,也可以放在同一个支持多种索引的系统中。
分开存储:BM25 索引放在搜索服务(如 Elasticsearch、OpenSearch),向量索引放在向量数据库(如 Redis、Qdrant、Milvus、pgvector),通过同一文档 ID 关联。优点是各系统独立扩展和选型。缺点是数据一致性边界变宽:同一文档需写入两个系统,一个失败则检索覆盖出现差异。
统一存储:使用原生支持混合搜索的数据库,如 Elasticsearch 的 BM25 + dense_vector、Redis Stack、MongoDB Atlas Search、Qdrant 等 [7]。文档只需入库一次,查询在一个系统中完成,减少数据同步和运维复杂度。代价是混合查询的调优能力受限于该系统的实现。
从架构演进角度,建议先在一个支持混合查询的系统中跑通全链路。评测证明某个通道是瓶颈,或需要引入专用重排服务时,再逐步拆分。
7. 工程实践:渐进式重构
7.1 开源组件与选型
实现 Advanced RAG 检索链路,每一步都有多种开源组件可选。选型的核心约束是:当前系统能承载的复杂度,以及团队维护能力。
各阶段常见选项及适用情况(不绑定版本):
- 混合检索系统:Elasticsearch / OpenSearch(BM25 成熟,向量支持逐步增强);Redis(检索 + 向量能力,运维轻);MongoDB(已以 MongoDB 为主存储的团队);Qdrant / Weaviate / Milvus(向量优先,稀疏向量支持程度不同)。
- Embedding 模型:bge 系列、e5 系列、text-embedding 系列等中文/多语言模型。
- 重排模型:bge-reranker 系列、cross-encoder 系列;推理框架如 vLLM、TEI 可作统一后端。
- LLM:OpenAI、Claude、通义、DeepSeek 或本地开源模型。重排与生成可选择不同模型。
- 编排框架:Haystack、LlamaIndex、LangChain 提供检索组件封装;也可以在项目内自行组织管道。框架帮助快速原型,但自定义阶段增加时,框架抽象可能成为阻碍。
选型原则:优先利用系统已有技术栈,出现瓶颈时再引入新组件。例如,已使用 Elasticsearch 的项目,从 BM25 索引开始,再加入向量字段,比同时引入向量数据库和 ES 集群的维护成本更低。
7.2 延迟与成本优化
延迟优化分为链路级和单点级。
链路级优化,减少每阶段需处理的数据量:
- 查询理解阶段,只对必要字段做改写。清晰的技术名词不必做 LLM 改写;
- 多路召回阶段,控制各通道返回量。top-100 是保守做法,实际许多查询各取 top-20 即可;
- Rerank 阶段,候选控制在 50 以内。候选太多时 Cross-Encoder 推理时间成比例增长,对质量提升边际递减;
- 上下文压缩阶段,精确控制输出长度。如果 prompt 长度可接受,也可跳过压缩。
单点级优化:
- 对不频繁变化的文档库和查询结果做缓存。缓存粒度可以是查询改写结果、检索结果、最终生成结果;
- 用批处理合并同时到达的请求。Rerank 阶段尤其适合批量推理,多请求共享 GPU,吞吐显著高于串行推理;
- 对 Embedding 模型和重排模型使用小型化或蒸馏模型。候选集不大时,小型 Cross-Encoder 的排序质量与大型模型差距较小,延迟差距可达数倍。
成本优化在架构上的体现是计算量的合理分配。整条流水线最贵的操作是 LLM 调用(查询改写、HyDE、压缩、生成)和 Cross-Encoder 重排。如果所有查询都走四次 LLM 调用,每个请求的成本会很高。合理的做法是让简单查询走“快速通道”:向量检索 + 生成;快速通道结果置信度不足时,再走完整链路。
7.3 从 Naive 到 Advanced 的重构步骤
渐进式重构的路线:每轮只加一个模块,用评测数据确认收益后再进入下一步。
第一步:建立基线
实现 Naive RAG:向量检索 top-5 → 直接生成。记录一组评测问题上的检索指标(Recall@k、MRR)和生成指标(忠实度、答案相关性)。这是后续所有改进的对照。
第二步:加入 BM25 通路与融合
实现 BM25 检索器(或直接用搜索引擎的 BM25 能力),实现 RRF 融合。将 向量 top-50 + BM25 top-50 → RRF → top-10 → 生成 与基线对比。这一步通常能看到 Recall 提升,尤其在代码和技术名词占比较高的文档库中。
第三步:加入重排
引入 Cross-Encoder,对融合后的 top-50 候选重排,取 top-5 送生成。此步骤不改变召回范围,Recall 不变,但 MRR 应上升,生成质量也应提升。
第四步:加入查询改写
在评测中找出检索失败的查询,分析是否由查询表述不清导致。引入 QueryAnalyzer 后,将改写后的查询送入检索链路。使用多条查询变体时,注意融合阶段对每个变体产生的候选统一去重。
第五步:加入上下文压缩与自适应检索
检索片段含大量无关内容时加入 Compressor。某些查询在融合后候选分数整体偏低时,加入自适应重查逻辑。这一步开始涉及分支逻辑,应针对评测集逐例检查。
每一步的对照实验保持其他阶段不变,只改变一个变量。评测集至少覆盖三类查询:精确匹配型(代码符号、错误类名)、语义匹配型(同义改写、自然语言问题)、长尾型(少见的术语组合),否则改进效果容易被高估或低估。
8. 评测:检索质量与 RAG 效果
8.1 离线检索指标:Recall@k、MRR、NDCG
离线检索评测衡量检索器本身质量,与生成阶段解耦。评测数据是一组 (query, relevantDocs) 对。
Recall@k:前 k 个检索结果中相关文档数占全部相关文档的比例。
Recall@k = |relevant ∩ retrieved_topk| / |relevant|RAG 场景中,最终进入生成阶段如果是 top-5,那么 Recall@5 比 Recall@50 更接近端到端质量。由于召回候选会在重排阶段收敛,通常同时报告多个 k 值(如 Recall@5、Recall@50),以判断召回通道的覆盖率和排序一致性。
MRR(Mean Reciprocal Rank):衡量第一个相关文档出现的位置。对每个查询,计算第一个相关文档排名的倒数,再对所有查询取平均:
MRR = (1/|Q|) Σ_{q∈Q} 1 / rank_first_relevant(q)MRR 关心“最关心的那个答案是否在靠前位置”,在 RAG 中对应“最终是否有一个相关文档进入上下文”。
NDCG(Normalized Discounted Cumulative Gain):适用于多级相关性标注。按位置折扣累计收益,再用理想排序的累计收益归一化:
DCG@k = Σ_{i=1..k} (2^{rel_i} - 1) / log2(i + 1)
NDCG@k = DCG@k / IDCG@kNDCG 反映排序的整体质量,但需要相关度分级标注,成本更高。
示例:查询 q 有 3 个相关文档,检索器返回前 5 个文档为:
| 位置 | 文档ID | 是否相关 |
|---|---|---|
| 1 | doc-b | 是 |
| 2 | doc-e | 否 |
| 3 | doc-a | 是 |
| 4 | doc-d | 否 |
| 5 | doc-c | 是 |
则:
- Recall@5 = 3 / 3 = 1.0;
- MRR = 1 / 1 = 1.0(第一个相关文档在第 1 位);
- 如果只有位置 3 是第一个相关文档,则 MRR = 1/3。
这些指标只评估检索阶段,不反映生成质量。当 Recall 高但生成答案仍不准确时,问题可能出在上下文压缩、prompt 构造或生成模型本身。
8.2 RAG 端到端指标:忠实度与答案相关性
RAG 端到端评测关注最终回答是否准确、是否忠于检索到的上下文。
忠实度(Faithfulness):衡量生成回答中的事实是否都能在上下文中找到依据。计算常用 LLM 打分或规则方法:将回答拆成多个声明,逐一判断是否被上下文支持。回答包含上下文没有的信息,则有幻觉嫌疑。
答案相关性(Answer Relevance):衡量回答是否对应用户问题。相关性高意味着回答直接回应了问题的信息需求;相关性低则是答非所问或模糊。
这两个指标通常用另一个 LLM(评测模型)对 (question, answer, contexts) 三元组打分,也可结合人工标注。RAG 评测框架如 RAGAS 提供了封装。使用这类指标时注意:
- 评测模型的打分与人类判断并不完全一致,尤其对技术性回答;
- 同一评测集上的分数在不同评测模型之间不一定可比;
- 评测集与业务场景差异越大,指标参考价值越低。
8.3 评估集构建与实验对照
评估集是评测的基础。构建 RAG 检索链路评测集的可行路线:
- 从真实查询日志中抽样(无日志时由业务方提供典型问题);
- 对每个查询,用当前检索链路取 top-50 候选,人工标注相关文档。如果相关文档不在候选集中,需人工从文档库中补充——这一步很重要,否则评估集会天然偏向当前检索链路;
- 用补充后的标注集计算基线指标,后续每种检索策略基于同一标注集评测;
- 文档库更新后定期重新评估,防止检索表现随文档分布变化而回退。
实验对照的原则:相同查询集、相同文档集、相同标注集,只改变链路中的一个阶段。报告指标时,至少报告 Recall@k(多个 k 值)、MRR 和生成侧指标。同时有基线和多个 Advanced 配置(如 +BM25、+RRF、+Rerank、+QueryRewrite)时,就能定位每个模块的边际贡献。
9. 总结与参考链接
9.1 关键设计要点
Advanced RAG 的多阶段检索将“查询 → Top-k → 生成”扩展为“查询理解 → 多路召回 → 融合 → 重排 → 上下文压缩 → 生成”的级联结构。
核心动机来自检索质量的三个约束:
- 单路检索(稀疏或稠密)各有失败模式,多路召回提高召回覆盖率;
- 召回阶段的排序精度不足以直接决定 Top-k,融合和重排负责把更可能的文档排到前面;
- 阶段数量增加带来延迟和成本,每个阶段的存在价值应由评测数据决定。
在实现与选型时,应关注以下边界:
- RRF 融合适合分数不可比的多路结果;各通道分数分布已知且稳定时,加权融合或学习式融合能达到更精细的排序;
- Rerank 模型的分数不可跨查询做绝对阈值判断,过滤应基于候选内的相对位置或分位数;
- 查询改写和 HyDE 等 LLM 调用会显著增加延迟,应作为可选择的分支而不是默认路径;
- 混合索引可以统一存储,也可以分拆成两个系统,取决于数据一致性、扩展性和运维成本。
9.2 参考链接
[1] Redis. Hybrid Search Explained — RRF 融合算法. https://redis.io/blog/hybrid-search-explained
[2] Redis. Hybrid Search Explained — Hybrid Search 工作原理. https://redis.io/blog/hybrid-search-explained
[3] Redis. Hybrid Search Explained — BM25 算法. https://redis.io/blog/hybrid-search-explained
[4] Redis. Hybrid Search Explained — 向量搜索语义匹配. https://redis.io/blog/hybrid-search-explained
[5] Redis. Hybrid Search Explained — 相似度度量选择. https://redis.io/blog/hybrid-search-explained
[6] Redis. Hybrid Search Explained — 混合搜索的工程益处. https://redis.io/blog/hybrid-search-explained
[7] MongoDB. Hybrid Search. https://www.mongodb.com/resources/products/capabilities/hybrid-search
[8] MongoDB. Hybrid Search — RRF 与 RSF 融合方法. https://www.mongodb.com/resources/products/capabilities/hybrid-search
[9] MongoDB. Hybrid Search — BM25 默认算法. https://www.mongodb.com/resources/products/capabilities/hybrid-search
[10] MongoDB. Hybrid Search — 向量搜索相似度度量. https://www.mongodb.com/resources/products/capabilities/hybrid-search
[11] Haystack. Hypothetical Document Embeddings (HyDE). https://docs.haystack.deepset.ai/docs/hypothetical-document-embeddings-hyde
[12] Haystack. HyDE — 适用场景. https://docs.haystack.deepset.ai/docs/hypothetical-document-embeddings-hyde
[13] Haystack. HyDE — Code Example. https://docs.haystack.deepset.ai/docs/hypothetical-document-embeddings-hyde
