检索策略深度:多路召回、融合排序与自查询检索

0 阅读34分钟

概述

系列定位:本文是“RAG 系统深度工程实战”系列的第 5 篇。在前一篇《嵌入模型工程:本地部署、批量推理与微调策略》中,我们完成了嵌入模型的选型、部署与微调,为文档和查询生成了高质量的向量表示。但向量检索仅仅是“万里长征第一步”,单一语义检索在面对精确关键词、复杂过滤条件和模糊意图时,往往漏掉关键文档(低召回率)或引入噪声(低精确率)。本文聚焦 RAG 系统的“心脏”——检索管道,深入剖析如何通过多路召回、融合排序与自查询检索,让系统从“能搜到”跃迁到“搜得准、排得好”。检索策略的设计是区分“玩具级”与“生产级”RAG 的关键分水岭。

总结性引言:如果你的 RAG 系统只是把用户查询变成向量,然后调用 Milvus 的 search() 返回最相似的几个文档块,你会很快发现一个尴尬的现实:当用户搜索“苹果”时,它不知道是水果还是手机;当用户问“如何给服务器开个后门”,它可能漏掉了“运维免密登录配置”这份正确文档。纯粹的向量检索在面对专有名词、模糊指代和复杂元数据条件时,往往力不从心。这就是为什么我们需要多路召回——让关键词、语义和知识图谱三路并行检索;需要融合排序——让结果列表从“各自为政”变为“综合最优”;需要自查询检索——让 LLM 将模糊的自然语言变成精确的结构化过滤条件。今天,我们将用 Java 构建一套完整的多级检索架构,通过 RRF、加权融合和自查询过滤,让 RAG 的召回率从 70% 一跃提升至 95%,并保证端到端延迟不升反降。

核心要点

  • 多路召回:向量(语义泛化)+ BM25(精确匹配)+ 图谱(实体关联)三路并行,解决单一检索的召回死角,并行设计将 TP99 延迟控制在 200ms 以内。
  • 融合排序:RRF(无参稳健)→ 线性加权(人工可调)→ LTR(智能高精)三级融合方案,实测 RRF 将 NDCG@10 提升约 15%,实现复杂度极低。
  • 自查询检索:LLM + 元数据 Schema → 结构化过滤 JSON,配合严格校验机制,将复杂搜索精确度提升 30% 以上,同时避免注入风险。
  • 评估驱动调优:通过 Hit Rate@K、MRR、NDCG@10 的离线实验,量化验证每一步优化(+BM25、+RRF、+自查询)的净收益。

文章组织架构图

flowchart TD
    subgraph 1[1.单一检索局限与多级架构设计]
        direction LR
        A1[向量检索痛点]
        A2[多路召回+融合+自查询架构]
    end
    subgraph 2[2.多路召回实战]
        B1[向量路-Milvus]
        B2[BM25路-ES]
        B3[图谱路-Neo4j]
        B4[并行CompletableFuture]
    end
    subgraph 3[3.融合排序]
        C1[RRF融合]
        C2[线性加权融合]
        C3[LTR融合]
    end
    subgraph 4[4.自查询检索]
        D1[LLM Prompt设计]
        D2[安全校验与降级]
        D3[Milvus expr生成]
    end
    subgraph 5[5.辅助优化]
        E1[查询改写/子问题拆分]
    end
    subgraph 6[6.评估与调优实验]
        F1[离线评估流程]
        F2[四组消融实验]
    end
    subgraph 7[7.贯穿案例]
        G1[企业知识库演进]
    end
    subgraph 8[8.前后系列衔接]
    end
    subgraph 9[9.面试高频专题]
    end

    1 --> 2
    2 --> 3
    3 --> 4
    4 --> 5
    3 --> 6
    4 --> 6
    5 --> 6
    6 --> 7
    7 --> 8
    8 --> 9

架构图说明

  • 总览说明:全文 9 个模块从单一检索的痛点出发,逐步构建多路、融合、过滤与评估的完整检索管道,最后以贯穿案例和面试题收尾。
  • 逐模块说明:模块 1-2 建立“多路”的必要性并落地并行架构;模块 3-4 是本文核心技术——融合排序与自查询过滤的工程实现;模块 5 是锦上添花的优化;模块 6 用数据说话;模块 7 推演演进路径;模块 8 承上启下;模块 9 面试巩固。
  • 关键结论检索策略的核心不是选择“最好的”单一算法,而是设计一套“可组合、可评估、可扩展”的多级管道。通过并行多路召回、无参 RRF 融合和自查询元数据过滤,RAG 系统能够在保持低延迟的同时,实现检索准确率的阶跃式提升。掌握这些策略后,你应该能够针对任何特定业务场景,通过离线实验组合出最优的检索架构,并部署上线。

1. 单一检索的局限与多级检索架构设计

纯粹基于向量相似度的检索(如直接使用 EmbeddingStoreRetriever 调用 Milvus)存在几个致命缺陷:

  1. 专有名词与短查询丢失:搜索“苹果”时,向量包含多种语义,无法区分水果与手机,召回文档充斥无关内容。短查询如“ESG报告”,关键词匹配精准度远高于向量泛化。
  2. 多维过滤条件无法处理:用户想查找“2024年Q3财务部发布的合同金额大于100万的文档”,向量检索无法利用 doc_typepublish_date 和自定义标量字段进行约束。
  3. 多跳推理与实体关联缺失:对于“治疗高血压的药物的副作用有哪些?”这种问题,文档可能分别记录了药物A治疗高血压和药物A的副作用,但单独的块缺少实体链接,向量检索可能只命中其中一个。
  4. 词义歧义与上下文省略:多轮对话中“那它的副作用呢?”缺乏主语,依赖历史上下文。

