Spring AI 2.0.1 深度解析-第 08 章 RAG 组件逐个拆解:实现细节与八个可投产组件
本章目标 :把第 07 章那六个 SPI 的全部内置实现 剖到源码级,并补齐官方没写但生产必需的四类组件:
DocumentPostProcessor(token 预算 / lost-in-the-middle 重排 / 内容去重)、RRF 多路融合DocumentJoiner、容错包装器、带引用编号的QueryAugmenter。取证方式 :全部来自
spring-ai-2.0.1实际源码,标注文件路径与行号。本章所有自定义组件代码都是可编译的,不是示意伪代码。
8.0 本章导读:官方给了什么,没给什么
第 07 章结尾列过一张"不存在清单"。把它翻过来,就是本章的任务:
| 你需要的能力 | 官方是否提供 | 本章处理 |
|---|---|---|
| 单次检索 + 拼 prompt | ✓ QuestionAnswerAdvisor |
§8.1 拆解 |
| 上下文拼装 | ✓ ContextualQueryAugmenter |
§8.2 拆解 |
| 查询改写 | ✓ RewriteQueryTransformer |
§8.4.2 |
| 指代消解 | ✓ CompressionQueryTransformer |
§8.4.1 |
| 查询翻译 | ✓ TranslationQueryTransformer |
§8.4.3 |
| 多路扩展 | ✓ MultiQueryExpander(但很脆) |
§8.3 拆解 + 强化版 |
| 向量检索 | ✓ VectorStoreDocumentRetriever |
§8.5 拆解 |
| 结果合并 | ✓ ConcatenationDocumentJoiner(无 RRF) |
§8.6 拆解 + 补 RRF |
| token 预算截断 | ✗ | §8.7.1 给实现 |
| lost-in-the-middle 重排 | ✗ | §8.7.2 给实现 |
| 内容级去重 | ✗ | §8.7.3 给实现 |
| 失败降级 | ✗ | §8.8 给实现 |
| 引用编号 | ✗ | §8.9 给实现 |
一句话概括:官方提供了"流水线骨架",没提供"生产护栏"。 本章干的活就是把护栏补上。
8.1 QuestionAnswerAdvisor:179 行的另一个 RAG 入口
第 07 章 §7.10 做过简略对照,这里完整拆。
8.1.1 类声明:public class,不是 final
java
// advisors/spring-ai-vector-store-advisor/.../QuestionAnswerAdvisor.java:55
public class QuestionAnswerAdvisor implements BaseAdvisor {
对比 RetrievalAugmentationAdvisor(:62):
java
public final class RetrievalAugmentationAdvisor implements BaseAdvisor {
一个是 public class,一个是 public final class。 这不是随意的------QuestionAnswerAdvisor 里有一个 protected 方法等着你覆写:
java
// :158-164
protected Filter.@Nullable Expression doGetFilterExpression(Map<String, @Nullable Object> context) {
Object ctxFilterExpr = context.get(FILTER_EXPRESSION);
if (ctxFilterExpr == null || !StringUtils.hasText(ctxFilterExpr.toString())) {
return this.searchRequest.getFilterExpression();
}
return new FilterExpressionTextParser().parse(ctxFilterExpr.toString());
}
protected + 非 final = 明确的扩展点。 你可以继承它,覆写过滤表达式的解析逻辑(比如从 Spring Security 拿租户,或者注入额外的强制过滤条件做"行级权限")。
RetrievalAugmentationAdvisor 是 final,对应地没有任何 protected 方法 ------它的扩展方式只有"换组件"一条路。两个类的扩展哲学完全不同。
8.1.2 两个 context 常量
java
// :57-59
public static final String RETRIEVED_DOCUMENTS = "qa_retrieved_documents";
public static final String FILTER_EXPRESSION = "qa_filter_expression";
注意命名差异(这是迁移时最容易出错的地方):
| 用途 | QuestionAnswerAdvisor |
RetrievalAugmentationAdvisor 生态 |
|---|---|---|
| 过滤表达式 key | "qa_filter_expression" |
"vector_store_filter_expression"(VectorStoreDocumentRetriever:59) |
| 检索结果 metadata key | "qa_retrieved_documents" |
"rag_document_context"(RetrievalAugmentationAdvisor:64) |
两个 key 完全不同,且都是字符串字面量。 如果你从 QA 迁移到 RAG,所有 resp.getMetadata().get("qa_retrieved_documents") 都要改成 "rag_document_context",而且编译期不会报错 ------只是拿到 null。
这是一个典型的"字符串常量 API"的代价。缓解方式:自己定义常量类,别散落在业务代码里。
8.1.3 默认 prompt 模板(原样摘录)
java
// :61-73
private static final PromptTemplate DEFAULT_PROMPT_TEMPLATE = new PromptTemplate("""
{query}
Context information is below, surrounded by ---------------------
---------------------
{question_answer_context}
---------------------
Given the context and provided history information and not prior knowledge,
reply to the user comment. If the answer is not in the context, inform
the user that you can't answer the question.
""");
和 ContextualQueryAugmenter 的默认模板对比 (ContextualQueryAugmenter.java:53-70):
java
private static final PromptTemplate DEFAULT_PROMPT_TEMPLATE = new PromptTemplate("""
Context information is below.
---------------------
{context}
---------------------
Given the context information and no prior knowledge, answer the query.
Follow these rules:
1. If the answer is not in the context, just say that you don't know.
2. Avoid statements like "Based on the context..." or "The provided information...".
Query: {query}
Answer:
""");
四处实质差异:
- 占位符名 :
{question_answer_context}vs{context}------ 迁移时必须改 - query 位置 :QA 把
{query}放在最前面 ;RAG 把{query}放在最后面 - 措辞约束:RAG 版明确禁止 "Based on the context..." 这类措辞;QA 版没有
- 结尾 :RAG 版有
Answer:引导词;QA 版没有
第 2 点值得单独说。 研究表明,把问题放在上下文之后(末尾)通常效果更好------模型在读完资料后立刻看到问题,注意力更集中。把问题放最前面,长上下文会把问题"冲淡"(这正是 lost-in-the-middle 现象)。
所以在这一点上 ContextualQueryAugmenter 的默认模板优于 QuestionAnswerAdvisor 的。如果你的 QA 效果不好,光换到 RAG advisor 就可能提升------不是因为架构,而是因为模板。
8.1.4 before() 的四步
java
// :108-141
@Override
public ChatClientRequest before(ChatClientRequest chatClientRequest, AdvisorChain advisorChain) {
// 1. Search for similar documents in the vector store.
var searchRequestBuilder = SearchRequest.from(this.searchRequest)
.query(Objects.requireNonNullElse(chatClientRequest.prompt().getUserMessage().getText(), ""));
var filterExpr = doGetFilterExpression(chatClientRequest.context());
if (filterExpr != null) {
searchRequestBuilder.filterExpression(filterExpr);
}
var searchRequestToUse = searchRequestBuilder.build();
List<Document> documents = this.vectorStore.similaritySearch(searchRequestToUse);
// 2. Create the context from the documents.
Map<String, Object> context = new HashMap<>(chatClientRequest.context());
context.put(RETRIEVED_DOCUMENTS, documents);
String documentContext = documents.stream()
.map(Document::getText)
.collect(Collectors.joining(System.lineSeparator()));
// 3. Augment the user prompt with the document context.
UserMessage userMessage = chatClientRequest.prompt().getUserMessage();
String augmentedUserText = this.promptTemplate
.render(Map.of("query", userMessage.getText(), "question_answer_context", documentContext));
// 4. Update ChatClientRequest with augmented prompt.
return chatClientRequest.mutate()
.prompt(chatClientRequest.prompt().augmentUserMessage(augmentedUserText))
.context(context)
.build();
}
四个要点:
要点 1:SearchRequest.from() 是"拷贝再改"
java
var searchRequestBuilder = SearchRequest.from(this.searchRequest).query(...);
SearchRequest.from()(SearchRequest.java:71-77)复制 query/topK/similarityThreshold/filterExpression 四个字段到新 Builder。
为什么要拷贝? 因为 this.searchRequest 是构造时固定、跨请求共享的模板。如果直接改它,多线程下会互相污染。
这是第 06 章那条"advisor 必须无状态"的具体落地:共享对象只读,每次请求复制一份再改。
对比 RetrievalAugmentationAdvisor 的 VectorStoreDocumentRetriever.retrieve()(:89-94)------它直接用 SearchRequest.builder() 新建,效果相同但没显式复制。
要点 2:空文本处理比 RAG advisor 温和,但仍然会炸
java
.query(Objects.requireNonNullElse(chatClientRequest.prompt().getUserMessage().getText(), ""))
SearchRequest.Builder.query(String) 只 Assert.notNull(SearchRequest.java:150 附近),所以 "" 能通过。这一步不炸。
但第 3 步:
java
Map.of("query", userMessage.getText(), ...)
Map.of() 不允许 null 值 ------传 null 直接 NullPointerException。
所以:
| Advisor | 空文本时的行为 | 异常类型 | 位置 |
|---|---|---|---|
QuestionAnswerAdvisor |
检索用空串(返回一堆随机结果),渲染时崩 | NullPointerException |
:134 |
RetrievalAugmentationAdvisor |
构造 Query 时就崩 | IllegalArgumentException |
:113-116 |
两个都会崩,只是时机和类型不同。
QuestionAnswerAdvisor 更糟一点:它先做了一次无意义的向量检索(用空串去查,返回 topK 个语义无关文档),然后才崩。浪费一次检索。
要点 3:文档拼接与 RAG 的 DEFAULT_DOCUMENT_FORMATTER 完全一致
java
// QuestionAnswerAdvisor.java:127-129
String documentContext = documents.stream()
.map(Document::getText)
.collect(Collectors.joining(System.lineSeparator()));
java
// ContextualQueryAugmenter.java:82-84
private static final Function<List<Document>, String> DEFAULT_DOCUMENT_FORMATTER = documents -> documents.stream()
.map(Document::getText)
.collect(Collectors.joining(System.lineSeparator()));
一模一样的代码,重复了两遍。 都用 System.lineSeparator()(Windows 上是 \r\n,Linux 上是 \n)------这意味着同样的配置在不同 OS 上产出的 prompt 字节数不同,对 token 计数有微小影响。
两处都没有:
- 文档序号(无法引用"第 3 篇")
- 来源标注(无法引用"来自 xx.pdf")
- token 预算(可能超窗口)
- null 文本保护(
Document.getText()是@Nullable,.map(Document::getText)会产生 null 元素,Collectors.joining对 null 元素会拼出字符串"null")
最后一条很隐蔽 :如果向量库返回一个 Media 型文档(text 为 null),拼出来的上下文里会有一行字面的 null。模型会看到 "null" 然后困惑。
要点 4:after() 的 metadata 回传
java
// :143-156
@Override
public ChatClientResponse after(ChatClientResponse chatClientResponse, AdvisorChain advisorChain) {
ChatResponse.Builder chatResponseBuilder = ChatResponse.builder();
if (chatClientResponse.chatResponse() != null) {
chatResponseBuilder.from(chatClientResponse.chatResponse());
}
if (chatClientResponse.context().get(RETRIEVED_DOCUMENTS) != null) {
chatResponseBuilder.metadata(RETRIEVED_DOCUMENTS, chatClientResponse.context().get(RETRIEVED_DOCUMENTS));
}
return ChatClientResponse.builder()
.chatResponse(chatResponseBuilder.build())
.context(chatClientResponse.context())
.build();
}
与 RetrievalAugmentationAdvisor.after()(:165-182)逻辑完全相同,只是常量名不同。
唯一写法差异:QA 用 if (chatResponse() != null) builder.from(...),RAG 用 if/else 分岔。又是同一件事两种写法。
8.1.5 protectFromBlocking(boolean):一个容易被忽略的开关
java
// :200-203
public Builder protectFromBlocking(boolean protectFromBlocking) {
this.scheduler = protectFromBlocking ? BaseAdvisor.DEFAULT_SCHEDULER : Schedulers.immediate();
return this;
}
BaseAdvisor.DEFAULT_SCHEDULER = Schedulers.boundedElastic();Schedulers.immediate() 是"在当前线程立即执行,不切换"。
RetrievalAugmentationAdvisor 没有这个开关 ------它只有 scheduler(Scheduler),默认 boundedElastic,不能关。
什么时候想关?当你的 VectorStore 是纯内存实现 (如 SimpleVectorStore)且你已经确定不在 Reactor 的 event loop 上时,切线程是纯浪费。
但默认开着是对的 ------因为绝大多数 VectorStore 实现都会做阻塞 IO(JDBC / HTTP)。关掉前请确认你的调用栈不在 Netty worker 线程上。
8.1.6 迁移检查清单(QA → RAG)
如果你从 QuestionAnswerAdvisor 迁到 RetrievalAugmentationAdvisor,逐项核对:
- 依赖:
spring-ai-vector-store-advisor→ 加spring-ai-rag(两个可共存) - 自定义模板占位符:
{question_answer_context}→{context} - metadata 读取:
"qa_retrieved_documents"→"rag_document_context" - 过滤表达式 key:
"qa_filter_expression"→"vector_store_filter_expression" -
SearchRequest配置位置:从 advisor 的 builder → 移进VectorStoreDocumentRetriever的 builder - 空上下文语义:QA 总是 拼进(哪怕空),RAG 默认拒答 。要保持一致就配
allowEmptyContext(true) -
protectFromBlocking:RAG 无此开关,改用scheduler(Schedulers.immediate()) - 继承扩展:QA 可继承覆写
doGetFilterExpression,RAG 是final,只能换组件
8.2 ContextualQueryAugmenter:把文档变成 prompt 的那 176 行
8.2.1 三个默认模板
java
// :53-70 主模板(已在 §8.1.3 摘录,此处略)
// :72-75 空上下文模板
private static final PromptTemplate DEFAULT_EMPTY_CONTEXT_PROMPT_TEMPLATE = new PromptTemplate("""
The user query is outside your knowledge base.
Politely inform the user that you can't answer it.
""");
// :77 默认策略
private static final boolean DEFAULT_ALLOW_EMPTY_CONTEXT = false;
⚠️ 这是本章最需要警惕的默认行为。
当检索结果为空(documents.isEmpty())且 allowEmptyContext=false 时:
java
// :126-133
private Query augmentQueryWhenEmptyContext(Query query) {
if (this.allowEmptyContext) {
logger.debug("Empty context is allowed. Returning the original query.");
return query;
}
logger.debug("Empty context is not allowed. Returning a specific query for empty context.");
return new Query(this.emptyContextPromptTemplate.render());
}
用户的原始问题被完全丢弃,替换成 "The user query is outside your knowledge base. Politely inform the user that you can't answer it."
然后 RetrievalAugmentationAdvisor:151:
java
.prompt(chatClientRequest.prompt().augmentUserMessage(augmentedQuery.text()))
用户消息被整段替换成了这句英文。 模型收到的 prompt 里完全没有用户的问题。
后果:
- 答案永远是英文的(哪怕用户问中文)------因为指令是英文
- 答案不包含上下文(因为替换掉的是 user message,system 还在,但用户问题没了)
- 你无法在事后知道用户问了什么(除非自己留日志)
- 多语言产品直接不可接受
这不是 bug,是一个"安全优先"的默认选择:宁可拒答,也不要模型编造。但对中文产品是灾难。
必须改。两种方式:
java
// 方式 A:允许空上下文,把原问题原样发过去(让 system prompt 兜底)
ContextualQueryAugmenter.builder()
.allowEmptyContext(true)
.build();
// 方式 B:自定义空上下文模板(推荐)
ContextualQueryAugmenter.builder()
.allowEmptyContext(false)
.emptyContextPromptTemplate(new PromptTemplate("""
知识库中没有与以下问题相关的资料。
请礼貌地用中文告知用户你无法回答,并说明原因。
不要编造答案。
用户的问题:{query}
你的回复:
"""))
.build();
⚠️ 但方式 B 有个陷阱 :augmentQueryWhenEmptyContext 调的是 emptyContextPromptTemplate.render()(无参 ,:132),所以模板里的 {query} 不会被填充 ------会以字面量 {query} 出现在 prompt 里,或者(取决于 ValidationMode)直接抛异常。
看 StTemplateRenderer 的默认配置:
java
// spring-ai-template-st/.../StTemplateRenderer.java:63-67
private static final char DEFAULT_START_DELIMITER_TOKEN = '{';
private static final char DEFAULT_END_DELIMITER_TOKEN = '}';
private static final ValidationMode DEFAULT_VALIDATION_MODE = ValidationMode.THROW;
ValidationMode.THROW 意味着模板里有未被填充的变量就抛异常 。而 PromptAssert 只校验 {query} 和 {context} 在主模板 里存在(:102),不校验空上下文模板。
所以方式 B 会抛异常。正确做法是方式 A,或者写一个不含任何占位符的空上下文模板。
8.2.2 documentFormatter:唯一的类型化扩展点
java
// :82-84
private static final Function<List<Document>, String> DEFAULT_DOCUMENT_FORMATTER = documents -> documents.stream()
.map(Document::getText)
.collect(Collectors.joining(System.lineSeparator()));
这是一个 Function<List<Document>, String>,你可以整个换掉。这是给文档加编号/来源的最干净入口。
生产可用的实现(带编号 + 来源 + null 保护):
java
public final class NumberedDocumentFormatter implements Function<List<Document>, String> {
@Override
public String apply(List<Document> documents) {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < documents.size(); i++) {
Document doc = documents.get(i);
sb.append("[").append(i + 1).append("] ");
// 来源标注:优先 source 字段,其次文件名,最后用 id
Object source = doc.getMetadata().get("source");
if (source != null) {
sb.append("(来源: ").append(source).append(") ");
}
String text = doc.getText();
sb.append(text != null ? text : "[该文档无文本内容]");
sb.append(System.lineSeparator());
}
return sb.toString();
}
}
配上去:
java
ContextualQueryAugmenter.builder()
.documentFormatter(new NumberedDocumentFormatter())
.promptTemplate(new PromptTemplate("""
仅依据下列资料回答。每个结论后用 [编号] 标注所依据的资料。
=====================
{context}
=====================
规则:
1. 资料中找不到答案时,直接说"根据现有资料无法回答"。
2. 不要使用资料之外的知识。
3. 不要出现"根据上下文"这类措辞。
问题:{query}
回答:
"""))
.build();
这样模型就能输出 [1]、[3] 这样的引用编号,前端可以据此高亮来源。
注意 :Document.getMetadata() 返回的是拷贝 (Document 构造器里 new HashMap<>(metadata),见 Document.java:226),所以读取是安全的,且性能开销可以接受。
8.2.3 augment() 返回的 Query 丢掉了 history 和 context
java
// :105-124
@Override
public Query augment(Query query, List<Document> documents) {
Assert.notNull(query, "query cannot be null");
Assert.notNull(documents, "documents cannot be null");
logger.debug("Augmenting query with contextual data");
if (documents.isEmpty()) {
return augmentQueryWhenEmptyContext(query);
}
// 1. Collect content from documents.
String documentContext = this.documentFormatter.apply(documents);
// 2. Define prompt parameters.
Map<String, Object> promptParameters = Map.of("query", query.text(), "context", documentContext);
// 3. Augment user prompt with document context.
return new Query(this.promptTemplate.render(promptParameters));
}
第 123 行:new Query(renderedText) ------ 用的是单参构造器 (Query.java:47-49),即 history = List.of()、context = Map.of()。
增强后的 Query 丢掉了 history 和 context。
这有影响吗?
对 RetrievalAugmentationAdvisor 的 before() 来说没有 ------它只用 augmentedQuery.text()(:151),history/context 用不上。
但对自定义 augmenter 是陷阱:如果你想在 augmenter 里读 query.context()(比如拿到 rag_document_context,见 §7.4.0),你返回的 Query 会丢掉它。如果链上还有下游组件需要,就断了。
好实践 :自定义 augmenter 用 query.mutate().text(...).build() 而不是 new Query(text),保留 history/context。
另外注意第 120 行 Map.of("query", query.text(), ...)------query.text() 由 Query 的 Assert.hasText 保证非空,所以这里不会 NPE(对比 QuestionAnswerAdvisor:134 会)。RAG 侧的类型约束更严,这是 record + 断言的收益。
8.3 MultiQueryExpander:184 行里的三个致命脆弱点
8.3.1 默认模板与参数
java
// :56-70
private static final PromptTemplate DEFAULT_PROMPT_TEMPLATE = new PromptTemplate("""
You are an expert at information retrieval and search optimization.
Your task is to generate {number} different versions of the given query.
Each variant must cover different perspectives or aspects of the topic,
while maintaining the core intent of the original query. The goal is to
expand the search space and improve the chances of finding relevant information.
Do not explain your choices or add any other text.
Provide the query variants separated by newlines.
Original query: {query}
Query variants:
""");
// :72-74
private static final Boolean DEFAULT_INCLUDE_ORIGINAL = true;
private static final Integer DEFAULT_NUMBER_OF_QUERIES = 3;
模板写了 "Do not explain your choices" 和 "separated by newlines"------意图明确,但没有强制格式(比如没说"不要编号")。
8.3.2 脆弱点 1:严格的行数相等校验
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);
}
this.numberOfQueries != queryVariants.size() ------ 严格相等。
现实中的 LLM 输出:
| 模型实际输出 | split("\n") 结果 | 是否通过 |
|---|---|---|
a\nb\nc |
3 | ✓ |
1. a\n2. b\n3. c |
3 | ✓(但内容带序号) |
- a\n- b\n- c |
3 | ✓(但内容带 - ) |
Here are 3 versions:\na\nb\nc |
4 | ✗ 全部放弃 |
a\nb\nc\n |
3(split 丢弃末尾空串) |
✓ |
a\n\nb\nc |
4(中间空行) | ✗ 全部放弃 |
1. a\n\n2. b\n\n3. c |
5 | ✗ 全部放弃 |
带空行的编号列表(很常见)直接全废。
而且失败是静默的 :只打 logger.warn(默认级别下你可能根本看不到),然后返回单查询。功能"看起来正常",只是多路检索悄悄退化成了单路。
这是最讨厌的一类 bug:不报错、性能/效果下降、难以察觉。
8.3.3 脆弱点 2:内容不清洗
java
// :126-129
var queries = queryVariants.stream()
.filter(StringUtils::hasText)
.map(queryText -> query.mutate().text(queryText).build())
.collect(Collectors.toList());
即使行数校验通过,如果模型输出 1. What is Spring AI?,检索 query 就是 "1. What is Spring AI?"(带序号前缀)。
对 embedding 模型来说,这个前缀是噪声。影响有多大取决于模型,但绝不会是正的。
8.3.4 脆弱点 3:includeOriginal=true 时的重复键崩溃
第 07 章 §7.4.4 讲过:RetrievalAugmentationAdvisor:134 用 Collectors.toMap 且无 merge function。
而 MultiQueryExpander 默认 includeOriginal=true:
java
// :131-134
if (this.includeOriginal) {
logger.debug("Including the original query in the result");
queries.add(0, query);
}
如果 LLM 生成的变体里恰好有一个与原 query 文本完全相同 (Query 是 record,text/history/context 全等即相等),toMap 抛:
vbnet
java.lang.IllegalStateException: Duplicate key Query[text=...]
偶发、不可复现、报错信息与配置看不出关联。
8.3.5 强化版 MultiQueryExpander(可直接投产)
java
/**
* 强化版多路扩展器:
* 1. 不要求行数精确相等,取前 N 个有效行
* 2. 清洗序号前缀(1. / 1) / - / * / " )
* 3. 全链路去重,杜绝 toMap 重复键
* 4. LLM 失败时降级为原 query,不抛异常
*/
public final class RobustMultiQueryExpander implements QueryExpander {
private static final PromptTemplate TEMPLATE = new PromptTemplate("""
You are a search optimization expert.
Generate exactly {number} alternative versions of the query below.
Hard requirements:
- Output ONLY the queries, one per line.
- Do NOT number them. Do NOT use bullets. Do NOT add any explanation.
- Each line must be a complete, standalone query.
Original query: {query}
""");
private static final Log logger = LogFactory.getLog(RobustMultiQueryExpander.class);
private final ChatClient chatClient;
private final int numberOfQueries;
private final boolean includeOriginal;
public RobustMultiQueryExpander(ChatClient.Builder b, int numberOfQueries, boolean includeOriginal) {
Assert.notNull(b, "chatClientBuilder cannot be null");
Assert.isTrue(numberOfQueries > 0, "numberOfQueries must be > 0");
this.chatClient = b.build();
this.numberOfQueries = numberOfQueries;
this.includeOriginal = includeOriginal;
}
@Override
public List<Query> expand(Query query) {
String raw;
try {
raw = this.chatClient.prompt()
.user(u -> u.text(TEMPLATE.getTemplate())
.param("number", this.numberOfQueries)
.param("query", query.text()))
.call()
.content();
}
catch (Exception ex) {
logger.warn("Query expansion failed; falling back to single query", ex);
return List.of(query);
}
if (!StringUtils.hasText(raw)) {
return List.of(query);
}
// 关键:LinkedHashSet 保序 + 去重,彻底消除重复键风险
Set<String> seen = new LinkedHashSet<>();
if (this.includeOriginal) {
seen.add(query.text());
}
for (String line : raw.split("\\R")) { // \\R 匹配任意换行符,比 \n 稳健
String cleaned = clean(line);
if (StringUtils.hasText(cleaned)) {
seen.add(cleaned);
}
if (seen.size() >= this.numberOfQueries + (this.includeOriginal ? 1 : 0)) {
break;
}
}
if (seen.isEmpty()) {
return List.of(query);
}
return seen.stream()
.map(t -> t.equals(query.text()) ? query : query.mutate().text(t).build())
.toList();
}
/** 去掉 "1. " / "1) " / "- " / "* " / 两侧引号与空白 */
private static String clean(String line) {
String s = line.trim();
s = s.replaceFirst("^\\s*\\d+\\s*[.、)]\\s*", ""); // 1. / 1、/ 1)
s = s.replaceFirst("^\\s*[-*•]\\s+", ""); // - / * / •
s = s.replaceFirst("^[\"'""'']+|[\"'""'']+$", ""); // 首尾引号
return s.trim();
}
public static Builder builder() { return new Builder(); }
public static final class Builder {
private ChatClient.@Nullable Builder chatClientBuilder;
private int numberOfQueries = 3;
private boolean includeOriginal = true;
public Builder chatClientBuilder(ChatClient.Builder b) { this.chatClientBuilder = b; return this; }
public Builder numberOfQueries(int n) { this.numberOfQueries = n; return this; }
public Builder includeOriginal(boolean v) { this.includeOriginal = v; return this; }
public RobustMultiQueryExpander build() {
Assert.state(this.chatClientBuilder != null, "chatClientBuilder cannot be null");
return new RobustMultiQueryExpander(this.chatClientBuilder, this.numberOfQueries, this.includeOriginal);
}
}
}
四处改进对应的源码缺陷:
| 改进 | 源码位置 | 原问题 |
|---|---|---|
| 取前 N 而非严格相等 | MultiQueryExpander:118 |
多一行全废 |
clean() 清洗前缀 |
MultiQueryExpander:126-129 |
序号进检索 query |
LinkedHashSet 去重 |
MultiQueryExpander:131-134 |
toMap 重复键崩溃 |
| try-catch 降级 | MultiQueryExpander:104-109 |
异常冒泡 |
顺带一个细节 :用了 split("\\R") 而不是 split("\n")。\R 是 Java 8+ 的正则,匹配任意 Unicode 换行序列(\n、\r\n、\u0085 等)。如果 LLM 返回 \r\n(某些代理会转换),split("\n") 会留下尾部 \r,导致 hasText 判断异常或内容带 \r。
8.4 三个 QueryTransformer:各 130-145 行的同构实现
三个类的结构几乎完全一致 ,只有模板和参数不同。这种高度同构既说明设计统一,也说明代码是复制粘贴的。
8.4.1 CompressionQueryTransformer(139 行)
java
// :49-61
private static final PromptTemplate DEFAULT_PROMPT_TEMPLATE = new PromptTemplate("""
Given the following conversation history and a follow-up query, your task is to synthesize
a concise, standalone query that incorporates the context from the history.
Ensure the standalone query is clear, specific, and maintains the user's intent.
Conversation history:
{history}
Follow-up query:
{query}
Standalone query:
""");
核心差异在 formatConversationHistory:
java
// :97-107
private String formatConversationHistory(List<Message> history) {
if (history.isEmpty()) {
return "";
}
return history.stream()
.filter(message -> message.getMessageType().equals(MessageType.USER)
|| message.getMessageType().equals(MessageType.ASSISTANT))
.map(message -> "%s: %s".formatted(message.getMessageType(), message.getText()))
.collect(Collectors.joining("\n"));
}
三个必须知道的细节:
(1)只保留 USER 和 ASSISTANT,SYSTEM 和 TOOL 被丢弃。
MessageType 在 2.x 只有四个值(第 03 章讲过 FUNCTION 已死):SYSTEM、USER、ASSISTANT、TOOL。
丢弃 SYSTEM 是合理的(系统提示对检索 query 无帮助)。但丢弃 TOOL 意味着:如果上一轮的工具调用结果里有关键信息,压缩后的 query 会丢失它。
(2)格式化成 USER: xxx / ASSISTANT: xxx。
message.getMessageType() 返回枚举,%s 触发 toString(),即枚举名(USER/ASSISTANT)。所以输出是:
makefile
USER: 丹麦的首都是哪里?
ASSISTANT: 丹麦的首都是哥本哈根。
(3)message.getText() 可能是 null。
%s 对 null 会输出 "null"。如果历史里有纯多模态消息,会出现 USER: null。
(4)history 里包含当前这条 user message 本身 (第 07 章 §7.4.0 讲过 history = prompt().getInstructions())。
所以模板里 {history} 的最后一行就是 {query} 的内容------重复了一次。无害但浪费 token。
改进版(如果你要自己写):把最后一条 USER 从 history 里剔除。
8.4.2 RewriteQueryTransformer(134 行)
java
// :45-53
private static final PromptTemplate DEFAULT_PROMPT_TEMPLATE = new PromptTemplate("""
Given a user query, rewrite it to provide better results when querying a {target}.
Remove any irrelevant information, and ensure the query is concise and specific.
Original query:
{query}
Rewritten query:
""");
// :55
private static final String DEFAULT_TARGET = "vector store";
targetSearchSystem 参数让你指定目标系统:"vector store" (默认)、"web search engine" 、"SQL database" 等。
这个参数确实有用------不同检索系统的最优 query 形态不同:
- 向量库:自然语言、去口语化
- 搜索引擎:关键词式
- SQL:结构化、带实体名
Javadoc 引用了 arXiv:2305.14283(:39)------这也是未在仓库里注明标题的引用之一。
@since 1.0.0,且是 public class(非 final) ------可以继承。对比 CompressionQueryTransformer 也是 public class,但 TranslationQueryTransformer 是 public final class (:50)。又是不一致。
8.4.3 TranslationQueryTransformer(144 行)
java
// :54-63
private static final PromptTemplate DEFAULT_PROMPT_TEMPLATE = new PromptTemplate("""
Given a user query, translate it to {targetLanguage}.
If the query is already in {targetLanguage}, return it unchanged.
If you don't know the language of the query, return it unchanged.
Do not add explanations nor any other text.
Original query: {query}
Translated query:
""");
唯一一个有必填参数的:
java
// :74
Assert.hasText(targetLanguage, "targetLanguage cannot be null or empty");
// :138 (Builder)
Assert.state(StringUtils.hasText(this.targetLanguage), "targetLanguage cannot be null or empty");
两道校验(构造器 + Builder)。
适用场景 (Javadoc :30-37):embedding 模型训练语料是某种语言(多为英语),而用户用别的语言提问。翻译后检索效果显著提升。
⚠️ 但记住第 07 章的核心结论:翻译只影响检索,不影响提问。所以:
erlang
用户:"Spring AI 是什么?"
→ 翻译为 "What is Spring AI?"
→ 向量检索(英文 embedding)
→ 召回英文文档
→ prompt 里的 query 仍是 "Spring AI 是什么?" ← originalQuery
→ 模型用中文回答(但依据是英文文档)
这通常是我们想要的。 但如果你期望"用英文提问以获得更准确的英文回答",就会失望。
8.4.4 三个 transformer 的完整对照
| Compression | Rewrite | Translation | |
|---|---|---|---|
| 行数 | 139 | 134 | 144 |
| 类修饰 | public class |
public class |
public final class |
| 必填参数 | 无(除 builder) | 无 | targetLanguage |
| 模板占位符 | {history} {query} |
{target} {query} |
{targetLanguage} {query} |
| 默认值 | --- | target = "vector store" |
---(必填) |
用 query.history() |
✓ | ✗ | ✗ |
用 query.context() |
✗ | ✗ | ✗ |
| 论文引用 | 无 | arXiv:2305.14283 |
无 |
| 空结果降级 | ✓ 返回原 query | ✓ | ✓ |
| 异常降级 | ✗ 无 try-catch | ✗ | ✗ |
三个都没有 try-catch。 第 07 章 §7.4.2 的容错包装对所有三个都适用:
java
public final class SafeQueryTransformer implements QueryTransformer {
private static final Log logger = LogFactory.getLog(SafeQueryTransformer.class);
private final QueryTransformer delegate;
public SafeQueryTransformer(QueryTransformer delegate) {
Assert.notNull(delegate, "delegate cannot be null");
this.delegate = delegate;
}
@Override
public Query transform(Query query) {
try {
Query result = this.delegate.transform(query);
// 防御:有些实现可能返回 null 或空文本
if (result == null || !StringUtils.hasText(result.text())) {
logger.warn("Transformer returned empty result; using original query");
return query;
}
return result;
}
catch (Exception ex) {
logger.warn("Query transformation failed; using original query", ex);
return query; // 关键:降级而非中断
}
}
/** 便捷:把一组 transformer 全部包上 */
public static List<QueryTransformer> wrapAll(List<QueryTransformer> delegates) {
return delegates.stream().map(SafeQueryTransformer::new).collect(Collectors.toList());
}
}
用法:
java
.queryTransformers(SafeQueryTransformer.wrapAll(List.of(
CompressionQueryTransformer.builder().chatClientBuilder(builder).build(),
RewriteQueryTransformer.builder().chatClientBuilder(builder).build())))
8.5 VectorStoreDocumentRetriever:176 行,唯一有"运行时动态能力"的组件
第 07 章 §7.13.4 讲过 FILTER_EXPRESSION 的传递链,这里补细节。
8.5.1 参数校验
java
// :75-77
Assert.isTrue(similarityThreshold == null || similarityThreshold >= 0.0,
"similarityThreshold must be equal to or greater than 0.0");
Assert.isTrue(topK == null || topK > 0, "topK must be greater than 0");
注意这里只校验了下界,没校验上界 1.0。
对比 SearchRequest.Builder.similarityThreshold(SearchRequest.java:170 附近):
java
Assert.isTrue(threshold >= 0 && threshold <= 1, "Similarity threshold must be in [0,1] range.");
SearchRequest 校验 0,1,VectorStoreDocumentRetriever 只校验 >= 0。
所以你可以传 similarityThreshold(2.0) 给 retriever,它能构造成功 ,但内部会用这个值去 SearchRequest.builder().similarityThreshold(...)(:92)------那里会抛异常。
结果:构造时不报错,第一次检索时才炸。 这是 fail-fast 原则的一个小破口。
(顺带一提:topK 也不一致。VectorStoreDocumentRetriever 要求 > 0,而 SearchRequest.Builder.topK 只要求 >= 0。两处方向相反。)
8.5.2 默认值来自 SearchRequest
java
// :79-82
this.similarityThreshold = similarityThreshold != null ? similarityThreshold
: SearchRequest.SIMILARITY_THRESHOLD_ACCEPT_ALL;
this.topK = topK != null ? topK : SearchRequest.DEFAULT_TOP_K;
this.filterExpression = filterExpression != null ? filterExpression : () -> null;
java
// SearchRequest.java:44,49
public static final double SIMILARITY_THRESHOLD_ACCEPT_ALL = 0.0;
public static final int DEFAULT_TOP_K = 4;
similarityThreshold 默认 0.0 = 接受一切 (SearchRequest 的 Javadoc 明说 "A threshold value of 0.0 means any similarity is accepted or disable the similarity threshold filtering")。
这意味着默认配置下,哪怕完全不相关的文档也会被召回------只要它在 topK 里。
这是很多"RAG 答非所问"的根因:检索召回的 4 篇里可能有 3 篇是噪声,而 ContextualQueryAugmenter 会把它们全塞进 prompt,模型在噪声里找答案。
生产必须显式设阈值 ,典型值 0.5~0.75(取决于 embedding 模型,需要实测标定)。
⚠️ 阈值是"客户端后处理" :SearchRequest.Builder.similarityThreshold 的 Javadoc 说:
Note that this is a post-processing step performed on the client not the server side.
所以向量库仍然会返回 topK 条,只是被 Spring AI 在内存里过滤掉。这意味着:
- 网络传输没省
- 向量库的计算没省
- 只是减少了 prompt 的长度
8.5.3 Supplier<Filter.Expression>:延迟求值的价值
java
// :67-70(原样注释)
// 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.
这是全模块唯一 一处解释"为什么用 Supplier 而不是值"的注释。它解决的是构造时 vs 执行时的时序问题:
java
// ❌ 构造时求值:此时 SecurityContext 可能还没建立
retriever = VectorStoreDocumentRetriever.builder()
.vectorStore(vs)
.filterExpression(buildTenantFilter()) // 立刻调用,可能拿到 null 用户
.build();
// ✓ 延迟求值:检索那一刻才调用
retriever = VectorStoreDocumentRetriever.builder()
.vectorStore(vs)
.filterExpression(() -> buildTenantFilter()) // 每次 retrieve 都重新求值
.build();
注意 :Supplier 每次 retrieve() 都会调用一次 (:120 this.filterExpression.get())。如果你在 Supplier 里做了昂贵操作(查库、RPC),会被调用 M 次(M = query 数)。要缓存就自己在 Supplier 里缓存。
8.5.4 多租户实战:强制过滤 + 用户可选过滤
一个常见需求:租户过滤是强制的(防越权),业务过滤是可选的。
doGetFilterExpression 在 QuestionAnswerAdvisor 里是 protected 可覆写的,但 VectorStoreDocumentRetriever 是 public final class (:57),不能继承。
所以只能靠组合:
java
public final class TenantScopedRetriever implements DocumentRetriever {
private final DocumentRetriever delegate;
private final Supplier<String> tenantIdSupplier;
private final FilterExpressionBuilder feb = new FilterExpressionBuilder();
public TenantScopedRetriever(DocumentRetriever delegate, Supplier<String> tenantIdSupplier) {
this.delegate = delegate;
this.tenantIdSupplier = tenantIdSupplier;
}
@Override
public List<Document> retrieve(Query query) {
// 把租户条件强制注入 context,覆盖调用方可能传入的值(防伪造)
String tenant = tenantIdSupplier.get();
Assert.hasText(tenant, "tenantId must be resolved");
Map<String, Object> ctx = new HashMap<>(query.context());
ctx.put(VectorStoreDocumentRetriever.FILTER_EXPRESSION,
feb.eq("tenant_id", tenant).build()); // 强制覆盖!
return this.delegate.retrieve(query.mutate().context(ctx).build());
}
}
安全要点 :用 ctx.put(...) 覆盖 而不是"如果没有才设"。否则调用方可以通过传 FILTER_EXPRESSION 绕过租户隔离------这是多租户 RAG 最常见的越权漏洞。
8.5.5 retrieve() 只有 11 行,但没有并发保护
java
// :85-96
@Override
public List<Document> retrieve(Query query) {
Assert.notNull(query, "query cannot be null");
var requestFilterExpression = computeRequestFilterExpression(query);
var searchRequest = SearchRequest.builder()
.query(query.text())
.filterExpression(requestFilterExpression)
.similarityThreshold(this.similarityThreshold)
.topK(this.topK)
.build();
return this.vectorStore.similaritySearch(searchRequest);
}
每次都新建 SearchRequest ------ 无状态,线程安全。✓
没有超时控制。 如果向量库慢,整个 RAG 就慢。CompletableFuture::join(第 07 章 :133)没有超时版本。
要加超时,得自己包:
java
public final class TimeoutRetriever implements DocumentRetriever {
private final DocumentRetriever delegate;
private final Duration timeout;
@Override
public List<Document> retrieve(Query query) {
try {
return CompletableFuture
.supplyAsync(() -> this.delegate.retrieve(query))
.orTimeout(this.timeout.toMillis(), TimeUnit.MILLISECONDS)
.join();
}
catch (Exception ex) {
logger.warn("Retrieval timed out/failed for query: " + query.text(), ex);
return List.of(); // 降级为空,让 joiner/augmenter 处理
}
}
}
(orTimeout 是 Java 9+,抛 TimeoutException,被 catch 吞掉后返回空列表。)
8.6 ConcatenationDocumentJoiner 与 RRF 补充实现
8.6.1 现有实现的三个假设
第 07 章 §7.4.5 拆过。这里补三个隐含假设的总结:
score越大越相似 ------ 接入"距离"型数据源会反序Document.getId()全局唯一 ------ 跨数据源时 id 可能撞车(但当前不支持多数据源,所以无害)- 所有 query 的 score 在同一尺度 ------ 单 retriever 时成立
8.6.2 RRF(倒数排序融合)实现
官方 Javadoc(DocumentJoiner.java:24)明确提到 "reciprocal ranking strategies" 但没实现。补上:
java
/**
* Reciprocal Rank Fusion (RRF) 多路融合。
*
* score(d) = Σ_i 1 / (k + rank_i(d))
*
* 相比直接按 score 排序,RRF 的优势是:
* - 不要求各路 score 在同一尺度(不同 embedding 模型/不同库可以直接混)
* - 对"在多数路都排前"的文档有天然加权
* - 无需归一化,k 是唯一的超参(通常 60)
*/
public final class ReciprocalRankFusionJoiner implements DocumentJoiner {
private static final Log logger = LogFactory.getLog(ReciprocalRankFusionJoiner.class);
private static final int DEFAULT_K = 60;
private static final int DEFAULT_MAX_RESULTS = 20;
private final int k;
private final int maxResults;
public ReciprocalRankFusionJoiner(int k, int maxResults) {
Assert.isTrue(k > 0, "k must be > 0");
Assert.isTrue(maxResults > 0, "maxResults must be > 0");
this.k = k;
this.maxResults = maxResults;
}
public ReciprocalRankFusionJoiner() {
this(DEFAULT_K, DEFAULT_MAX_RESULTS);
}
@Override
public List<Document> join(Map<Query, List<List<Document>>> documentsForQuery) {
Assert.notNull(documentsForQuery, "documentsForQuery cannot be null");
// 用 LinkedHashMap 保证分数相同时的确定性顺序(源码的 HashMap 做不到)
Map<String, Double> scores = new LinkedHashMap<>();
Map<String, Document> byId = new LinkedHashMap<>();
// LinkedHashSet 保证外层遍历顺序 == 传入顺序(query 的提交顺序)
for (Map.Entry<Query, List<List<Document>>> e : new LinkedHashSet<>(documentsForQuery.entrySet())) {
// 当前实现下内层 List 恒为 size 1,但仍然双层展开以保持兼容
List<Document> ranked = e.getValue().stream()
.flatMap(List::stream)
.filter(Objects::nonNull)
// 关键:按该路自己的 score 降序排,得到 rank
.sorted(Comparator.comparingDouble(
(Document d) -> d.getScore() != null ? d.getScore() : 0.0).reversed())
.toList();
for (int i = 0; i < ranked.size(); i++) {
Document d = ranked.get(i);
int rank = i + 1; // rank 从 1 开始
double contribution = 1.0 / (this.k + rank);
scores.merge(d.getId(), contribution, Double::sum);
byId.putIfAbsent(d.getId(), d); // 保留首次出现的实例
}
}
List<Document> merged = scores.entrySet().stream()
.sorted(Map.Entry.<String, Double>comparingByValue().reversed())
.limit(this.maxResults)
// 用 mutate() 把 RRF 分数写回,便于下游观测
.map(e -> byId.get(e.getKey()).mutate().score(e.getValue()).build())
.toList();
logger.debug(() -> "RRF merged " + merged.size() + " documents (k=" + this.k + ")");
return merged;
}
}
三点说明:
LinkedHashMap+LinkedHashSet保证确定性 ------ 源码用HashMap,输出不稳定。这里刻意改掉- 每路内部先按自己的 score 排序 ------ 得到 rank。这一步是 RRF 的核心
mutate().score(...)写回 RRF 分 ------ 下游(比如 token 预算后处理器)能看到融合后的分数
(第 03 章讲过 Document.mutate() 返回 new Builder().id(...).text(...).media(...).metadata(...).score(...),见 Document.java:360。)
8.6.3 什么时候用 RRF,什么时候用 concat
| 场景 | 推荐 |
|---|---|
| 单 retriever,多路 query(MultiQueryExpander) | 两者皆可;RRF 通常更好(投票效应) |
| 多路 query + 各路 topK 相同 | RRF |
| 你自己实现了混合检索(向量 + BM25 关键词) | 必须用 RRF(score 尺度不同) |
| 只想要"去重 + 按分排序"的简单行为 | ConcatenationDocumentJoiner 够用 |
| 需要稳定输出(快照测试) | RRF 实现版(因为用了 LinkedHashMap) |
8.7 DocumentPostProcessor:三类生产必需的实现
这是官方零实现的扩展点。下面三个都是可直接用的。
8.7.1 Token 预算截断(防超上下文窗口)
第 07 章 §7.11.2 指出:多路召回后文档数暴涨,而没有任何组件做长度限制。
java
/**
* 按 token 预算贪心截断。
*
* 策略:按传入顺序(已由 joiner 按相关度排好)累加,超出预算就停。
* 可选:对最后一个超大文档做硬截断,尽量塞满预算。
*/
public final class TokenBudgetPostProcessor implements DocumentPostProcessor {
private static final Log logger = LogFactory.getLog(TokenBudgetPostProcessor.class);
/** 粗估:中文按字符数,英文按 4 字符/token。生产建议换 jtokkit 精确算 */
private static final double CHARS_PER_TOKEN_CJK = 1.0;
private static final double CHARS_PER_TOKEN_LATIN = 4.0;
private final int maxTokens;
private final boolean truncateLast;
public TokenBudgetPostProcessor(int maxTokens, boolean truncateLast) {
Assert.isTrue(maxTokens > 0, "maxTokens must be > 0");
this.maxTokens = maxTokens;
this.truncateLast = truncateLast;
}
public TokenBudgetPostProcessor(int maxTokens) {
this(maxTokens, false);
}
@Override
public List<Document> process(Query query, List<Document> documents) {
Assert.notNull(documents, "documents cannot be null");
if (documents.isEmpty()) {
return documents;
}
List<Document> kept = new ArrayList<>();
int used = 0;
for (Document d : documents) {
String text = d.getText();
if (text == null || text.isBlank()) {
continue; // 顺手过滤空文档
}
int tokens = estimateTokens(text);
if (used + tokens <= this.maxTokens) {
kept.add(d);
used += tokens;
}
else if (this.truncateLast && used < this.maxTokens) {
int remainingTokens = this.maxTokens - used;
if (remainingTokens > 32) { // 太短就没意义了
String cut = headTruncate(text, remainingTokens);
kept.add(d.mutate().text(cut + " ...[截断]").build());
}
break;
}
else {
break;
}
}
if (kept.size() < documents.size()) {
logger.debug(() -> "Token budget: kept " + kept.size() + "/" + documents.size()
+ " docs, ~" + used + " tokens (budget " + this.maxTokens + ")");
}
return kept;
}
private static int estimateTokens(String text) {
int cjk = 0, latin = 0;
for (int i = 0; i < text.length(); i++) {
char c = text.charAt(i);
if (c >= '\u4e00' && c <= '\u9fff') { cjk++; }
else if (!Character.isWhitespace(c)) { latin++; }
}
return (int) Math.ceil(cjk / CHARS_PER_TOKEN_CJK + latin / CHARS_PER_TOKEN_LATIN);
}
private static String headTruncate(String text, int tokens) {
int limit = (int) (tokens * CHARS_PER_TOKEN_LATIN);
return text.length() <= limit ? text : text.substring(0, limit);
}
}
生产建议 :把 estimateTokens 换成 jtokkit(Spring AI 已经通过 TokenTextSplitter 引入了它,第 15 章会讲)。粗估在中文场景误差可达 ±30%,要么留足余量,要么精确算。
8.7.2 Lost-in-the-Middle 重排
DocumentPostProcessor 的 Javadoc 第一个点名的就是这个问题。原理:模型对长上下文的开头和结尾 注意力最强,中间最弱。
策略:把最相关的文档放首尾,次相关的放中间。
java
/**
* "Lost in the middle" 缓解重排。
*
* 把按相关度降序的 [d1 d2 d3 d4 d5 d6] 重排为 [d1 d3 d5 d6 d4 d2],
* 即:奇数位(1st,3rd,5th...)正序放前半,偶数位倒序放后半。
* 效果:最相关的 d1 在最前、d2 在最后,最弱的落在正中。
*/
public final class LostInTheMiddleReorderPostProcessor implements DocumentPostProcessor {
@Override
public List<Document> process(Query query, List<Document> documents) {
if (documents == null || documents.size() <= 2) {
return documents; // 太少没必要重排
}
List<Document> result = new ArrayList<>(documents.size());
int lo = 0, hi = documents.size() - 1;
boolean takeFromFront = true;
while (lo <= hi) {
if (takeFromFront) {
result.add(documents.get(lo++)); // 从最相关端取
}
else {
result.add(documents.get(hi--)); // 从最弱端取
}
takeFromFront = !takeFromFront;
}
// 此刻 result 是 [d1,dn,d2,d(n-1),...],把后半段反转
int mid = (result.size() + 1) / 2;
List<Document> tail = new ArrayList<>(result.subList(mid, result.size()));
Collections.reverse(tail);
List<Document> finalList = new ArrayList<>(result.subList(0, mid));
finalList.addAll(tail);
return finalList;
}
}
验证(6 篇,按相关度 d1>d2>...>d6):
makefile
输入: d1 d2 d3 d4 d5 d6
交错: d1 d6 d2 d5 d3 d4
mid=3 → 前段 [d1 d6 d2],后段 [d5 d3 d4] 反转 → [d4 d3 d5]
输出: d1 d6 d2 d4 d3 d5
最相关的 d1 在最前、次相关的 d2 在第三位;最弱的 d4/d3/d5 在尾部。 这符合"首尾注意力强"的分布。
8.7.3 内容级去重(比 id 去重更实用)
ConcatenationDocumentJoiner 按 getId() 去重。但同一个 PDF 被切成多个 chunk 时,id 不同但内容可能高度重复(比如页眉页脚、重复段落)。
java
/**
* 基于内容指纹的近似去重(SimHash 风格的简化版:用 token 集合的 Jaccard 相似度)。
* 比 id 去重更能抓到"重复切片"。
*/
public final class ContentDeduplicationPostProcessor implements DocumentPostProcessor {
private final double threshold; // 0.0~1.0,超过则视为重复
private final int shingleSize; // 字符 n-gram 大小
public ContentDeduplicationPostProcessor(double threshold, int shingleSize) {
Assert.isTrue(threshold >= 0 && threshold <= 1, "threshold must be in [0,1]");
Assert.isTrue(shingleSize > 0, "shingleSize must be > 0");
this.threshold = threshold;
this.shingleSize = shingleSize;
}
@Override
public List<Document> process(Query query, List<Document> documents) {
if (documents == null || documents.size() <= 1) {
return documents;
}
List<Document> kept = new ArrayList<>();
List<Set<String>> keptShingles = new ArrayList<>();
for (Document d : documents) {
String text = d.getText();
if (text == null || text.isBlank()) {
continue;
}
Set<String> shingles = shingle(text);
boolean dup = false;
for (Set<String> existing : keptShingles) {
if (jaccard(existing, shingles) >= this.threshold) {
dup = true;
break;
}
}
if (!dup) {
kept.add(d);
keptShingles.add(shingles);
}
}
return kept;
}
private Set<String> shingle(String text) {
String norm = text.replaceAll("\\s+", "").toLowerCase(Locale.ROOT);
Set<String> s = new HashSet<>();
for (int i = 0; i + this.shingleSize <= norm.length(); i++) {
s.add(norm.substring(i, i + this.shingleSize));
}
return s;
}
private double jaccard(Set<String> a, Set<String> b) {
if (a.isEmpty() || b.isEmpty()) { return 0.0; }
Set<String> inter = new HashSet<>(a);
inter.retainAll(b);
Set<String> union = new HashSet<>(a);
union.addAll(b);
return (double) inter.size() / union.size();
}
}
复杂度 :O(n² × |shingles|)。n 通常 < 30,可接受。如果 n 很大,改用 MinHash / SimHash。
典型阈值 :0.85(Jaccard 相似度超过 85% 视为重复)。
8.7.4 三个后处理器的组合顺序
顺序很重要:
java
.documentPostProcessors(
new ContentDeduplicationPostProcessor(0.85, 5), // 1. 先去重(减少后续计算量)
new TokenBudgetPostProcessor(3000, true), // 2. 再截预算(保证不超窗口)
new LostInTheMiddleReorderPostProcessor()) // 3. 最后重排(此时数量已定)
为什么这个顺序?
- 去重在最前:去掉冗余后,token 预算能容纳更多有效信息
- 预算在中间:确定最终文档集
- 重排在最后:如果先重排再截断,尾部的高相关文档可能被砍掉,重排就白做了
⚠️ 记住第 07 章的结论 :后处理器收到的是 originalQuery (:142),不是改写后的 query。所以任何"基于 query 做相关性判断"的后处理器,用的都是原始问题。
8.8 容错层:让 RAG 从"一崩全崩"变成"带伤运行"
第 07 章列了 18 个陷阱,其中 4、6、12 都是"单点失败导致整体失败"。本节给完整的容错方案。
8.8.1 失败模式矩阵
| 环节 | 失败形式 | 默认后果 | 期望行为 |
|---|---|---|---|
QueryTransformer |
LLM 超时/限流 | 异常冒泡,整个请求失败 | 用原 query 继续 |
QueryExpander |
LLM 返回格式不对 | 静默退回单查询 | 接受(但应清洗后使用) |
DocumentRetriever |
向量库不可用 | CompletionException,整批失败 |
该路返回空,其他路继续 |
DocumentJoiner |
重复键 | IllegalStateException |
不该发生(用 §8.3.5 消除) |
DocumentPostProcessor |
自定义逻辑抛异常 | 整个请求失败 | 跳过该处理器 |
QueryAugmenter |
模板渲染失败 | 整个请求失败 | 回退到默认模板 |
最终 ChatModel |
模型不可用 | advisor 无能为力 | 业务层处理 |
8.8.2 容错 Retriever
java
public final class ResilientDocumentRetriever implements DocumentRetriever {
private static final Log logger = LogFactory.getLog(ResilientDocumentRetriever.class);
private final DocumentRetriever delegate;
private final Duration timeout;
private final boolean returnEmptyOnFailure;
public ResilientDocumentRetriever(DocumentRetriever delegate, Duration timeout, boolean returnEmptyOnFailure) {
Assert.notNull(delegate, "delegate cannot be null");
this.delegate = delegate;
this.timeout = timeout;
this.returnEmptyOnFailure = returnEmptyOnFailure;
}
@Override
public List<Document> retrieve(Query query) {
try {
return CompletableFuture
.supplyAsync(() -> this.delegate.retrieve(query))
.orTimeout(this.timeout.toMillis(), TimeUnit.MILLISECONDS)
.join();
}
catch (CompletionException | TimeoutException ex) {
Throwable root = (ex instanceof CompletionException ce && ce.getCause() != null)
? ce.getCause() : ex;
logger.warn("Retrieval failed for query '" + query.text() + "'", root);
if (this.returnEmptyOnFailure) {
return List.of(); // 降级:这一路贡献 0 篇
}
throw new IllegalStateException("Retrieval failed", root);
}
}
}
关键设计 :orTimeout + CompletionException 拆包。CompletableFuture::join 会把受检异常包进 CompletionException,真实原因在 getCause() 里 ------不拆包的话,你的日志里只会看到一行 CompletionException,排查时非常痛苦。
8.8.3 容错 PostProcessor 包装
java
public final class SafeDocumentPostProcessor implements DocumentPostProcessor {
private static final Log logger = LogFactory.getLog(SafeDocumentPostProcessor.class);
private final DocumentPostProcessor delegate;
public SafeDocumentPostProcessor(DocumentPostProcessor delegate) {
this.delegate = delegate;
}
@Override
public List<Document> process(Query query, List<Document> documents) {
try {
List<Document> result = this.delegate.process(query, documents);
return (result != null) ? result : documents; // 防 null
}
catch (Exception ex) {
logger.warn("Post-processor " + this.delegate.getClass().getSimpleName()
+ " failed; skipping", ex);
return documents; // 跳过,不中断
}
}
public static DocumentPostProcessor[] wrapAll(DocumentPostProcessor... delegates) {
return Arrays.stream(delegates).map(SafeDocumentPostProcessor::new)
.toArray(DocumentPostProcessor[]::new);
}
}
8.8.4 完整容错装配
java
RetrievalAugmentationAdvisor.builder()
// 变换:每个都包 SafeQueryTransformer
.queryTransformers(SafeQueryTransformer.wrapAll(List.of(
CompressionQueryTransformer.builder().chatClientBuilder(transformBuilder).build())))
// 扩展:用强化版(自带 try-catch)
.queryExpander(RobustMultiQueryExpander.builder()
.chatClientBuilder(transformBuilder)
.numberOfQueries(3)
.build())
// 检索:超时 + 降级 + 租户强制过滤
.documentRetriever(new ResilientDocumentRetriever(
new TenantScopedRetriever(
VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.similarityThreshold(0.5)
.topK(5)
.build(),
() -> TenantContext.currentId()),
Duration.ofSeconds(3), true))
// 合并:RRF(自带确定性,无重复键风险)
.documentJoiner(new ReciprocalRankFusionJoiner(60, 20))
// 后处理:每个都包 SafeDocumentPostProcessor
.documentPostProcessors(SafeDocumentPostProcessor.wrapAll(
new ContentDeduplicationPostProcessor(0.85, 5),
new TokenBudgetPostProcessor(3000, true),
new LostInTheMiddleReorderPostProcessor()))
// 增强:允许空上下文 + 带编号 formatter
.queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(true)
.documentFormatter(new NumberedDocumentFormatter())
.build())
.taskExecutor(ragTaskExecutor())
.order(0)
.build();
这一套下来,第 07 章 18 个陷阱里能被工程手段消除的有:1(需另行处理)、4、6、7、15、17、18。
剩下无法靠组件消除的,必须在业务层处理:
- 陷阱 1(多模态空文本):在调用前判断,或直接不用 RAG advisor
- 陷阱 11/12(线程池):靠 §7.13.4 的 bean 配置
- 陷阱 14/14b(order):靠显式设 order
8.9 带引用编号的增强:让答案可溯源
NumberedDocumentFormatter(§8.2.2)让模型能输出 [1],但要真正"可溯源",还需要把文档列表带出到响应里。
第 07 章 §7.5 讲过 after() 会把文档放进 ChatResponse 的 metadata(key = rag_document_context)。取用:
java
public record RagAnswer(String text, List<Citation> citations) {}
public record Citation(int index, String source, double score, String excerpt) {}
public final class RagService {
private final ChatClient chatClient;
public RagService(ChatClient.Builder builder, Advisor ragAdvisor) {
this.chatClient = builder.defaultAdvisors(ragAdvisor).build();
}
public RagAnswer ask(String question) {
ChatResponse resp = this.chatClient.prompt()
.user(question)
.call()
.chatResponse();
String text = resp.getResult().getOutput().getText();
@SuppressWarnings("unchecked")
List<Document> docs = (List<Document>) resp.getMetadata()
.get(RetrievalAugmentationAdvisor.DOCUMENT_CONTEXT);
List<Citation> citations = new ArrayList<>();
if (docs != null) {
for (int i = 0; i < docs.size(); i++) {
Document d = docs.get(i);
Object source = d.getMetadata().get("source");
citations.add(new Citation(
i + 1,
source != null ? source.toString() : d.getId(),
d.getScore() != null ? d.getScore() : 0.0,
excerpt(d.getText(), 120)));
}
}
return new RagAnswer(text, citations);
}
private static String excerpt(@Nullable String text, int max) {
if (text == null) { return ""; }
return text.length() <= max ? text : text.substring(0, max) + "...";
}
}
⚠️ 三个坑:
getMetadata().get(...)返回Object,需要强制转型且有@SuppressWarnings("unchecked")。泛型擦除导致运行时无法校验------如果 metadata 里存的不是List<Document>,会在后续遍历时 才ClassCastException- 流式调用可能拿不到 (第 07 章 §7.5.3:
after()依赖onFinishReason()) List<Document>里的Document是共享可变对象 (metadata是拷贝、text是 String,所以基本安全)
8.10 模板渲染的三个隐藏陷阱
PromptTemplate 默认用 StTemplateRenderer:
java
// spring-ai-model/.../PromptTemplate.java:51
private static final TemplateRenderer DEFAULT_TEMPLATE_RENDERER = StTemplateRenderer.builder().build();
java
// spring-ai-template-st/.../StTemplateRenderer.java:63-69
private static final char DEFAULT_START_DELIMITER_TOKEN = '{';
private static final char DEFAULT_END_DELIMITER_TOKEN = '}';
private static final ValidationMode DEFAULT_VALIDATION_MODE = ValidationMode.THROW;
private static final boolean DEFAULT_VALIDATE_ST_FUNCTIONS = false;
8.10.1 陷阱 A:模板里有 JSON 会崩
{ } 是分隔符,所以模板里写 JSON:
java
new PromptTemplate("""
按要求回答。
输出格式:{"answer": "...", "confidence": 0.9}
问题:{query}
""");
ST 会尝试把 {"answer": "...", "confidence": 0.9} 解析成表达式 → 渲染失败或抛异常。
官方给的解法 (retrieval-augmented-generation.adoc 的自定义模板示例里用的就是这套):换分隔符。
java
PromptTemplate.builder()
.renderer(StTemplateRenderer.builder()
.startDelimiterToken('<')
.endDelimiterToken('>')
.build())
.template("""
按要求回答。
输出格式:{"answer": "...", "confidence": 0.9}
问题:<query>
""")
.build();
用 < > 后,JSON 的 {} 就成了普通字符。
⚠️ 但注意 :ContextualQueryAugmenter 的 PromptAssert 校验的是字符串 "query" / "context"------不带分隔符。
java
// ContextualQueryAugmenter.java:102
PromptAssert.templateHasRequiredPlaceholders(this.promptTemplate, "query", "context");
java
// PromptAssert.java:50-52
if (!promptTemplate.getTemplate().contains(placeholder)) {
missingPlaceholders.add(placeholder);
}
它检查的是模板里包含子串 "query" 。所以 <query> 能通过(包含 "query"),{query} 也能通过。这个校验与渲染器的分隔符无关 ------它只是个朴素的 contains。
后果:如果你配了 < > 渲染器但忘了写 <query>(比如写成了 {query}),PromptAssert 会放过 (因为 "query" 存在),但渲染时会失败 (ST 找不到 <query> 变量 → ValidationMode.THROW → 抛异常)。
8.10.2 陷阱 B:ValidationMode.THROW 让未填变量变成异常
java
// spring-ai-commons/.../ValidationMode.java:25-43
public enum ValidationMode {
THROW, // 默认:校验失败抛异常
WARN, // 打日志,模板原样输出(含未替换的占位符)
NONE // 不校验
}
THROW 是默认。所以:
- 模板里有
{foo}但没传foo→ 抛异常 - 空上下文模板里写了
{query}但render()无参 → 抛异常(§8.2.1 讲过)
WARN 模式会把 {foo} 原样留在输出里------不推荐用于生产 (用户会看到 {foo},且你不会立刻发现)。
推荐保持 THROW,它能在上线前暴露模板错误。
8.10.3 陷阱 C:检索到的文档内容不会被二次扫描
一个常见的担心:如果文档内容里含 {something},会不会被当成占位符?
答案是不会 。ST 的渲染流程是:解析模板 → 求值变量 → 替换为字符串 。替换后的内容不会被重新解析。
所以文档里的 {、} 是安全的。
但 :如果你把文档内容放进模板字符串(而不是作为变量值),比如:
java
// ❌ 危险:把文档内容拼进模板
String template = "上下文:" + documentText + "\n问题:{query}";
new PromptTemplate(template);
这时文档里的 {} 会被 ST 解析。 一定要用变量:
java
// ✓ 正确
new PromptTemplate("上下文:{context}\n问题:{query}")
.render(Map.of("context", documentText, "query", q));
8.10.4 分隔符选型建议
| 场景 | 分隔符 | 理由 |
|---|---|---|
| 纯自然语言模板 | { }(默认) |
与 Spring 生态一致 |
| 模板内含 JSON / 代码 | < > |
避开 {} |
| 模板内含 XML / HTML | { } |
避开 <> |
| 两者都有 | [[ ]] |
但 ST 只支持单字符分隔符! |
⚠️ StTemplateRenderer 的分隔符是 char 类型,不是 String (:71-73):
java
private final char startDelimiterToken;
private final char endDelimiterToken;
所以不支持多字符分隔符 (如 {{ }} 或 [[ ]])。如果你的模板既有 JSON 又有 HTML,只能二选一,或者预处理掉冲突字符。
8.11 测试策略:RAG 怎么测
RAG 的难点是"模型输出不确定",所以分层测。
8.11.1 第一层:纯组件单元测试(无 LLM)
可直接测的:ConcatenationDocumentJoiner、ReciprocalRankFusionJoiner、三个 DocumentPostProcessor、PromptAssert。
java
@Test
void rrfShouldFuseByRank() {
Document a = Document.builder().id("a").text("A").score(0.9).build();
Document b = Document.builder().id("b").text("B").score(0.8).build();
Document c = Document.builder().id("c").text("C").score(0.7).build();
Query q1 = new Query("q1");
Query q2 = new Query("q2");
// q1 的排名:a(1) b(2);q2 的排名:c(1) a(2)
Map<Query, List<List<Document>>> input = Map.of(
q1, List.of(List.of(a, b)),
q2, List.of(List.of(c, a)));
List<Document> merged = new ReciprocalRankFusionJoiner(60, 20).join(input);
// a 的 RRF 分 = 1/(60+1) + 1/(60+2) ≈ 0.01639 + 0.01613 = 0.03252
// b = 1/62 ≈ 0.01613,c = 1/61 ≈ 0.01639
// 所以 a > c > b
assertThat(merged).extracting(Document::getId).containsExactly("a", "c", "b");
assertThat(merged.get(0).getScore()).isCloseTo(0.03252, within(1e-5));
}
注意 Map.of(...) 的迭代顺序是不确定的 ,但 RRF 实现里用了 LinkedHashSet<>(entrySet())------它继承的是 Map.of 的顺序,仍然不确定。
上面这个测试断言 containsExactly("a","c","b") 之所以能过,是因为 a 的分数严格最高、b 严格最低,中间 c 只有一个可能位置。如果分数有并列,这个测试就会 flaky。
更稳的断言 :用 containsSubsequence 或只断言相对顺序的关键对。或者用 LinkedHashMap 构造输入。
8.11.2 第二层:用固定 ChatModel 测 transformer
第 18 章会讲 spring-ai-test 提供的能力。这里用最朴素的方式------自己实现一个返回固定文本的 ChatModel:
java
/** 返回固定文本的桩 ChatModel,用于确定性地测试 transformer */
public final class FixedChatModel implements ChatModel {
private final String response;
public FixedChatModel(String response) { this.response = response; }
@Override
public ChatResponse call(Prompt prompt) {
return ChatResponse.builder()
.generations(List.of(new Generation(new AssistantMessage(this.response))))
.build();
}
@Override
public ChatOptions getOptions() { return ChatOptions.builder().build(); }
}
@Test
void multiQueryExpanderShouldCleanNumberedList() {
// 模拟 LLM 返回带序号的列表(最常见的"脏输出")
ChatModel stub = new FixedChatModel("1. What is Spring AI?\n2. Spring AI features\n3. Spring AI tutorial");
ChatClient.Builder builder = ChatClient.builder(stub);
RobustMultiQueryExpander expander = RobustMultiQueryExpander.builder()
.chatClientBuilder(builder)
.numberOfQueries(3)
.includeOriginal(false)
.build();
List<Query> queries = expander.expand(new Query("Spring AI"));
assertThat(queries).hasSize(3);
// 关键断言:序号前缀被清洗掉了
assertThat(queries.get(0).text()).isEqualTo("What is Spring AI?");
assertThat(queries.get(1).text()).isEqualTo("Spring AI features");
}
@Test
void builtInExpanderWouldFailOnSameInput() {
// 对照:内置实现在带序号时原样保留
ChatModel stub = new FixedChatModel("1. a\n2. b\n3. c");
MultiQueryExpander builtIn = MultiQueryExpander.builder()
.chatClientBuilder(ChatClient.builder(stub))
.numberOfQueries(3)
.includeOriginal(false)
.build();
List<Query> r = builtIn.expand(new Query("x"));
assertThat(r.get(0).text()).isEqualTo("1. a"); // ← 序号进检索 query
}
@Test
void builtInExpanderGivesUpOnExtraLine() {
// 对照:多一行就整体放弃
ChatModel stub = new FixedChatModel("Here:\na\nb\nc");
MultiQueryExpander builtIn = MultiQueryExpander.builder()
.chatClientBuilder(ChatClient.builder(stub))
.numberOfQueries(3)
.includeOriginal(false)
.build();
List<Query> r = builtIn.expand(new Query("orig"));
assertThat(r).hasSize(1); // 退回单查询
assertThat(r.get(0).text()).isEqualTo("orig");
}
这三个测试的价值 :把第 07 章讲的"脆弱点"变成了可执行的回归测试。如果哪天 Spring AI 改了行为,测试会立刻告诉你。
8.11.3 第三层:advisor 集成测试
java
@Test
void ragAdvisorShouldInjectContextIntoPrompt() {
// 桩 VectorStore:固定返回两篇文档
VectorStore vs = new SimpleVectorStore(embeddingModel()); // 或用 Mockito
vs.add(List.of(
Document.builder().id("1").text("Spring AI 2.0 于 2025 年发布").build(),
Document.builder().id("2").text("它支持 RAG 与 MCP").build()));
Advisor rag = RetrievalAugmentationAdvisor.builder()
.documentRetriever(VectorStoreDocumentRetriever.builder().vectorStore(vs).build())
.build();
// 桩模型:把收到的 user message 原样返回,以便断言
CapturingChatModel capture = new CapturingChatModel();
ChatClient client = ChatClient.builder(capture).defaultAdvisors(rag).build();
client.prompt().user("Spring AI 是什么?").call().content();
String sentToModel = capture.lastUserText();
assertThat(sentToModel)
.contains("Spring AI 2.0 于 2025 年发布")
.contains("Spring AI 是什么?");
}
这个测试能验证整条链路:advisor → retriever → joiner → augmenter → prompt 改写。
8.11.4 第四层:效果评估(不能自动化,但必须做)
准备一个 50-100 条的问答对(question + 标准答案 + 应该命中的文档 id),跑:
ini
命中率@K = 标准文档是否出现在召回的 topK 里
答案准确率 = 人工/LLM-as-judge 打分
拒答率 = 空上下文时是否正确拒答(而非编造)
延迟 P50/P95
至少要建立的基线:改任何 RAG 配置前先跑一遍,改后再跑一遍对比。
8.12 端到端追踪:一次请求到底发生了什么
前面 11 节都是"局部",这一节把整条链路串起来,用具体数据走一遍。
8.12.1 场景设定
java
// 配置
Advisor rag = RetrievalAugmentationAdvisor.builder()
.queryTransformers(
CompressionQueryTransformer.builder().chatClientBuilder(transformBuilder).build())
.queryExpander(RobustMultiQueryExpander.builder()
.chatClientBuilder(transformBuilder).numberOfQueries(3).includeOriginal(true).build())
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(vs).similarityThreshold(0.5).topK(5)
.filterExpression(() -> feb.eq("tenant_id", "acme").build())
.build())
.documentJoiner(new ReciprocalRankFusionJoiner(60, 20))
.documentPostProcessors(
new ContentDeduplicationPostProcessor(0.85, 5),
new TokenBudgetPostProcessor(3000, true),
new LostInTheMiddleReorderPostProcessor())
.queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(true)
.documentFormatter(new NumberedDocumentFormatter())
.build())
.taskExecutor(ragExecutor) // core 8 / max 32 / queue 200
.order(0)
.build();
// 调用(多轮对话,第 2 轮)
chatClient.prompt()
.advisors(rag)
.advisors(a -> a.param(VectorStoreDocumentRetriever.FILTER_EXPRESSION, "lang == 'zh'"))
.user("它第二大城市是哪里?")
.call()
.content();
调用前链上已有的 advisor (第 06 章讲过 buildAdvisorChain() 自动追加):
ini
[0] MessageChatMemoryAdvisor order = HIGHEST + 200
[1] ToolCallingAdvisor order = HIGHEST + 300
[2] RetrievalAugmentationAdvisor order = 0
[3] ChatModelCallAdvisor order = LOWEST_PRECEDENCE
8.12.2 逐步推演
阶段 A:MessageChatMemoryAdvisor.before()(order 最小,最外)
从 ChatMemory 取 conversationId 对应的历史,塞进 prompt。假设历史是:
makefile
SYSTEM: 你是一个地理知识助手。
USER: 丹麦的首都是哪里?
ASSISTANT: 丹麦的首都是哥本哈根。
加上当前问题,prompt 变成 4 条消息:
csharp
[0] SYSTEM 你是一个地理知识助手。
[1] USER 丹麦的首都是哪里?
[2] ASSISTANT 丹麦的首都是哥本哈根。
[3] USER 它第二大城市是哪里? ← 当前
这一步必须在 RAG 之前 ------否则 CompressionQueryTransformer 拿不到历史,指代消解失效(第 07 章陷阱 14)。
阶段 B:ToolCallingAdvisor.before()
没有工具回调(本例没配工具),直接 nextCall 往下走。RAG 在它"里面"------如果本例有工具且触发了 3 轮迭代,下面 C--F 会跑 3 遍(第 07 章陷阱 14b)。
阶段 C:RetrievalAugmentationAdvisor.before() ------ Step 0
java
Map<String, Object> context = new HashMap<>(request.context());
// context = { "vector_store_filter_expression" -> "lang == 'zh'" }
String text = request.prompt().getUserMessage().getText();
// text = "它第二大城市是哪里?"
Query originalQuery = Query.builder()
.text(text)
.history(request.prompt().getInstructions()) // 4 条消息(含当前这条!)
.context(context) // 共享引用
.build();
注意 history 有 4 条,其中第 4 条就是当前问题本身。
阶段 D:Step 1 ------ CompressionQueryTransformer
java
formatConversationHistory(history)
// 过滤掉 SYSTEM(第 0 条),保留 USER/ASSISTANT(1、2、3 条)
// 输出:
// "USER: 丹麦的首都是哪里?
// ASSISTANT: 丹麦的首都是哥本哈根。
// USER: 它第二大城市是哪里?" ← 当前问题也在这里,重复了一次
LLM 调用 #1,返回:
丹麦的第二大城市是哪里?
java
transformedQuery = query.mutate().text("丹麦的第二大城市是哪里?").build();
// history 和 context 原样保留
originalQuery 仍然是 "它第二大城市是哪里?" ------ 记住这个,阶段 G 会用到。
阶段 E:Step 2 ------ RobustMultiQueryExpander
LLM 调用 #2,假设返回:
markdown
1. 丹麦第二大城市
2. Denmark second largest city
3. 丹麦人口第二多的城市是哪个
强化版清洗后(clean() 去掉 1. 等前缀):
csharp
[0] "丹麦的第二大城市是哪里?" ← includeOriginal=true,插在最前
[1] "丹麦第二大城市"
[2] "Denmark second largest city"
[3] "丹麦人口第二多的城市是哪个"
4 个 query,且已去重 (LinkedHashSet)。
如果用内置 MultiQueryExpander:序号前缀会进 query,且如果原 query 恰好等于某个变体,toMap 会崩。
阶段 F:Step 3 ------ 并发检索
java
expandedQueries.stream()
.map(q -> CompletableFuture.supplyAsync(() -> getDocumentsForQuery(q), taskExecutor))
.toList() // ← 4 个任务全部提交到 ragExecutor(core 8,所以 4 个都能并行)
.stream()
.map(CompletableFuture::join)
...
4 路并发,每路:
scss
VectorStoreDocumentRetriever.retrieve(query)
→ computeRequestFilterExpression(query)
→ query.context().get("vector_store_filter_expression") = "lang == 'zh'"
→ 是 String → FilterExpressionTextParser().parse("lang == 'zh'")
→ 返回解析后的 Expression(**覆盖了构造器里的 tenant_id 过滤!**)
→ SearchRequest(query.text(), filterExpression, 0.5, 5)
→ vs.similaritySearch(...)
⚠️ 注意这里发生了什么 :运行时传的 lang == 'zh' 完全覆盖 了构造器配的 tenant_id == 'acme'。
computeRequestFilterExpression(:110-121)的语义是"context 优先,否则用默认"------不是合并。
所以多租户场景下,如果允许调用方传 FILTER_EXPRESSION,租户隔离就被绕过了。 这正是 §8.5.4 TenantScopedRetriever 要解决的问题:它强制覆盖 context 里的值,而不是"没有才设"。
假设检索结果(每路 topK=5,阈值 0.5 后过滤):
| query | 召回(id / score) |
|---|---|
| q0 丹麦的第二大城市是哪里? | d1(0.91) d3(0.84) d7(0.62) |
| q1 丹麦第二大城市 | d1(0.89) d2(0.80) d3(0.71) |
| q2 Denmark second largest city | d5(0.88) d1(0.77) |
| q3 丹麦人口第二多的城市是哪个 | d3(0.86) d6(0.55) d2(0.52) |
(注意:similarityThreshold=0.5 是客户端后处理,向量库实际返回了 5 条,Spring AI 在内存里过滤掉低于 0.5 的。)
阶段 G:Step 4 ------ RRF 融合
每路内部先按自己的 score 降序,得到 rank:
css
q0: d1(1) d3(2) d7(3)
q1: d1(1) d2(2) d3(3)
q2: d5(1) d1(2)
q3: d3(1) d6(2) d2(3)
k=60,累加 1/(60+rank):
| doc | 贡献 | 合计 | 排名 |
|---|---|---|---|
| d1 | 1/61 + 1/61 + 1/62 | 0.032787 + 0.032787 + 0.016129 = 0.081703 | 1 |
| d3 | 1/62 + 1/63 + 1/61 | 0.016129 + 0.015873 + 0.032787 = 0.064789 | 2 |
| d2 | 1/62 + 1/63 | 0.016129 + 0.015873 = 0.032002 | 3 |
| d5 | 1/61 | 0.032787 | --- |
| d7 | 1/63 | 0.015873 | 5 |
| d6 | 1/62 | 0.016129 | 4 |
排序(降序):d1 (0.0817) > d3 (0.0648) > d5 (0.0328) > d2 (0.0320) > d6 (0.0161) > d7 (0.0159)
(d5 只在 q2 出现但排第 1,所以分数不低------RRF 对"某路的 top1"有加权。)
输出 6 篇,且每篇的 score 被 mutate().score(rrf) 改成了 RRF 分。
如果用 ConcatenationDocumentJoiner :直接按原始 score 排序,d1 会被 q0 的 0.91 排第一,但 d5(只有一路命中、score 0.88)可能挤掉 d3(三路命中但最高 0.86)。RRF 的"投票效应"更合理。
阶段 H:Step 5 ------ 后处理链
H1 去重 (ContentDeduplicationPostProcessor(0.85, 5))
假设 d2 和 d6 都来自同一个 PDF 的相邻切片,Jaccard = 0.91 > 0.85 → d6 被丢弃。
剩余:[d1, d3, d5, d2, d7](5 篇)
H2 token 预算 (TokenBudgetPostProcessor(3000, true))
累加估算:
ini
d1 800 tokens → used=800 ✓
d3 950 tokens → used=1750 ✓
d5 700 tokens → used=2450 ✓
d2 1200 tokens → 2450+1200=3650 > 3000
truncateLast=true, remaining=550 > 32 → 硬截断到 550 tokens + "...[截断]"
used=3000, break
d7 未处理
剩余:[d1, d3, d5, d2(截断)](4 篇,合计约 3000 tokens)
H3 重排 (LostInTheMiddleReorderPostProcessor)
输入(按相关度):[d1, d3, d5, d2]
less
交错: d1 d2 d3 d5
mid=2 → 前段 [d1 d2],后段 [d3 d5] 反转 → [d5 d3]
输出: d1 d2 d5 d3
最相关的 d1 在最前,次相关的 d3 在最后,最弱的 d5 在倒数第二。 符合"首尾注意力强"。
阶段 I:Step 5.5 ------ 写入 context
java
context.put(DOCUMENT_CONTEXT, documents);
因为 originalQuery.context() 持有同一个 Map 引用 (第 07 章 §7.4.0),此刻 originalQuery.context().get("rag_document_context") 也能拿到这 4 篇------自定义 QueryAugmenter 可以用它。
阶段 J:Step 6 ------ ContextualQueryAugmenter.augment(originalQuery, documents)
⚠️ 关键:传的是 originalQuery,不是 transformedQuery。
java
Map<String, Object> promptParameters = Map.of(
"query", "它第二大城市是哪里?", // ← 原始问题(带指代词"它")
"context", documentFormatter.apply(documents));
NumberedDocumentFormatter 输出:
scss
[1] (来源: denmark.pdf) 丹麦首都哥本哈根...
[2] (来源: cities.pdf) 奥胡斯是丹麦第二大城市...
[3] (来源: europe.pdf) 北欧主要城市人口...
[4] (来源: denmark.pdf) 丹麦主要城市包括......[截断]
渲染后的 prompt:
scss
仅依据下列资料回答。每个结论后用 [编号] 标注所依据的资料。
=====================
[1] (来源: denmark.pdf) 丹麦首都哥本哈根...
[2] (来源: cities.pdf) 奥胡斯是丹麦第二大城市...
[3] (来源: europe.pdf) 北欧主要城市人口...
[4] (来源: denmark.pdf) 丹麦主要城市包括......[截断]
=====================
规则:
1. 资料中找不到答案时,直接说"根据现有资料无法回答"。
2. 不要使用资料之外的知识。
3. 不要出现"根据上下文"这类措辞。
问题:它第二大城市是哪里?
回答:
注意最后那行 :问题里的"它"没有被消解 。但因为 prompt 里保留了完整历史 (MessageChatMemoryAdvisor 塞的),模型能从 "丹麦的首都是哥本哈根" 推断出"它"指丹麦。
这正是"提问用 originalQuery"这个设计的价值 ------历史 + 原话 = 完整语境。如果这里用 transformedQuery,问题会变成"丹麦的第二大城市是哪里?",虽然更明确,但丢失了用户原本的表达(如果原话里有语气、限定条件,就没了)。
返回的 Query 丢掉了 history 和 context (§8.2.3),但 before() 只用它的 text(),无影响。
阶段 K:Step 7 ------ 改写 prompt
java
return chatClientRequest.mutate()
.prompt(chatClientRequest.prompt().augmentUserMessage(augmentedQuery.text()))
.context(context)
.build();
augmentUserMessage 从后往前找最后一条 UserMessage(第 [3] 条),替换其 text。
最终 prompt:
csharp
[0] SYSTEM 你是一个地理知识助手。
[1] USER 丹麦的首都是哪里?
[2] ASSISTANT 丹麦的首都是哥本哈根。
[3] USER <上面那一大段渲染后的内容>
阶段 L:ChatModelCallAdvisor ------ LLM 调用 #3
模型看到资料 2 说"奥胡斯是丹麦第二大城市",回答:
css
丹麦的第二大城市是奥胡斯。[2]
阶段 M:after() 回溯(由内向外)
scss
ChatModelCallAdvisor → 返回 ChatResponse
RetrievalAugmentationAdvisor.after()
→ chatResponseBuilder.metadata("rag_document_context", [d1,d3,d5,d2])
ToolCallingAdvisor → 无工具调用,直接透传
MessageChatMemoryAdvisor.after()
→ 把这一轮的 USER + ASSISTANT 写回 ChatMemory
注意顺序是"由内向外":栈式链的性质(第 05 章)。
⚠️ 一个实际影响 :MessageChatMemoryAdvisor 写回记忆时,存的是增强后的 user message(那一大段带资料的内容),不是用户原话。
这意味着下一轮对话时,历史里会带上上一轮的全部检索资料------上下文快速膨胀。
这是 MessageChatMemoryAdvisor + RAG 组合的固有问题。缓解方式:
- 用
MessageWindowChatMemory限制窗口(第 15 章) - 或者自定义 memory advisor,只存原始 user text
8.12.3 完整账单
| 项 | 数量 | 说明 |
|---|---|---|
| LLM 调用 | 3 | 1(Compression)+ 1(Expand)+ 1(生成) |
| 向量检索 | 4 | 4 个 query,并发执行 |
| 向量库实际返回 | 4 × 5 = 20 条 | topK=5 |
| 阈值过滤后 | 11 条 | 客户端后处理,网络没省 |
| RRF 去重后 | 6 条 | 唯一文档 |
| 内容去重后 | 5 条 | d6 被判定为 d2 的重复 |
| token 预算后 | 4 条(约 3000 tokens) | d2 被截断,d7 被丢弃 |
| 线程切换(非流式) | 0 | 非流式不切线程 |
| 线程切换(流式) | 2 + 4 | 2 次 boundedElastic 进出 + 4 次检索线程池 |
如果触发 3 轮工具调用 (本例没有,但配置里有 ToolCallingAdvisor):
ini
LLM 调用 = 3 + 3×1 = 6 (每轮一次生成)
向量检索 = 4 × 3 = 12
这就是为什么第 07 章说"普通问答应该把 RAG 的 order 设成小于 HIGHEST+300"。
8.13 本章小结
如果本章你只记五条,记这五条:
第一条:ContextualQueryAugmenter 默认 allowEmptyContext=false,会把用户问题整段替换成英文拒答文案。 中文产品的必改项。而且自定义空上下文模板时不能写 {query} ------因为 render() 是无参调用,ValidationMode.THROW 会抛异常。
第二条:QuestionAnswerAdvisor 与 RetrievalAugmentationAdvisor 的两个 context key 完全不同。 "qa_retrieved_documents" vs "rag_document_context"、"qa_filter_expression" vs "vector_store_filter_expression"。迁移时编译不报错,只是静默拿到 null。
第三条:MultiQueryExpander 有三个脆弱点,建议直接换掉。 严格行数相等(多一行全废)、不清洗序号前缀、includeOriginal=true 时可能触发 toMap 重复键崩溃。§8.3.5 给了强化版。
第四条:官方零实现的 DocumentPostProcessor 是生产护栏的主要缺口。 token 预算、lost-in-the-middle 重排、内容去重------三个都必须自己补,§8.7 给了实现。组合顺序是去重 → 截预算 → 重排。
第五条:模板渲染用 StTemplateRenderer,默认 { } + ValidationMode.THROW。 模板里有 JSON 就换 < > 分隔符(但分隔符是 char,不支持多字符)。检索到的文档内容不会被二次扫描------但拼进模板字符串(而非作为变量值)就会被扫描。
本章交付的 8 个可投产组件:
| # | 组件 | 解决什么 | 位置 |
|---|---|---|---|
| 1 | RobustMultiQueryExpander |
扩展器的三个脆弱点 | §8.3.5 |
| 2 | ReciprocalRankFusionJoiner |
官方提了没实现的 RRF;同时修掉 HashMap 顺序不确定 | §8.6.2 |
| 3 | TokenBudgetPostProcessor |
召回暴涨导致超上下文窗口 | §8.7.1 |
| 4 | LostInTheMiddleReorderPostProcessor |
长上下文中段注意力衰减 | §8.7.2 |
| 5 | ContentDeduplicationPostProcessor |
id 去重抓不到的重复切片 | §8.7.3 |
| 6 | SafeQueryTransformer |
transformer 异常无降级 | §8.4.4 |
| 7 | ResilientDocumentRetriever |
单路检索失败全批失败 | §8.8.2 |
| 8 | NumberedDocumentFormatter + TenantScopedRetriever |
引用溯源 + 多租户越权 | §8.2.2 / §8.5.4 |
本章发现的"不一致"一览(延续第 02 章的观察):
QuestionAnswerAdvisor是public class(可继承),RetrievalAugmentationAdvisor是public final classCompressionQueryTransformer/RewriteQueryTransformer是public class,TranslationQueryTransformer是public final classVectorStoreDocumentRetriever只校验similarityThreshold >= 0,SearchRequest校验[0,1]VectorStoreDocumentRetriever要求topK > 0,SearchRequest.Builder只要求>= 0(方向相反)- 文档拼接逻辑在
QuestionAnswerAdvisor:127-129和ContextualQueryAugmenter:82-84重复了两遍 after()的 metadata 回传逻辑在两个 advisor 里写了两遍 ,一个用if一个用if/else- 两个 advisor 对空文本都崩,但一个
NullPointerException(:134)、一个IllegalArgumentException(:113-116)
到第 08 章为止,RAG 部分(07--08)完结。第 09 章开始进入工具调用:从 @Tool 注解到 JSON Schema 生成。
8.14 思考题
第 1 题 :ContextualQueryAugmenter.augmentQueryWhenEmptyContext 调的是 emptyContextPromptTemplate.render()(无参)。而 StTemplateRenderer 默认 ValidationMode.THROW。请说明:为什么自定义空上下文模板里写 {query} 会抛异常?如果改成 ValidationMode.WARN 会怎样?哪种更好?
第 2 题 :QuestionAnswerAdvisor 与 RetrievalAugmentationAdvisor 的默认模板,一个把 {query} 放最前,一个放最后。请从"注意力衰减"(lost-in-the-middle)的角度论证哪种更好,并设计一个 A/B 实验来验证你的判断(说明变量、指标、样本量)。
第 3 题 :MultiQueryExpander 要求 numberOfQueries == queryVariants.size()(严格相等)。请列举至少 3 种"模型合规输出但仍被判失败"的情况,以及 2 种"模型输出脏但被判成功"的情况。然后说明:为什么"判失败"这件事在日志上几乎不可见?
第 4 题 :SearchRequest.similarityThreshold 的 Javadoc 明确说这是"客户端后处理"。请说明这对性能 和成本分别意味着什么,并给出一个能真正减少向量库负载的替代方案(提示:想想各向量库的原生过滤参数)。
第 5 题 :VectorStoreDocumentRetriever 的 filterExpression 是 Supplier,每次 retrieve() 都会调一次。在 MultiQueryExpander(4 路)场景下,一次请求会调用几次?如果你的 Supplier 里有数据库查询,如何优化?请给出代码。
第 6 题 :ReciprocalRankFusionJoiner 的实现里用了 LinkedHashSet<>(documentsForQuery.entrySet())。请说明:这能保证遍历顺序等于传入顺序 吗?(提示:Map.of(...) 的迭代顺序是什么?)如果不能,怎么改才能让 RRF 结果完全确定?
第 7 题 :StTemplateRenderer 的 startDelimiterToken 是 char 而非 String。请说明这个限制带来的实际约束,并给出"模板里同时含 JSON 和 HTML"时的两种应对方案。
第 8 题:三个后处理器的推荐顺序是"去重 → 截预算 → 重排"。如果把"重排"放到最前面,会发生什么?请用一个具体例子(6 篇文档,第 2 篇超长)推演。
8.15 关键文件索引
ini
advisors/spring-ai-vector-store-advisor/.../vectorstore/
├── QuestionAnswerAdvisor.java 222 行
│ :55 public class(非 final,可继承)
│ :57 RETRIEVED_DOCUMENTS = "qa_retrieved_documents"
│ :59 FILTER_EXPRESSION = "qa_filter_expression"
│ :61-73 DEFAULT_PROMPT_TEMPLATE({query} 在最前!)
│ :75 DEFAULT_ORDER = 0
│ :87-97 构造器(vectorStore / searchRequest 必填)
│ :108-141 before() 四步
│ :111 SearchRequest.from() 拷贝再改(无状态)
│ :112 requireNonNullElse(getText(), "") ← 不炸
│ :121 similaritySearch
│ :127-129 文档拼接(与 ContextualQueryAugmenter 重复)
│ :134 Map.of("query", userMessage.getText(), ...) ← NPE!
│ :143-156 after()(if 版)
│ :158-164 doGetFilterExpression(protected,扩展点)
│ :200-203 protectFromBlocking(RAG 无此开关)
└── VectorStoreChatMemoryAdvisor.java (第 15 章)
spring-ai-rag/src/main/java/org/springframework/ai/rag/
├── generation/augmentation/ContextualQueryAugmenter.java 176 行
│ :53-70 主模板({query} 在最后,禁止 "Based on the context")
│ :72-75 空上下文模板(英文!)
│ :77 DEFAULT_ALLOW_EMPTY_CONTEXT = false ★ 必改
│ :82-84 DEFAULT_DOCUMENT_FORMATTER(无编号/无来源/无预算)
│ :102 PromptAssert 校验 {query},{context}
│ :120 Map.of(text 由 Query 保证非空)
│ :123 new Query(render) ← 丢掉 history/context
│ :126-133 augmentQueryWhenEmptyContext(render() 无参!)
├── preretrieval/query/expansion/MultiQueryExpander.java 184 行
│ :56-70 模板(要求换行分隔)
│ :72 includeOriginal = true
│ :74 numberOfQueries = 3
│ :116 split("\n") ← 不处理 \r\n
│ :118 严格相等校验 ★ 脆弱点 1
│ :126-129 无清洗 ★ 脆弱点 2
│ :131-134 add(0, query) ★ 脆弱点 3(重复键)
├── preretrieval/query/transformation/
│ ├── CompressionQueryTransformer.java 139 行
│ │ :49-61 模板({history},{query})
│ │ :97-107 formatConversationHistory(只留 USER/ASSISTANT)
│ ├── RewriteQueryTransformer.java 134 行
│ │ :45-53 模板({target},{query})
│ │ :55 DEFAULT_TARGET = "vector store"
│ │ :39 arXiv:2305.14283
│ └── TranslationQueryTransformer.java 144 行
│ :50 public final class
│ :54-63 模板({targetLanguage},{query})
│ :74,138 targetLanguage 两道校验
├── retrieval/join/ConcatenationDocumentJoiner.java 66 行
│ :47-64 join()(HashMap → 顺序不确定)
├── retrieval/search/VectorStoreDocumentRetriever.java 176 行
│ :57 public final class(不可继承)
│ :59 FILTER_EXPRESSION = "vector_store_filter_expression"
│ :67-70 Supplier 的延迟求值意图(原注释)
│ :75-77 只校验下界(与 SearchRequest 不一致)
│ :110-121 computeRequestFilterExpression(context 优先)
│ :158-166 两个 filterExpression 重载
└── util/PromptAssert.java 59 行
:50-52 朴素 contains(与分隔符无关!)
spring-ai-model/src/main/java/org/springframework/ai/chat/prompt/
└── PromptTemplate.java:51 DEFAULT_TEMPLATE_RENDERER = StTemplateRenderer.builder().build()
spring-ai-template-st/src/main/java/org/springframework/ai/template/st/
└── StTemplateRenderer.java
:63-65 默认分隔符 { }
:67 DEFAULT_VALIDATION_MODE = THROW
:69 DEFAULT_VALIDATE_ST_FUNCTIONS = false
:71-73 分隔符是 char(不支持多字符!)
spring-ai-commons/src/main/java/org/springframework/ai/template/
└── ValidationMode.java:25-43 THROW / WARN / NONE
spring-ai-commons/src/main/java/org/springframework/ai/document/
├── Document.java:220-223 id/text/media/metadata 断言
├── Document.java:360 mutate()
└── DefaultContentFormatter.java:45-51 默认 metadata/text 模板
spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/
└── SearchRequest.java
:44 SIMILARITY_THRESHOLD_ACCEPT_ALL = 0.0(接受一切!)
:49 DEFAULT_TOP_K = 4
:71-77 from() 拷贝
:~150 query 只 Assert.notNull
:~170 similarityThreshold 校验 [0,1]
:~160 topK 只要求 >= 0
8.16 复核命令(可直接在仓库里跑)
shell
cd /d/CODE/spring-ai-2.0.1
# 1. 对比两个 advisor 的类修饰(一个 final 一个不)
grep -n "^public.*class QuestionAnswerAdvisor" \
advisors/spring-ai-vector-store-advisor/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/QuestionAnswerAdvisor.java
grep -n "^public.*class RetrievalAugmentationAdvisor" \
spring-ai-rag/src/main/java/org/springframework/ai/rag/advisor/RetrievalAugmentationAdvisor.java
# 2. 对比两套 context key(确认完全不同)
grep -n "RETRIEVED_DOCUMENTS\s*=\|FILTER_EXPRESSION\s*=" \
advisors/spring-ai-vector-store-advisor/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/QuestionAnswerAdvisor.java
grep -n "DOCUMENT_CONTEXT\s*=" spring-ai-rag/src/main/java/org/springframework/ai/rag/advisor/RetrievalAugmentationAdvisor.java
grep -n "FILTER_EXPRESSION\s*=" spring-ai-rag/src/main/java/org/springframework/ai/rag/retrieval/search/VectorStoreDocumentRetriever.java
# 3. 确认 allowEmptyContext 默认是 false
grep -n "DEFAULT_ALLOW_EMPTY_CONTEXT" \
spring-ai-rag/src/main/java/org/springframework/ai/rag/generation/augmentation/ContextualQueryAugmenter.java
# 4. 确认空上下文模板是英文且 render() 无参
sed -n '72,75p;126,133p' \
spring-ai-rag/src/main/java/org/springframework/ai/rag/generation/augmentation/ContextualQueryAugmenter.java
# 5. 确认 MultiQueryExpander 的严格相等校验
sed -n '116,124p' spring-ai-rag/src/main/java/org/springframework/ai/rag/preretrieval/query/expansion/MultiQueryExpander.java
# 6. 确认 PromptAssert 用的是朴素 contains(与分隔符无关)
sed -n '42,57p' spring-ai-rag/src/main/java/org/springframework/ai/rag/util/PromptAssert.java
# 7. 确认 StTemplateRenderer 的分隔符是 char、默认 THROW
sed -n '63,69p;71,73p' spring-ai-template-st/src/main/java/org/springframework/ai/template/st/StTemplateRenderer.java
# 8. 确认 ValidationMode 三个取值
sed -n '25,43p' spring-ai-commons/src/main/java/org/springframework/ai/template/ValidationMode.java
# 9. 对比 similarityThreshold / topK 的校验差异(方向相反!)
grep -n "similarityThreshold\|topK must\|TopK should" \
spring-ai-rag/src/main/java/org/springframework/ai/rag/retrieval/search/VectorStoreDocumentRetriever.java
grep -n "Similarity threshold must be\|TopK should be" \
spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/SearchRequest.java
# 10. 确认文档拼接逻辑重复了两遍
sed -n '127,129p' advisors/spring-ai-vector-store-advisor/src/main/java/org/springframework/ai/chat/client/advisor/vectorstore/QuestionAnswerAdvisor.java
sed -n '82,84p' spring-ai-rag/src/main/java/org/springframework/ai/rag/generation/augmentation/ContextualQueryAugmenter.java
# 11. 确认 SimilarityThreshold 默认 0.0 = 接受一切 + 客户端后处理
sed -n '38,50p' spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/SearchRequest.java
grep -n "post-processing step" spring-ai-vector-store/src/main/java/org/springframework/ai/vectorstore/SearchRequest.java
# 12. 确认 CompressionQueryTransformer 丢弃 SYSTEM/TOOL
sed -n '97,107p' spring-ai-rag/src/main/java/org/springframework/ai/rag/preretrieval/query/transformation/CompressionQueryTransformer.java
# 13. 确认三个 transformer 的类修饰不一致
grep -n "^public.*class.*QueryTransformer" spring-ai-rag/src/main/java/org/springframework/ai/rag/preretrieval/query/transformation/*.java
# 14. 确认 protectFromBlocking 只在 QA advisor 有
grep -rn "protectFromBlocking" --include=*.java . | grep -v target
# 15. 确认 DocumentPostProcessor 零内置实现
grep -rln "implements DocumentPostProcessor" --include=*.java . | grep -v target
下一章 :第 09 章进入工具调用(上)------从 @Tool 注解到 JSON Schema 生成,看 Java 方法签名如何变成 LLM 能理解的工具定义。