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

cover

一、单路检索的瓶颈:向量召回为何总是"差一点"

在 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)和最终答案的准确率,而非单一通道的得分。

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