因此,我们需要设计一个多级检索引擎,其核心架构遵循责任链模式多路召回(并行)→ 融合排序 → 自查询过滤(可选)→ 重排序(后续篇章)。每一级处理不同的信号,最终输出高质量候选集。下图展示了整体数据流。

flowchart LR
    User(用户查询) --> SelfQuery{自查询<br>过滤生成}
    SelfQuery -- 结构化过滤条件 --> MultiRecall[多路召回]
    SelfQuery -- 原查询 --> MultiRecall
    MultiRecall --> Vector[向量路 Milvus]
    MultiRecall --> BM25[BM25路 ES]
    MultiRecall --> Graph[图谱路 Neo4j]
    Vector --> Fusion[融合排序]
    BM25 --> Fusion
    Graph --> Fusion
    Fusion --> TopK[Top-K候选集]
    TopK --> Reranker(重排序-第6篇)

图1 多级检索架构总览图

  • a) 主旨概括:该图展示了从用户查询到产生最终候选集的全链路,核心组件包括自查询预处理、并行多路召回和融合排序。
  • b) 逐元素分解:①自查询模块先于召回执行,利用LLM将自然语言转为过滤条件,既可用于预过滤,也可作为召回请求的一部分;②多路召回包含三条独立路径,各自利用不同算法并发请求;③融合排序负责合并三路结果并综合打分,输出统一排名的Top-K列表;④后续重排序(第6篇)将在此基础上做精排。
  • c) 设计原理映射:整体架构是典型的管道-过滤器模式策略模式的复合。多路召回通过策略模式封装不同的检索算法,融合排序通过责任链依次处理结果,而自查询则是一个装饰器,在原始查询上附加过滤能力。
  • d) 工程联系与关键结论生产环境常见误配置:直接将自查询生成的过滤条件应用于所有召回支路,可能导致某一路(如图谱)因过滤条件不兼容而返回空,应允许各路选择性应用过滤。关键结论:多级检索的核心优势在于信号互补,每一路都可能成为另一路的“救命稻草”,但在工程上必须通过超时和异常隔离防止短板效应拖垮整个链路。

2. 多路召回实战:向量 + BM25 + 知识图谱的并行架构

2.1 向量路:MilvusEmbeddingStore 的调优

