RAG 多路召回与重排序:从检索策略到答案融合的工程实践
RAG 多路召回与重排序:从检索策略到答案融合的工程实践

一、单路检索的瓶颈:向量召回为何总是"差一点"
在 RAG(检索增强生成)系统中,向量检索是最常用的召回方式。将文档分块后编码为向量,查询时计算余弦相似度取 Top-K,看似简单直接。然而,生产环境中的反馈往往令人沮丧:专业术语查询召回不相关文档、长尾知识被高频内容淹没、精确匹配场景反而不如关键词检索。
根本原因在于:单一检索通道存在固有的"盲区"。向量检索擅长语义相似度匹配,但对精确关键词、专业缩写、编号类查询表现不佳;BM25 关键词检索擅长精确匹配,但无法理解语义近义关系。多路召回(Multi-Route Recall)通过同时使用多种检索策略,再用重排序模型融合结果,是当前 RAG 系统提升召回质量的主流方案。
二、多路召回与重排序的架构原理
2.1 多路召回的整体架构
flowchart TB
Q[用户查询] --> P[查询预处理与改写]
P --> V[向量检索通道]
P --> K[关键词检索通道 BM25]
P --> R[规则检索通道]
V --> V1[Embedding 编码]
V1 --> V2[ANN 索引 Top-K]
K --> K1[分词与停用词过滤]
K1 --> K2[倒排索引 Top-K]
R --> R1[实体识别]
R1 --> R2[知识图谱查询]
V2 --> M[结果合并与去重]
K2 --> M
R2 --> M
M --> RR[重排序模型 Reranker]
RR --> F[Top-N 精排结果]
F --> G[LLM 答案生成]
2.2 各检索通道的互补性分析
| 检索通道 | 擅长场景 | 弱点 | 典型召回率 |
|---|---|---|---|
| 向量检索 | 语义近义、模糊查询 | 精确匹配、专业术语 | 70-80% |
| BM25 关键词 | 精确匹配、专业术语 | 语义近义、同义替换 | 60-70% |
| 知识图谱 | 实体关系、结构化查询 | 非结构化文本 | 40-50% |
多路召回的理论基础是:不同通道的召回结果存在互补性。向量检索的"漏召"可能被关键词检索补回,反之亦然。通过 Reciprocal Rank Fusion(RRF)或重排序模型融合,可以显著提升最终召回质量。
三、多路召回与重排序的代码实现
3.1 检索通道接口定义
package retrieval
import "context"
// Document 检索到的文档片段
type Document struct {
ID string // 文档唯一标识
Content string // 文档内容
Score float64 // 检索得分
Source string // 来源通道标识
Metadata map[string]string
}
// Retriever 检索通道接口
type Retriever interface {
Retrieve(ctx context.Context, query string, topK int) ([]Document, error)
Name() string
}
3.2 向量检索通道实现
package retrieval
import (
"context"
"sort"
)
// VectorRetriever 向量检索通道
type VectorRetriever struct {
embedder Embedder // 文本向量化接口
index ANNIndex // 近似最近邻索引
store DocumentStore // 文档原文存储
}
// Embedder 文本向量化接口
type Embedder interface {
Embed(ctx context.Context, text string) ([]float32, error)
}
// ANNIndex 近似最近邻索引接口
type ANNIndex interface {
Search(ctx context.Context, vector []float32, topK int) ([]SearchHit, error)
}
// SearchHit 索引搜索结果
type SearchHit struct {
ID string
Score float64
}
func (v *VectorRetriever) Retrieve(ctx context.Context, query string, topK int) ([]Document, error) {
// 将查询文本编码为向量
vector, err := v.embedder.Embed(ctx, query)
if err != nil {
return nil, fmt.Errorf("embed query: %w", err)
}
// 在 ANN 索引中搜索
hits, err := v.index.Search(ctx, vector, topK)
if err != nil {
return nil, fmt.Errorf("search index: %w", err)
}
// 获取文档原文并组装结果
docs := make([]Document, 0, len(hits))
for _, hit := range hits {
content, err := v.store.Get(ctx, hit.ID)
if err != nil {
continue // 单条获取失败不影响整体
}
docs = append(docs, Document{
ID: hit.ID,
Content: content,
Score: hit.Score,
Source: v.Name(),
})
}
return docs, nil
}
func (v *VectorRetriever) Name() string { return "vector" }
3.3 BM25 关键词检索通道
package retrieval
import (
"context"
"strings"
)
// BM25Retriever BM25 关键词检索通道
type BM25Retriever struct {
index BM25Index
}
type BM25Index interface {
Search(ctx context.Context, terms []string, topK int) ([]SearchHit, error)
}
func (b *BM25Retriever) Retrieve(ctx context.Context, query string, topK int) ([]Document, error) {
// 分词处理
terms := b.tokenize(query)
if len(terms) == 0 {
return nil, nil
}
hits, err := b.index.Search(ctx, terms, topK)
if err != nil {
return nil, fmt.Errorf("bm25 search: %w", err)
}
docs := make([]Document, 0, len(hits))
for _, hit := range hits {
docs = append(docs, Document{
ID: hit.ID,
Score: hit.Score,
Source: b.Name(),
})
}
return docs, nil
}
// tokenize 简单分词:按空格和标点切分,过滤停用词
func (b *BM25Retriever) tokenize(query string) []string {
stopWords := map[string]bool{"的": true, "了": true, "是": true, "在": true,
"the": true, "is": true, "a": true, "an": true}
parts := strings.FieldsFunc(query, func(r rune) bool {
return r == ' ' || r == ',' || r == ',' || r == '。' || r == '?'
})
var terms []string
for _, p := range parts {
p = strings.TrimSpace(p)
if p != "" && !stopWords[strings.ToLower(p)] {
terms = append(terms, p)
}
}
return terms
}
func (b *BM25Retriever) Name() string { return "bm25" }
3.4 RRF 融合与重排序
package retrieval
import (
"context"
"sort"
)
// MultiRouteRetriever 多路召回聚合器
type MultiRouteRetriever struct {
retrievers []Retriever
reranker Reranker
}
// Reranker 重排序模型接口
type Reranker interface {
Rerank(ctx context.Context, query string, docs []Document) ([]Document, error)
}
// NewMultiRouteRetriever 创建多路召回聚合器
func NewMultiRouteRetriever(reranker Reranker, retrievers ...Retriever) *MultiRouteRetriever {
return &MultiRouteRetriever{
retrievers: retrievers,
reranker: reranker,
}
}
// Retrieve 执行多路召回、RRF 融合与重排序
func (m *MultiRouteRetriever) Retrieve(ctx context.Context, query string, topK int) ([]Document, error) {
// 并行执行各路召回
type result struct {
docs []Document
err error
}
ch := make(chan result, len(m.retrievers))
for _, r := range m.retrievers {
go func(ret Retriever) {
docs, err := ret.Retrieve(ctx, query, topK*2) // 每路多召回一些
ch <- result{docs: docs, err: err}
}(r)
}
// 收集所有通道结果
var allDocs []Document
for i := 0; i < len(m.retrievers); i++ {
res := <-ch
if res.err != nil {
continue // 单通道失败不影响整体
}
allDocs = append(allDocs, res.docs...)
}
// RRF 融合
fused := m.rrfFuse(allDocs, 60) // k=60 是 RRF 常用参数
// 取 Top-K 送入重排序模型
if len(fused) > topK*2 {
fused = fused[:topK*2]
}
// 重排序精排
reranked, err := m.reranker.Rerank(ctx, query, fused)
if err != nil {
// 重排序失败时降级使用 RRF 结果
if len(fused) > topK {
return fused[:topK], nil
}
return fused, nil
}
if len(reranked) > topK {
return reranked[:topK], nil
}
return reranked, nil
}
// rrfFuse Reciprocal Rank Fusion 融合算法
// RRF 公式:score(d) = Σ 1/(k + rank_i(d))
func (m *MultiRouteRetriever) rrfFuse(docs []Document, k int) []Document {
// 按来源通道分组排序
sourceDocs := make(map[string][]Document)
for _, d := range docs {
sourceDocs[d.Source] = append(sourceDocs[d.Source], d)
}
// 计算每个文档的 RRF 得分
scores := make(map[string]float64)
docMap := make(map[string]Document)
for source, sDocs := range sourceDocs {
// 按原始得分降序排序
sort.Slice(sDocs, func(i, j int) bool {
return sDocs[i].Score > sDocs[j].Score
})
for rank, doc := range sDocs {
scores[doc.ID] += 1.0 / float64(k+rank+1)
docMap[doc.ID] = doc
}
}
// 按 RRF 得分排序
var result []Document
for id, score := range scores {
doc := docMap[id]
doc.Score = score
doc.Source = "rrf_fused"
result = append(result, doc)
}
sort.Slice(result, func(i, j int) bool {
return result[i].Score > result[j].Score
})
return result
}
四、多路召回的架构权衡
4.1 通道数量与延迟的权衡
每增加一个检索通道,召回率理论上会提升,但延迟也会增加。即使各通道并行执行,最终延迟取决于最慢的通道。在生产环境中,建议设置通道超时(如 200ms),超时的通道直接跳过,用已有结果融合。
4.2 RRF vs 重排序模型
- RRF:无需额外模型,计算速度快,适合通道数较少(2-3路)且对延迟敏感的场景。
- 重排序模型:精度更高,能理解查询与文档的深层语义关系,但需要额外的推理开销。适合对召回质量要求极高的场景。
4.3 适用边界
| 场景 | 推荐策略 |
|---|---|
| 通用知识库问答 | 向量 + BM25 双路 + RRF |
| 专业领域(医疗、法律) | 向量 + BM25 + 知识图谱三路 + 重排序 |
| 精确匹配为主(编号、代码) | BM25 为主 + 向量辅助 |
| 实时性要求极高 | 单路向量检索,牺牲召回换延迟 |
五、总结
RAG 系统的召回质量直接决定生成答案的可靠性。单路检索存在固有的"盲区",多路召回通过组合不同检索策略的互补性,显著提升了召回覆盖率。RRF 融合算法提供了轻量级的结果合并方案,重排序模型则进一步提升了精排质量。落地时建议从向量 + BM25 双路召回起步,根据业务反馈逐步增加通道和引入重排序模型。核心指标是召回率(Recall@K)和最终答案的准确率,而非单一通道的得分。
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)