【从零理解 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 这几乎是必配) |
| 关键词多义、同义改写频繁漏召回 | 加查询扩展 |
调优顺序永远是:先把切分和基础检索磨好 → 出现明确的质量瓶颈 → 再按瓶颈加对应的层。 上来就堆满所有模块的管线,出问题时你都不知道该怪谁。
五、这篇的四个要点
- 向量检索够快但不够准------它分清"话题相近",分不清"是否回答了问题"。
- 两段式检索------宽召回(topK 放大)保不漏,精排序(逐对打分)保排得准,最后截 top5 省钱降噪。
- 检索前的两个优化------查询改写(多轮对话近乎必配)与查询扩展(防漏召回),Spring AI 的 RAG 模块都有现成实现。
- 按瓶颈加层,不要堆满------每一层都是钱和延迟,先磨切分和基础检索。
延伸思考
到这里,第四辑把"让模型知道得更多"讲完了:闭卷变开卷、四环节流水线、宽召回加精排。
回头看这 15 篇:模型能稳定对话(第二辑)、能做操作(第三辑)、有据可答(第四辑)------每个能力都是挂在这条 Advisor 链上的一环。
最后一辑回答终极问题:这些能力拼在一起,怎么组织成"能自主完成任务的智能体"?什么该交给模型决定,什么必须写死?
下一篇
《Agent 与工作流:什么该写死,什么该让模型决定》------第五辑开始。流程固定的工作流和动态决策的 Agent,边界怎么划、代价各是什么。
本专栏按 Spring AI 当前版本讲解。