LangChain4j 提供了 MilvusEmbeddingStore 封装,其 search() 方法最终映射到 PyMilvus 或 gRPC 的 search 操作。关键参数是搜索参数 ef(HNSW 索引)或 nprobe(IVF 索引)。以 HNSW 为例,ef 值越大,召回率越高但延迟增加。实测 BGE v1.5 1024 维向量,在 100 万文档规模下:

  • ef=64:P95 延迟 18ms,Recall@100 为 0.92
  • ef=128:P95 延迟 28ms,Recall@100 为 0.96 (推荐
  • ef=256:P95 延迟 45ms,Recall@100 为 0.98

下面代码展示了在 LangChain4j 中配置 MilvusEmbeddingStore 并构建 EmbeddingStoreRetriever

// MilvusConfig.java
@Configuration
public class MilvusConfig {
    @Bean
    public MilvusEmbeddingStore milvusEmbeddingStore() {
        return MilvusEmbeddingStore.builder()
                .host("localhost")
                .port(19530)
                .collectionName("rag_docs")
                .dimension(1024)
                .indexType(IndexType.HNSW)
                .metricType(MetricType.COSINE)
                .searchParams(Map.of("ef", 128)) // 设置搜索参数ef=128
                .build();
    }

    @Bean
    public EmbeddingStoreRetriever vectorRetriever(
            MilvusEmbeddingStore store, EmbeddingModel embeddingModel) {
        return EmbeddingStoreRetriever.builder()
                .embeddingStore(store)
                .embeddingModel(embeddingModel)
                .maxResults(20)
                .minScore(0.75)
                .build();
    }
}

设计意图解读maxResults(20) 控制返回候选数,minScore 过滤低相关度文档。这里的 20 条会参与后续融合,给予 BM25 和图谱足够的上限空间。生产影响分析:如果 ef 设置过低且 minScore 较高,可能导致向量路返回空,完全丢失语义信号,因此 minScore 不宜超过 0.8。

2.2 BM25 路:Elasticsearch 关键词检索

Elasticsearch 8.x 中,我们使用 RestHighLevelClient(虽然官方推荐新客户端,但 RHLC 仍在广泛使用,且 LangChain4j 已有适配)。构建基于 BM25 的 match 查询,同时可以利用 term 查询做精确匹配。配置 EmbeddingStore 的另一种方案是直接使用 ElasticsearchEmbeddingStore,但对于 BM25 纯文本检索,我们可以实现一个自定义的 Retriever,直接调用 ES 的 REST API。

@Service
public class BM25Retriever {
    private final RestHighLevelClient client;

    public List<TextSegment> search(String query, int maxResults) throws IOException {
        SearchRequest request = new SearchRequest("documents");
        SearchSourceBuilder sourceBuilder = new SearchSourceBuilder();
        sourceBuilder.query(QueryBuilders.matchQuery("content", query));
        sourceBuilder.size(maxResults);
        request.source(sourceBuilder);

        SearchResponse response = client.search(request, RequestOptions.DEFAULT);
        return Arrays.stream(response.getHits().getHits())
                .map(hit -> TextSegment.from(hit.getSourceAsString()))
                .collect(Collectors.toList());
    }
}

BM25 在处理专有名词、缩写(如“API”“DTO”)和精确短语时表现优异,弥补了向量检索泛化导致的漏召。但它的局限也很明显:无法理解同义词和跨语言的语义关联。

2.3 图谱路:Neo4j 实体检索

对于需要多跳推理的场景,例如“导致某零件故障的根本原因是什么?”,文档可能分别存储“零件A故障现象”和“零件A由材料B制造,材料B在高温下失效”,普通向量或关键词检索难以将两者关联。引入知识图谱,通过 Neo4j 存储实体和关系,使用 cypher 查询补充召回。简单实现:

@Service
public class GraphRetriever {
    private final Neo4jClient neo4jClient;

    public List<Document> retrieve(String queryText) {
        // 简化示例:提取实体并查询相关节点
        String cypher = "MATCH (e:Entity)-[r:RELATED_TO]->(e2) WHERE e.name CONTAINS $keyword RETURN e2.name, r.type";
        return neo4jClient.query(cypher)
                .bindAll(Map.of("keyword", extractKeyword(queryText)))
                .fetch()
                .all()
                .stream()
                .map(record -> new Document(record.get("e2.name").toString()))
                .collect(Collectors.toList());
    }
}

实际落地中,图谱路常作为补充召回源,尤其适用于因果链、事理图谱等特定领域。

2.4 并行架构设计与实现

三路召回如果串行执行,总延迟会累加(30ms 向量 + 20ms BM25 + 50ms 图谱 ≈ 100ms),且相互阻塞。使用 CompletableFuture 并行请求,并设置超时,避免某路拖慢整体。以下是 RetrievalService 的核心实现:

@Service
public class RetrievalService {
    private final EmbeddingStoreRetriever vectorRetriever;
    private final BM25Retriever bm25Retriever;
    private final GraphRetriever graphRetriever;
    private final ExecutorService executor = Executors.newFixedThreadPool(10);

    public MultiRecallResult multiRecall(String query) {
        // 向量召回异步
        CompletableFuture<List<Document>> vectorFuture = CompletableFuture
                .supplyAsync(() -> vectorRetriever.findRelevant(query), executor)
                .orTimeout(200, TimeUnit.MILLISECONDS)
                .exceptionally(ex -> {
                    log.error("Vector recall failed", ex);
                    return List.of();
                });

        // BM25召回异步
        CompletableFuture<List<Document>> bm25Future = CompletableFuture
                .supplyAsync(() -> {
                    try {
                        return bm25Retriever.search(query, 20);
                    } catch (IOException e) {
                        throw new RuntimeException(e);
                    }
                }, executor)
                .orTimeout(200, TimeUnit.MILLISECONDS)
                .exceptionally(ex -> {
                    log.error("BM25 recall failed", ex);
                    return List.of();
                });

        // 图谱召回异步
        CompletableFuture<List<Document>> graphFuture = CompletableFuture
                .supplyAsync(() -> graphRetriever.retrieve(query), executor)
                .orTimeout(200, TimeUnit.MILLISECONDS)
                .exceptionally(ex -> {
                    log.error("Graph recall failed", ex);
                    return List.of();
                });

        // 等待所有完成,并收集结果
        CompletableFuture<Void> allFutures = CompletableFuture.allOf(
                vectorFuture, bm25Future, graphFuture);
        try {
            allFutures.get(500, TimeUnit.MILLISECONDS); // 总超时限制
        } catch (TimeoutException e) {
            log.warn("Multi-recall overall timeout, return partial results");
        }

        return new MultiRecallResult(
                vectorFuture.getNow(List.of()),
                bm25Future.getNow(List.of()),
                graphFuture.getNow(List.of()));
    }
}

设计意图解读:每个召回支路使用独立的 CompletableFuture 和超时,防止某个存储不可用或慢查询导致整体不可用。exceptionally 保证了即使某路抛异常,也能正常返回空列表,不会中断整个流程。

生产影响分析:①若 timeout 设置小于某路召回的 P95 延迟(如向量路偶尔 250ms),则会导致该路频繁空返回,降低召回率;②线程池大小需根据并发请求量调整,避免线程饥饿;③并行化使得 TP99 延迟约等于最慢一路的延迟(而非三路之和),实测从串行 110ms 降至 28ms(向量最慢)。未设置并行超时将导致某路 hang 住时整个检索请求超时,引发雪崩。

下图展示了并行执行的时序与异常处理流程。

sequenceDiagram
    participant Client
    participant RetrievalService
    participant VectorFuture
    participant BM25Future
    participant GraphFuture
    participant Milvus
    participant ES
    participant Neo4j

    Client->>RetrievalService: multiRecall(query)
    RetrievalService->>VectorFuture: supplyAsync
    RetrievalService->>BM25Future: supplyAsync
    RetrievalService->>GraphFuture: supplyAsync
    VectorFuture->>Milvus: search(ef=128)
    BM25Future->>ES: match query
    GraphFuture->>Neo4j: cypher
    Milvus-->>VectorFuture: results (28ms)
    ES-->>BM25Future: results (20ms)
    Neo4j-->>GraphFuture: results (45ms)
    VectorFuture-->>RetrievalService: list
    BM25Future-->>RetrievalService: list
    GraphFuture-->>RetrievalService: list
    RetrievalService-->>Client: MultiRecallResult
    Note over RetrievalService,GraphFuture: 任何一路超时(200ms)则返回空列表

图2 三路召回并行执行时序图

  • a) 主旨概括:展示了RetrievalService通过三个异步任务并行调用向量、BM25和图谱存储,并处理各自的超时与异常,最终聚合结果。
  • b) 逐元素分解:①RetrievalService 作为协调者,同时触发三路调用;②每个 Future 独立与后端存储交互,设置 200ms 超时;③即使某路超时或异常,也不会取消其他任务,通过 exceptionally 降级为空列表;④最后通过 allOf 等待,并设置总超时,确保整体响应时间可控。
  • c) 设计原理映射:并行调用采用了分支-合并模式,通过 CompletableFuture 的异步特性实现;而各路召回策略则是策略模式的体现,每种检索算法被封装成独立可替换的组件。
  • d) 工程联系与关键结论误配置案例:未设置 orTimeout,当 Neo4j 因 GC 暂停导致响应延迟 5 秒时,整个检索接口卡死,上游网关超时导致连锁失败。关键结论:并行化必须配合超时和异常隔离,生产环境应结合熔断器(如 Resilience4j)防止故障扩散。

