【从零理解 Spring AI · 15】让检索更准:先宽召回,再精排序

【从零理解 Spring AI · 15】让检索更准:先宽召回,再精排序

写在开头

四个环节都打磨到位后,RAG 的回答质量会上一个台阶。但很快你会遇到下一层瓶颈:

检索回来的 5 段里,真正回答问题的只有 1 段,剩下 4 段是"话题相近但没用"的噪声------它们占着上下文的空间(费钱)、稀释模型的注意力(答不准)。

这一篇讲怎么把"检索质量"再往上拧一档:先宽召回,再精排序 。以及同样重要的另一面------什么时候别加这层。


一、先理解向量检索"够快但不够准"

向量检索比的是语义坐标的距离。它很快(专门的向量索引),但它的判断粒度是粗的:

它知道两段话话题相近,却分不清**"真正回答了问题"和"只是提到了同一个词"**。

举例:问题"退货要付运费吗?"

复制代码
段落 A(相关):退货时若非质量问题,运费由买家承担......        ← 真正回答了
段落 B(噪声):退货流程请在前台提交申请,审核需 3 个工作日...... ← 提到退货,没答运费

在向量空间里,A 和 B 的距离可能非常接近------都和"退货"强相关。向量检索负责把"A 和 B 都捞回来",但"哪段才是答案"它判断不了。


二、解法:两段式检索(宽召回 + 精排序)

漏斗:宽召回保不漏,精排保管得准

第一步:宽召回。 向量检索时把门槛放低、条数放大------取 top 50 而不是 top 5。宁可多捞,不可漏掉------漏掉的答案后面神仙也救不回来。

第二步:精排序(Rerank)。 用一个专门的重排模型 ,拿"问题 + 每个候选段落"逐对精细打分------它不像向量检索那样只比距离,而是真正去"读"内容和问题的匹配程度。打完分,把真正相关的顶到前面。

第三步:截取。 只取重排后的 top 5 塞进上下文。噪声被过滤,成本也降了。

类比 :向量检索像"海选"------凭第一印象快速筛出 50 人;重排像"面试"------挨个深聊后排出真正的名次。海选快而糙,面试准而慢,先海选再面试,两头都占。


三、在 Spring AI 里的落点

3.1 检索阶段的宽召回

第 14 篇的 SearchRequest 调低门槛、调大 topK 即可:

java 复制代码
// 宽召回:门槛放低、条数放大,给精排留足候选
SearchRequest searchRequest = SearchRequest.builder()
        .query("退货要付运费吗?")
        .similarityThreshold(0.5)   // 放低:宁可多捞
        .topK(50)                   // 放大:给精排留料
        .build();

List<Document> candidates = vectorStore.similaritySearch(searchRequest);
System.out.println("宽召回候选数:" + candidates.size()); // 期望接近 50

3.2 精排阶段:文档后处理器

Spring AI 的 RAG 模块里有一个 DocumentPostProcessor 接口------检索到文档之后、交给模型之前 的加工点。官方文档给它的定位就是:按相关性重排、剔除无关冗余文档、压缩内容降噪。接入一个重排模型实现这个接口,就能把精排插进管线。

下面是一个最小可用的重排实现(示意:调用重排模型逐对打分后重排并截断):

java 复制代码
public class RerankDocumentPostProcessor implements DocumentPostProcessor {

    private final RerankModel rerankModel; // 你的重排模型封装
    private final int topN;

    public RerankDocumentPostProcessor(RerankModel rerankModel, int topN) {
        this.rerankModel = rerankModel;
        this.topN = topN;
    }

    @Override
    public List<Document> process(Query query, List<Document> documents) {
        if (documents == null || documents.isEmpty()) {
            return documents;
        }
        // 1. 问题 + 每个候选段落逐对打分
        List<ScoredDocument> scored = rerankModel.score(query.getText(), documents);

        // 2. 按分数从高到低排序
        scored.sort(Comparator.comparingDouble(ScoredDocument::score).reversed());

        // 3. 只保留 topN,噪声在这里被过滤掉
        return scored.stream()
                .limit(topN)
                .map(ScoredDocument::document)
                .toList();
    }
}

把它挂到 Advisor 上,精排就正式进入管线:

java 复制代码
Advisor advisor = RetrievalAugmentationAdvisor.builder()
        .documentRetriever(VectorStoreDocumentRetriever.builder()
                .vectorStore(vectorStore)
                .similarityThreshold(0.5)   // 宽召回
                .topK(50)
                .build())
        .documentPostProcessors(new RerankDocumentPostProcessor(rerankModel, 5))
        .build();

注意:DocumentPostProcessor 的签名以你所用 Spring AI 版本为准,核心思路不变------输入候选列表,输出重排后的列表。

3.3 顺手的两个"前置优化"

精排解决"召回后的排序",还有两个问题出在召回之前 ,Spring AI 也给了现成模块(spring-ai-rag 依赖):

① 查询改写(RewriteQueryTransformer)------用户的原始问题往往啰嗦、含糊、夹带无关信息,直接拿去检索效果差。先用模型把问题改写成"适合检索的干净版本":

java 复制代码
// "我在学机器学习,那 LLM 是个啥?"
//   → 改写后:"什么是大语言模型(LLM)?"
Advisor advisor = RetrievalAugmentationAdvisor.builder()
        .queryTransformers(RewriteQueryTransformer.builder()
                .chatClientBuilder(chatClientBuilder.build().mutate())
                .build())
        .documentRetriever(VectorStoreDocumentRetriever.builder()
                .vectorStore(vectorStore)
                .build())
        .build();

② 查询扩展(MultiQueryExpander) ------把一个问题扩成多个语义不同的变体,多路检索再合并,提高"换种说法也能命中"的概率:

