各位朋友大家好,我是一言,继续更新 Spring AI 2.0 系列。
前几篇文章已经跑过了 Tool、MCP 和 Agent Skills,今天来谈谈 Modular RAG。
普通的 RAG 会使用用户的问题检索向量库,然后把找到的信息交给模型来作答。用户只用一句话就提出了多个问题,一次检索就容易漏掉资料。
Modular RAG 把问题处理、文档检索、结果合并以及上下文组装都分解成了独立的模块。Spring AI 使用 RetrievalAugmentationAdvisor 将它们串联起来。
本文主要看 MultiQueryExpander,它可以将一个问题拆分成多条查询,然后分别去检索,再把结果合并起来。
记住三个词
- Direct:直接使用用户的原话进行检索。
- Rewrite:先把原话改成一条比较干净的查询。
- MultiQuery:把原来的句子拆成多条查询,每条各查一次。
TopK 表示每次检索返回多少份资料。TopK=1 就是只取最相似的一份。
同一道题做四遍
用户提问:
我那笔订单还没发货,现在不想要了,取消以后银行卡的钱多久能回来?
这句话的意思就是“能否取消”和“退款多久到账”。
本地测试环境如下:
| 项目 | 本次使用 |
|---|---|
| Java / Spring AI | Java 21 / Spring AI 2.0.1 |
| Chat Model | Qwen2.5 3B,负责拆问题和生成答案 |
| Embedding Model | Qwen3 Embedding 0.6B,负责向量检索 |
| VectorStore | SimpleVectorStore |
演示知识库只存放三份资料:
| 文件 | 关键内容 |
|---|---|
order-cancel.md | 待发货订单可以直接取消 |
refund-arrival.md | 银行卡退款 1~3 个工作日到账 |
invoice.md | 电子发票的申请和下载方式 |
invoice.md 是干扰项,用以检测无关资料是否会混入结果中。
模型集成测试命令:
RUN_REAL_MODEL_TEST=true mvn -Dtest=RealModelRagComparisonTest test
| 方案 | 命中资料 | 最终答案 | Chat 调用 |
|---|---|---|---|
| Direct Top 1 | 退款到账 | 只回答退款时间 | 1 |
| Direct Top 2 | 取消规则 + 退款到账 | 仍只回答退款时间 | 1 |
| Rewrite Top 1 | 退款到账 | 只回答退款时间 | 2 |
| MultiQuery,2 × Top 1 | 取消规则 + 退款到账 | 两个问题都回答了 | 2 |
Direct 只在最后调用模型;Rewrite 和 MultiQuery 在检索之前会多调用一次模型来处理问题。
MultiQuery 产生了两个查询词:
我那笔订单还没发货,现在不想要了,如何取消订单?
银行卡里的钱多久能退回来?
它们分别命中取消规则和退款时间,最后的答案是:
未发货订单取消成功后,
银行卡支付的退款会在1至3个工作日内到账。
Spring AI 如何把这条链路串起来
本文实际上分成了四步:
| 步骤 | 组件 | 作用 |
|---|---|---|
| 1 | MultiQueryExpander | 把原问题拆成两条查询 |
| 2 | VectorStoreDocumentRetriever | 每条查询各取 Top 1 |
| 3 | ConcatenationDocumentJoiner | 合并两路资料并去重 |
| 4 | ContextualQueryAugmenter | 把原问题和资料交给模型 |

图中的 Transformer 和 PostProcessor 是可选扩展点,本次没有使用。
主要的功能
检索器每条路只取最相似的一份资料:
var retriever = VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.similarityThreshold(0.0)
.topK(1)
.build();
similarityThreshold(0.0) 会放行所有的分数,便于查看 Top 1。生产阈值要使用自己制作的问答集来测试。
MultiQuery Prompt 的主要限制有三条:
输出恰好 {number} 行,每行一条查询。
每条查询只解决一个意图,避免近义重复。
保留订单状态、支付方式和时间等关键条件。
然后组装 Advisor:
var expander = MultiQueryExpander.builder()
.chatClientBuilder(queryExpansionClientBuilder) // 拆问题用的模型
.promptTemplate(multiQueryPrompt)
.numberOfQueries(2)
.includeOriginal(false)
.build();
Advisor advisor = RetrievalAugmentationAdvisor.builder()
.queryExpander(expander)
.documentRetriever(retriever)
.queryAugmenter(queryAugmenter) // 组装原问题和命中资料
.build();
numberOfQueries(2) 产生两个查询,includeOriginal(false) 不再加入用户的原话。
Spring AI 2.0.1 默认生成三条查询,并且保留原始问题,最多执行四路检索。
两个实际测试中出现的问题
第一版 Prompt 生成:
订单取消后退款时间
银行卡退款到账时间
两条查询都涉及退款时间,最后命中同一份资料,但是取消条件却丢失了。
第二次,模型把两个查询写在了一起。MultiQueryExpander 发现行数不对,直接使用用户的原话进行检索。
请求还是会正常返回,因此项目中要记录实际生成的查询。
把 TopK 设为 2 不就可以了吗?
Direct Top 2 也进行了测试。它找到了取消规则以及退款时间,但是最后的答案只说了:
银行卡支付的退款会在1至3个工作日内到账。
这里要分开检查下面两件事情:
- 需要的资料找全了吗?
- 找到的信息都已经写到答案里了吗?
单个问题并不能说明 MultiQuery 总是更好的。调大 TopK 很便宜,可以先做一个基线;MultiQuery 需要多一次模型调用,可以给每个意图分配一路检索。
三种方案如何选择
- 问题清楚,只问一件事情:Direct RAG。
- 只问一件事情,使用的是比较口语化的表达方式:
RewriteQueryTransformer。 - 一句话问了多件事情,答案分布在多份资料中:
MultiQueryExpander。
本次实验得出的结论是:RAG 需要同时考虑“查到了什么”以及“最后回答了什么”。
RetrievalAugmentationAdvisor 的 Javadoc 注释为 Since 1.0.0。标题中 2.0 表示本文使用 Spring AI 2.0.1 完成代码和测试。
作者:雷一言说。
转载自 CSDN-专业IT技术社区
原文链接:https://blog.csdn.net/m0_63830057/article/details/169338658