三路召回贡献率分析

我们在内部测试集(1000 条查询)上统计各路的唯一召回贡献(即某相关文档只被该路召回,而其他路未召回的比例):

召回路径单独命中相关文档的比例唯一贡献率
向量路78%12%
BM25路65%8%
图谱路30%3%

可以看出,虽然图谱路贡献较低,但对于特定类型问题(如因果推理)价值显著。多路并行使整体召回率达到 89%,远高于单路最高的 78%。


3. 融合排序:RRF、线性加权与 LTR 的 Java 落地

获得三路独立的排序列表后,如何将它们合并成一张统一的相关性榜单?我们需要一个融合策略。本文将实现并对比三种方案:RRF(倒数排名融合)、线性加权融合、以及简要介绍基于学习排序(LTR)的工程化方案。

3.1 倒数排名融合(RRF)实现

RRF 公式:score(doc) = Σ 1/(k + rank_i(doc)),其中 rank_i 是文档在第 i 路中的排名(从 1 开始),k 是平滑常数,经典取值为 60。RRF 的优点是无须知道各路分数分布,对排名合并鲁棒性强。下面给出 RRFMerger 核心实现:

public class RRFMerger {
    public static List<Document> merge(Map<String, List<Document>> results, int k, int topK) {
        Map<String, Double> docScores = new LinkedHashMap<>();
        for (Map.Entry<String, List<Document>> entry : results.entrySet()) {
            List<Document> docs = entry.getValue();
            for (int i = 0; i < docs.size(); i++) {
                String docId = docs.get(i).id(); // 假设Document有唯一ID
                double score = 1.0 / (k + i + 1); // rank = i+1
                docScores.merge(docId, score, Double::sum);
            }
        }
        return docScores.entrySet().stream()
                .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
                .limit(topK)
                .map(e -> new Document(e.getKey(), e.getValue()))
                .collect(Collectors.toList());
    }
}

设计意图解读:通过倒数和的方式,排名靠前的文档获得更高权重,且不同召回列表长度不同也不会影响公平性。k=60 被广泛验证能稳定排序,避免 k 过小导致第一名优势过大,或过大导致区分度下降。实验表明,k=60 在我们的数据集上 NDCG@10 最优。

生产影响分析:RRF 不需要访问原始分数,但要求各路返回的文档必须有全局唯一 ID,否则跨路去重会出错。内存消耗与返回文档数成正比,单路最多 20 条时基本无性能问题。

3.2 线性加权融合

当各路都能提供归一化的置信度分数时,线性加权能更精细地控制信号。实现步骤:

  1. 分数归一化:对各路分数进行 Min-Max 归一化,使分数落于 [0,1]。
  2. 加权求和score(doc) = w_vec * norm_score_vec + w_bm25 * norm_score_bm25 + w_graph * norm_score_graph
  3. 权重调优:通过网格搜索或 Optuna 在验证集上最大化 NDCG@10。例如我们搜索得到最优权重为向量 0.6、BM25 0.3、图谱 0.1。
public class WeightedFusionMerger {
    private final double wVector;
    private final double wBm25;
    private final double wGraph;

    public List<Document> merge(List<Document> vecDocs, List<Document> bm25Docs, 
                                List<Document> graphDocs, int topK) {
        // 归一化各分数 (略去具体实现)
        Map<String, Double> vecScoreMap = normalize(vecDocs);
        Map<String, Double> bm25ScoreMap = normalize(bm25Docs);
        Map<String, Double> graphScoreMap = normalize(graphDocs);

        Map<String, Double> fused = new HashMap<>();
        vecScoreMap.forEach((id, s) -> fused.merge(id, wVector * s, Double::sum));
        bm25ScoreMap.forEach((id, s) -> fused.merge(id, wBm25 * s, Double::sum));
        graphScoreMap.forEach((id, s) -> fused.merge(id, wGraph * s, Double::sum));

        return fused.entrySet().stream()
                .sorted(Map.Entry.<String, Double>comparingByValue().reversed())
                .limit(topK)
                .map(e -> new Document(e.getKey(), e.getValue()))
                .collect(Collectors.toList());
    }
}

设计意图解读:加权融合要求各路提供有意义的分数,这限制了无法提供分数的召回源(如纯关键词匹配无内置分数)。对于 ES 的 _score 和 Milvus 的 distance,需注意尺度差异,务必归一化。

生产影响分析:权重的选择对最终效果非常敏感。使用硬编码权重在业务变化时可能失效,建议通过离线实验持续更新,或引入简单的在线反馈调整机制。

3.3 基于学习排序(LTR)的融合

当特征维度增加(如文档质量分、来源权威度、历史点击率等),线性加权难以捕获非线性关系。LTR 模型(如 LambdaMART)可将各路得分和额外特征作为输入,训练一个重排序模型。工程上通常将模型导出为 PMML 或 ONNX,然后在 Java 中加载推理。由于篇幅和本系列聚焦检索管道,详细模型训练与部署将在第 6 篇(重排序技术)中展开。此处仅强调:LTR 的延迟通常高于 RRF 和加权(单次推理 ~5-10ms),但 NDCG 提升显著(可达 10%+)。适用于对精度要求极高且可容忍少量额外延迟的场景。

3.4 三种融合方案对比

我们在 5000 条查询的测试集上评估了三种方案(不包含自查询过滤,仅多路召回+融合):

融合方案NDCG@10MRR平均融合延迟(ms)配置复杂度
单路向量0.620.580 (无融合)
RRF (k=60)0.710.67<1极低
线性加权0.730.69<1
LTR (LambdaMART)0.780.748

