第十一章 · 调阅之道:模块化 RAG
下一章具体怎么按 token 切,看⼦类 TokenTextSplitter 。它⽤ jtokkit 的 CL100K_BASE 编码( TokenTextSplitter.java:52 )按 token ⽽⾮字符计数——这⽐按字符切更贴合 LLM 的真实上下⽂消耗。默认 chunkSize=800 tokens( :40 )。核⼼切分逻辑 doSplit ⾥有⼀处特别克制的设计:
// Only apply punctuation-based truncation if we have more tokens than the// chunk size// This prevents unnecessary splitting of small texts
if (tokens.size() > chunkSize) {// Find the last period or punctuation mark in the chunk
int lastPunctuation = getLastPunctuationIndex(chunkText);
if (lastPunctuation != -1 && lastPunctuation > this.minChunkSizeChars) {// Truncate the chunk text at the punctuation mark
chunkText = chunkText.substring(0, lastPunctuation + 1);
}
}TokenTextSplitter.java:179读懂这段:它先取前 chunkSize 个 token 解码成⽂本,然后只有当剩余 token 仍然超过 chunkSize(即后⾯还有⼀⼤段要切)时,才去找这块⾥最后⼀个标点( . ? ! \n ),在那⼉断句,让切⼝落在句⼦边界⽽不是把单词/句⼦拦腰斩断。换句话说,对⼀段本来就短于 800 token 的⽂本,它不会多此⼀举去找标点硬切—— prevents unnecessary splitting of small texts ,注释⾃⼰说了。这种"够⻓才讲究断句、不够⻓就整段放过"的克制,避免了把短⽂本切成⼀地鸡⽑。
⚠ 暗礁:TokenTextSplitter 默认没有重叠(overlap)。
翻遍 TokenTextSplitter 的全部字段—— chunkSize 、 minChunkSizeChars 、 minChunkLengthToEmbed 、 maxNumChunks 、 keepSeparator 、punctuationMarks ( :40 – :50 )——你找不到⼀个 overlap / chunkOverlap 。它切块是⾸尾相接、互不重叠的:第⼀块吃掉前 800 token,第⼆块从第801 个接着吃。
这是个真实的取舍,也是个真实的隐患。业界很多 RAG 实践(⽐如 LangChain 的 RecursiveCharacterTextSplitter 默认带 overlap)会让相邻块重叠⼏⼗到上百 token,⽬的是防⽌⼀个完整语义(⼀句横跨块边界的关键句、⼀个定义和它的解释)被切⼝劈成两半、导致检索时两块都不完整命中。Spring AI 这⾥默认零重叠,意味着正好骑在切⼝上的信息有割裂⻛险。框架既没提供 overlap 参数,也没在⽂档⾥⼤声提醒——你得⾃⼰意识到这点,要么接受它、要么换/扩⼀个带重叠的 splitter。这是 ETL 这⼀层我认为最该被吐槽的⼀处:⼀个对 RAG 召回质量影响巨⼤的旋钮,被默默地设成了"关",且⽆从调起。
另外, TokenTextSplitter 那⼀⻓串构造器全被 @Deprecated(since = "2.0.0-M3", forRemoval = true) 打上了标记( :77 – :115 ),官⽅引导你改⽤builder() ( :138 )。这是 2.0 时代典型的 API 迁移信号——⽼代码该挪窝了。
🎯 三句带⾛1. Document 是 ETL 唯⼀货物:text 与 media 由构造器 text != null ^ media != null 强制⼆选⼀;metadata 必须扁平、⾮空、限标量以兼容向量库;score 检索期才有值;全程 Jackson 可序列化。
2. ETL 三道⼯序 = JDK 函数三件套的化名: DocumentReader = Supplier 、 DocumentTransformer = Function 、 DocumentWriter = Consumer ,天⽣
可 lambda、可 andThen 组合,⼀⾏ vectorStore.write(splitter.apply(reader.read())) ⾛完全程。3. 分块两件事必须记牢:Tika 整篇只产⼀个 Document,下游必须再接 TextSplitter ; TokenTextSplitter 按 CL100K_BASE token 切、默认
chunkSize=800 且⽆ overlap,切块时⾃动追加 parent_document_id / chunk_index / total_chunks 三枚溯源印章。承上启下⼊藏流⽔线讲完了:外来⽂书被读进来、裁成带溯源印章的⻚册、誊抄进藏经阁。典籍已经安静地躺在书架上,每⼀本都背着⾃⼰的向量指纹和元数据标签。
可是——把书放进阁,只是上半场。真正考验译馆功⼒的,是当客官递来⼀个含糊其辞的问题时,驿丞如何聪明地去调阅这些典籍:要不要先把问题改写得更适合检索?要不要把⼀个问题拆成好⼏个⻆度并⾏去查?查回来的⼀堆典籍,怎么去重、怎么重排、怎么拼进国书才不让模型分⼼、⼜不让它胡编乱造?
下⼀章「第⼗⼀章 · 调阅之道:模块化 RAG」,我们就钻进那条预处理→调阅→后处理的七步管线,看 RetrievalAugmentationAdvisor 是怎么把"⼀问⼀查"升级成⼀套可逐环替换的精密调阅术的。书已⼊阁,且看怎么调。
第⼗⼀章 · 调阅之道:模块化 RAG"我不会凭空答你。先让我去藏经阁翻⼏卷,翻不到,我就⽼实说翻不到。" —— ⼀段 ContextualQueryAugmenter 的⾃⽩上⼀章我们看着 ETL 流⽔线把外来⽂书裁切、归档、⼊了藏经阁。可典籍躺在阁中,本身⼀个字也不会说话。真正让"藏书"变成"答案"的,是调阅之道——客官递来⼀句话,译馆得知道去翻哪⼏卷、怎么翻、翻回来怎么拼给模型看。这,就是 RAG(检索增强⽣成)。
Spring AI 在这件事上摆了两副担⼦,⼀轻⼀重,⻔⼝挂着两块匾。
轻的那块写着「⼀问⼀查」: QuestionAnswerAdvisor 。你问⼀句,它去阁⾥查⼀次,把查到的典籍原⽂⼀股脑塞进国书,递给模型作答。简单、直给、五分钟能跑通。
重的那块写着「七步调阅」: RetrievalAugmentationAdvisor 。它把"查⼀次"这个动作拆成了七道⼯序,每道⼯序都是⼀个可拆可换的差役——重写、扩展、并发检索、去重排序、后处理、增强、回写。对应论⽂⾥的Modular RAG 范式(源码 @see ⾥⽼⽼实实挂着 arXiv:2407.21059)。
这⼀章,我们就把这两副担⼦⼀前⼀后挑起来,称称分量。
⼀、「⼀问⼀查」:QuestionAnswerAdvisor 的四步直球先看轻的。它本质上是⼀名只会⼀招的驿丞,但这⼀招⼲净利落。整段逻辑全在 before() ⾥,四步,毫不拖泥带⽔:
@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);// ...
}—— advisors/spring-ai-vector-store-advisor/.../advisor/vectorstore/QuestionAnswerAdvisor.java:109-121第⼀步就藏着⼀个值得拍⼀下⼤腿的⼩设计: SearchRequest.from(this.searchRequest) 。构造这名驿丞时你给它配⼀份"检索模板"(topK ⼏条、阈值多⾼、默认 filter 是什么),每次来活,它不是新捏⼀个请求,⽽是拷⻉模板再覆盖 query 字段。模板⾥的所有配置原样继承,唯独把那⼀句"客官原话"换上去。这就是SearchRequest 那个 static Builder from(SearchRequest) 拷⻉构造的⽤武之地——配置⼀次,复⽤千遍。
查回来之后,第⼆、三、四步连成⼀⽓:
String documentContext = documents.stream()
.map(Document::getText)
.collect(Collectors.joining(System.lineSeparator()));
UserMessage userMessage = chatClientRequest.prompt().getUserMessage();
String augmentedUserText = this.promptTemplate
.render(Map.of("query", userMessage.getText(), "question_answer_context", documentContext));
return chatClientRequest.mutate()
.prompt(chatClientRequest.prompt().augmentUserMessage(augmentedUserText))
.context(context)
.build();—— 同⽂件 :127-140
把查到的典籍⽂本⽤换⾏拼成⼀坨 documentContext ,套进内置模板,再⽤ augmentUserMessage 把客官原本那句问话就地撑⼤成⼀封带着上下⽂的新国书。模板本身也写得克制:
{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, informthe user that you can't answer the question.—— 同⽂件 :61-73 , DEFAULT_PROMPT_TEMPLATE"答不出就说答不出"——这句防幻觉的硬话,直接焊死在默认模板⾥。
✨ 「爽点时刻」:整条链路没有 transformer、没有 expander、没有 joiner、没有 post-processor。客官问 → 查⼀次 → 拼上下⽂ → 回答。少即是快。⼋成场景,
这⼀招够⽤了。
⚠ 「暗礁」:正因为它只查"客官原话"那⼀次,所以客官问得糙,它就查得糙。⼀句"那个东⻄后来咋样了"丢进 similaritySearch ,向量库会拿这句没头没脑的话去算
余弦相似度——查出来的多半是噪⾳。 QuestionAnswerAdvisor 没有任何"先把问题问清楚"的环节。想要那⼀环,你得换上重担⼦。
🦴 「⻣灰级细节」:别⼩看 doGetFilterExpression 那个分⽀。它先去 context ⾥摸 qa_filter_expression 这个 key,摸到了就⽤
FilterExpressionTextParser 现场解析成 Filter.Expression ,摸不到才回退到模板⾥那份静态 filter( :158-164 )。这意味着同⼀名驿丞,可以为每个请求挂不同的过滤条陈——多租户隔离的钩⼦,在这副"轻担⼦"上也悄悄留了⼀个。后⾯那副重担⼦,会把这个钩⼦做得更彻底。
⼆、「七步调阅」:RetrievalAugmentationAdvisor 的⼯序拆解现在挑重的。 RetrievalAugmentationAdvisor 的 before() 是本章的主菜,我把它整段端上来——它读起来⼏乎像⼀份带编号的施⼯图,每⼀步源码作者都⽤注释亲⼿编了号:
@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();
}—— spring-ai-rag/.../rag/advisor/RetrievalAugmentationAdvisor.java:107-154这是⼀座流⽔线作坊。客官的⼀句话从第 0 道⼯序进来,经过七只⼿,出去时已经是⼀封武装到⽛⻮的国书。我们顺着⼯序号往下⾛。
第 0 步:打造⼀枚 Query
进⻋间的第⼀件事,是把客官的原话包进⼀枚 Query 。注意 Query 不只装⽂本,还把对话历史( getInstructions() )和整个 context 都揣进了兜⾥( :112-116 )。
这枚 Query 是个不可变 record( Query.java:36 ),后⾯每道⼯序对它的"修改"其实都是 mutate() 出⼀枚新的——这⼀点很重要,它让七道⼯序之间不会互相踩脏数据。
第 1 步(pre):QueryTransformer 链——先把话问清楚
Query transformedQuery = originalQuery;
for (var queryTransformer : this.queryTransformers) {
transformedQuery = queryTransformer.apply(transformedQuery);
}⼀个朴素的 for 循环,把多个 transformer 串成⼀条⼩链。每个 transformer 是 Function<Query,Query> ,吃⼀枚 Query 吐⼀枚 Query,前⼀个的产物喂给后⼀个。
官⽅给了三名现成的差役: RewriteQueryTransformer (重写⼝语化的烂问题)、 CompressionQueryTransformer (结合历史把多轮对话压成⼀句独⽴查询)、
TranslationQueryTransformer (翻译)。挑 RewriteQueryTransformer 看⼀眼它怎么⼲活:
var rewrittenQueryText = this.chatClient.prompt()
.user(user -> user.text(this.promptTemplate.getTemplate())
.param("target", this.targetSearchSystem)
.param("query", query.text()))
.call()
.content();
if (!StringUtils.hasText(rewrittenQueryText)) {
logger.warn("Query rewrite result is null/empty. Returning the input query unchanged.");
return query;
}
return query.mutate().text(rewrittenQueryText).build();—— RewriteQueryTransformer.java:82-94它⾃⼰也是个 LLM 调⽤——拿模型把"那个东⻄后来咋样了"重写成"X 项⽬在 2024 年之后的进展"。 target 默认是 "vector store" ( :55 ),告诉模型"我这是要去查向量库,你给我写得检索友好点"。最稳的⼀笔在 :89 :重写结果若为空,原样退回,绝不把⼀个空字符串硬塞进检索。这种"失败就退回原值"的兜底,在这⼀章⾥你会看到⼀遍⼜⼀遍,⼏乎是 RAG 模块的肌⾁记忆。
第 2 步(pre):QueryExpander——⼀问拆成多问
List<Query> expandedQueries = this.queryExpander != null ? this.queryExpander.expand(transformedQuery)
: List.of(transformedQuery);QueryExpander 是 Function<Query, List<Query>> ——吃⼀枚,吐⼀串。⼀句问题,从此分身成多句语义不同的变体,从不同⻆度去藏经阁捞。这是召回率的放⼤器。注意这⼀步可空:没配 expander,就退化成 List.of(transformedQuery) ,后⾯流程照跑不误。
官⽅实现 MultiQueryExpander 同样靠 LLM ⽣成 numberOfQueries=3 个变体。但这名差役脾⽓最⼤,我们留到「暗礁」⼀节单独审它。
第 3 步(retrieval):DocumentRetriever + 并发检索来到全章最亮的⼀笔:
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())));第⼀个 stream 把每个扩展查询包成⼀个 CompletableFuture.supplyAsync ,丢进线程池⽴刻起跑。这⾥ .toList() 不是凑数——它先把所有 future 收集成list(全部启动),再开第⼆个 stream 逐个 join 。这个"先全部 submit、再全部 join"的两段式写法,正是并发的精髓:三句变体的检索是同时打向向量库的,⽽不是排队⼀句⼀句来。
✨ 「爽点时刻」:多查询本来意味着 N 倍延迟,这⾥⽤ CompletableFuture + 线程池把 N 次串⾏检索压成了"⼀次最慢检索"的耗时。默认线程池配置也讲究:
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;
}—— 同⽂件 :194-202core=4 / max=16 是兜底默认,你可以换。但真正的点睛之笔是那个 ContextPropagatingTaskDecorator ——它保证 Micrometer 的追踪上下⽂、MDC 这些线程绑定的玩意⼉能跨线程传播到⼦任务⾥。否则⼀并发,trace 就断了,账房(观测)记的账对不上号。这个 decorator,是"并发"和"可观测"两件事不打架的关键缝合线。
🦴 「⻣灰级细节」:盯住那个返回类型 Map<Query, List<List<Document>>> ——为什么是双层 List?单个 query 检索回来明明只是 List<Document> 。看
:134 那句 entry -> List.of(entry.getValue()) ,它硬是把单层结果⼜包了⼀层。这层"多余"的 List 不是⼿滑,⽽是为 joiner 留的接⼝:它预设了"⼀个 query可能从多个数据源各捞⼀批"的未来——外层 List 区分数据源,内层 List 是该源的命中。眼下官⽅的 getDocumentsForQuery 只接⼀个 retriever、只产⼀批,所以外层 List 永远只有⼀个元素;但类型签名已经替"多源检索"把位⼦占好了。这是⼀处典型的"为扩展预留形状"的设计。
再看 retriever 本身。 VectorStoreDocumentRetriever 把 Query 翻成 SearchRequest 打向藏经阁,关键在它的 filter:
// 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.
private final Supplier<Filter.Expression> filterExpression;—— VectorStoreDocumentRetriever.java:67-70
🦴 filter 不是⼀个写死的 Filter.Expression ,⽽是⼀个 Supplier<Filter.Expression> ——惰性求值。每次检索时才 .get() 算⼀次。为什么要绕这⼀道?
注释说得明明⽩⽩:你可能想按"当前⽤户身份"或"租户 ID"动态过滤。请求 A 是张三,只能看张三的库;请求 B 是李四,⾃动换成李四的库——靠的就是这个每次现算的Supplier。更进⼀步,它还认 query context ⾥的钩⼦:
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();
}—— 同⽂件 :110-121context ⾥塞了 vector_store_filter_expression ( :59 )就⽤它,且字符串和 Filter.Expression 对象两种都收——字符串就现场解析,对象就直接⽤。没塞才回退到那个 Supplier。这是把多租户隔离从"构造时"⼀路延伸到了"每次请求时"的完整钩⼦,⽐上半场 QuestionAnswerAdvisor 那个 qa_filter_expression更周到。
第 4 步(retrieval):DocumentJoiner——去重 + 排序多查询捞回⼀堆典籍,难免重复。 ConcatenationDocumentJoiner 来收摊:
return new ArrayList<>(documentsForQuery.values()
.stream()
.flatMap(List::stream)
.flatMap(List::stream)
.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());—— ConcatenationDocumentJoiner.java:54-63
两次 flatMap 把那个双层 List 彻底摊平(回应了上⾯第 3 步留的双层结构),然后 toMap(Document::getId, ..., (existing, duplicate) -> existing)——按⽂档 ID 去重,冲突时保留⾸次出现的那份。最后按 score 降序排。
🦴 注意排序那句的 null 守护: doc.getScore() != null ? doc.getScore() : 0.0 。 Document 的 score 是个 @Nullable Double (检索时才被赋值,创建时通常为 null)。万⼀某条⽂档没 score,这⾥不会 NPE,⽽是当成 0.0 沉底。⼀个不起眼的三⽬,挡住了⼀类边界崩溃。这是默认 joiner,不配也有它兜着。
第 5 步(post):DocumentPostProcessor——重排/压缩/过滤的留⽩
for (var documentPostProcessor : this.documentPostProcessors) {
documents = documentPostProcessor.process(originalQuery, documents);
}后处理是 BiFunction<Query, List<Document>, List<Document>> ,⼀连串地处理。这⾥是重排器(reranker)、上下⽂压缩、⼆次过滤该插的地⽅。
但要说⼀句实话——RAG 模块本身只给了接⼝,没给任何⼀个开箱即⽤的实现。 DocumentPostProcessor 在 spring-ai-rag ⾥是个光秃秃的接⼝,具体的重排器要靠各模型/集成模块(⽐如某些 reranker provider)另⾏提供。所以默认情况下 documentPostProcessors 是空 list( :95 ),这个 for 循环空转⼀圈什么都不做。这⼀步是"为⽣态预留的插槽",不是"⾃带电池"。想⽤重排,你得⾃⼰接电。
第 6 步(generation):QueryAugmenter——拼上下⽂,且防幻觉
Query augmentedQuery = this.queryAugmenter.augment(originalQuery, documents);默认是 ContextualQueryAugmenter 。它把⽂档拼进模板的 {context} ,凑成最终问给模型的话。它的防幻觉细节是本章我最想夸的⼀处:
@Override
public Query augment(Query query, List<Document> documents) {// ...
if (documents.isEmpty()) {
return augmentQueryWhenEmptyContext(query);
}// ...正常拼 context
}
private Query augmentQueryWhenEmptyContext(Query query) {
if (this.allowEmptyContext) {
return query;
}
return new Query(this.emptyContextPromptTemplate.render());
}—— ContextualQueryAugmenter.java:106-133
🦴 「⻣灰级细节」: allowEmptyContext 默认 false( :77 )。当检索⼀篇都没捞到、 documents 为空时,它不会傻乎乎地把⼀个空 context 丢给模型——空
context 是幻觉的温床,模型会⾃由发挥编⼀段。它⾛的是 DEFAULT_EMPTY_CONTEXT_PROMPT_TEMPLATE :
The user query is outside your knowledge base.Politely inform the user that you can't answer it.—— 同⽂件 :72-75直接换⼀封"礼貌地告诉⽤户超出知识库"的国书。这是⽤模板硬性挡住"⽆据⽽答"的设计——⽐起在正常模板⾥写⼀句"答不出就说不知道"(那是软约束,模型可能不听),这⾥是空检索直接换模板(硬约束)。⼀软⼀硬两道闸,层层防漏。
⚠ 但也别把这当万能符: allowEmptyContext=true 时它会原样放⾏原始 query,等于关掉这道闸。要这个保护,就别去动那个默认值。
第 7 步:回写国书
return chatClientRequest.mutate()
.prompt(chatClientRequest.prompt().augmentUserMessage(augmentedQuery.text()))
.context(context)
.build();最后把武装好的⽂本 augmentUserMessage 回写进 prompt,这封国书才递向下⼀名驿丞、最终抵达模型。
🦴 「⻣灰级细节(最隐蔽的⼀处)」:回头看第 6 步的 augment(originalQuery, documents) ——传进去的是 originalQuery ,不是 transformedQuery 或某个
扩展变体。同样,第 5 步后处理 process(originalQuery, documents) ⽤的也是 originalQuery。这说明⼀件很多⼈会搞反的事:重写和扩展只⽤来"指挥去哪⼉翻书",绝不污染最终问给模型的那句话。客官原话"那个东⻄后来咋样了"会被重写成检索友好的版本去查库,但拼进最终 prompt 的 {query} ,仍是客官的原话(或经augmenter 包装的原话)。检索的归检索,作答的归作答——transformer 链产出的 transformedQuery 在第 3 步检索完,使命就到此为⽌了。这条边界划得极⼲净,却没⼏⾏注释提醒你,极易看漏。
三、默认值兜底:除了⼀处,全有备胎把构造器摊开,你会发现这座作坊的"少配也能跑"是刻意设计的:
this.queryTransformers = queryTransformers != null ? queryTransformers : List.of();
this.queryExpander = queryExpander; //可空,空就不扩展
this.documentRetriever = documentRetriever;
this.documentJoiner = documentJoiner != null ? documentJoiner : new ConcatenationDocumentJoiner();
this.documentPostProcessors = documentPostProcessors != null ? documentPostProcessors : List.of();
this.queryAugmenter = queryAugmenter != null ? queryAugmenter : ContextualQueryAugmenter.builder().build();
this.taskExecutor = taskExecutor != null ? taskExecutor : buildDefaultTaskExecutor();
this.scheduler = scheduler != null ? scheduler : BaseAdvisor.DEFAULT_SCHEDULER;
this.order = order != null ? order : 0;—— RetrievalAugmentationAdvisor.java:91-99transformers 默认空链、expander 默认不扩展、joiner 默认拼接、augmenter 默认上下⽂增强(⾃带防幻觉)、线程池默认 4/16、scheduler 默认……唯⼀没有备胎的,是 documentRetriever :
Assert.notNull(documentRetriever, "documentRetriever cannot be null");—— 同⽂件 :89 (Builder ⾥ :289 再校验⼀次)
逻辑上⽆可辩驳:RAG 的本质就是"去检索",没有 retriever 谈何检索增强?所以它是唯⼀必填项。最⼩可⽤配置就是⼀句 .documentRetriever(...) ,其余六步全由默认值撑起⼀条完整的简版链路。这种"必填最少、默认完整"的取舍,是这个 Builder 最舒服的地⽅。
四、⚠ 暗礁:MultiQueryExpander 对模型输出的"⾏数洁癖"
夸了⼀路,得拍⼀块真正的暗礁了。 MultiQueryExpander 这名差役,设计思路漂亮,落地却脆。看它怎么解析模型返回:
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);
}
var queries = queryVariants.stream()
.filter(StringUtils::hasText)
.map(queryText -> query.mutate().text(queryText).build())
.collect(Collectors.toList());—— MultiQueryExpander.java:116-129它对模型有⼀条铁律:返回的⽂本按 \n 切开后,⾏数必须恰好等于 numberOfQueries (默认 3),不多不少,否则整个扩展作废、退回单查询( :118-124 )。
问题在于,LLM 是出了名的不守格式。任何⼀个常⻅的"⼩动作"都能把这道校验掀翻:
模型客⽓⼀句"Here are 3 variants:" 当开头 → 多出⼀⾏ → size 变 4 → 作废。
模型在变体之间留了空⾏(很常⻅的排版习惯)→ split("\n") 把空⾏也算⼀⾏ → size 超标 → 作废。
模型给你编了 4 个或 2 个变体 → 直接作废。
🦴 更刁的⼀处:校验在 split 之后、 filter(StringUtils::hasText) 之前。也就是说,空⾏在"数⾏数"那⼀刻是被算进去的,过了这⼀关才被 hasText 滤掉。
这造成⼀种割裂——校验数的是"原始⾏数"(含空⾏),真正⽣成 Query 数的是"⾮空⾏数"。所以哪怕模型乖乖给了 3 个变体,只要中间夹了⼀个空⾏,size 就会变成 4 ⽽当场判死;反过来,这两个数字之间天然存在不⼀致的缝隙。⼀个 .filter(StringUtils::hasText) 提到校验之前、或者改⽤"⾄少 N ⾏"的宽松判定,本可以让它结实得多。
这不是 bug——失败时它⽼实退回单查询,⾏为是安全的,不会崩、不会乱答。但它把"多查询召回"这个能⼒的稳定性,押在了模型严格遵守换⾏格式上,⽽这恰恰是LLM 最不可靠的维度之⼀。⽣产⾥上 MultiQueryExpander ,你⼤概率会在⽇志⾥频繁看到那句 "does not contain the requested 3 variants",然后默默退回单查询——多查询的钱花了(⼀次额外 LLM 调⽤),召回的货没收到。要稳,要么⾃定义⼀个解析更宽容的 expander,要么把 prompt 模板调得更严防⽌模型加戏。
五、两副担⼦,怎么挑?
把两块匾放⼀起称:
维度 QuestionAnswerAdvisor(⼀问⼀查) RetrievalAugmentationAdvisor(七步调阅)检索次数 固定⼀次(查客官原话) 可多查询并发(expander 扩展)
问题预处理 ⽆ QueryTransformer 链(重写/压缩/翻译)
多源去重排序 ⽆ DocumentJoiner后处理(重排) ⽆ DocumentPostProcessor(留⽩,需⾃接)
防幻觉 模板软约束 软约束 + 空检索硬换模板必填项 vectorStore documentRetriever适合 快速上⼿、⼋成场景 ⽣产级精细控制、召回敏感结论很实在:别⼀上来就上重担⼦。 QuestionAnswerAdvisor 能解决的事,没必要为了"显得专业"去装七步流⽔线——多出来的每⼀步(尤其 transformer /expander)都是⼀次额外的 LLM 调⽤,是真⾦⽩银的延迟和 token。等你确实遇到"客官问得糙、单次召回不够、要按租户隔离、要重排"这些硬需求,再换RetrievalAugmentationAdvisor ,把对应的⼯序⼀步步插上去。这正是模块化的好处——你按需付费,不为⽤不上的⼯序买单。
🎯 三句带⾛
1. 两档 RAG: QuestionAnswerAdvisor 是单步"检索→拼 context→回答",⽆ transform/expand/join/post; RetrievalAugmentationAdvisor.before() 是七步可插拔管线,六步有默认兜底,唯⼀必填是 documentRetriever 。
2. 检索 vs 作答分家:transformer/expander 的产物只⽤于"指挥检索⽅向",最终 augment / postProcess 喂给模型的是 originalQuery ——重写不污染最
终 prompt。并发检索靠 CompletableFuture + ThreadPoolTaskExecutor (默认 4/16,带 ContextPropagatingTaskDecorator 传播追踪上下⽂)。3. 两处⽣产坑: MultiQueryExpander 要求模型返回⾏数严格等于 N(默认 3),且校验在过滤空⾏之前,格式稍乱即退回单查询; DocumentPostProcessor (重排)
在 RAG 模块只有接⼝、⽆⾃带实现,需另接。
承上启下这⼀章⾥,我们看 Query 时⼀笔带过了它兜⾥揣着的"对话历史"( history )——RAG 把它喂给 CompressionQueryTransformer 去压缩多轮上下⽂,也喂给最终模板让模型有迹可循。可这份"历史"本身是从哪⼉来的?它怎么在⼀轮轮对话间被记下、被读出、被裁剪到不撑爆窗⼝?藏经阁管的是"天下典籍",⽽每位客官⾃⼰那本流⽔账——他刚才说过什么、上⼀句问了啥——⼜归谁打理?
下⼀章,我们⾛进译馆专为每位客官设的那间⼩屋:「第⼗⼆章 · 起居注:ChatMemory、窗⼝裁剪与档案库房」。看看译馆是怎么替你记⽇记、⼜怎么在⽇记太厚时狠⼼撕掉最旧的⼏⻚的。