✨ 我是 Muzi 的「文章捕手」,擅长在文字的星海中打捞精华。每当新的篇章诞生,我就会像整理贝壳一样,将思想的闪光点串成珍珠项链~
本文介绍了Mu-ai-agent项目中对RAG(检索增强生成)链路的优化,解决了多轮对话中检索结果稀疏的问题。核心改进是替换了原有的QuestionAnswerAdvisor,加入了预检索优化环节(pre-retrieval),通过四种查询处理策略:查询压缩、重写、翻译和多查询扩展,提升了检索的上下文理解和召回率。文中强调了advisor调用顺序的语义重要性,避免指代消解失败,并将检索参数显式配置以增强行为可复现性。此外,针对中文场景,进行了上下文模板本地化和合理的默认配置调优,保证性能和资源消耗的平衡。项目通过分层单元测试保障功能可靠性,提供了详尽的代码装配设计和实践经验,提升了系统的检索质量和多轮交互效果,具备较高的实用价值和扩展性。
# Mu-ai-agent-10
# 前言
前三篇把 RAG 这条链路从无到有搭了起来:#7 用 TikaDocumentReader + TokenTextSplitter + text-embedding-v3 跑通检索增强问答,#8 把内存版向量库换成 PgVectorStore 做持久化,#9 把 embedding 请求的切批职责收进 BatchingStrategy。
链路是通的,但检索质量一直有个说不清的毛病:多轮追问时,检索结果会稀散。
问题出在 QuestionAnswerAdvisor 上。它做的事很朴素——拿用户的原始 query,直接丢给向量库:
chatClient = ChatClient.builder(chatModel)
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
QuestionAnswerAdvisor.builder(vectorStore).build(), // 原始 query 直接检索
MyLoggerAdvisor.builder().build()
)
.build();
用户第二句话说 "它和实体有什么区别?",向量库收到的就是这一整句。没有主语、没有上下文,embedding 出来的向量自然抓不住重点,检索回来的片段和真正想问的东西对不上。
这一篇换掉这个 advisor,在检索发生之前插进一道加工环节——也就是 Spring AI 里的「预检索优化」(pre-retrieval)。
# 预检索在解决什么
传统 RAG 的隐含假设是「用户问什么,就拿什么去检索」。这个假设在真实场景里基本不成立:
- 多轮追问:"它和实体有什么区别?" —— 「它」是谁?
- 口语化表达:"那个啥,聚合根到底是个啥玩意儿" —— 用词和文档完全不一致
- 长会话淹没:关键信息夹在几十轮闲聊里,单看最后一句根本不知道在问什么
预检索就是在向量检索之前先对 query 做加工,把它变成一条「检索友好」的完整问题。Spring AI 1.1.8 提供了四类手段:
| 能力 | 实现类 | 干的事 |
|---|---|---|
| 查询压缩 | CompressionQueryTransformer |
把整段对话历史压成一句可独立检索的问题 |
| 查询重写 | RewriteQueryTransformer |
把口语化提问润色成检索友好的措辞 |
| 查询翻译 | TranslationQueryTransformer |
翻译成目标语言后再检索 |
| 多查询扩展 | MultiQueryExpander |
一条问题扩成若干变体,各自检索后合并去重 |
四类能力都藏在 spring-ai-rag 这个模块里,包路径是 org.springframework.ai.rag.preretrieval.query.*——不是 spring-ai-advisors-vector-store,两个模块职责完全不同。
# 换掉 advisor
RetrievalAugmentationAdvisor 实现的是 BaseAdvisor,和项目里既有的记忆、日志 advisor 是同一种东西,所以替换方式很自然:
chatClient = ChatClient.builder(chatModel)
.defaultSystem("你是一个专业的技术问答助手,请根据提供的知识库文档回答用户问题。如果知识库中没有相关信息,请如实告知。")
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory)
.order(MEMORY_ADVISOR_ORDER)
.build(),
retrievalAugmentationAdvisor, // 预检索 + 检索 + 上下文注入
MyLoggerAdvisor.builder().order(LOGGER_ADVISOR_ORDER).build()
)
.build();
装配方式和改造前一模一样,真正的变化在这个 advisor 内部——它把「加工 query → 检索 → 拼上下文」串成了一条流水线:
用户提问
↓
① MessageChatMemoryAdvisor 把对话历史注入 prompt
↓
② RetrievalAugmentationAdvisor
从 prompt 取「用户最新发言」当 query、取「全部 instructions」当 history
→ 串行跑完转换器链
→ 展开成多条查询
→ 每条查询各自检索一次,合并去重
→ 用模板把上下文拼进用户消息
↓
③ MyLoggerAdvisor 记录「模型真正看到的请求」
↓
qwen-plus 基于知识库作答
# 顺序不是风格问题
三个 advisor 现在都显式写了 order。这不是为了好看,顺序在这里是有语义的。
public static final int MEMORY_ADVISOR_ORDER = Ordered.HIGHEST_PRECEDENCE + 1000; // -2147482648
public static final int LOGGER_ADVISOR_ORDER = 1;
关键在这一句:RetrievalAugmentationAdvisor 在 before 阶段是从 request.prompt().getInstructions() 里取对话历史来构造检索用的 Query 的。而这份历史,是 MessageChatMemoryAdvisor 注入进 prompt 的。
所以记忆必须先跑。顺序反了会怎样?不会抛异常、不会报错——只是压缩组件拿到一份空历史,"它和实体有什么区别?" 里的「它」永远指不明白,检索质量悄悄退化。这类「静默失效」比抛异常难查得多。
日志 advisor 排在最后同理:它记录的是「模型真正看到的请求」,也就包含了检索注入的上下文。排在检索之前的话,日志里只有用户原话,排查 "到底检索到了什么" 时无从下手。
因为这三条关系都是隐式的,我把顺序值提成了常量,并写了一条断言把不变量钉死:
Assertions.assertTrue(order > RagApp.MEMORY_ADVISOR_ORDER,
"检索增强 advisor 必须排在记忆 advisor 之后,否则读不到对话历史,多轮指代消解会静默失效");
Assertions.assertTrue(order < RagApp.LOGGER_ADVISOR_ORDER,
"检索增强 advisor 必须排在日志 advisor 之前,否则日志看不到注入后的上下文");
# 四项能力,默认只开两项
四项全部实现、全部可单独开关,但默认配置是「压缩 + 扩展」开,「改写 + 翻译」关。这个取舍有明确理由:
| 能力 | 默认 | 理由 |
|---|---|---|
| 压缩 | 开 | 项目有对话记忆,多轮追问是主要交互形态,收益最直接 |
| 改写 | 关 | 与压缩职责重叠(都在解决多轮指代与措辞),串行跑白白多一次大模型调用 |
| 翻译 | 关 | 知识库与提问都是中文,翻成英文再检索中文库必然降低召回 |
| 扩展 | 开 | 对召回提升最明显,代价是多一次大模型调用,可以接受 |
也就是说,默认链路每轮问答比改造前多 2 次大模型调用(压缩 1 次 + 扩展 1 次)。想进一步省成本,把 expansion.enabled 也设为 false,恢复到只多 1 次——不用改一行代码。
翻译这项特别值得说明一下:它在标准预检索里是标配能力,但对中文提问 + 中文知识库的场景是有害的。所以我保留了它的完整实现与配置项,只是默认关着——它的价值在「知识库换成外文语料」时才显现。这种「能力实现,但默认不启用」的处理,比直接不做要诚实。
# 装配设计:转换器为什么不注册成 Bean
四个组件都实现/继承自 QueryTransformer 或 QueryExpander。最直觉的做法是把它们都注册成 Bean,让 Spring 注入。我没这么做,原因是「开关」会因此变成隐式状态:
- 关掉的组件不该出现在链路上,但容器里 Bean 存不存在与
enabled开关是两回事; - 注册成同类型 Bean 后,注入
List<QueryTransformer>既可能出现多候选歧义; - 想知道「哪些开关真正生效了」,只能靠反射去翻
RetrievalAugmentationAdvisor的私有字段。
改成:组件在装配方法里按需构建,装配结果收敛到一个显式的 record 上:
public record PreRetrievalPipeline(List<QueryTransformer> transformers, QueryExpander expander) {
public PreRetrievalPipeline {
transformers = (transformers == null) ? List.of() : List.copyOf(transformers);
}
}
这样测试可以直接注入这个 Bean,断言「链路里有几个转换器、分别是谁、扩展器开没开」,不用启大模型也不用碰反射。
控制「开关 → 链路内容」的映射抽成了一个不依赖 Spring 的静态纯函数:
public static List<QueryTransformer> composeQueryTransformers(PreRetrievalProperties props,
QueryTransformer compression,
QueryTransformer rewrite,
QueryTransformer translation) {
List<QueryTransformer> chain = new ArrayList<>();
if (props.getCompression().isEnabled() && compression != null) {
chain.add(compression);
}
if (props.getRewrite().isEnabled() && rewrite != null) {
chain.add(rewrite);
}
if (props.getTranslation().isEnabled() && translation != null) {
chain.add(translation);
}
return chain;
}
纯函数的意义在于:最容易出错的地方(配置读取 + 装配顺序)可以用零成本的单元测试覆盖,不需要启动容器。
# 转换器的执行顺序
三个转换器的排列顺序遵循「先消解指代,再润色措辞,最后换语言」:
- 压缩最优先——它负责把 "它 / 这个 / 那怎么办" 补全成完整问题。放在改写之后的话,改写面对的仍是一个没有主语的残缺句子,等于白跑;
- 改写居中——此时句子已经完整,可以专注优化措辞与术语;
- 翻译最后——它的输出语言与输入不同,必须放在所有「同语言内加工」之后,否则后面几步都要在非母语文本上工作。
# 检索参数:从「框架默认」变成「显式配置」
改造前 QuestionAnswerAdvisor 的检索参数全是默认值。现在显式写出来:
@Bean
public DocumentRetriever documentRetriever(VectorStore vectorStore, PreRetrievalProperties props) {
Double threshold = (props.getRetrieval().getSimilarityThreshold() > 0)
? props.getRetrieval().getSimilarityThreshold()
: null; // 非正数解释为「不设阈值」
return VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.topK(props.getRetrieval().getTopK())
.similarityThreshold(threshold)
.build();
}
取值刻意与改造前保持一致(topK=4、similarityThreshold=0.0),目的是让「换 advisor」这件事本身不改变检索结果——行为差异只来自新增的预检索环节,否则以后排查时分不清是哪里引起的变化。
为什么不吃默认值:默认值属于框架的实现细节,升级 Spring AI 时可能变化,而它直接决定「给模型喂多少上下文」。写死在配置里,行为才可复现、可对比。
阈值留 0 也是有意为之。改用余弦距离后,本项目实测的相似度绝对值偏高(命中 0.82、勉强相关也有 0.43),拿绝对阈值拦截很容易把有用内容一起丢掉。真要设阈值,应该先用日志观察一批真实查询的分布再定。
注意
topK是每条查询的上限。启用多查询扩展后,最终合并进上下文的总条数可能是它的若干倍(变体条数 × topK,再去重)。
# 上下文模板中文化
RetrievalAugmentationAdvisor 默认用 ContextualQueryAugmenter 把检索结果拼进用户消息,但这个模板是英文的。中文问答里夹一段英文指令,实测表现为回答风格突变、偶尔夹带 "Based on the context..." 这类腔调。
所以换成了等价的中文模板,规则一条没增没减,只是换语言:
private static final String CONTEXT_PROMPT_TEMPLATE = """
以下是检索到的上下文信息。
---------------------
{context}
---------------------
请严格依据上述上下文回答问题,不要使用上下文之外的知识。
请遵守以下规则:
1. 如果上下文中没有答案,直接说明你不知道。
2. 不要出现 "根据上下文"、"提供的资料" 这类说法。
问题:{query}
回答:
""";
{context} 与 {query} 是框架回填的占位符,两个都必须保留,少一个渲染时就会抛错。
另一个开关是 allowEmptyContext。框架默认 false——检索为空时用兜底提示词让模型拒答。本项目设成 true,因为 system 提示词里已经写了「如果知识库中没有相关信息,请如实告知」,拒答的职责统一由 system 承担;两处都管会造成提示词重复,也让回答风格多一层不可控的模板影响。
# 配置树
11 个配置项、5 个分组,全部带默认值,开箱即用:
mu-ai:
rag:
preretrieval:
compression:
enabled: true # 多轮追问是最常见形态,默认开
rewrite:
enabled: false # 与压缩重叠,默认关
target-search-system: vector store
translation:
enabled: false # 中文库翻译有害,默认关
target-language: English
expansion:
enabled: true
number-of-queries: 3 # 再多收益递减,检索次数线性增长
include-original: true # 保留原查询,保证「最坏也不比不扩展差」
retrieval:
top-k: 4 # 与改造前一致
similarity-threshold: 0.0 # <= 0 解释为「不设阈值」
augmenter:
allow-empty-context: true # 拒答统一交给 system 提示词
advisor-order: 0 # 必须大于记忆 advisor 的 order
include-original: true 是召回率的保险:大模型改写有可能跑偏,留下原始查询,能保证「至少不比不做扩展更差」。
这里用 @ConfigurationProperties 而不是逐条 @Value,是因为配置项到了 11 个、还带嵌套结构,@Value 要写 11 行参数再逐个拼默认值。既有的 EmbeddingBatchingConfig 只有 3 个平铺参数,用 @Value 是合适的;配置一多,取舍就反过来了。
# 单元测试
分三层,各自守一段职责:
| 测试类 | 用例数 | 特点 |
|---|---|---|
PreRetrievalPipelineTest |
7 | 纯逻辑,不启 Spring、不调模型 |
PreRetrievalConfigTest |
4 | 容器装配 + order 不变量,不调模型 |
PreRetrievalTransformerLlmTest |
4 | 全部 @Tag("llm"),逐项验证真实效果 |
PgVectorVectorStoreConfigTest |
7(新增 1) | 端到端,其中 2 例带 @Tag("llm") |
# 断言按配置,而不是按写死的默认值
PreRetrievalConfigTest 里验证「链路内容与开关一致」用的是这个写法:
Assertions.assertEquals(preRetrievalProperties.getCompression().isEnabled(),
activeTransformers.contains(CompressionQueryTransformer.class),
"压缩开关与链路内容不一致,请检查 PreRetrievalConfig.composeQueryTransformers");
四个方向都验一遍:开关说开的必须真在链路里(漏装配),说关的必须真不在(装配了但没生效)。
这里特意不写死「默认恰好 1 个转换器」。因为链路内容与 YAML 里的开关是同一份事实,写死之后一改 YAML 就得跟着改测试——测试变成配置的复读机,反而不如直接校验一致性。真正需要钉死的默认值放在 PreRetrievalPipelineTest#testDocumentedDefaults 里单独守。
# 大模型用例的断言为什么这么松
PreRetrievalTransformerLlmTest 里每个用例都真实调用 qwen-plus。断言只写「确定性事实」:
Assertions.assertNotEquals(query.text(), compressed.text(),
"压缩结果与原始问题完全相同,说明这一环没有生效");
不写死期望的加工结果——大模型输出无法逐字预测,写死只会得到一个天天红的测试。加工结果打印到日志里供人工核对,质量判断交给人,稳定性交给断言。
翻译那条更刻意:只断言「变了」,不断言「一定是英文」。模型偶尔会把专有名词原样保留,逐字校验语言很容易造成偶发失败。
# 端到端补了一例多轮追问
原来那例 testChatWithRag 用的是全新会话,历史为空,压缩组件拿不到任何上下文——等于没被真正执行。而多轮追问恰恰是压缩存在的理由,所以补了一例:
String chatId = "test-rag-multiturn-" + System.currentTimeMillis();
ragApp.doChatWithRag("什么是聚合根?", chatId);
// 第二轮故意省略主语
ragApp.doChatWithRag("它和实体有什么区别?", chatId);
同一个 chatId 才会共享对话记忆。这个用例实际验证的是「记忆 advisor 先注入历史 → 检索增强 advisor 才去读」这个顺序,也就是前面那条 order 不变量的真实效果。
# 跑法
mvn -B test -DexcludedGroups=llm # 22 个用例,日常回归
mvn -B test -Dtest=PreRetrievalTransformerLlmTest # 单独看四项能力效果
# 实测效果
四项能力各自单独跑了一次,加工结果如下(日志原件):
【压缩】原始问题:它和实体有什么区别?
【压缩】加工结果:聚合根与普通实体在领域驱动设计(DDD)中的核心区别是什么?
【重写】原始问题:那个啥,聚合根到底是个啥玩意儿,跟实体啥区别啊
【重写】加工结果:聚合根与实体的核心区别是什么?
【翻译】原始问题:什么是聚合根?它和实体有什么区别?
【翻译】加工结果:What is an aggregate root? How does it differ from an entity?
【扩展】一条问题扩成 3 条变体,连同保留的原始查询共 4 条参与检索
压缩那条最能说明问题:输入的 "它" 被正确还原成了 "聚合根"——这正是多轮场景下检索质量的关键一步。
搜索链路端到端也验了。多轮用例第二轮的回答正确理解了 "它" 指代聚合根,说明「记忆先注入 → 预检索再读」这条顺序在真实运行中是通的。
测试汇总:
- 排除 llm:22/22 绿
- 带 llm:
PreRetrievalTransformerLlmTest4/4、PgVectorVectorStoreConfigTest7/7 绿
# 新增/修改文件
src/main/java/com/muzi/muaiagent/
└── rag/
├── app/
│ └── RagApp.java # [修改] QuestionAnswerAdvisor → RetrievalAugmentationAdvisor,
│ # 三个 advisor 显式 order,顺序值提为常量
└── config/
├── PreRetrievalProperties.java # 新增:11 个配置项 / 5 个分组
├── PreRetrievalPipeline.java # 新增:链路装配结果 record
└── PreRetrievalConfig.java # 新增:4 个 Bean + 中文模板 + 静态装配函数
src/main/resources/application.yml # [修改] 新增 mu-ai.rag.preretrieval.* 配置树
pom.xml # [修改] 显式声明 spring-ai-rag
src/test/java/com/muzi/muaiagent/rag/
├── PreRetrievalPipelineTest.java # 新增:7 例纯逻辑
├── PreRetrievalConfigTest.java # 新增:4 例容器装配
├── PreRetrievalTransformerLlmTest.java # 新增:4 例,全带 @Tag("llm")
└── PgVectorVectorStoreConfigTest.java # [修改] 补多轮追问端到端用例
# 遇到的问题
# 1. 两个对称的 API,空值行为完全相反
装配时有两处需要「关掉就不传」:转换器链和扩展器。按直觉,两处的处理方式应该一样,但实际正好相反。
反查字节码后确认:
queryExpander(null)是安全的——RetrievalAugmentationAdvisor.before里对它是if (queryExpander != null)判断,传 null 只是跳过扩展;- 而转换器列表一侧,框架调的是
Assert.noNullElements(...),要求集合本身非 null(元素也不允许 null)。
所以扩展器传 null、转换器列表归一成空列表:
public PreRetrievalPipeline {
transformers = (transformers == null) ? List.of() : List.copyOf(transformers);
}
这两个 API 长得很对称,行为却相反。顺带一提,这类地方的差异靠读文档经常看不出来——我这次是直接反查本地 Maven 仓库里的 jar(javap 看方法签名与字节码)确认的,一共挡掉了五处类似的误判,其中就包括 PromptTemplate 的实际位置(在 spring-ai-model,不是直觉上的 client-chat)。凡是按记忆写 import 或者猜 builder 方法名,编译期就会付出代价。
# 2. 依赖必须显式声明
改造前项目直接 import 的类型里没有 spring-ai-rag 的,改造后有了。这个模块之前是经 spring-ai-alibaba-starter-dashscope → autoconfigure-dashscope → dashscope 传递引入的(声明 spring-ai-rag:1.1.2),版本再被 spring-ai-bom:1.1.8 覆盖。
传递依赖能编译通过,但隐患和 #9 里 jtokkit 的情况一样:上游一调整依赖树,这些类型会在编译期无声消失。
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-rag</artifactId>
</dependency>
不写 version,由 BOM 管理。这条原则再重复一次:只要代码里 import 了某个库的类型,它就该出现在自己的 pom.xml 里,哪怕是「反正会被传递进来」的。
# 3. 一个不再被引用的依赖
spring-ai-advisors-vector-store 里只有 QuestionAnswerAdvisor 和 VectorStoreChatMemoryAdvisor 两个类。改造后前者不再被引用(测试里还留了一处反向断言:容器里不该再有 QuestionAnswerAdvisor Bean)。
我暂时保留了它,只把注释改成与实际一致——因为 VectorStoreChatMemoryAdvisor 是下一步「记忆也走向量召回」的自然选项,而移除会动到传递依赖树,收益不抵风险。
# 心得体会
这一篇做的是「往检索前面插一道加工」,但真正花时间的不是那四个组件——它们都是框架现成的。花时间的是把隐式关系变成显式约束。
三条值得记下来:
第一,组件能力全都要,但默认值要按场景定。 四项预检索能力对中文知识库的价值完全不同:压缩和扩展收益直接,改写与压缩重叠,翻译甚至有害。合理的做法不是「能开就开」,而是四项都实现、都留开关,然后给一组有理由的默认值。默认配置是给绝大多数情况的,不是给「功能展示」的。
第二,顺序是一种契约,不是配置细节。 记忆 advisor 必须在检索增强之前,这个约束来自 RetrievalAugmentationAdvisor 内部从 prompt 读历史的实现方式。它没有写在任何接口签名里,违反了也不报错——只是多轮指代永远消解不掉。对这类隐式契约,光注释不够,得写成断言。我把 order 提为常量并让测试做相对比较,就是为了让「顺序不能反」这件事有地方可验。
第三,大模型参与的逻辑,测试要分开两层。 「开关生效了没、链路装对了没」是确定性的,可以用纯逻辑测试断言,跑一次几毫秒;「加工出来的 query 好不好」是概率性的,只能看日志人工判断。把这两者混在一起,结果要么测试天天红,要么放弃了本该有的保护。分层之后,日常回归跑前者,改了相关代码再跑后者看日志——成本与收益才对得上。
下一篇想回头看看检索本身,把这一篇里故意留成 0 的相似度阈值调一调——先拿真实查询的分布数据说话。
项目地址:mu-ai-agent