图3 三种融合策略架构与数据流对比图

flowchart TD
    subgraph RRF[RRF融合]
        A1[多路结果] --> B1[计算1/k+rank]
        B1 --> C1[合并得分]
        C1 --> D1[排序输出]
    end
    subgraph Weighted[线性加权]
        A2[多路结果+原始分数] --> B2[分数归一化]
        B2 --> C2[加权求和]
        C2 --> D2[排序输出]
    end
    subgraph LTR[LTR融合]
        A3[多路结果+特征] --> B3[特征提取]
        B3 --> C3[模型推理PMML/ONNX]
        C3 --> D3[排序输出]
    end
  • a) 主旨概括:对比了RRF、线性加权和LTR三种融合方案的数据流。RRF仅依赖排名,加权依赖归一化分数,LTR依赖额外特征和模型推理。
  • b) 逐元素分解:①RRF无需原始分数,直接将排名转换为倒数和,简单高效;②线性加权需要各路提供置信分数,并经过归一化后加权求和;③LTR引入外部特征(文档质量、点击率等),通过机器学习模型计算最终得分,流程最复杂。
  • c) 设计原理映射:三种方案都可作为策略接口的不同实现,可以在运行时通过配置切换。RRF和加权属于无监督组合,LTR属于有监督融合
  • d) 工程联系与关键结论误配置案例:在生产中直接使用加权融合但未归一化分数,由于 Milvus 返回的余弦相似度在 0.7-1.0 之间,而 BM25 的 _score 可能达到几十,导致 BM25 主导排序,语义信号被淹没。关键结论:RRF 是无参稳健的基线选择,建议任何项目优先部署 RRF,然后根据离线评估决定是否升级为加权或 LTR。

4. 自查询检索:从自然语言到元数据过滤

当用户查询“上周市场部发布的设计规范”时,我们需要将“市场部”“上周”“设计规范”这些自然语言约束转换为结构化的元数据过滤条件,并下推到向量数据库或搜索引擎。这就是自查询检索(Self-Querying)。

4.1 Prompt 模板设计

我们使用 GPT-4o-mini(temperature=0)来生成过滤 JSON。Prompt 需要注入可用的元数据 Schema,并给出 Few-Shot 示例。示例如下:

System: 你是一个查询分析助手。根据用户问题,生成一个过滤条件的JSON对象。可用字段:
- doc_type (string): 文档类型,如“设计规范”“合同”
- department (string): 发布部门
- publish_date (date): 发布日期,格式YYYY-MM-DD
- amount (number): 合同金额(万元)

输出严格JSON,不包含任何解释。如果没有过滤条件,返回{}。

示例1:
用户: 财务部去年的合同
输出: {"department": "财务部", "publish_date": {">=": "2023-01-01", "<=": "2023-12-31"}, "doc_type": "合同"}

示例2:
用户: 最新的设计规范
输出: {"doc_type": "设计规范"}

4.2 SelfQueryService 实现与安全校验

@Service
public class SelfQueryService {
    private final ChatLanguageModel llm;
    private final Set<String> ALLOWED_FIELDS = Set.of("doc_type", "department", "publish_date", "amount");
    private final ObjectMapper mapper = new ObjectMapper();

    public Map<String, Object> generateFilter(String query) {
        String prompt = buildPrompt(query);
        String jsonStr = llm.generate(prompt);
        try {
            Map<String, Object> filter = mapper.readValue(jsonStr, Map.class);
            // 校验字段白名单
            for (String key : filter.keySet()) {
                if (!ALLOWED_FIELDS.contains(key)) {
                    log.warn("Illegal field generated: {}, fallback to empty filter", key);
                    return Map.of();
                }
                // 类型校验与注入防御 (示例略)
            }
            return filter;
        } catch (Exception e) {
            log.error("Self-query generation failed, fallback to empty filter", e);
            return Map.of();
        }
    }

    // 转换为Milvus expr
    public String toMilvusExpr(Map<String, Object> filter) {
        // 简化实现: doc_type == "设计规范" && publish_date >= 1716163200
        StringBuilder expr = new StringBuilder();
        filter.forEach((key, value) -> {
            if (value instanceof String) {
                expr.append(key).append(" == \"").append(value).append("\" && ");
            } else if (value instanceof Map) { // 处理范围
                // 处理 >=, <= 等
            }
        });
        // remove trailing " && "
        return expr.toString();
    }
}

设计意图解读SelfQueryService 先通过 LLM 生成 JSON,然后经过严格的字段白名单和类型校验,只有合法的过滤条件才会被转换为 Milvus expr 或 ES filter,否则自动降级为空过滤(即纯向量检索),保证了系统的健壮性。

生产影响分析:temperature=0 大幅提高 JSON 格式正确率,但仍可能出现非法字段或注入(如 "department": "'; DROP TABLE documents; --")。因此必须在应用层做严格校验,不能信任 LLM 的输出。同时,应监控自查询成功率,当成功率低于阈值(如 95%)时告警。

4.3 自查询检索的完整交互时序

sequenceDiagram
    participant User
    participant Agent
    participant LLM
    participant Validator
    participant Milvus/ES

    User->>Agent: "上周市场部发布的设计规范"
    Agent->>LLM: generateFilter(query, schema)
    LLM-->>Agent: {"department":"市场部","doc_type":"设计规范","publish_date":{">=":"2025-05-19","<=":"2025-05-25"}}
    Agent->>Validator: validate(filter)
    Validator-->>Agent: filter passed
    Agent->>Milvus/ES: search(vector, expr="doc_type==\"设计规范\" && ...")
    Milvus/ES-->>Agent: filtered results
    Agent-->>User: Top-K documents

