Spring AI 2.0.1 深度解析-第 07 章 RAG 模块化架构:RetrievalAugmentationAdvisor 流水线与 Query 体系
本章目标 :把
spring-ai-rag这 28 个源文件、6 个子包、约 2000 行代码,逐行拆到能回答"我这次 RAG 请求到底发起了几次 LLM 调用、检索了几轮、上下文是怎么拼进 prompt 的"这个颗粒度。取证方式 :所有类名、字段名、行号、模板字符串均来自
spring-ai-2.0.1的实际源码。凡源码里没有的(比如很多人以为存在的DocumentRanker),会明确标注"不存在"。
7.0 为什么 RAG 单独占两章
在第 05、06 章我们看了 13 个 advisor。它们有一个共同点:逻辑是"扁平"的 ------before() 做点事,after() 做点事,中间交给链的下一段。
RetrievalAugmentationAdvisor 不是这样。它的 before() 里又套了一整条流水线:
vbnet
before()
└── Step 1 queryTransformers ──(可能是 N 次 LLM 调用)
└── Step 2 queryExpander ──(1 次 LLM 调用,产出 M 个 query)
└── Step 3 对每个 query 并发检索 ──(M 次向量库查询,跑在自定义线程池上)
└── Step 4 documentJoiner ──(合并去重排序)
└── Step 5 documentPostProcessors ──(可能是 N 次 LLM 调用)
└── Step 6 queryAugmenter ──(0 次,纯字符串拼装)
└── Step 7 改写 prompt
它的复杂度比第 06 章所有 advisor 加起来还高。而且它是唯一一个自己内部有并发 的 advisor------别的 advisor 只在流式路径上用 Reactor 的 Scheduler,而它额外引入了一套 Spring 的 TaskExecutor 线程池。
所以本章(07)讲架构与流水线编排 ,第 08 章讲每个可插拔组件的逐个拆解与实战配方。
7.1 spring-ai-rag 模块拓扑
7.1.1 文件清单:28 个源文件,其中 12 个是 package-info
先看清全貌。spring-ai-rag/src/main/java 下只有 28 个 .java 文件、合计 2031 行 ,其中 12 个是 package-info.java (只放 @NullMarked 之类的包级注解)。真正有代码的是 16 个:
perl
spring-ai-rag/src/main/java/org/springframework/ai/rag/
├── Query.java 96 行 ★ 记录类
├── package-info.java
├── advisor/
│ ├── RetrievalAugmentationAdvisor.java 297 行 ★★ 本章主角
│ └── package-info.java
├── generation/
│ ├── package-info.java
│ └── augmentation/
│ ├── QueryAugmenter.java 46 行 SPI
│ ├── ContextualQueryAugmenter.java 176 行 ★ 默认实现
│ └── package-info.java
├── postretrieval/
│ ├── package-info.java
│ └── document/
│ ├── DocumentPostProcessor.java 45 行 SPI
│ └── package-info.java
├── preretrieval/
│ ├── package-info.java
│ └── query/
│ ├── transformation/
│ │ ├── QueryTransformer.java 44 行 SPI
│ │ ├── CompressionQueryTransformer.java 139 行 ★
│ │ ├── RewriteQueryTransformer.java 134 行 ★
│ │ ├── TranslationQueryTransformer.java 144 行 ★
│ │ └── package-info.java
│ └── expansion/
│ ├── QueryExpander.java 45 行 SPI
│ ├── MultiQueryExpander.java 184 行 ★
│ └── package-info.java
├── retrieval/
│ ├── join/
│ │ ├── DocumentJoiner.java 48 行 SPI
│ │ ├── ConcatenationDocumentJoiner.java 66 行 ★
│ │ └── package-info.java
│ └── search/
│ ├── DocumentRetriever.java 47 行 SPI
│ └── package-info.java
└── util/
├── PromptAssert.java 59 行
└── package-info.java
几个立刻能读出来的信息:
(1)目录结构就是 RAG 的阶段划分。 preretrieval(检索前)/ retrieval(检索)/ postretrieval(检索后)/ generation(生成)------四个包,与"Modular RAG"论文的阶段划分一一对应。这不是随意的组织,而是把架构主张写进了包结构。你打开包浏览器就能看出这条流水线该怎么走。
(2)SPI 与实现严格成对。 每个 Xxx 接口旁边就放着 XxxImpl 或默认实现。唯一例外是 DocumentRetriever,它的默认实现 VectorStoreDocumentRetriever 放在同包下但命名不同(因为它是"按数据源"命名而非"按策略"命名)。
(3)DocumentPostProcessor 有接口但没有任何内置实现**。全库 grep:
shell
grep -rln "implements DocumentPostProcessor" --include=*.java .
# 只有接口自己(含 default apply 方法),零实现
这是一个刻意留出的扩展点------官方给你接口和文档,实现(重排序、压缩、去重)要你自己写或用第三方。第 08 章会给两个可直接用的实现。
(4)DocumentRanker 不存在。 这个必须说清楚,因为很多 1.x 时代的文章和很多人的印象里都有它:
shell
find . -name "DocumentRanker.java" -not -path "*/target/*"
# 无输出
全库搜索 Ranker 也一无所获。Spring AI 从来没有把"重排序"做成独立的顶层抽象,而是把它归到 DocumentPostProcessor 的职责里(接口的 Javadoc 明说:"it could rank documents based on their relevance to the query, remove irrelevant or redundant documents, or compress the content")。
7.1.2 依赖层次:spring-ai-rag 站在哪一层
spring-ai-rag/pom.xml 的生产依赖只有两个:
xml
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-client-chat</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-vector-store</artifactId>
</dependency>
upgrade-notes.adoc:2917 官方给的依赖层次总结印证了这一点:
markdown
* `spring-ai-commons` (foundation)
* `spring-ai-model` (depends on commons)
* `spring-ai-vector-store` and `spring-ai-client-chat` (both depend on model)
* `spring-ai-vector-store-advisor` and `spring-ai-rag` (depend on both client-chat and vector-store)
所以完整拓扑是:
arduino
spring-ai-commons
│
spring-ai-model
╱ ╲
spring-ai-client-chat spring-ai-vector-store
╲ ╱
spring-ai-rag
注意 spring-ai-rag 不直接依赖 spring-ai-model ------它通过 client-chat 和 vector-store 传递拿到 ChatModel、Message、Document。这也是为什么 RetrievalAugmentationAdvisor 里能直接用 ChatClientRequest(来自 client-chat)和 Document(来自 commons,经 vector-store 传递)。
这个层次关系有一个重要推论:spring-ai-rag 是一个"编排层"模块,不是一个"抽象层"模块 。它不定义新的核心抽象(Query 勉强算),而是把已有的 ChatClient / VectorStore 拼起来。
7.1.3 spring-ai-rag 与 spring-ai-vector-store-advisor 的分工
这是最容易混淆的一点,官方文档(retrieval-augmented-generation.adoc)也把两者写在同一页里,加剧了混淆。
spring-ai-vector-store-advisor |
spring-ai-rag |
|
|---|---|---|
| 定位 | "桥接"层:让 chat 能用上 vector store | "框架"层:完整的 RAG 流水线 |
| 入口 | QuestionAnswerAdvisor、VectorStoreChatMemoryAdvisor |
RetrievalAugmentationAdvisor |
| 可配置性 | 一个 SearchRequest + 一个 PromptTemplate |
6 类可插拔组件 |
| 并发 | 无 | 有(自建线程池) |
| 查询变换 | 无 | 有(3 个 transformer + 1 个 expander) |
| 官方描述 | "Bridges chat with vector stores for RAG"(upgrade-notes.adoc:2890) |
"Comprehensive framework for Retrieval Augmented Generation"(:2903) |
选型建议 :如果你只是想"把向量库里的东西塞进 prompt",用 QuestionAnswerAdvisor(第 08 章会细讲),它是一个 179 行的类,心智负担小。如果你需要查询改写、多路检索、结果后处理中的任意一个 ,就必须上 spring-ai-rag。
两者不冲突,可以同时用(但几乎没人这么干,因为功能重叠)。
7.2 Modular RAG:论文主张如何落在包结构上
RetrievalAugmentationAdvisor 的类级 Javadoc 引了三篇 arXiv 论文:
java
// spring-ai-rag/.../advisor/RetrievalAugmentationAdvisor.java:50-61
/**
* Advisor that implements common Retrieval Augmented Generation (RAG) flows using the
* building blocks defined in the {@link org.springframework.ai.rag} package and following
* the Modular RAG Architecture.
*
* @author Christian Tzolov
* @author Thomas Vitale
* @since 1.0.0
* @see <a href="http://export.arxiv.org/abs/2407.21059">arXiv:2407.21059</a>
* @see <a href="https://export.arxiv.org/abs/2312.10997">arXiv:2312.10997</a>
* @see <a href="https://export.arxiv.org/abs/2410.20878">arXiv:2410.20878</a>
*/
其中 2407.21059 的标题在官方文档里被明确写出了:
Spring AI implements a Modular RAG architecture inspired by the concept of modularity detailed in the paper "Modular RAG: Transforming RAG Systems into LEGO-like Reconfigurable Frameworks". ------
retrieval-augmented-generation.adoc:216-218
另外两篇(2312.10997、2410.20878)在本仓库里没有注明标题 。我不做猜测------如果你要追,自己查 arXiv 编号即可。这里只想指出一个方法论问题:Javadoc 里挂了三篇论文但只注明一篇的标题,这本身就是"引用未经整理"的信号。工程师引用前应该自己读过。
7.2.1 "LEGO" 这个比喻在代码里的具体体现
Modular RAG 的核心主张是:RAG 不该是"一个单体流程",而应该是可自由重组的模块。Spring AI 的落地方式是:
- 每个阶段一个 Java 函数式接口 (
Function/BiFunction) - 阶段之间通过
Query/List<Document>这两个数据类型流转 - advisor 只负责编排,不负责任何具体算法
看这五个接口的类型(这是全章最值得记住的一张表):
| 阶段 | 接口 | 继承的 JDK 函数式接口 | 本质类型 |
|---|---|---|---|
| 检索前·变换 | QueryTransformer |
Function<Query, Query> |
Query → Query |
| 检索前·扩展 | QueryExpander |
Function<Query, List<Query>> |
Query → List<Query> |
| 检索 | DocumentRetriever |
Function<Query, List<Document>> |
Query → List<Document> |
| 检索·合并 | DocumentJoiner |
Function<Map<Query, List<List<Document>>>, List<Document>> |
Map<Query, 双层List<Document>> → List<Document> |
| 检索后 | DocumentPostProcessor |
BiFunction<Query, List<Document>, List<Document>> |
(Query, List<Document>) → List<Document> |
| 生成·增强 | QueryAugmenter |
BiFunction<Query, List<Document>, Query> |
(Query, List<Document>) → Query |
为什么都要 extends 一个 JDK 函数式接口?
因为这让它们能直接用在 Stream 里。比如你可以写:
java
QueryTransformer a = ...;
QueryTransformer b = ...;
Query q = ...;
Query result = Stream.of(a, b).reduce(Query.identity(), ...) // 直接当 Function 用
虽然 RetrievalAugmentationAdvisor 内部没这么写(它用了 for 循环,见 §7.4.2),但接口开放了这个可能性。这是"面向组合设计"的典型手法:接口本身不承诺什么,但类型决定了它能被怎么组合。
注意 DocumentJoiner 的类型签名很怪:Map<Query, List<List<Document>>>。为什么是双层 List?
因为设计上允许"一个 Query 对应多个数据源",每个数据源返回 List<Document>,所以一个 Query 得到 List<List<Document>>。但当前实现(RetrievalAugmentationAdvisor:134)永远只包一层:
java
.collect(Collectors.toMap(Map.Entry::getKey, entry -> List.of(entry.getValue())))
// ^^^^^^^^^^^^^^^^^^^^^^^^
// List<Document> → List<List<Document>>
这是一个"为未来预留"的类型形状。 目前 RetrievalAugmentationAdvisor 只支持一个 DocumentRetriever(构造器里是单数 documentRetriever,不是 List),所以内层 List 永远是 size 1。如果你自己在写 DocumentJoiner,不要假设内层有多元素。
7.2.2 阶段与包名的对应关系(以及一个不一致)
| Modular RAG 阶段 | Spring AI 包 | 组件 |
|---|---|---|
| Pre-Retrieval | rag.preretrieval.query.transformation |
QueryTransformer ×3 |
| Pre-Retrieval | rag.preretrieval.query.expansion |
QueryExpander ×1 |
| Retrieval | rag.retrieval.search |
DocumentRetriever ×1 |
| Retrieval | rag.retrieval.join |
DocumentJoiner ×1 |
| Post-Retrieval | rag.postretrieval.document |
DocumentPostProcessor ×0(无实现) |
| Generation | rag.generation.augmentation |
QueryAugmenter ×1 |
不一致的地方:RetrievalAugmentationAdvisor 自己放在 rag.advisor 包,而不是 rag.generation 或 rag 根包。这是合理的------它是"编排者",不属于任何一个阶段。
7.3 RetrievalAugmentationAdvisor 类结构全景
7.3.1 类声明:final
java
// spring-ai-rag/.../advisor/RetrievalAugmentationAdvisor.java:62
public final class RetrievalAugmentationAdvisor implements BaseAdvisor {
final。 你不能继承它。这和 QuestionAnswerAdvisor(也是 final)一致,但和第 06 章看到的 BaseChatMemoryAdvisor(abstract,供继承)不同。
为什么是 final?因为它是编排器,行为完全由构造时注入的组件决定,没有"模板方法"可以给你覆盖。想扩展就换组件,不要继承。
它实现 BaseAdvisor 而不是 CallAdvisor/StreamAdvisor,这一点很关键------BaseAdvisor 提供了 adviseCall/adviseStream 的默认实现,把"around"语义翻译成 before() / after() 两个钩子。这意味着 RetrievalAugmentationAdvisor 的 before() 在非流式和流式两条链路上都会被调用,且是同一份代码。
代价是什么?第 05 章讲过:BaseAdvisor.adviseStream 的 after() 依赖 AdvisorUtils.onFinishReason() 谓词,可能不触发。对 RetrievalAugmentationAdvisor 来说这无所谓 ------它的 after() 只是把文档塞进响应 metadata(§7.5),漏掉不影响正确性。
7.3.2 字段与默认值:一张表
java
// :64-99
public static final String DOCUMENT_CONTEXT = "rag_document_context";
private final List<QueryTransformer> queryTransformers;
private final @Nullable QueryExpander queryExpander;
private final DocumentRetriever documentRetriever;
private final DocumentJoiner documentJoiner;
private final List<DocumentPostProcessor> documentPostProcessors;
private final QueryAugmenter queryAugmenter;
private final TaskExecutor taskExecutor;
private final Scheduler scheduler;
private final int order;
构造器(:84-100)的默认值逻辑:
| 字段 | 未配置时的默认 | 源码位置 | 备注 |
|---|---|---|---|
queryTransformers |
List.of()(空,不是 null) |
:91 |
空 = 跳过 Step 1 |
queryExpander |
null |
:92 |
null = 单查询分支(Step 2) |
documentRetriever |
无默认,必填 | :89 |
构造即 Assert.notNull |
documentJoiner |
new ConcatenationDocumentJoiner() |
:94 |
|
documentPostProcessors |
List.of() |
:95 |
空 = 跳过 Step 5 |
queryAugmenter |
ContextualQueryAugmenter.builder().build() |
:96 |
|
taskExecutor |
buildDefaultTaskExecutor() |
:97 |
4/16 线程池 |
scheduler |
BaseAdvisor.DEFAULT_SCHEDULER |
:98 |
Schedulers.boundedElastic() |
order |
0 |
:99 |
见 §7.9 |
三个值得说的点:
(1)documentRetriever 是唯一必填项 ,且有两道校验:构造器 Assert.notNull(:89)和 Builder Assert.state(:289)。Builder 用 Assert.state 而非 Assert.notNull 是有点随意的(两者都抛 IllegalArgumentException vs IllegalStateException),但这确实是源码现状。
(2)queryExpander 允许为 null,其他集合类都是空 List。 这是两种表达"未配置"的方式混用。为什么会这样?
看 Step 2 的代码(:125-126):
java
List<Query> expandedQueries = this.queryExpander != null ? this.queryExpander.expand(transformedQuery)
: List.of(transformedQuery);
queryExpander 是"有或没有"的二选一,用 null 表达。而 queryTransformers 是"有 0 个或多个",用空 List 表达。语义上说得通,但类型上不一致 ------理想情况应该用 Optional 或让 queryExpander 也有个 NoOpQueryExpander 默认实现。
这是第 02 章那条结论的又一次印证:Spring AI 2.x 里同一件事常有多种写法,取决于谁在什么时间写的。
(3)order 默认 0。 这个数字很危险,见 §7.9。
7.3.3 DOCUMENT_CONTEXT 常量:唯一对外暴露的 context key
java
// :64
public static final String DOCUMENT_CONTEXT = "rag_document_context";
这是全类唯一的 public 常量 。它的用途是:在 after() 里把检索到的文档列表放进 ChatResponse 的 metadata,让调用方(或下游 advisor)能拿到"这次回答用了哪些文档"。
java
// 使用方式
ChatResponse resp = chatClient.prompt()...call().chatResponse();
List<Document> docs = (List<Document>) resp.getMetadata().get("rag_document_context");
这是一个非常实用的功能------RAG 系统最常被追问的就是"你凭什么这么答"。有了这个 key,你可以直接把引用来源返回给前端。
但有一个坑:after() 里的 ctx 是 Object,你需要强制转型,且泛型信息在运行时被擦除。建议写个工具方法封装,别在业务代码里裸转。
7.3.4 Builder:11 个方法,2 个是变参重载
Builder(:204-295)提供:
| 方法 | 重载 | 校验 |
|---|---|---|
queryTransformers(List<QueryTransformer>) |
✓ QueryTransformer... |
Assert.noNullElements |
queryExpander(QueryExpander) |
✗ | 无 |
documentRetriever(DocumentRetriever) |
✗ | 无(构造时校验) |
documentJoiner(DocumentJoiner) |
✗ | 无 |
documentPostProcessors(List<...>) |
✓ DocumentPostProcessor... |
Assert.noNullElements |
queryAugmenter(QueryAugmenter) |
✗ | 无 |
taskExecutor(TaskExecutor) |
✗ | 无 |
scheduler(Scheduler) |
✗ | 无 |
order(Integer) |
✗ | 无 |
build() |
--- | Assert.state(documentRetriever != null) |
注意 order(Integer) 接受的是包装类型 ,构造器里再判 null(:99)。这意味着你无法显式设置 order = 0 和"不设置"区分开------虽然效果一样(默认就是 0),但这暴露了 API 设计上的小瑕疵。
对比第 06 章的 SimpleLoggerAdvisor,它的 order 也是 Integer。这是 Spring AI 的统一惯例:builder 字段用包装类型接收,构造器里判 null 给默认。
7.4 before():七步流水线逐行拆解
这是全章的核心。before()(:106-154)只有 48 行,但每一行都有话说。
先完整贴一遍,然后逐步拆:
java
// spring-ai-rag/.../RetrievalAugmentationAdvisor.java:106-154
@Override
public ChatClientRequest before(ChatClientRequest chatClientRequest, @Nullable AdvisorChain advisorChain) {
Map<String, Object> context = new HashMap<>(chatClientRequest.context());
// 0. Create a query from the user text, parameters, and conversation history.
String text = chatClientRequest.prompt().getUserMessage().getText();
Query originalQuery = Query.builder()
.text(Objects.requireNonNullElse(text, ""))
.history(chatClientRequest.prompt().getInstructions())
.context(context)
.build();
// 1. Transform original user query based on a chain of query transformers.
Query transformedQuery = originalQuery;
for (var queryTransformer : this.queryTransformers) {
transformedQuery = queryTransformer.apply(transformedQuery);
}
// 2. Expand query into one or multiple queries.
List<Query> expandedQueries = this.queryExpander != null ? this.queryExpander.expand(transformedQuery)
: List.of(transformedQuery);
// 3. Get similar documents for each query.
Map<Query, List<List<Document>>> documentsForQuery = expandedQueries.stream()
.map(query -> CompletableFuture.supplyAsync(() -> getDocumentsForQuery(query), this.taskExecutor))
.toList()
.stream()
.map(CompletableFuture::join)
.collect(Collectors.toMap(Map.Entry::getKey, entry -> List.of(entry.getValue())));
// 4. Combine documents retrieved based on multiple queries and from multiple data
// sources.
List<Document> documents = this.documentJoiner.join(documentsForQuery);
// 5. Post-process the documents.
for (var documentPostProcessor : this.documentPostProcessors) {
documents = documentPostProcessor.process(originalQuery, documents);
}
context.put(DOCUMENT_CONTEXT, documents);
// 6. Augment user query with the document contextual data.
Query augmentedQuery = this.queryAugmenter.augment(originalQuery, documents);
// 7. Update ChatClientRequest with augmented prompt.
return chatClientRequest.mutate()
.prompt(chatClientRequest.prompt().augmentUserMessage(augmentedQuery.text()))
.context(context)
.build();
}
7.4.0 Step 0:从 ChatClientRequest 构造 Query
java
// :108
Map<String, Object> context = new HashMap<>(chatClientRequest.context());
第一行就是防御性拷贝。 ChatClientRequest 是 record,它的 context 是一个 Map<String, @Nullable Object>。这里 new HashMap<>(...) 拷贝了一份,所以你对 context 的修改不会影响原始 request 的 context ------直到 Step 7(:152)显式 .context(context) 把它写回去。
这是标准的"读-改-写"模式,写得很干净。
java
// :111
String text = chatClientRequest.prompt().getUserMessage().getText();
这里取的是用户消息的文本 。注意是 getUserMessage()------第 03 章讲过,Prompt 提供 getUserMessage() 便捷方法取最后一条 USER 消息。
⚠️ 陷阱 1:纯多模态请求会让整个 advisor 抛异常。
getText() 的返回类型是 @Nullable String。如果用户的消息只有 Media(图片/音频)没有文本,getText() 返回 null。
然后看 :113:
java
.text(Objects.requireNonNullElse(text, ""))
Objects.requireNonNullElse(null, "") → ""。
再往下看 Query 的 Builder(:78-80):
java
public Query build() {
Assert.hasText(this.text, "text cannot be null or empty");
return new Query(this.text, this.history, this.context);
}
Assert.hasText("") 会抛出 IllegalArgumentException (StringUtils.hasText("") 返回 false)。
所以:Objects.requireNonNullElse(text, "") 这行看起来是在防 NPE,实际上只是把 NPE 换成了 IAE。 两种情况都会炸。
scss
用户发一张图,无文字
→ getText() = null
→ requireNonNullElse → ""
→ Query.Builder.build() → Assert.hasText("") 抛 IllegalArgumentException
"text cannot be null or empty"
这是一个真实存在的边界 bug。修复方式应该是:
java
// 如果 text 为空,应该跳过 RAG 或用其他字段兜底
if (!StringUtils.hasText(text)) {
return chatClientRequest; // 直接透传,不做 RAG
}
如果你在做多模态 RAG,必须自己包一层。
java
// :114
.history(chatClientRequest.prompt().getInstructions())
history 装的不是"历史对话",是"当前 prompt 的全部消息"。
getInstructions() 返回 List<Message>,包含 system、user、assistant、tool 所有角色。变量名叫 history 会让人以为它只装历史,但实际上它也包含当前这条 user message 本身。
这对 CompressionQueryTransformer 有直接影响(§7.6.3 会看到它怎么格式化 history):当前问题会被格式化进 {history},然后又作为 {query} 单独出现一次。这是重复,但语义上无害(LLM 通常能处理),只是浪费 token。
java
// :115
.context(context)
关键细节:Query 持有的是这个 Map 的引用,不是拷贝。
Query 是 record,字段 Map<String, Object> context。构造时传入的是刚创建的 context 变量。这意味着:
:144的context.put(DOCUMENT_CONTEXT, documents)会同时 反映到originalQuery.context()里- 所以 Step 6 的
queryAugmenter.augment(originalQuery, documents)中,自定义 augmenter 可以通过query.context().get("rag_document_context")拿到原始文档列表
这是一个隐藏的能力:自定义 QueryAugmenter 能拿到结构化文档对象,而不只是拼好的字符串。 如果你想在 prompt 里输出引用编号(如 [1]、[2])而不是纯文本拼接,这就是入口。
(顺带一提,Query 的紧凑构造器只断言 context 非 null 和 key 非 null,不做拷贝------见 §7.6.1。)
7.4.1 关于 advisorChain 参数被忽略
java
public ChatClientRequest before(ChatClientRequest chatClientRequest, @Nullable AdvisorChain advisorChain) {
// ...advisorChain 从头到尾没被用过
@Nullable AdvisorChain advisorChain 这个参数在整个方法体里一次都没出现。
对比第 06 章的 ToolCallingAdvisor,它必须用 advisorChain.copy(this).nextCall(...) 来实现递归循环。RetrievalAugmentationAdvisor 是纯前置处理------它不需要推进链,只需要改写 request,链会自然往下走。
所以这里的 advisorChain 是接口要求但实现不需要 。这是 BaseAdvisor 接口设计的一个小冗余:为了统一签名,所有 before() 都得收这个参数。
如果你在写自己的 advisor,忽略它是完全正常的 。但如果你的 advisor 需要"根据上下文动态调整要不要继续走链",那就得用它(第 06 章的 SafeGuardAdvisor 是另一个例子------它通过返回改写的 response 来短路,而不是用 chain)。
7.4.2 Step 1:QueryTransformer 链 ------ 一个 fold(左折叠)
java
// :118-122
// 1. Transform original user query based on a chain of query transformers.
Query transformedQuery = originalQuery;
for (var queryTransformer : this.queryTransformers) {
transformedQuery = queryTransformer.apply(transformedQuery);
}
这是最朴素的顺序折叠:上一个 transformer 的输出是下一个的输入。
css
originalQuery ──[T1]──> q1 ──[T2]──> q2 ──[T3]──> transformedQuery
顺序完全由你传入 List 的顺序决定 ,没有排序、没有 order 值。这一点和第 05 章讲的 advisor 链排序完全不同------advisor 有 getOrder(),transformer 没有。
⚠️ 陷阱 2:每个 transformer 都是一次完整的 LLM 调用。
三个内置 transformer(CompressionQueryTransformer、RewriteQueryTransformer、TranslationQueryTransformer)内部都持有 ChatClient 并调用 .call().content()(分别是 :82-87、:82-87、:91-96)。
所以如果你配了 3 个 transformer:
markdown
1 次用户请求
= 3 次 transformer LLM 调用
+ 1 次 expander LLM 调用(如果配了)
+ 1 次最终生成 LLM 调用
= 5 次 LLM 调用
延迟和成本都是 5 倍。 这是 RAG 系统最常见的性能事故来源。§7.11 会给完整的成本模型。
⚠️ 陷阱 3:transformer 是"有损"的,且不可逆。
originalQuery 被保存下来了(:119 赋值给 transformedQuery 之前先赋值),但只在 Step 5 和 Step 6 用到 (见 §7.4.5、§7.4.6)。Step 3 的检索用的是 transformedQuery。
这意味着:改写后的 query 只用于检索,不用于提问。 这个反直觉的设计是本章最重要的结论之一,§7.4.6 详述。
⚠️ 陷阱 4:transformer 抛异常会连累整个请求。
:121 的 queryTransformer.apply(...) 没有任何 try-catch。如果 LLM 调用超时或返回异常,异常会直接冒泡 出 before(),整个 ChatClient 调用失败。
三个内置 transformer 内部也没有 try-catch------它们只处理了"返回 null/空"的情况(:89-92、RewriteQueryTransformer:89-92、TranslationQueryTransformer:98-101):
java
if (!StringUtils.hasText(rewrittenQueryText)) {
logger.warn("Query rewrite result is null/empty. Returning the input query unchanged.");
return query;
}
只防了空结果,没防异常。 生产环境必须自己包:
java
public class SafeQueryTransformer implements QueryTransformer {
private final QueryTransformer delegate;
@Override
public Query transform(Query query) {
try { return delegate.transform(query); }
catch (Exception e) {
logger.warn("Query transform failed, using original", e);
return query; // 降级:原样返回
}
}
}
第 08 章 §8.x 会给完整实现。
7.4.3 Step 2:扩展 ------ 单查询 vs 多查询的分岔
java
// :124-126
// 2. Expand query into one or multiple queries.
List<Query> expandedQueries = this.queryExpander != null ? this.queryExpander.expand(transformedQuery)
: List.of(transformedQuery);
两种分支:
- 未配
queryExpander:List.of(transformedQuery)------ 单元素列表,走"单查询"路径 - 配了
queryExpander:通常是MultiQueryExpander,产出 N 个 query
MultiQueryExpander 的默认行为(:72-74):
java
private static final Boolean DEFAULT_INCLUDE_ORIGINAL = true;
private static final Integer DEFAULT_NUMBER_OF_QUERIES = 3;
即:生成 3 个变体,并且把原 query 也加进去 (:131-134),所以最终是 4 个 query。
java
// MultiQueryExpander.java:131-134
if (this.includeOriginal) {
logger.debug("Including the original query in the result");
queries.add(0, query);
}
注意 add(0, query) ------ 原 query 插在第一个位置。
⚠️ 陷阱 5:MultiQueryExpander 的"数量校验"极其脆弱。
java
// MultiQueryExpander.java:116-124
var queryVariants = Arrays.asList(response.split("\n"));
if (CollectionUtils.isEmpty(queryVariants) || this.numberOfQueries != queryVariants.size()) {
if (logger.isWarnEnabled()) {
logger.warn("Query expansion result does not contain the requested " + this.numberOfQueries
+ " variants. Returning the input query unchanged.");
}
return List.of(query);
}
它要求 LLM 返回的行数必须精确等于 numberOfQueries (默认 3)。多一行少一行,就整体放弃扩展,退回单查询。
现实中 LLM 有多听话?
- 可能返回
1. xxx\n2. xxx\n3. xxx(带序号)→ 3 行,✓ 但内容带序号前缀 - 可能返回 markdown 列表
- a\n- b\n- c→ 3 行,✓ 但带-前缀 - 可能返回带空行的格式 → 4+ 行,✗ 全部放弃
- 可能在开头说 "Here are 3 versions:" → 4 行,✗ 全部放弃
重点是:即使"通过"了行数校验,内容也可能带序号或列表符号 ,而代码没有做任何清洗 ------直接 query.mutate().text(queryText)(:128)。
所以你的检索 query 可能是 "1. What is Spring AI?"。这对向量检索的影响取决于 embedding 模型,通常不大,但确实是脏数据。
改进做法 (第 08 章给完整代码):自定义 promptTemplate 强制 LLM 只输出裸文本,并在 filter 里去掉序号前缀。
另一个改进:把"严格相等"改成"取前 N 个":
java
// 自己实现 QueryExpander,别用内置的严格校验
var queries = Arrays.stream(response.split("\n"))
.map(String::trim)
.filter(s -> !s.isBlank())
.limit(numberOfQueries)
.map(t -> query.mutate().text(t).build())
.toList();
7.4.4 Step 3:并发检索 ------ 全章技术含量最高的 6 行
java
// :128-134
// 3. Get similar documents for each query.
Map<Query, List<List<Document>>> documentsForQuery = expandedQueries.stream()
.map(query -> CompletableFuture.supplyAsync(() -> getDocumentsForQuery(query), this.taskExecutor))
.toList()
.stream()
.map(CompletableFuture::join)
.collect(Collectors.toMap(Map.Entry::getKey, entry -> List.of(entry.getValue())));
这 6 行里藏着 4 个关键设计决策,逐一说。
决策 1:为什么是"两阶段 Stream"?
java
.stream() // 阶段一:为每个 query 提交异步任务
.map(query -> CompletableFuture.supplyAsync(..., this.taskExecutor))
.toList() // ← 中间物化!
.stream() // 阶段二:逐个等待结果
.map(CompletableFuture::join)
.toList() 是 load-bearing(承重)的。
如果写成单阶段:
java
// ❌ 这样写是串行的!
expandedQueries.stream()
.map(q -> CompletableFuture.supplyAsync(() -> retrieve(q), executor))
.map(CompletableFuture::join) // 立即 join → 阻塞等这一个完成
.collect(...);
由于 Java Stream 是惰性的 ,每个元素会走完整条流水线 才处理下一个。也就是说:提交 query1 的任务 → 立刻 join() 阻塞等它完成 → 再提交 query2 → 再等......完全串行,线程池白建了。
所以必须先用 .toList() 把所有 CompletableFuture 物化出来(此时所有任务都已提交到线程池),再 逐个 join()。
这是一个非常经典的并发模式,但代码里没有任何注释说明 ------如果你不知道这个背景,很容易在重构时"顺手"把 .toList().stream() 删掉,然后并发悄无声息地退化成串行,性能掉 N 倍且没有任何报错。
这是本章最实用的一条:不要动那两行。
决策 2:supplyAsync(..., taskExecutor) ------ 显式线程池
CompletableFuture.supplyAsync 有两个重载:
java
supplyAsync(Supplier<U> supplier) // 用 ForkJoinPool.commonPool()
supplyAsync(Supplier<U> supplier, Executor executor) // 用指定 executor
这里用了第二个 ,传的是 this.taskExecutor。
为什么不用默认的 commonPool?因为 commonPool 是全局共享 的,JVM 里所有 CompletableFuture、所有并行 Stream 都抢它。如果 RAG 检索(IO 密集、可能几十毫秒到几百毫秒)占满 commonPool,整个应用的其他并行计算都会被拖慢。
这是一个正确的、有意识的决策。 自建线程池做隔离。
决策 3:join() 会阻塞当前线程,且吞掉受检异常
CompletableFuture::join 和 get() 的区别:
get()抛ExecutionException(受检)、InterruptedException(受检)join()抛CompletionException(非受检)、CancellationException
用 join() 的好处是不用写 try-catch。坏处是:任何检索异常都会被包成 CompletionException 冒泡 ,且原始栈信息藏在 cause 里。
所以如果某个 query 的向量检索失败,整批失败------没有任何部分成功的降级。
⚠️ 陷阱 6:一个 query 检索失败 = 整个 RAG 失败。
生产环境建议:包一层容错。
java
// 自定义 DocumentRetriever,内部容错
public class FaultTolerantRetriever implements DocumentRetriever {
private final DocumentRetriever delegate;
@Override
public List<Document> retrieve(Query query) {
try { return delegate.retrieve(query); }
catch (Exception e) {
logger.error("Retrieval failed for query: " + query.text(), e);
return List.of(); // 返回空,让 joiner 处理
}
}
}
决策 4:Collectors.toMap 的重复键崩溃风险
java
.collect(Collectors.toMap(Map.Entry::getKey, entry -> List.of(entry.getValue())))
Collectors.toMap 没有提供 merge function(三参版本)。当两个 key 相等时,会抛:
vbnet
java.lang.IllegalStateException: Duplicate key Query[text=...]
Query 是 record,equals 比较 text + history + context。
什么时候会撞?
MultiQueryExpander的includeOriginal=true,而 LLM 恰好生成了一个与原 query 完全相同 的变体 → 必然撞- LLM 生成了两个相同的变体 → 撞
queryExpander返回了重复的 query → 撞
第 1 种情况有多常见?如果用户问的是一个很明确的问题(比如 "Spring AI 是什么"),LLM 生成的变体里出现一模一样的一句,并不罕见。
这是一个真实的、会在生产上偶发炸掉的配置。 而且它炸的方式很讨厌:偶发、不可复现、报的错(Duplicate key)和你配的东西(RAG)看起来没关系。
规避方式 :给 MultiQueryExpander 配 includeOriginal(false),或者在自定义 expander 里做去重。
另外注意 Map 的实现是 Collectors.toMap 默认给的 HashMap,所以:
⚠️ 陷阱 7:检索结果的顺序在进 joiner 之前就丢了。
HashMap 迭代顺序不确定。ConcatenationDocumentJoiner 会按 score 重排(§7.4.5),所以最终顺序是确定的------但如果两个文档 score 相同,顺序就不确定了。这在需要稳定输出(比如 A/B 测试、快照测试)时会造成困扰。
getDocumentsForQuery 私有方法
java
// :156-163
/**
* Processes a single query by routing it to document retrievers and collecting
* documents.
*/
private Map.Entry<Query, List<Document>> getDocumentsForQuery(Query query) {
List<Document> documents = this.documentRetriever.retrieve(query);
return Map.entry(query, documents);
}
注释说 "routing it to document retrievers"(复数),但实现只有一个 this.documentRetriever(单数)。又一处注释与实现脱节(本章第 2 处,前面是 §7.2 的论文引用)。
Map.entry() 返回的是不可变的 Map.Entry ,且不允许 null 值 。如果 retrieve() 返回 null,.entry() 会 NPE。DocumentRetriever.retrieve 的契约没说不能返回 null,所以这是一个隐含约定。
7.4.5 Step 4:合并 ------ ConcatenationDocumentJoiner
java
// :136-138
// 4. Combine documents retrieved based on multiple queries and from multiple data
// sources.
List<Document> documents = this.documentJoiner.join(documentsForQuery);
默认实现(:94)是 ConcatenationDocumentJoiner。完整源码:
java
// ConcatenationDocumentJoiner.java:47-64
@Override
public List<Document> join(Map<Query, List<List<Document>>> documentsForQuery) {
Assert.notNull(documentsForQuery, "documentsForQuery cannot be null");
Assert.noNullElements(documentsForQuery.keySet(), "documentsForQuery cannot contain null keys");
Assert.noNullElements(documentsForQuery.values(), "documentsForQuery cannot contain null values");
logger.debug("Joining documents by concatenation");
return new ArrayList<>(documentsForQuery.values()
.stream()
.flatMap(List::stream) // 第一层:List<List<Document>> → List<Document>
.flatMap(List::stream) // 第二层:(当前实现下是 no-op,因为内层 size=1)
.collect(Collectors.toMap(Document::getId, Function.identity(), (existing, duplicate) -> existing))
.values()
.stream()
.sorted(Comparator.comparingDouble((Document doc) -> doc.getScore() != null ? doc.getScore() : 0.0)
.reversed())
.toList());
}
四个要点:
(1)去重靠 Document::getId,保留"先出现的"。
(existing, duplicate) -> existing ------ 冲突时保留已存在的那个,即"第一次遇到的"。
但如 §7.4.4 决策 4 所说,外层是 HashMap,迭代顺序不确定 ,所以"第一个"是不确定的。
类 Javadoc(:38-40)声称:
In case of duplicate documents, the first occurrence is kept.
这个承诺在当前实现下是不成立的。 如果两个 id 相同的文档来自不同 query,保留哪个取决于 HashMap 的哈希顺序。
不过------在实践中这几乎无害。因为同一个向量库里 id 相同的文档本来就是同一份 ,内容一致。真正需要担心的是"跨多个数据源"的场景,而当前 RetrievalAugmentationAdvisor 不支持多 retriever(构造器只收一个)。
(2)score 为 null 时按 0.0 处理。
java
doc.getScore() != null ? doc.getScore() : 0.0
Document.getScore() 是 @Nullable Double。第 02 章讲过,向量库返回的文档未必带 score (取决于 SearchRequest 和具体实现)。如果全部为 null,排序就退化成"按 HashMap 顺序"。
(3)按 score 降序(.reversed())。
这里假设 score 越大越相似 。Document 类 Javadoc(:146-153)印证:
csharp
* A numeric score associated with this document that can represent various types of
* ... and a query vector, where higher scores indicate greater similarity (opposite of
注意原文 "opposite of" ------ 它想说"与距离相反,距离是越小越相似"。
这意味着:如果你要接入一个返回"距离"而非"相似度"的数据源,必须自己做转换 (1 - distance 或 1/(1+distance)),否则排序会完全相反------最不相关的排在最前面。
这是一个非常隐蔽的错误:代码不报错,只是答案质量莫名变差。
(4)返回 new ArrayList<>(...)。
最后包一层 ArrayList,说明返回的是可变列表。这有点意外(大部分 Spring API 返回不可变),但对调用方是好事------Step 5 的后处理器可能想改它。
(实际上 Step 5 用的是重新赋值 documents = ...,不改原列表,所以这个可变性没被用上。)
7.4.6 Step 5 & 6:注意这里用的是 originalQuery
java
// :140-147
// 5. Post-process the documents.
for (var documentPostProcessor : this.documentPostProcessors) {
documents = documentPostProcessor.process(originalQuery, documents);
}
context.put(DOCUMENT_CONTEXT, documents);
// 6. Augment user query with the document contextual data.
Query augmentedQuery = this.queryAugmenter.augment(originalQuery, documents);
⚠️ 这是本章最重要的一个反直觉发现:
- Step 3 检索用的是
transformedQuery(改写后的) - Step 5 后处理用的是
originalQuery(原始) - Step 6 增强用的也是
originalQuery(原始)
也就是说:
ini
用户问:"呃...那个...我想知道 Spring AI 是啥玩意儿?"
│
[RewriteQueryTransformer]
↓
transformedQuery = "Spring AI 的定义与核心特性"
│
[向量检索:用 transformedQuery] ← 改写后的用于检索 ✓
↓
documents
│
[PostProcessor / Augmenter:用 originalQuery] ← 原始的用于提问
↓
prompt: "Context: ...\n\nQuery: 呃...那个...我想知道 Spring AI 是啥玩意儿?"
这是合理的设计吗?
是。理由:
- 检索需要"标准查询"------去除口语、消除歧义、翻译到目标语言,都对检索有利
- 生成需要"用户的原意"------包括语气、上下文、隐含约束。改写成"Spring AI 的定义与核心特性"后,模型可能会回答得过于教科书化,丢失用户真正的意图
但也带来一个后果:如果你用了 TranslationQueryTransformer,用户问中文,检索用英文,但喂给 LLM 的 query 还是中文。 这通常是我们想要的(LLM 用中文回答),但如果你期望"翻译后的 query 也用于提问",就会失望。
⚠️ 陷阱 8:documentPostProcessor.process(originalQuery, documents) 传的是原始 query。
这意味着基于 query 做重排序的后处理器,用的是改写前的 query。如果改写幅度很大(比如翻译),重排序的相关性判断会失准。
这不是 bug------是设计选择。但你写自定义 DocumentPostProcessor 时必须知道这一点。
如果你确实想让后处理器用改写后的 query,怎么办?没法通过配置实现 ------before() 里写死了 originalQuery。你得自己实现一个 RetrievalAugmentationAdvisor 的变体(它是 final,不能继承,只能复制一份改)。
context.put(DOCUMENT_CONTEXT, documents) 的时机
:144 发生在 Step 5 之后、Step 6 之前。
- 所以存进 context 的是"后处理之后"的文档列表(更有用)
- 且因为
originalQuery.context()持有同一个 Map 引用(§7.4.0),Step 6 的 augmenter 也能读到
7.4.7 Step 7:改写 prompt
java
// :149-153
// 7. Update ChatClientRequest with augmented prompt.
return chatClientRequest.mutate()
.prompt(chatClientRequest.prompt().augmentUserMessage(augmentedQuery.text()))
.context(context)
.build();
augmentUserMessage(String) 的实现(第 03 章讲过,这里复看关键行):
java
// spring-ai-model/.../Prompt.java:282-284
public Prompt augmentUserMessage(String newUserText) {
return augmentUserMessage(userMessage -> userMessage.mutate().text(newUserText).build());
}
java
// Prompt.java:261-275
public Prompt augmentUserMessage(Function<UserMessage, UserMessage> userMessageAugmenter) {
var messagesCopy = new ArrayList<>(this.messages);
for (int i = messagesCopy.size() - 1; i >= 0; i--) {
Message message = messagesCopy.get(i);
if (message instanceof UserMessage userMessage) {
messagesCopy.set(i, userMessageAugmenter.apply(userMessage));
break;
}
if (i == 0) {
messagesCopy.add(userMessageAugmenter.apply(new UserMessage("")));
}
}
return new Prompt(messagesCopy, this.chatOptions);
}
行为:
- 从后往前 找最后一条
UserMessage,用增强后的文本替换它的 text - 如果一条 USER 消息都没有(
i == 0还没找到),追加 一条新的UserMessage chatOptions原样保留 (:274)
⚠️ 陷阱 9:user message 的 Media 会丢失。
userMessage.mutate().text(newUserText).build() ------ 只设了 text。UserMessage.mutate() 会保留 media 吗?
看第 03 章的 UserMessage:mutate() 返回 new Builder().text(this.text).media(...).metadata(...),所以 media 会被保留。好,这里没问题。
但**UserMessage 原有的 metadata 呢**?也会保留(mutate() 全量拷贝)。
⚠️ 陷阱 10:替换的是"最后一条" user message,不是"第一条"。
在多轮对话里,prompt 里可能有多条 USER 消息(历史 + 当前)。augmentUserMessage 从后往前找,替换最后一条------即当前这条。正确。
但如果你的 prompt 结构是 USER, ASSISTANT, USER, ASSISTANT(最后一条是 assistant,比如 few-shot 结尾),那它会替换倒数第一条 USER ,也就是历史里的某条。这是错的。
不过这种情况在 RAG 场景少见(用户的当前问题总是最后一条),所以实践中问题不大。
.context(context)
:152 把修改后的 context(含 rag_document_context)写回 request。
注意 :ChatClientRequest 是 record,.mutate() 返回 Builder。这里 .context(context) 传的是同一个 HashMap 引用(没有再拷贝)。
这意味着:返回的 ChatClientRequest 里 context 字段指向的 Map,和 originalQuery.context() 指向的是同一个对象。 如果下游 advisor 改了它,originalQuery 也会"看到"变化------虽然那时 originalQuery 已经没人用了。
这是一个无害但值得知道的共享。第 05 章讲过 advisor 的 context 是"跨 advisor 传状态"的通道,这里是同一机制的体现。
7.5 after():把文档回传到响应 metadata
java
// :165-182
@Override
public ChatClientResponse after(ChatClientResponse chatClientResponse, @Nullable AdvisorChain advisorChain) {
ChatResponse.Builder chatResponseBuilder;
if (chatClientResponse.chatResponse() == null) {
chatResponseBuilder = ChatResponse.builder();
}
else {
chatResponseBuilder = ChatResponse.builder().from(chatClientResponse.chatResponse());
}
Object ctx = chatClientResponse.context().get(DOCUMENT_CONTEXT);
if (ctx != null) {
chatResponseBuilder.metadata(DOCUMENT_CONTEXT, ctx);
}
return ChatClientResponse.builder()
.chatResponse(chatResponseBuilder.build())
.context(chatClientResponse.context())
.build();
}
三件事:
7.5.1 空安全的 response 重建
:167-173 先判断 chatResponse() 是否为 null。为什么不直接 ChatResponse.builder().from(...)?
因为 ChatClientResponse.chatResponse() 是 @Nullable(某些 advisor 短路场景下 response 可能为 null)。from(null) 大概率会 NPE。
所以这里用了分岔构造 :null 就建个空的,非 null 就 from() 拷贝。
7.5.2 metadata(String, Object) 的语义
java
chatResponseBuilder.metadata(DOCUMENT_CONTEXT, ctx);
ChatResponse.Builder.metadata(String key, Object value) 往 ChatResponseMetadata 的自定义 kv 区写值(第 02 章讲过 AbstractResponseMetadata 的 ConcurrentHashMap)。
取用时:
java
ChatResponse resp = ...;
Object docs = resp.getMetadata().get("rag_document_context");
注意这是"自定义 metadata"通道 ,不是 ChatResponseMetadata 的强类型字段(如 getUsage())。所以它:
- 不会有编译期类型检查
- 不会被 Micrometer 观测自动采集(第 17 章会看到 observation convention 只认强类型字段)
- 会出现在
toString()里(因为AbstractResponseMetadata的 toString 会打印自定义 kv)
7.5.3 after() 在流式链路可能不触发
第 05 章的结论在这里复现一次:BaseAdvisor.adviseStream 的 after() 依赖 AdvisorUtils.onFinishReason() 谓词。如果供应商不在流里返回 finishReason,after() 不会执行。
对 RetrievalAugmentationAdvisor 的后果:流式调用时,你可能拿不到 rag_document_context。
这不会导致错误(只是 metadata 里少了这个 key),但如果你依赖它做"引用来源展示",流式场景下会失效。
解决方案 :不要依赖 after() 的 metadata,而是自己在业务层包一层------在调用前记录 query,调用后单独查一次向量库。或者用非流式拿 metadata。
7.5.4 context 原样透传
java
return ChatClientResponse.builder()
.chatResponse(chatResponseBuilder.build())
.context(chatClientResponse.context()) // 同一个 Map 引用
.build();
没有拷贝。 ChatClientResponse 的 context 和传入的是同一个对象。
对比 before() 的第一行 new HashMap<>(chatClientRequest.context())(:108)做了拷贝。
同一对方法,一个拷贝一个不拷贝。 又是第 02 章那条"同一件事多种写法"的例子。
7.6 Query:一个 record + 一个 Builder 的双面设计
java
// spring-ai-rag/.../Query.java:37
public record Query(String text, List<Message> history, Map<String, Object> context) {
7.6.1 紧凑构造器的四道断言
java
// :39-45
public Query {
Assert.hasText(text, "text cannot be null or empty");
Assert.notNull(history, "history cannot be null");
Assert.noNullElements(history, "history elements cannot be null");
Assert.notNull(context, "context cannot be null");
Assert.noNullElements(context.keySet(), "context keys cannot be null");
}
五个断言,其中三个值得说:
(1)Assert.hasText(text) ------ 非空且非空白。这就是 §7.4.0 陷阱 1 的根源。
(2)Assert.noNullElements(context.keySet()) ------ 只检查 key 不为 null,不检查 value。
对比 Document 的构造器(spring-ai-commons/.../Document.java:220-223):
java
Assert.notNull(metadata, "metadata cannot be null");
Assert.noNullElements(metadata.keySet(), "metadata cannot have null keys");
Assert.noNullElements(metadata.values(), "metadata cannot have null values");
Document key 和 value 都检查 。Query 只检查 key。
为什么?因为 Query.context 允许存 null value(ChatClientRequest 的 context 类型就是 Map<String, @Nullable Object>)。这是有意的------advisor context 里经常用 put(key, null) 表示"显式清空"。
(3)没有 Assert.noNullElements(context.values()) 意味着:如果你在自定义组件里 query.context().get(key) 拿到 null,那可能是"没设"也可能是"设了 null",无法区分。用 containsKey() 判断。
7.6.2 不做防御性拷贝:一个刻意的共享
java
public record Query(String text, List<Message> history, Map<String, Object> context) {
// 紧凑构造器里没有:this.history = List.copyOf(history);
// this.context = Map.copyOf(context);
}
history 和 context 都直接存引用,不拷贝。
对比第 03 章的 UserMessage / AssistantMessage------它们也不拷贝 metadata。所以这是 Spring AI 的一致风格:记录类不负责深拷贝,调用方负责不可变性。
后果:
Query号称不可变(record 的字段是 final),但它持有的 Map 和 List 是可变的RetrievalAugmentationAdvisor:144的context.put(...)会反映到已构造的originalQuery上(§7.4.0)- 如果外部持有
historyList 的引用并add(),Query的内容会变
这是一个"浅不可变"(shallow immutability)设计。 在单线程流水线里没问题,但如果把 Query 丢给另一个线程(比如自定义组件里异步处理),就有数据竞争风险。
7.6.3 便捷构造器与 mutate()
java
// :47-49
public Query(String text) {
this(text, List.of(), Map.of());
}
单参构造器:空 history、空 context。文档示例里大量使用:
java
Query query = new Query("And what is its second largest city?");
java
// :51-53
public Builder mutate() {
return new Builder().text(this.text).history(this.history).context(this.context);
}
mutate() 返回 Builder,预填当前值。 这是三个内置 transformer 的标准用法:
java
// RewriteQueryTransformer.java:94
return query.mutate().text(rewrittenQueryText).build();
注意:mutate() 只改 text,history 和 context 原样带过去 。所以改写后的 query 仍然携带完整的对话历史和上下文------这对 CompressionQueryTransformer(需要 history)很重要。
7.6.4 Builder 的字段默认值
java
// :60-64
private @Nullable String text;
private List<Message> history = List.of();
private Map<String, Object> context = Map.of();
只有 text 是可空的(因为 build() 会断言),history / context 有默认值。
有趣的是:history 和 context 的 setter 没有 null 检查 ,但它们有默认值保底。如果你显式 history(null),紧凑构造器的 Assert.notNull 会拦住。两道防线。
7.7 六个 SPI 接口的设计一致性(与不一致)
现在系统看一遍这六个接口。它们的代码量都很小(44-48 行),但放在一起能看出很多东西。
7.7.1 统一模式:"主方法 + apply 默认转发"
五个接口(除了 DocumentRetriever 稍有不同)都是这个形状:
java
public interface Xxx extends Function<A, B> {
B doSomething(A a); // 主方法,需实现
default B apply(A a) { return doSomething(a); } // 转发到主方法
}
逐个看:
java
// QueryTransformer.java:32-42
public interface QueryTransformer extends Function<Query, Query> {
Query transform(Query query);
default Query apply(Query query) { return transform(query); }
}
java
// QueryExpander.java:34-43
public interface QueryExpander extends Function<Query, List<Query>> {
List<Query> expand(Query query);
default List<Query> apply(Query query) { return expand(query); }
}
java
// DocumentRetriever.java:31-45
public interface DocumentRetriever extends Function<Query, List<Document>> {
List<Document> retrieve(Query query);
default List<Document> apply(Query query) { return retrieve(query); }
}
java
// DocumentJoiner.java:33-46
public interface DocumentJoiner extends Function<Map<Query, List<List<Document>>>, List<Document>> {
List<Document> join(Map<Query, List<List<Document>>> documentsForQuery);
default List<Document> apply(Map<Query, List<List<Document>>> documentsForQuery) { return join(documentsForQuery); }
}
java
// DocumentPostProcessor.java:35-43
public interface DocumentPostProcessor extends BiFunction<Query, List<Document>, List<Document>> {
List<Document> process(Query query, List<Document> documents);
default List<Document> apply(Query query, List<Document> documents) { return process(query, documents); }
}
java
// QueryAugmenter.java:32-44
public interface QueryAugmenter extends BiFunction<Query, List<Document>, Query> {
Query augment(Query query, List<Document> documents);
default Query apply(Query query, List<Document> documents) { return augment(query, documents); }
}
为什么要多一个 apply 默认方法?
因为 Function/BiFunction 的抽象方法是 apply。如果接口只声明 transform() 而不提供 apply() 的默认实现,那么实现类就必须同时 实现 transform() 和 apply()------因为接口继承了 Function 的抽象方法 apply。
加一个 default apply() { return transform(); } 就把 apply 变成了"已实现",实现类只需写 transform()。
这是 Java 8 函数式接口适配的经典手法。 好处是实现类只需关心业务方法名(语义清晰:transform 比 apply 好懂),同时又能当 Function 用。
RetrievalAugmentationAdvisor 用的是哪个?
java
// :121
transformedQuery = queryTransformer.apply(transformedQuery); // 用 apply!
// :142
documents = documentPostProcessor.process(originalQuery, documents); // 用 process!
混用了。 Step 1 调 apply,Step 5 调 process。两者等价(apply 转发到 process),但不一致。
这是无害的,但它说明:这两个方法名都存在,你可以随便选,代码库里也没统一。
7.7.2 Javadoc 里的职责描述(原样摘录)
DocumentPostProcessor 的 Javadoc 最能说明它的扩展意图:
java
// DocumentPostProcessor.java:19-32
/**
* A component for post-processing retrieved documents based on a query, addressing
* challenges such as "lost-in-the-middle", context length restrictions from the model,
* and the need to reduce noise and redundancy in the retrieved information.
* <p>
* For example, it could rank documents based on their relevance to the query, remove
* irrelevant or redundant documents, or compress the content of each document to reduce
* noise and redundancy.
*
* @author Thomas Vitale
* @since 1.0.0
*/
三个明确点名的场景:
- "lost-in-the-middle" ------ 大模型对长上下文中间部分注意力不足的现象(Liu et al. 2023)。对应策略:重排序,把最相关的放开头和结尾
- 上下文长度限制 ------ 截断、压缩
- 降噪去冗余 ------ MMR(最大边际相关性)、去重
这三个场景官方一个都没实现。第 08 章会补上两个可运行的。
DocumentJoiner 的 Javadoc 也提到了重排:
java
// DocumentJoiner.java:19-25
/**
* A component for combining documents retrieved based on multiple queries and from
* multiple data sources into a single collection of documents. As part of the joining
* process, it can also handle duplicate documents and reciprocal ranking strategies.
*/
"reciprocal ranking strategies"(倒数排序融合,RRF) ------ 这是一个具体的多路检索融合算法。默认的 ConcatenationDocumentJoiner 没实现 RRF,只做了"按 score 排序"。
RRF 的公式是:
scss
score(d) = Σ 1 / (k + rank_i(d))
i
其中 rank_i(d) 是文档 d 在第 i 路检索结果中的排名(从 1 开始),k 通常取 60。
它的好处是不需要各路的 score 可比较 ------不同向量库、不同 embedding 模型返回的 score 尺度不同,直接混排没有意义,但排名是可比的。
ConcatenationDocumentJoiner 直接按 score 排序,隐含假设是"所有检索结果的 score 在同一尺度上"。 当你只有一个 retriever 时成立;多路检索(比如向量检索 + 关键词检索)时就不成立了------这时你需要自己实现 RRF。
第 08 章会给一个 ReciprocalRankFusionDocumentJoiner。
7.7.3 作者与 @since 的一致性
六个接口的 Javadoc 作者行:
| 接口 | @author |
@since |
|---|---|---|
QueryTransformer |
Thomas Vitale | 1.0.0 |
QueryExpander |
Thomas Vitale | 1.0.0 |
DocumentRetriever |
Christian Tzolov, Thomas Vitale | 1.0.0 |
DocumentJoiner |
Thomas Vitale | 1.0.0 |
DocumentPostProcessor |
Thomas Vitale | 1.0.0 |
QueryAugmenter |
Thomas Vitale | 1.0.0 |
RetrievalAugmentationAdvisor |
Christian Tzolov, Thomas Vitale | 1.0.0 |
全部 @since 1.0.0,到 2.0.1 没有签名变化。
这和 spring-ai-model 的情况一致(第 02 章):越靠近根部越稳定 。RAG 模块自 1.0.0 起就没动过------2.x 的大重构集中在 ChatModel / options / advisor 体系,RAG 只是被动适配(BaseAdvisor 接口)。
这是一个重要的稳定性信号:如果你在 1.x 上写的 RAG 代码,在模块层面(接口、类名、包名)几乎不用改。要改的是 advisor 注册方式(第 05 章)。
7.8 并发模型:TaskExecutor 与 Scheduler 的双轨制
RetrievalAugmentationAdvisor 有两个并发抽象字段:
java
private final TaskExecutor taskExecutor; // :78 Spring 的,用于 Step 3 并发检索
private final Scheduler scheduler; // :80 Reactor 的,用于 BaseAdvisor 的流式路径
7.8.1 buildDefaultTaskExecutor()
java
// :194-202
private static TaskExecutor buildDefaultTaskExecutor() {
ThreadPoolTaskExecutor taskExecutor = new ThreadPoolTaskExecutor();
taskExecutor.setThreadNamePrefix("ai-advisor-");
taskExecutor.setCorePoolSize(4);
taskExecutor.setMaxPoolSize(16);
taskExecutor.setTaskDecorator(new ContextPropagatingTaskDecorator());
taskExecutor.initialize();
return taskExecutor;
}
参数解读:
| 参数 | 值 | 含义 |
|---|---|---|
| 线程名前缀 | ai-advisor- |
jstack 里能一眼认出 |
| corePoolSize | 4 | 常驻 4 线程 |
| maxPoolSize | 16 | 峰值 16 线程 |
| queueCapacity | 未设置 | 默认 Integer.MAX_VALUE! |
| taskDecorator | ContextPropagatingTaskDecorator |
传播 ThreadLocal |
| rejectedExecutionHandler | 未设置 | 默认 AbortPolicy |
⚠️ 陷阱 11:队列容量是 Integer.MAX_VALUE。
ThreadPoolTaskExecutor 如果不设 queueCapacity,底层用 LinkedBlockingQueue 无界队列。
后果:maxPoolSize=16 永远不会生效。
Java 线程池的工作顺序是:
arduino
提交任务
→ 当前线程数 < corePoolSize ? 新建线程
→ 否则:队列未满 ? 入队 ← 无界队列永远"未满"
→ 否则:线程数 < maxPoolSize ? 新建线程
→ 否则:拒绝策略
因为队列无界,第二步永远成功,永远不会走到第三步。所以实际行为是:
ini
线程数最多 = corePoolSize = 4
多余的任务全排在一个无限增长的队列里
这意味着:
- 并发度实际是 4,不是 16
- 高并发下队列无限堆积,最终 OOM
maxPoolSize=16是一个摆设
这是一个真实的配置缺陷 (Spring 的 ThreadPoolTaskExecutor 默认行为坑了很多项目,不止 Spring AI)。
生产环境必须显式设置:
java
@Bean
TaskExecutor ragTaskExecutor() {
ThreadPoolTaskExecutor e = new ThreadPoolTaskExecutor();
e.setThreadNamePrefix("rag-");
e.setCorePoolSize(8);
e.setMaxPoolSize(32);
e.setQueueCapacity(200); // ← 关键
e.setRejectedExecutionHandler(new ThreadPoolExecutor.CallerRunsPolicy()); // ← 关键
e.setTaskDecorator(new ContextPropagatingTaskDecorator());
e.setWaitForTasksToCompleteOnShutdown(true);
e.setAwaitTerminationSeconds(30);
e.initialize();
return e;
}
// 然后
RetrievalAugmentationAdvisor.builder()
.taskExecutor(ragTaskExecutor())
...
CallerRunsPolicy 的好处:队列满时让提交任务的线程自己执行,形成天然背压,不会丢任务也不会 OOM。
⚠️ 陷阱 12:默认线程池没人负责关闭。
buildDefaultTaskExecutor() 是 private static,每次调用 RetrievalAugmentationAdvisor.builder()...build() 且未指定 taskExecutor 时,都会新建一个线程池。
而这个类没有实现 AutoCloseable / DisposableBean,也不会被 Spring 容器管理(除非你自己注册成 bean)。
所以:
java
// ❌ 每个请求建一个 advisor = 每个请求建一个线程池
for (int i = 0; i < 1000; i++) {
Advisor a = RetrievalAugmentationAdvisor.builder().documentRetriever(r).build();
chatClient.prompt().advisors(a).user(q).call().content();
}
// → 1000 个线程池,每个 4 线程 = 4000 个线程,永不回收
正确做法:advisor 是单例的。 把它注册成 Spring bean,全局复用。
java
@Bean
Advisor retrievalAugmentationAdvisor(VectorStore vs, TaskExecutor ragExecutor) {
return RetrievalAugmentationAdvisor.builder()
.documentRetriever(VectorStoreDocumentRetriever.builder().vectorStore(vs).build())
.taskExecutor(ragExecutor)
.build();
}
这也符合第 06 章的结论:advisor 必须是无状态的、可共享的。
7.8.2 ContextPropagatingTaskDecorator:ThreadLocal 传播
java
taskExecutor.setTaskDecorator(new ContextPropagatingTaskDecorator());
这个类的作用是:把提交任务时的 ThreadLocal 快照 下来,在任务执行线程里还原 ,执行完再清理。
它传播的是 Spring 注册的 ThreadLocalAccessor bean。Spring Boot 3+ 默认会为以下内容注册 accessor:
- Micrometer Observation 的上下文 (
ObservationThreadLocalAccessor)------ 所以子线程里的检索也能被 trace 到父 span - Spring Security 的
SecurityContext(如果配了DelegatingSecurityContextTaskExecutor或相关 accessor) - Reactor 的 Context (通过
ContextPropagation) - 你自己注册的
ThreadLocalAccessor
这对 RAG 的可观测性至关重要。 没有它,Step 3 的向量检索会脱离父 trace,你在 Zipkin/Jaeger 里会看到"主 span 没有子 span"------RAG 内部耗时成了黑盒。
有了它,你能看到:
scss
chat_client (root)
├── rag_query_transform 120ms
├── rag_query_expand 340ms
├── rag_retrieve (query 1) 45ms ← 子线程,仍然挂在主 span 下
├── rag_retrieve (query 2) 38ms
├── rag_retrieve (query 3) 51ms
└── chat_model_call 1820ms
注意 :ContextPropagatingTaskDecorator 需要 io.micrometer:context-propagation 依赖,且需要 Spring Boot 的自动装配把 accessor 注册进去。如果你在非 Spring Boot 环境用,这个 decorator 什么也不传。
7.8.3 scheduler:BaseAdvisor.DEFAULT_SCHEDULER
java
// :98
this.scheduler = scheduler != null ? scheduler : BaseAdvisor.DEFAULT_SCHEDULER;
java
// spring-ai-client-chat/.../advisor/api/BaseAdvisor.java:44
Scheduler DEFAULT_SCHEDULER = Schedulers.boundedElastic();
getScheduler() 被 BaseAdvisor.adviseStream 用来决定流式 before()/after() 在哪个 Reactor 线程上执行。
所以完整图景是:
| 场景 | 用的并发抽象 | 默认实现 |
|---|---|---|
| 非流式 调用 | 不用(同步阻塞在调用线程) | --- |
流式 before()/after() |
Scheduler |
Schedulers.boundedElastic() |
| Step 3 并发检索(流式与非流式都一样) | TaskExecutor |
自建 4/16 ThreadPoolTaskExecutor |
为什么流式要切到 boundedElastic?
因为 before() 里会做阻塞的 向量检索(JDBC、HTTP 调用)。在 Reactor 里,阻塞操作绝对不能 跑在 event loop 线程(如 parallel() 或 Netty 的 worker)上,否则会卡死整个反应堆。
Schedulers.boundedElastic() 是"为阻塞 IO 设计"的调度器:线程数有上界(默认 CPU 核数 × 10),空闲线程会回收。
所以流式路径是:
scss
event loop 线程
→ .publishOn(boundedElastic) ← BaseAdvisor 做的
→ before() 执行阻塞检索
→ 继续流式处理
⚠️ 陷阱 13:流式 RAG 的线程跳跃比你想象的多。
markdown
1. Netty event loop(接收请求)
2. boundedElastic(before()) ← 切 1 次
3. ai-advisor-N(Step 3 并发检索) ← 切 N 次(每个 query 一个)
4. 回到 boundedElastic(before() 收尾)
5. 回到 event loop(调用 ChatModel,发起 HTTP SSE)
加上 ContextPropagatingTaskDecorator 的快照/还原开销,线程上下文切换成本不可忽略。如果你的 RAG 请求 QPS 很高且用了 MultiQueryExpander,这一块会成为瓶颈。
优化方向:
- 减少 query 数量(3 → 2)
- 或者把
taskExecutor的 corePoolSize 调大 - 或者干脆用非流式(少两次切换)
7.9 order 默认 0:一个需要警惕的数字
java
// :99
this.order = order != null ? order : 0;
第 05 章讲过:order 越小越外层(OrderComparator.sort() 升序 + Deque.pop() 取头)。
默认 0 意味着 RetrievalAugmentationAdvisor 会被排在很多内置 advisor 之前。
看几个关键 advisor 的默认 order(第 06 章的表格):
核实后的真实取值(源码取证):
| Advisor | 默认 order | 源码位置 | 相对位置 |
|---|---|---|---|
MessageChatMemoryAdvisor |
Advisor.DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = Ordered.HIGHEST_PRECEDENCE + 200 |
MessageChatMemoryAdvisor.java:171 Advisor.java:39 |
最外层 |
ToolCallingAdvisor |
Ordered.HIGHEST_PRECEDENCE + 300 |
ToolCallingAdvisor.java:75 |
次外层 |
RetrievalAugmentationAdvisor |
0 |
RetrievalAugmentationAdvisor.java:99 |
中间 |
SafeGuardAdvisor |
0 |
SafeGuardAdvisor.java:52 |
中间 |
SimpleLoggerAdvisor |
0 |
SimpleLoggerAdvisor.java:130 |
中间 |
ChatModelCallAdvisor |
Ordered.LOWEST_PRECEDENCE |
ChatModelCallAdvisor.java:110 |
最内层 |
Ordered.HIGHEST_PRECEDENCE 是 Integer.MIN_VALUE,所以 HIGHEST_PRECEDENCE + 200 ≈ -2147483448,远小于 0。记忆方式:负数越大越靠外,0 是"中间地带",LOWEST_PRECEDENCE(Integer.MAX_VALUE)垫底。
order = 0 的后果:
场景:RAG + ChatMemory
如果你的链里有 MessageChatMemoryAdvisor(负责把历史消息塞进 prompt)和 RetrievalAugmentationAdvisor,谁先执行?
java
ChatClient.builder(model)
.defaultAdvisors(
MessageChatMemoryAdvisor.builder(chatMemory).build(),
RetrievalAugmentationAdvisor.builder().documentRetriever(r).build())
.build();
MessageChatMemoryAdvisor 的 order 是 HIGHEST_PRECEDENCE + 200(约 -21.47 亿),RAG 是 0。-21.47 亿 < 0,所以 memory 一定在外层、先执行 ,与你在 defaultAdvisors(...) 里写的顺序无关。
Advisor.java:33-38 的 Javadoc 把这个意图写得很清楚:
java
/**
* Default order for chat-memory advisors. Placed before (outside)
* {@link ...ToolCallingAdvisor#DEFAULT_ORDER}
* so the memory advisor wraps the tool-call loop, and the {@code ToolCallingAdvisor}
* manages its own intermediate conversation history.
*/
int DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = Ordered.HIGHEST_PRECEDENCE + 200;
这是对的 :先把历史塞进 prompt,RAG 才能看到完整 history 去做 CompressionQueryTransformer。
反过来的后果 :如果 RAG 先跑,此时 prompt 里没有历史,CompressionQueryTransformer 拿到的 history 是空的,压缩出来的 standalone query 就是原话------指代消解失效("它第二大城市是哪里?" 没法被解析)。
⚠️ 陷阱 14:CompressionQueryTransformer 依赖历史已经注入。
必须保证:
MessageChatMemoryAdvisor在RetrievalAugmentationAdvisor之前执行(order 更小,或插入更早)- 且
.conversationId()已设置,否则 memory 取不到历史
场景:RAG + 工具调用 ------ 本章最容易被忽略的一个后果
ToolCallingAdvisor.DEFAULT_ORDER = Ordered.HIGHEST_PRECEDENCE + 300(ToolCallingAdvisor.java:75)。这个数比 memory 的 +200 大,但依然远小于 0。
所以默认排序是:
scss
memory (+200) ⊃ tool (+300) ⊃ RAG (0) ⊃ ChatModelCall (LOWEST)
⚠️ 陷阱 14b:默认配置下,RAG 在工具循环「里面」,每轮工具迭代都会重跑一次检索。
证据链:
DefaultAroundAdvisorChain.copy(CallAdvisor after)取的是advisors.subList(indexOf(after) + 1, size)(DefaultAroundAdvisorChain.java:183-189)ToolCallingAdvisor.adviseCall每轮迭代调callAdvisorChain.copy(this).nextCall(...)(第 166 行,见第 06 章)copy(this)得到的就是"排在 ToolCallingAdvisor 之后的所有 advisor"------包括 order=0 的 RAG
于是:
kotlin
第 1 轮:tool.before → RAG.before(检索 4 路)→ ChatModel → 模型要求调工具
第 2 轮:tool.copy(this).nextCall → RAG.before(再检索 4 路)→ ChatModel → ...
第 3 轮:同上
后果 :若一次对话触发 N 轮工具调用,向量检索次数变成 N × M,且每轮拼进 prompt 的上下文可能不同(因为检索是基于同一条 user text,通常是相同的,但成本是实打实的)。
ToolCallingAdvisor.java:70-75 的 Javadoc 解释了为什么把工具放在 memory 里面:
java
/**
* Default advisor order. Higher than
* {@link ...Advisor#DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER}
* (+200) so that memory advisors at their default order are placed outside this
* advisor and do not participate in each tool-call iteration.
*/
但官方只解释了"工具 vs memory",没解释"工具 vs RAG"。 按默认值,RAG 是参与每轮迭代的。
如果你希望"只检索一次" ,把 RAG 的 order 设成比 +300 更小(更外层):
java
RetrievalAugmentationAdvisor.builder()
.documentRetriever(retriever)
.order(Ordered.HIGHEST_PRECEDENCE + 250) // 在 memory 之后、tool 之前
.build();
但这样 RAG 就拿不到工具结果------取舍:
| 放置 | 检索次数 | 能否看到工具结果 | 适用场景 |
|---|---|---|---|
| tool 之内(默认,order=0) | N × M |
✓ | 需要"根据工具返回值再检索"的 Agentic RAG |
| tool 之外(order < +300) | 1 × M |
✗ | 常规问答,只需一次检索 |
Agentic RAG 恰恰需要默认行为(先调工具拿到用户 ID,再用它过滤检索),所以这不是 bug,是特性。但你必须知道它。
7.9.1 建议:显式设置 order
不要依赖默认值。给 RAG 一个明确的、能表达意图的值:
java
RetrievalAugmentationAdvisor.builder()
.documentRetriever(retriever)
.order(100) // 明确:在 memory(更早)之后,在工具(更晚)之前
.build();
7.10 与 QuestionAnswerAdvisor 的对照(简版)
QuestionAnswerAdvisor(在 spring-ai-vector-store-advisor 模块)是另一个 RAG 入口。这里只做对照,第 08 章会完整拆解。
| 维度 | RetrievalAugmentationAdvisor |
QuestionAnswerAdvisor |
|---|---|---|
| 模块 | spring-ai-rag |
spring-ai-vector-store-advisor |
| 类大小 | 297 行 | 179 行 |
| 依赖 | client-chat + vector-store | 同 |
| 查询变换 | ✓ 3 个 transformer + 1 expander | ✗ |
| 多路检索 | ✓ | ✗ |
| 结果合并 | ✓ DocumentJoiner |
✗ |
| 结果后处理 | ✓ DocumentPostProcessor |
✗ |
| 动态过滤 | ✓ FILTER_EXPRESSION context key |
✓ 同 |
| 并发 | ✓ 自建线程池 | ✗ |
| 空上下文策略 | ✓ allowEmptyContext |
✗(总是拼空 context) |
| 文档回传 | ✓ rag_document_context |
✗ |
| 可定制模板 | ✓ promptTemplate |
✓ promptTemplate |
选型一句话:
- 只要"向量库 → prompt":
QuestionAnswerAdvisor - 要任何查询改写 / 多路 / 后处理:
RetrievalAugmentationAdvisor
一个容易被忽略的差异 :QuestionAnswerAdvisor 的模板占位符是 question_answer_context (不是 context)。如果你从 QuestionAnswerAdvisor 迁移到 RetrievalAugmentationAdvisor,自定义模板的占位符必须改 ------否则 PromptAssert 会抛异常(§7.12)。
7.11 成本模型:一次 RAG 调用到底发起了多少次请求
这是工程上最该算清的一笔账。
7.11.1 LLM 调用次数
设:
T=queryTransformers数量E= 是否配了queryExpander(1 或 0)P=documentPostProcessors中"会调 LLM 的"数量(内置无,自定义的可能有)M=MultiQueryExpander产出的 query 数(默认 4)
css
LLM 调用次数 = T + E + P + 1
| 配置 | T | E | P | 总 LLM 调用 |
|---|---|---|---|---|
| 朴素 RAG | 0 | 0 | 0 | 1 |
| + Rewrite | 1 | 0 | 0 | 2 |
| + MultiQuery | 0 | 1 | 0 | 2 |
| + Rewrite + MultiQuery | 1 | 1 | 0 | 3 |
| 全配(3 transformer + expander) | 3 | 1 | 0 | 5 |
延迟模型 (设单次 LLM 平均延迟 L,向量检索平均延迟 V):
ini
朴素 RAG: L + V
+ Rewrite: L + L + V = 2L + V
+ MultiQuery: L + L + max(V₁..V₄) ≈ 2L + V (检索并发!)
全配: 3L + L + max(V₁..V₄) + L = 5L + V
注意检索是并发的 ,所以 M 个 query 只花 max(V) 而不是 M × V。这是 §7.4.4 那个并发设计的价值。
但 transformer 是串行的 (§7.4.2 的 for 循环),所以 T 个 transformer 花 T × L。
优化点:把 transformer 也并发化?
不行------因为它们有依赖(T2 的输入是 T1 的输出)。除非你重新设计成"多个独立改写然后合并",那就是另一种架构了(类似 MultiQueryExpander 的思路)。
7.11.2 向量库查询次数
erlang
向量查询次数 = M(= expander 产出的 query 数,默认 4;未配 expander 时 1)
朴素 RAG:1 次
- MultiQueryExpander(3):4 次(3 变体 + 原 query)
如果每次向量检索 topK=4,那 MultiQuery 会召回 4 × 4 = 16 个文档,去重后可能 10 个左右,全部塞进 prompt。
⚠️ 陷阱 15:MultiQueryExpander 会让 prompt 变长 2-3 倍,但不截断。
ConcatenationDocumentJoiner 只去重、排序,不限制数量 。ContextualQueryAugmenter 把所有文档的文本用换行拼起来(:82-84):
java
private static final Function<List<Document>, String> DEFAULT_DOCUMENT_FORMATTER = documents -> documents.stream()
.map(Document::getText)
.collect(Collectors.joining(System.lineSeparator()));
没有 token 预算检查。 如果召回的文档总长度超过模型上下文窗口,请求会直接失败(或产生高昂费用)。
必须自己加保护:
java
// 自定义 DocumentPostProcessor 做 token 截断(第 08 章给完整实现)
public class TokenBudgetPostProcessor implements DocumentPostProcessor {
private final int maxTokens;
@Override
public List<Document> process(Query query, List<Document> documents) {
int budget = maxTokens;
List<Document> kept = new ArrayList<>();
for (Document d : documents) {
int t = estimateTokens(d.getText());
if (budget - t < 0) break;
kept.add(d);
budget -= t;
}
return kept;
}
}
7.11.3 一个完整的配置-成本对照表
| 配置 | LLM | 向量查询 | 召回文档数(topK=4) | 相对成本 |
|---|---|---|---|---|
| 朴素 | 1 | 1 | 4 | 1× |
| + topK=8 | 1 | 1 | 8 | 1.5×(prompt 变长) |
| + RewriteQueryTransformer | 2 | 1 | 4 | 2× |
| + MultiQueryExpander(3) | 2 | 4 | ~12(去重后) | 2× + prompt 3× |
| + Rewrite + MultiQuery | 3 | 4 | ~12 | 3× + prompt 3× |
| + 3 Transformers + MultiQuery | 5 | 4 | ~12 | 5× + prompt 3× |
实践建议:
- 先用朴素 RAG 建基线,测出准确率
- 只加一个变换,测准确率提升是否值回成本
MultiQueryExpander的收益通常不如一个好的 embedding 模型 + 合理的 chunk 策略- 如果延迟敏感,
TranslationQueryTransformer可以用传统的语言检测(不调 LLM)替代
7.12 PromptAssert:一个 59 行但救过很多人的工具类
java
// spring-ai-rag/.../util/PromptAssert.java:33-57
public final class PromptAssert {
private PromptAssert() {
}
/**
* Assert that the given prompt template contains the required placeholders.
* @param promptTemplate the prompt template to check
* @param placeholders the placeholders that must be present in the prompt template
*/
public static void templateHasRequiredPlaceholders(PromptTemplate promptTemplate, String... placeholders) {
Assert.notNull(promptTemplate, "promptTemplate cannot be null");
Assert.notEmpty(placeholders, "placeholders cannot be null or empty");
List<String> missingPlaceholders = new ArrayList<>();
for (String placeholder : placeholders) {
if (!promptTemplate.getTemplate().contains(placeholder)) {
missingPlaceholders.add(placeholder);
}
}
if (!missingPlaceholders.isEmpty()) {
throw new IllegalArgumentException("The following placeholders must be present in the prompt template: %s"
.formatted(String.join(",", missingPlaceholders)));
}
}
}
被调用的地方(每个可定制模板的组件构造器里):
| 组件 | 位置 | 校验的占位符 |
|---|---|---|
MultiQueryExpander |
:93 |
number, query |
CompressionQueryTransformer |
:73 |
history, query |
RewriteQueryTransformer |
:71 |
target, query |
TranslationQueryTransformer |
:80 |
targetLanguage, query |
ContextualQueryAugmenter |
:102 |
query, context |
这是 fail-fast 的典范 :构造时就校验模板,而不是等到运行时才发现拼出了 {query} 这样的字面量。
注意校验方式是朴素的字符串 contains------不是解析模板 AST。所以:
- ✓ 能抓到"忘了写
{query}" - ✗ 抓不到"写成了
{{query}}"(双括号会被 contains 命中,但渲染时可能出错) - ✗ 抓不到"写在了注释里"
但对 99% 的误用已经够了。
⚠️ 陷阱 16:自定义模板必须保留全部必需占位符。
比如你自定义 ContextualQueryAugmenter 的模板,只想改措辞:
java
PromptTemplate t = new PromptTemplate("""
根据以下资料回答问题。
资料:{context}
问题:{query}
""");
{query} 和 {context} 都在 → 通过。
但如果写成"问题:{question}":
arduino
IllegalArgumentException: The following placeholders must be present in the prompt template: query
注意 ContextualQueryAugmenter 的 emptyContextPromptTemplate 不校验占位符 ------因为它不需要任何参数(augmentQueryWhenEmptyContext 调 render() 无参,:132)。
7.13 四档可运行配置:从朴素到生产
下面四档配置都基于真实 API(不是伪代码),从简到繁。
7.13.1 档位一:朴素 RAG(1 次 LLM + 1 次检索)
java
@Bean
Advisor naiveRagAdvisor(VectorStore vectorStore, TaskExecutor ragExecutor) {
return RetrievalAugmentationAdvisor.builder()
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.similarityThreshold(0.5)
.topK(4)
.build())
.taskExecutor(ragExecutor)
.build();
}
默认生效的组件 :ConcatenationDocumentJoiner + ContextualQueryAugmenter(allowEmptyContext=false)。
行为 :检索 4 篇,按 score 降序,拼进 ContextualQueryAugmenter.DEFAULT_PROMPT_TEMPLATE,检索为空时让模型说"不知道"。
7.13.2 档位二:+ 查询改写(2 次 LLM + 1 次检索)
java
@Bean
Advisor rewriteRagAdvisor(VectorStore vectorStore, ChatClient.Builder builder, TaskExecutor ragExecutor) {
return RetrievalAugmentationAdvisor.builder()
.queryTransformers(
RewriteQueryTransformer.builder()
.chatClientBuilder(builder)
.targetSearchSystem("vector store")
.build())
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.topK(4)
.build())
.taskExecutor(ragExecutor)
.build();
}
官方强烈建议 (retrieval-augmented-generation.adoc:243-244):
IMPORTANT: When using a
QueryTransformer, it's recommended to configure theChatClient.Builderwith a low temperature (e.g., 0.0) to ensure more deterministic and accurate results, improving retrieval quality. The default temperature for most chat models is typically too high for optimal query transformation, leading to reduced retrieval effectiveness.
所以正确写法是给 transformer 一个专用的低温度 ChatClient:
java
@Bean
ChatClient.Builder ragTransformClientBuilder(ChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultOptions(ChatOptions.builder().temperature(0.0).build()); // 第 03 章的 options
}
注意 :ChatClient.Builder 是 prototype 作用域(第 04 章讲过),注入后 .build() 每次得到新实例,修改互不影响。但 RewriteQueryTransformer 内部是 chatClientBuilder.build()(:67)------构造时就 build 了一次并持有 ,后续不再变。所以必须在构造 transformer 之前把 temperature 设好。
7.13.3 档位三:+ 多路扩展(2 次 LLM + 4 次并发检索)
java
@Bean
Advisor multiQueryRagAdvisor(VectorStore vectorStore, ChatClient.Builder builder, TaskExecutor ragExecutor) {
return RetrievalAugmentationAdvisor.builder()
.queryExpander(
MultiQueryExpander.builder()
.chatClientBuilder(builder)
.numberOfQueries(3)
.includeOriginal(true)
.build())
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.topK(4)
.build())
// 必须配 joiner?不配也行,默认是 ConcatenationDocumentJoiner
.taskExecutor(ragExecutor)
.build();
}
召回量估算 :4 query × topK 4 = 16 → 去重后约 8-12 篇 → 全部进 prompt。
务必配一个 token 预算后处理器(第 08 章给实现):
java
.documentPostProcessors(new TokenBudgetPostProcessor(3000))
7.13.4 档位四:生产级(含动态过滤、空上下文、token 预算、文档回传)
java
@Configuration
public class RagConfig {
/** 专用线程池:必须有界队列 + 背压 + 优雅关闭 */
@Bean
TaskExecutor ragTaskExecutor() {
ThreadPoolTaskExecutor e = new ThreadPoolTaskExecutor();
e.setThreadNamePrefix("rag-");
e.setCorePoolSize(8);
e.setMaxPoolSize(32);
e.setQueueCapacity(200); // 关键:无界队列会让 maxPoolSize 失效
e.setRejectedExecutionHandler(new CallerRunsPolicy()); // 背压
e.setTaskDecorator(new ContextPropagatingTaskDecorator()); // trace 传播
e.setWaitForTasksToCompleteOnShutdown(true);
e.setAwaitTerminationSeconds(30);
e.initialize();
return e;
}
/** 变换用的低温度 client */
@Bean
@Scope("prototype")
ChatClient.Builder ragTransformBuilder(ChatModel chatModel) {
return ChatClient.builder(chatModel)
.defaultOptions(ChatOptions.builder().temperature(0.0).build());
}
@Bean
Advisor productionRagAdvisor(VectorStore vectorStore,
ChatClient.Builder ragTransformBuilder,
TaskExecutor ragTaskExecutor) {
return RetrievalAugmentationAdvisor.builder()
// 1. 指代消解:把"它第二大城市"补成完整问题
.queryTransformers(
CompressionQueryTransformer.builder()
.chatClientBuilder(ragTransformBuilder)
.build())
// 2. 三路检索
.queryExpander(
MultiQueryExpander.builder()
.chatClientBuilder(ragTransformBuilder)
.numberOfQueries(3)
.includeOriginal(true)
.build())
// 3. 检索器:动态过滤 + 相似度阈值
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.similarityThreshold(0.5)
.topK(5)
.build())
// 4. 后处理:token 预算保护(自定义,第 08 章)
.documentPostProcessors(new TokenBudgetPostProcessor(3000))
// 5. 增强:允许空上下文(不硬拒答)
.queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(false) // 保持默认:空上下文时明确拒答
.promptTemplate(new PromptTemplate("""
请仅依据以下资料回答问题,不要使用资料之外的知识。
=====================
{context}
=====================
规则:
1. 资料中没有答案时,直接回答"根据现有资料无法回答"。
2. 不要说"根据上下文"这类措辞。
3. 每个结论后用 [编号] 标注引用的资料序号。
问题:{query}
回答:
"""))
.build())
.taskExecutor(ragTaskExecutor)
.order(0)
.build();
}
}
运行时动态过滤(多租户场景最常用):
java
String answer = chatClient.prompt()
.advisors(productionRagAdvisor)
// 关键:这行的 param 会进 advisor context,进而进 Query.context
.advisors(a -> a.param(VectorStoreDocumentRetriever.FILTER_EXPRESSION,
"tenant_id == '" + tenantId + "'"))
.user(question)
.call()
.content();
这条链路是怎么打通的(把三章的知识串起来):
scss
advisors(a -> a.param(K, V))
→ ChatClientRequest.context[K] = V (第 04 章 AdvisorSpec)
→ RAG.before(): new HashMap<>(request.context()) (:108)
→ Query.builder().context(context) (:115)
→ VectorStoreDocumentRetriever.retrieve(query)
→ computeRequestFilterExpression(query)
→ query.context().get(FILTER_EXPRESSION) (:111)
→ 是 Filter.Expression?直接用
→ 是 String?FilterExpressionTextParser().parse() (:117)
→ SearchRequest.builder().filterExpression(...) (:91)
→ vectorStore.similaritySearch(searchRequest) (:95)
这是一个跨模块协作的完整闭环 ,也是"为什么 context 要设计成 Map<String, Object>"的最好注解------它是唯一 能把运行时参数从最外层的 ChatClient 一路带到最内层 VectorStore 的通道。
⚠️ 注意 FILTER_EXPRESSION 的两种类型都支持:
java
// VectorStoreDocumentRetriever.java:110-121
private Filter.Expression computeRequestFilterExpression(Query query) {
var contextFilterExpression = query.context().get(FILTER_EXPRESSION);
if (contextFilterExpression != null) {
if (contextFilterExpression instanceof Filter.Expression) {
return (Filter.Expression) contextFilterExpression;
}
else if (StringUtils.hasText(contextFilterExpression.toString())) {
return new FilterExpressionTextParser().parse(contextFilterExpression.toString());
}
}
return this.filterExpression.get();
}
- 传
Filter.Expression对象 → 直接用(类型安全,推荐) - 传
String→ 用FilterExpressionTextParser解析(SQL 风格,如"type == 'Spring'")
优先级 :context 里的 > 构造器配的 Supplier。context 为空时才 this.filterExpression.get()(:120)。
Supplier 的意义 (源码注释 :67-69):
java
// Supplier to allow for lazy evaluation of the filter expression,
// which may depend on the execution content. For example, you may want to
// filter dynamically based on the current user's identity or tenant ID.
延迟求值。 如果你用 SecurityContextHolder 拿当前用户,构造时(可能还没认证)求值会失败,用 Supplier 就能推迟到检索那一刻。
java
VectorStoreDocumentRetriever.builder()
.vectorStore(vs)
.filterExpression(() -> {
String tenant = SecurityContextHolder.getContext().getAuthentication().getName();
return new FilterExpressionBuilder().eq("tenant_id", tenant).build();
})
.build();
注意 Builder 有两个重载(:158-166):
java
public Builder filterExpression(Filter.Expression filterExpression) {
this.filterExpression = () -> filterExpression; // 包装成常量 Supplier
return this;
}
public Builder filterExpression(Supplier<Filter.Expression> filterExpression) {
this.filterExpression = filterExpression;
return this;
}
传值版本被包成 () -> filterExpression,所以内部统一是 Supplier。
7.14 陷阱总表(本章 16 个 + 2 个补充)
| # | 陷阱 | 触发条件 | 后果 | 位置 |
|---|---|---|---|---|
| 1 | 纯多模态请求崩溃 | user message 无文本 | IllegalArgumentException: text cannot be null or empty |
:111-116 |
| 2 | 每个 transformer 一次 LLM | 配 N 个 transformer | 成本/延迟 ×(N+1) | :120-122 |
| 3 | transformer 有损且不可逆 | 任何 transformer | 检索 query ≠ 用户原话 | §7.4.2 |
| 4 | transformer 异常不降级 | LLM 调用失败 | 整个请求失败 | :121 |
| 5 | MultiQuery 行数严格校验 | LLM 返回行数 ≠ 3 | 静默退回单查询 | MultiQueryExpander:118 |
| 6 | 单路检索失败全批失败 | 任一 query 检索异常 | CompletionException |
:133 |
| 7 | HashMap 导致顺序不确定 | 多路检索 | 同分文档顺序随机 | :134 |
| 8 | 后处理用 originalQuery | 配了大改写的 transformer | 重排序/增强失准 | :142,147 |
| 9 | user message media 保留 | --- | 实际无问题(mutate() 保留 media) |
Prompt:283 |
| 10 | 替换"最后一条"USER | few-shot 以 ASSISTANT 结尾 | 替换错消息 | Prompt:263-268 |
| 11 | 默认线程池无界队列 | 不配 taskExecutor |
maxPoolSize=16 失效,可能 OOM |
:194-202 |
| 12 | 默认线程池不关闭 | 每次请求 new advisor | 线程泄漏 | :97,194 |
| 13 | 流式线程跳跃多 | 流式 + MultiQuery | 上下文切换开销 | §7.8.3 |
| 14 | memory 必须在外层 | 用 CompressionQueryTransformer |
指代消解失效 | §7.9 |
| 14b | RAG 默认在工具循环内 | 有工具调用 | 每轮迭代重跑检索 | §7.9 |
| 15 | 多路召回不截断 | 配 MultiQueryExpander | prompt 暴涨,可能超窗口 | §7.11.2 |
| 16 | 自定义模板缺占位符 | 自定义 promptTemplate | IllegalArgumentException |
PromptAssert |
| 17 | toMap 重复键崩溃 |
LLM 生成重复 query | IllegalStateException: Duplicate key |
:134 |
| 18 | score 为"距离"时排序反了 | 自定义 retriever 返回距离 | 最不相关的排最前 | ConcatenationDocumentJoiner:60-62 |
7.15 本章小结
如果本章你只记五条,记这五条:
第一条:before() 的七步流水线里,检索用 transformedQuery,提问用 originalQuery。 这是 §7.4.6 的核心结论,也是 RAG 语义的关键设计。查询改写只影响"找什么",不影响"问什么"。
第二条:Step 3 的 .toList().stream() 是承重的,删掉它就从并发退化成串行。 Java Stream 的惰性求值决定了:不先物化所有 CompletableFuture,join() 就会逐个阻塞。这是本章最容易在重构中踩坏的一行,而且踩坏了没有任何报错。
第三条:默认线程池的队列是无界的,maxPoolSize=16 永远不生效;且没人负责关闭它。 生产环境必须自建 TaskExecutor(有界队列 + CallerRunsPolicy + 优雅关闭),并把 advisor 做成单例 bean。
第四条:RAG 默认 order=0,落在工具循环「里面」。 ToolCallingAdvisor 是 HIGHEST_PRECEDENCE + 300,MessageChatMemoryAdvisor 是 +200。所以默认排序是 memory ⊃ tool ⊃ RAG ⊃ ChatModel。这意味着每轮工具迭代都会重跑一次检索------对 Agentic RAG 是特性,对普通问答是浪费。
第五条:一次 RAG 调用的 LLM 次数 = 变换器数 + 扩展器(0/1) + 后处理器中的 LLM 数 + 1。 朴素 RAG 是 1 次;3 变换 + 扩展是 5 次。向量检索是并发的(max(V)),变换是串行的(T × L)------这个不对称是优化的关键切入点。
本章的"不存在清单"(避免你按旧资料找错):
- ✗
DocumentRanker------ 全库无此类,重排序归DocumentPostProcessor - ✗
DocumentPostProcessor的任何内置实现 ------ 接口有,实现零个 - ✗ 多
DocumentRetriever支持 ------ 构造器只收一个,List<List<Document>>的内层永远是 size 1 - ✗
ConcatenationDocumentJoiner的 RRF ------ Javadoc 提到但没实现 - ✗ token 预算/长度截断 ------ 全线没有
本章发现的"注释与实现脱节"(累计 2 处):
getDocumentsForQuery的 Javadoc 说 "document retrievers"(复数),实现只有一个(:156-163)ConcatenationDocumentJoiner的 Javadoc 承诺"保留第一个重复项",但外层是HashMap,"第一个"不确定(:38-40)
本章覆盖的是架构与编排 。第 08 章会把六个 SPI 的每一个实现(含 QuestionAnswerAdvisor)逐个剖到源码级,并给出四个可直接投产的自定义组件:RRF joiner、token 预算后处理器、容错 transformer/retriever、带引用编号的 augmenter。
7.16 思考题
第 1 题:Step 3 的并发代码是:
java
expandedQueries.stream()
.map(query -> CompletableFuture.supplyAsync(() -> getDocumentsForQuery(query), this.taskExecutor))
.toList()
.stream()
.map(CompletableFuture::join)
.collect(Collectors.toMap(...));
如果把它"简化"成单阶段(删掉 .toList().stream()),会发生什么?请用 Java Stream 的惰性求值 语义解释为什么。进一步:如果改成 expandedQueries.parallelStream().map(...).collect(...) 会怎样?哪个更好?为什么 Spring AI 没用 parallelStream?
第 2 题 :Objects.requireNonNullElse(text, "") 之后紧接 Query.Builder.build() 的 Assert.hasText(text)。请证明:当 user message 无文本时,这行代码必然 抛异常,因此 requireNonNullElse 是徒劳的。然后给出两种修复方案,并说明它们各自的语义差异("跳过 RAG" vs "用占位符检索")。
第 3 题 :MultiQueryExpander 要求 LLM 返回的行数精确等于 numberOfQueries,否则整体放弃扩展。请估算在真实模型上这个校验的通过率,并说明:为什么"通过率不高"这件事既让扩展经常失效、又不会被人察觉?(提示:想想失败时的日志级别和用户看到的结果。)
第 4 题 :ConcatenationDocumentJoiner 按 score 降序排列,并假设"score 越大越相似"。请说明:如果你接入一个返回余弦距离(0=最相似,2=最不相似)的数据源会发生什么?这种 bug 的表现形式是什么(会不会报错)?如何设计测试才能捕获它?
第 5 题 :默认 order=0 让 RAG 落在 ToolCallingAdvisor(HIGHEST_PRECEDENCE + 300)之内 。请推导:一次触发 3 轮工具调用的对话,在第 13.3 档位配置(MultiQuery,4 路)下会产生多少次向量检索、多少次 LLM 调用?如果你只想检索一次,order 应该设成多少?设成 HIGHEST_PRECEDENCE + 250 和 HIGHEST_PRECEDENCE + 150 分别会怎样?
第 6 题 :buildDefaultTaskExecutor() 创建的 ThreadPoolTaskExecutor 没有设置 queueCapacity,默认是 Integer.MAX_VALUE。请说明:为什么这导致 maxPoolSize=16 永远不会生效?请画出 JDK 线程池的任务提交决策流程(线程数 < core?入队?线程数 < max?拒绝?),并标出无界队列在哪一步"吃掉"了后续分支。最后说明 CallerRunsPolicy 相比 AbortPolicy 在这里为什么更好。
第 7 题 :Query 是 record(字段 final),但它的 context 字段是一个不拷贝 的 Map 引用。RetrievalAugmentationAdvisor:144 的 context.put(DOCUMENT_CONTEXT, documents) 会同时反映到已构造的 originalQuery 上。请说明这个"浅不可变"设计在什么场景下会出问题,以及为什么 Spring AI 仍然选择它(提示:想想 §7.13.4 里 FILTER_EXPRESSION 的传递链------如果 Query 做了深拷贝,这条链会怎样)。
7.17 关键文件索引
css
spring-ai-rag/src/main/java/org/springframework/ai/rag/
├── Query.java 96 行
│ :37 record Query(String text, List<Message> history, Map<String,Object> context)
│ :39-45 紧凑构造器 5 道断言(text 用 hasText!)
│ :47-49 Query(String text) 便捷构造
│ :51-53 mutate() → Builder 预填
│ :60-64 Builder 字段默认值
│ :78-80 build() 的 Assert.hasText
├── advisor/RetrievalAugmentationAdvisor.java 297 行 ★★
│ :62 public final class ... implements BaseAdvisor
│ :64 DOCUMENT_CONTEXT = "rag_document_context"
│ :66-82 9 个 final 字段
│ :84-100 构造器与默认值表
│ :106-154 before() 七步流水线
│ :108 new HashMap<>(request.context()) 防御性拷贝
│ :111 getUserMessage().getText() @Nullable
│ :113 requireNonNullElse(text, "") ← 陷阱 1
│ :114 history = prompt().getInstructions()
│ :115 .context(context) ← 共享引用
│ :119-122 Step 1 transformer 折叠
│ :125-126 Step 2 expander 分岔
│ :129-134 Step 3 并发检索(.toList() 承重)
│ :138 Step 4 joiner
│ :141-143 Step 5 postProcessors(用 originalQuery!)
│ :144 context.put(DOCUMENT_CONTEXT, documents)
│ :147 Step 6 augmenter(用 originalQuery!)
│ :150-153 Step 7 mutate prompt
│ :156-163 getDocumentsForQuery(注释说复数,实现是单数)
│ :165-182 after() 文档回传
│ :184-192 getScheduler() / getOrder()
│ :194-202 buildDefaultTaskExecutor()(无界队列!)
│ :204-295 Builder(11 方法)
├── generation/augmentation/
│ ├── QueryAugmenter.java 46 行
│ └── ContextualQueryAugmenter.java 176 行
│ :53-70 DEFAULT_PROMPT_TEMPLATE(原样见 §7.13.4 的改写版)
│ :72-75 DEFAULT_EMPTY_CONTEXT_PROMPT_TEMPLATE
│ :77 DEFAULT_ALLOW_EMPTY_CONTEXT = false
│ :82-84 DEFAULT_DOCUMENT_FORMATTER(纯文本 join,无预算)
│ :102 PromptAssert 校验 {query},{context}
│ :112-114 空文档分支
│ :126-133 augmentQueryWhenEmptyContext
├── postretrieval/document/DocumentPostProcessor.java 45 行(无实现)
├── preretrieval/query/transformation/
│ ├── QueryTransformer.java 44 行
│ ├── CompressionQueryTransformer.java 139 行
│ │ :49-61 DEFAULT_PROMPT_TEMPLATE({history},{query})
│ │ :97-107 formatConversationHistory(只留 USER/ASSISTANT)
│ ├── RewriteQueryTransformer.java 134 行
│ │ :45-53 DEFAULT_PROMPT_TEMPLATE({target},{query})
│ │ :55 DEFAULT_TARGET = "vector store"
│ └── TranslationQueryTransformer.java 144 行
│ :54-63 DEFAULT_PROMPT_TEMPLATE({targetLanguage},{query})
├── preretrieval/query/expansion/
│ ├── QueryExpander.java 45 行
│ └── MultiQueryExpander.java 184 行
│ :56-70 DEFAULT_PROMPT_TEMPLATE({number},{query})
│ :72 DEFAULT_INCLUDE_ORIGINAL = true
│ :74 DEFAULT_NUMBER_OF_QUERIES = 3
│ :116-124 严格行数校验(陷阱 5)
│ :131-134 add(0, query) 原 query 插最前
├── retrieval/join/
│ ├── DocumentJoiner.java 48 行
│ └── ConcatenationDocumentJoiner.java 66 行
│ :47-64 join()(双层 flatMap + toMap + 降序排序)
├── retrieval/search/
│ ├── DocumentRetriever.java 47 行
│ └── VectorStoreDocumentRetriever.java 176 行
│ :59 FILTER_EXPRESSION = "vector_store_filter_expression"
│ :67-70 Supplier 延迟求值的设计意图(原注释)
│ :75-77 similarityThreshold >= 0 / topK > 0 校验
│ :110-121 computeRequestFilterExpression(context 优先)
│ :158-166 两个 filterExpression 重载
└── util/PromptAssert.java 59 行
:42-57 templateHasRequiredPlaceholders(朴素 contains)
跨模块引用(本章依赖的前序章节结论):
ini
spring-ai-client-chat/.../advisor/api/
├── Advisor.java:39 DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER = HIGHEST+200
├── BaseAdvisor.java:44 DEFAULT_SCHEDULER = Schedulers.boundedElastic()
└── DefaultAroundAdvisorChain.java:183-189 copy(after) = subList(idx+1, size)
spring-ai-client-chat/.../advisor/
└── ToolCallingAdvisor.java:75 DEFAULT_ORDER = HIGHEST + 300
spring-ai-model/.../chat/prompt/
└── Prompt.java:261-275 augmentUserMessage(从后往前找 USER)
spring-ai-commons/.../document/
└── Document.java:220-223 id/text/media/metadata/score 的断言
spring-ai-vector-store/.../
└── SearchRequest.java:44,49 SIMILARITY_THRESHOLD_ACCEPT_ALL=0.0 / DEFAULT_TOP_K=4
7.18 复核命令(可直接在仓库里跑)
shell
cd /d/CODE/spring-ai-2.0.1
# 1. 确认 spring-ai-rag 只有 28 个源文件,其中 12 个是 package-info
find spring-ai-rag/src/main -name "*.java" | wc -l # 28
find spring-ai-rag/src/main -name "package-info.java" | wc -l # 12
find spring-ai-rag/src/main -name "*.java" -exec cat {} + | wc -l # 2031
# 2. 确认 DocumentRanker 不存在(应无输出)
find . -name "DocumentRanker.java" -not -path "*/target/*"
# 3. 确认 DocumentPostProcessor 零实现(应只出接口自己)
grep -rln "implements DocumentPostProcessor" --include=*.java . | grep -v target
# 4. 确认全库只有 1 个 DocumentRetriever 实现
grep -rln "implements DocumentRetriever" --include=*.java . | grep -v target # 1 行
# 5. 复现陷阱 1:Query.Builder 对空串的反应
grep -n "Assert.hasText" spring-ai-rag/src/main/java/org/springframework/ai/rag/Query.java
grep -n "requireNonNullElse" spring-ai-rag/src/main/java/org/springframework/ai/rag/advisor/RetrievalAugmentationAdvisor.java
# 6. 确认 .toList().stream() 的存在(承重的两行)
sed -n '129,134p' spring-ai-rag/src/main/java/org/springframework/ai/rag/advisor/RetrievalAugmentationAdvisor.java
# 7. 确认 Step5/Step6 用的是 originalQuery 而非 transformedQuery
sed -n '141,147p' spring-ai-rag/src/main/java/org/springframework/ai/rag/advisor/RetrievalAugmentationAdvisor.java
# 8. 确认默认线程池参数(注意:没有 setQueueCapacity)
sed -n '194,202p' spring-ai-rag/src/main/java/org/springframework/ai/rag/advisor/RetrievalAugmentationAdvisor.java
# 9. 确认 order 常量
grep -n "DEFAULT_CHAT_MEMORY_PRECEDENCE_ORDER" spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/api/Advisor.java
grep -n "DEFAULT_ORDER" spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ToolCallingAdvisor.java
grep -n "Ordered.LOWEST_PRECEDENCE" spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/ChatModelCallAdvisor.java
# 10. 确认 copy(after) 的 subList 语义
sed -n '169,190p' spring-ai-client-chat/src/main/java/org/springframework/ai/chat/client/advisor/DefaultAroundAdvisorChain.java
# 11. 原样查看 6 个默认 prompt 模板
sed -n '53,70p' spring-ai-rag/src/main/java/org/springframework/ai/rag/generation/augmentation/ContextualQueryAugmenter.java
sed -n '49,61p' spring-ai-rag/src/main/java/org/springframework/ai/rag/preretrieval/query/transformation/CompressionQueryTransformer.java
sed -n '45,53p' spring-ai-rag/src/main/java/org/springframework/ai/rag/preretrieval/query/transformation/RewriteQueryTransformer.java
sed -n '54,63p' spring-ai-rag/src/main/java/org/springframework/ai/rag/preretrieval/query/transformation/TranslationQueryTransformer.java
sed -n '56,70p' spring-ai-rag/src/main/java/org/springframework/ai/rag/preretrieval/query/expansion/MultiQueryExpander.java
# 12. 确认 MultiQueryExpander 的严格行数校验
sed -n '116,124p' spring-ai-rag/src/main/java/org/springframework/ai/rag/preretrieval/query/expansion/MultiQueryExpander.java
# 13. 确认 joiner 的排序假设(score 越大越相似)
sed -n '58,64p' spring-ai-rag/src/main/java/org/springframework/ai/rag/retrieval/join/ConcatenationDocumentJoiner.java
# 14. 确认依赖层次(pom 只有 2 个生产依赖)
grep -n "artifactId" spring-ai-rag/pom.xml
# 15. 确认 @since 全部是 1.0.0
grep -rn "@since" spring-ai-rag/src/main/java/org/springframework/ai/rag/ | grep -v package-info | sort -u
下一章 :第 08 章逐个拆解六个 SPI 的实现(QuestionAnswerAdvisor、ContextualQueryAugmenter、三个 transformer、MultiQueryExpander、VectorStoreDocumentRetriever),并给出四个可直接投产的自定义组件。