java 复制代码
Advisor advisor = RetrievalAugmentationAdvisor.builder()
        .queryTransformers(MultiQueryExpander.builder()
                .chatClientBuilder(chatClientBuilder.build().mutate())
                .numberOfQueries(3)   // 扩成 3 个变体
                .build())
        .documentRetriever(VectorStoreDocumentRetriever.builder()
                .vectorStore(vectorStore)
                .topK(10)
                .build())
        .build();

改写和扩展也可以叠加使用------先改写把问题变干净,再扩展成多路,最后统一交给精排:

java 复制代码
Advisor advisor = RetrievalAugmentationAdvisor.builder()
        .queryTransformers(
                RewriteQueryTransformer.builder()
                        .chatClientBuilder(chatClientBuilder.build().mutate())
                        .build(),
                MultiQueryExpander.builder()
                        .chatClientBuilder(chatClientBuilder.build().mutate())
                        .numberOfQueries(3)
                        .build())
        .documentRetriever(VectorStoreDocumentRetriever.builder()
                .vectorStore(vectorStore)
                .similarityThreshold(0.5)
                .topK(50)
                .build())
        .documentPostProcessors(new RerankDocumentPostProcessor(rerankModel, 5))
        .build();

💡 改写/扩展模型建议用低温度配置------检索前处理要的是确定性,不要发散。


3.4 一个完整的两段式检索管线

把前面几块拼起来,就是一条"改写 → 扩展 → 宽召回 → 精排 → 截取"的完整链路:

java 复制代码
@Bean
public Advisor ragAdvisor(ChatClient.Builder chatClientBuilder,
                          VectorStore vectorStore,
                          RerankModel rerankModel) {
    return RetrievalAugmentationAdvisor.builder()
            // 召回前:改写 + 扩展
            .queryTransformers(
                    RewriteQueryTransformer.builder()
                            .chatClientBuilder(chatClientBuilder.build().mutate())
                            .build(),
                    MultiQueryExpander.builder()
                            .chatClientBuilder(chatClientBuilder.build().mutate())
                            .numberOfQueries(3)
                            .build())
            // 宽召回:多捞
            .documentRetriever(VectorStoreDocumentRetriever.builder()
                    .vectorStore(vectorStore)
                    .similarityThreshold(0.5)
                    .topK(50)
                    .build())
            // 精排:重排后只留 top5
            .documentPostProcessors(new RerankDocumentPostProcessor(rerankModel, 5))
            .build();
}

调用侧完全不用改,还是那句熟悉的 chatClient.prompt().advisors(ragAdvisor)------复杂度被收在 Advisor 内部,业务代码保持干净。


四、什么时候别加这层

管线每一层都有成本(额外的模型调用 = 钱和延迟,额外的组件 = 维护负担)。诚实的建议:

情况 建议
资料量小(几百段),召回本来就准 别加,简单管线最好维护
资料量大、召回噪声明显 加重排
用户问题普遍啰嗦、含糊(多轮追问) 加查询改写(多轮 RAG 这几乎是必配)
关键词多义、同义改写频繁漏召回 加查询扩展

调优顺序永远是:先把切分和基础检索磨好 → 出现明确的质量瓶颈 → 再按瓶颈加对应的层。 上来就堆满所有模块的管线,出问题时你都不知道该怪谁。


五、这篇的四个要点

  1. 向量检索够快但不够准------它分清"话题相近",分不清"是否回答了问题"。
  2. 两段式检索------宽召回(topK 放大)保不漏,精排序(逐对打分)保排得准,最后截 top5 省钱降噪。
  3. 检索前的两个优化------查询改写(多轮对话近乎必配)与查询扩展(防漏召回),Spring AI 的 RAG 模块都有现成实现。
  4. 按瓶颈加层,不要堆满------每一层都是钱和延迟,先磨切分和基础检索。

延伸思考

到这里,第四辑把"让模型知道得更多"讲完了:闭卷变开卷、四环节流水线、宽召回加精排。

回头看这 15 篇:模型能稳定对话(第二辑)、能做操作(第三辑)、有据可答(第四辑)------每个能力都是挂在这条 Advisor 链上的一环。

最后一辑回答终极问题:这些能力拼在一起,怎么组织成"能自主完成任务的智能体"?什么该交给模型决定,什么必须写死?


下一篇

《Agent 与工作流:什么该写死,什么该让模型决定》------第五辑开始。流程固定的工作流和动态决策的 Agent,边界怎么划、代价各是什么。


本专栏按 Spring AI 当前版本讲解。

相关推荐
回家路上绕了弯12 分钟前
要人确认的那一步,在确认什么?
后端
IT_陈寒1 小时前
JavaScript项目实战经验分享:从内存泄漏到性能优化的血泪史
前端·人工智能·后端
杨凯凡1 小时前
Spring Boot 链路追踪实战:Micrometer、TraceId 与异步线程上下文传播
java·spring boot·后端
计算机毕设定制辅导-无忧学长2 小时前
《基于Spring Boot的迷游哈游戏综合服务平台的开发》
java·vue.js·spring boot·mysql·迷游哈游戏综合服务平台
非优秀程序员10 小时前
第07章 孙德良的阳谋|《互联网产品恩仇录》
javascript·后端
非优秀程序员10 小时前
第06章 浙江帮的内战|《互联网产品恩仇录》
后端
非优秀程序员10 小时前
第08章 马云的反击|《互联网产品恩仇录》
后端
非优秀程序员11 小时前
题记|《互联网产品恩仇录》
后端·程序员·创业
非优秀程序员11 小时前
第04章 邮箱大战,163 与 QQ 的暗战|《互联网产品恩仇录》
java·vue.js·架构