图4 自查询检索完整交互时序图

  • a) 主旨概括:描述了从用户自然语言查询到生成过滤条件、校验、并应用于向量/关键词检索的全过程。
  • b) 逐元素分解:①Agent 调用 LLM 并传入可用字段 Schema 和查询;②LLM 返回结构化 JSON;③Validator 进行字段白名单、类型、注入校验;④校验通过后构建存储层过滤表达式,嵌入检索请求;⑤检索返回满足元数据条件的文档。
  • c) 设计原理映射:自查询模块是管道-过滤器中的过滤阶段,LLM 承担了自然语言到结构化查询的转换器角色,安全校验则是装饰器模式,在不改变 LLM 调用接口的情况下增加防御层。
  • d) 工程联系与关键结论误配置案例:若 Schema 中包含过多无用字段,LLM 可能生成不必要的过滤条件,导致零结果;或者某个字段未在存储层建立索引,使得过滤扫描全表带来性能灾难。关键结论:自查询检索能大幅提升复杂查询的准确率,但必须配合严格的校验和降级策略,并在元数据字段上建立有效索引。

5. 辅助优化:查询改写与子问题拆分

多轮对话中的指代消解和隐式意图是 RAG 的另一大挑战。我们使用 LangChain4j 的 ChatMemory 存储历史,通过 LLM 改写查询。

@Service
public class QueryRewriter {
    private final ChatLanguageModel llm;
    private final ChatMemory chatMemory;

    public String rewrite(String userMessage) {
        List<ChatMessage> history = chatMemory.messages();
        // 构建带历史的Prompt,要求LLM生成独立查询
        String prompt = "基于对话历史,将用户问题重写为独立的、明确的查询。历史:" + history + "\n用户:" + userMessage;
        return llm.generate(prompt);
    }
}

此外,对于复合问题,可将其拆分为多个子问题分别检索,最后合并结果。例如“苹果手机的最新款与上一代有什么升级?”可拆为“苹果手机最新款是什么?”和“上一代苹果手机规格”,然后融合文档。

这些优化可作为多级检索管道的预处理步骤,进一步提升召回质量。


6. 检索评估与调优实验(含量化数据)

没有评估的检索架构是盲目的。我们设计了一套离线评估流程:

  1. 测试集构建:人工标注 2000 条 (Query, Relevant_Doc_IDs) 对,覆盖专有名词、多条件筛选、模糊查询等场景。
  2. 指标计算:使用 Hit Rate@K(K=10)、MRRNDCG@10
  3. 消融实验:逐步叠加策略,评估净收益。
flowchart LR
    TestSet[标注测试集] --> Run[执行检索管道]
    Run --> Metrics[计算Hit Rate/MRR/NDCG]
    Metrics --> Compare[对比不同策略组合]
    Compare --> Insight[调优建议]

图5 检索评估离线实验流程与指标对比图

实验结果如下:

策略组合Hit Rate@10MRRNDCG@10
单路向量0.700.550.62
向量+BM25 (无融合)0.800.630.68
向量+BM25+RRF融合0.850.690.73
向量+BM25+图谱+RRF融合0.870.710.75
以上 + 自查询过滤 + 查询改写0.950.820.85
  • a) 主旨概括:展示了离线评估的流水线和多组策略的量化指标对比,清晰呈现每一步优化的收益。
  • b) 逐元素分解:①离线流程包括标注数据、运行检索、计算三项核心指标;②实验数据表明,引入 BM25 召回率提升 10%,RRF 融合再提升 5%,图谱贡献额外 2%,自查询和改写带来最后 8% 的显著提升;③MRR 和 NDCG 同步增长,说明排序质量也在改善。
  • c) 设计原理映射:评估体系实现了检索策略的可观测性,使得策略选择从经验主义转向数据驱动,符合持续优化理念。
  • d) 工程联系与关键结论误配置案例:仅依靠 Hit Rate 评估,忽略排序质量指标(MRR/NDCG),可能导致融合策略的排序效果差,用户真正想要的文档排在后面,影响体验。关键结论:务必采用多指标评估,尤其 NDCG 能综合反映排序质量。自查询+改写是投入产出比极高的优化,因为过滤和消歧在检索前完成,减少了无效计算。

7. 贯穿案例:企业知识库的多级检索演进

某企业内部知识库最初采用纯向量检索,暴露出召回率低下、精确查询失效等问题。其检索管道经历了三个阶段的演进:

  • 阶段1(初始):纯向量检索 → 召回率不足 70%,如搜索“ESG”返回大量无关文档。
  • 阶段2(优化):加入 BM25 双路召回 + RRF 融合 → 召回率提升至 85%,但“2024年财务报告”这类复杂条件仍无法准确过滤。
  • 阶段3(完善):引入自查询过滤(部门、日期、文档类型)和查询改写 → 召回率稳定在 95%,NDCG@10 提升 22%,用户搜索满意度大幅提高。

失败场景推演:在阶段3上线初期,自查询 LLM 因负载偶尔超时或生成非法 JSON(如字段名拼写错误)。监控告警触发后,系统自动降级为空过滤,回退到多路召回+RRF 方案,保证基础可用性。后续优化了 LLM 资源配额,并将自查询成功率从 92% 提升至 99.5%。

flowchart LR
    subgraph Stage1[阶段1: 纯向量]
        S1[查询] --> V1[向量检索]
    end
    subgraph Stage2[阶段2: 双路+RRF]
        S2[查询] --> V2[向量]
        S2 --> B2[BM25]
        V2 --> R2[RRF融合]
        B2 --> R2
    end
    subgraph Stage3[阶段3: 完整管道]
        S3[查询] --> QW[查询改写]
        QW --> SQ[自查询过滤]
        SQ --> V3[向量]
        SQ --> B3[BM25]
        SQ --> G3[图谱]
        V3 --> R3[RRF融合]
        B3 --> R3
        G3 --> R3
        R3 --> TopK[候选集]
    end
    Stage1 --> Stage2 --> Stage3

图6 企业知识库检索管道演进架构对比图

  • a) 主旨概括:形象对比了从单路向量到完整多级检索管道的演进,体现了架构逐步增强的过程。
  • b) 逐元素分解:①阶段1仅有单路向量,结构简单但效果有限;②阶段2增加BM25和RRF融合,构成双路互补;③阶段3在前置加入查询改写和自查询过滤,并扩展图谱路,形成当前最优架构。
  • c) 设计原理映射:演进过程符合增量演进原则,每一步都可以独立评估收益。架构设计上采用了管道-过滤器,新组件可插拔添加。
  • d) 工程联系与关键结论误配置案例:阶段2上线时未配置自查询降级,当过滤条件导致零结果时直接返回空,引发用户投诉。关键结论:任何新增组件都必须设计降级策略,确保核心检索链路不受影响。演进过程中保留开关,能快速回滚到前一阶段是生产稳定性的保障。

8. 与前后系列的衔接

  • 前接嵌入模型与切片策略:本文的多路召回依赖前文部署的 BGE v1.5 嵌入模型生成高质量向量,同时文档切片粒度(大小块)直接决定向量和 BM25 的检索粒度。在多路召回后,可通过重排序后返回父块(大块)来增强上下文连贯性。
  • 后启精排(第6篇):融合排序输出的 Top-K 候选集(如 20 篇)将作为 Cross-Encoder 精排模型的输入,进一步压榨相关性。本文的 RRF 和加权融合可视为粗排,与精排构成级联排序架构。

9. 面试高频专题

以下问题均基于实际大厂面试深度设计,独立于正文,帮助读者巩固并检测理解。

Q1: 多路召回中,为什么要同时使用向量检索和 BM25?各自的适用场景是什么?
A: 向量检索擅长语义泛化,处理同义词和长文本,但面对专有名词和短查询时容易迷失方向;BM25 基于精确词频统计,对关键词和缩写匹配极准,但无法理解语义。两者互补能覆盖更全面的查询意图。例如“ESG报告”,BM25 直接命中,而向量可能关联到环境相关文档但相关性低。追问:如果只有一路可用,你选哪个?为什么?:选择向量,因为多数查询为自然语言,泛化需求更高,但必须配合重排序补偿精确性。加分回答:可动态根据查询长度选择:短查询偏向 BM25,长查询偏向向量。

Q2: RRF 中的 k 值如何影响排序?为什么常用 60?
A: k 控制排名优势的衰减速度。k 越小,排名第一的权重极大,导致融合排序完全由第一名决定;k 越大,所有排名权重趋近,区分度降低。经实验,k=60 在各种数据集上表现稳定,既能突出头部又保留一定平滑性。追问:如果某路召回结果质量很差(全是噪音),RRF 如何处理?:RRF 仅基于排名,如果该路给了某个噪音文档高排名,会不公正地提升其得分,因此前置的召回质量必须保障。加分回答:可结合各路的历史置信度,对低质量通路的结果进行降权或末尾截断,再输入 RRF。

Q3: LangChain4j 的 EmbeddingStoreRetriever 内部是如何调用 Milvus 的?
A: 它通过 EmbeddingStore.search(EmbeddingSearchRequest) 方法,底层调用 Milvus 客户端的 search API,传递向量和搜索参数。配置中的 searchParams(如 ef)会被注入到请求中。追问:如果需要动态调整 ef 值,怎么做?:自定义实现 Retriever,直接使用 Milvus 原生客户端,或扩展 EmbeddingStoreRetriever 重写相关方法。加分回答:可通过 AOP 或包装类,在运行时根据查询复杂度动态选择搜索参数,实现自适应检索。

Q4: 请写出自查询检索中,将 “2024年金额大于100万的合同” 转换为 Milvus expr 的过程。
A: LLM 生成过滤 {"doc_type":"合同","year":2024,"amount":{">=":100}}。转换 expr:doc_type == "合同" && year == 2024 && amount >= 100。注意字段需为 Milvus 中定义的类型,日期如为时间戳需转换。追问:如果用户说“去年”,LLM 如何知道具体年份?:在 Prompt 中注入当前日期,让 LLM 计算,或由应用层预处理后将具体日期替换进查询再送入 LLM。加分回答:可维护一个相对日期解析器,先转为具体日期范围,然后直接用范围构建过滤,降低 LLM 出错概率。

Q5: 多路召回并行中,如果某一路频繁超时,如何不影响整体 SLA?
A: 设置合理超时并通过 CompletableFuture.orTimeout 隔离。同时,引入熔断器(如 Resilience4j),当一路连续超时达到阈值时,短时间内将其临时剔除,只使用剩余路召回,定时探测恢复。追问:剔除后会不会降低召回率?:会有一定下降,但优于被拖垮导致整个服务不可用,属于有损降级。加分回答:监控各路召回贡献率和超时率,自动调整是否启用某路,实现弹性召回。

Q6: 线性加权融合时,如何自动调优权重?
A: 使用离线标注数据集,定义目标指标(如 NDCG@10),使用网格搜索或贝叶斯优化(Optuna)搜索最佳权重组合。将验证结果最佳权重部署上线,并定期重新调优。追问:线上效果衰退怎么办?:监控线上指标,结合用户反馈(点击率)建立闭环,触发重新训练或调优。加分回答:引入多臂老虎机在线学习权重,实时适应分布变化。

Q7: 自查询 LLM 生成非法 JSON 导致解析失败的降级方案是什么?
A: 捕获异常后返回空过滤 {},降级为纯多路召回,确保检索可用。同时记录日志和告警。追问:降级后性能是否受影响?:过滤失效可能返回较多不相关文档,增加后续重排序压力,但整体可接受。加分回答:可缓存近期成功生成的过滤条件,当相同查询再次出现时直接复用,避免重复调用 LLM。

Q8: 简述 MRR 和 NDCG 的区别,为什么评估排序时 NDCG 更全面?
A: MRR 只看第一个相关文档的排名倒数,适用于用户极度关注首位结果;NDCG 考虑所有相关文档的位置和分级相关性,更符合多文档需求。NDCG 通过折损累加,能够区分“相关文档排在 2、3 名”和“排在 10、11 名”的差异。追问:如果只有二值相关度(0/1),NDCG 还有意义吗?:有意义,此时退化为基于排名的折损累积增益,仍能体现排序质量。加分回答:实际中使用 NDCG@10 作为主要优化指标,因为前 10 结果对 RAG 最终答案生成影响最大。

Q9: 知识图谱召回在 RAG 中的典型应用场景是什么?工程上如何避免查全率低?
A: 适用于需要多跳推理和实体关联的查询,例如故障根因分析、供应链关系等。工程上可采用实体识别提取查询中的实体,然后检索子图;同时与向量路融合,弥补图谱覆盖不全的缺陷。追问:如何保证图谱召回的延迟?:限制跳数(1-2 跳),对热点实体预先缓存,必要时使用并行子查询。

Q10: 在处理海量文档时,如何保证 RRF 融合的效率?
A: RRF 计算复杂度 O(N),其中 N 为各路返回文档总数,通常很小(如 60 个),不会成为瓶颈。但去重时依赖 ID 查找,使用 HashMap 即可。追问:如果结果文档 ID 设计不当导致大量哈希碰撞?:使用全局唯一的 UUID 作为 ID,HashMap 性能有保障。加分回答:极端场景下可考虑使用数组排序后合并的方式,但代码复杂度增加,一般不必要。

Q11: 使用 LangChain4j 的 RetrievalAugmentor 如何集成自定义的多路召回和融合?
A: RetrievalAugmentor 默认使用单一的 Retriever,我们可以自定义一个 CompositeRetriever 实现 Retriever<TextSegment> 接口,内部完成多路召回和融合,然后注入到 RetrievalAugmentor追问:这样是否破坏原有流程?:不会,因为它遵循依赖倒置,高层模块只依赖 Retriever 接口,我们的复合实现可以无缝替换。

Q12: 如何防止自查询生成的过滤条件导致 Milvus 全表扫描?
A: 确保过滤字段在 Milvus 中建立了标量索引。Milvus 支持对常用标量字段创建索引(如字典树),如果没有索引,expr 过滤会触发暴力扫描,严重影响性能。追问:如果过滤字段很多,是否都要索引?:权衡存储和查询性能,通常为高频过滤字段建索引。加分回答:可在 Schema 设计阶段根据业务预期过滤模式,选择性建立索引。

Q13: 请描述 RAG 检索中台的系统设计,要求支持高并发下的多路召回、自动融合和自查询过滤。
A: 设计如下:

  • 接入层:Spring Boot 网关,负责鉴权、限流。
  • 检索编排层RetrievalOrchestrator 接收查询,调用自查询服务(有缓存)生成过滤条件,然后并行分发至向量服务、BM25 服务和图谱服务。
  • 融合层:可配置的融合策略(默认 RRF),异步聚合后排序。
  • 降级机制:每路调用设置超时与熔断,自查询失败降级为空过滤;整体检索超过总超时返回当前已获得结果。
  • 评估反馈:异步记录检索日志,离线评估模型持续优化参数。 架构图如下:省略(见正文)。追问:两路召回同时超时的极端场景如何降级?:若向量和 BM25 均超时,可尝试返回基于缓存的流行查询结果,或提示用户稍后重试。系统内部,应快速失败并释放资源,避免堆积。加分回答:引入备用搜索引擎(如 Elasticsearch 的简单全文检索)作为终极兜底,保障可用性。

Q14: 相比使用 Google Vertex AI Search 或 Cohere rerank API,自建多路召回+融合方案的优势和劣势是什么?
A: 优势是完全控制数据隐私、可定制性强、长期成本可能更低;劣势是开发维护成本高、需要持续调优。对于敏感数据和独特领域,自建是必选。追问:如何结合 Cohere 的 rerank API?:在融合排序后,将 Top-20 结果发送至 Cohere rerank,利用其精排能力进一步提升相关性,作为精排的替代或补充。


检索策略速查表

组件/策略核心方法/类关键参数适用场景
向量召回EmbeddingStoreRetrievermaxResults, minScore, ef长文本语义匹配
BM25召回RestHighLevelClientmatchQuery, size专有名词、短查询
图谱召回Neo4jClientcypher, 实体提取多跳推理、因果链
并行召回CompletableFutureorTimeout(200ms), 线程池所有多路场景
RRF融合RRFMergerk=60, topK无原始分数的快速融合基线
线性加权融合WeightedFusionMerger归一化方法, 权重 (0.6,0.3,0.1)可提供置信分数的各路
自查询过滤SelfQueryService + LLM元数据 Schema, 校验白名单复杂条件筛选
查询改写QueryRewriter + ChatMemory历史窗口大小多轮对话指代消解
评估指标自定义评估器Hit Rate@10, MRR, NDCG@10所有策略效果量化

延伸阅读

至此,你已掌握从多路召回到融合排序、再到自查询过滤的完整检索引擎设计能力。这些策略可根据你的业务场景灵活组合,支撑起生产级 RAG 系统的检索核心。下一篇文章,我们将进入检索管道的最后一环——重排序技术,解读 Cross-Encoder 模型如何在极短候选集上压榨出最后的精确率。