QuestionAnswerAdvisor 用一行代码就搞定了"检索 + 生成",但它是个黑盒,检索策略、查询处理、结果过滤全部固定。当业务场景变复杂,你会发现它不够用。
1. 模块化 vs 一体化
先看两种方案的差异:
| QuestionAnswerAdvisor | RetrievalAugmentationAdvisor | |
|---|---|---|
| 查询预处理 | 无,原样检索 | 支持重写、压缩、扩展 |
| 检索控制 | 有限的 SearchRequest 配置 | 独立的 DocumentRetriever,可自定义 |
| 空结果处理 | 固定行为 | 可配置是否兜底 |
| 扩展性 | 封闭 | 每个阶段可替换、可组合 |
模块化 RAG 把管道拆分为四个独立阶段:
每个阶段有独立的接口,可以像搭积木一样自由组合。不需要的阶段直接跳过,用默认实现即可。
添加依赖:
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-rag</artifactId>
</dependency>2. 查询表述不佳:查询重写
用户提问往往口语化、结构松散,直接拿去做向量检索效果不好。比如"spring ai的rag咋搞的",关键词都在,但表述不够规范,检索评分可能不高。
RewriteQueryTransformer 用 LLM 将用户查询重写为更适合向量检索的形式:把口语化表述转为书面表达,补全缩写,理清句子结构。
RewriteQueryTransformer rewriter = RewriteQueryTransformer.builder()
.chatClientBuilder(chatClientBuilder)
.build();2.1 测试查询重写
src/test/java/com/albertstack/rag/rag/QueryRewriteTest.java
package com.albertstack.rag.rag;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.preretrieval.query.transformation.RewriteQueryTransformer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
@Slf4j
@SpringBootTest
class QueryRewriteTest {
@Autowired
private ChatClient.Builder chatClientBuilder;
@Test
void shouldRewriteColloquialQuery() {
var rewriter = RewriteQueryTransformer.builder()
.chatClientBuilder(chatClientBuilder)
.build();
// 口语化查询:关键词都在,但表述不够检索友好
Query original = new Query("spring ai的rag咋搞的");
Query rewritten = rewriter.transform(original);
log.info("原始查询:{}", original.text());
log.info("重写后:{}", rewritten.text());
// 重写后应该包含核心关键词,且表述更规范
assertThat(rewritten.text()).isNotEqualTo(original.text());
assertThat(rewritten.text()).satisfiesAnyOf(
t -> assertThat(t).containsIgnoringCase("Spring AI"),
t -> assertThat(t).containsIgnoringCase("RAG")
);
}
@Test
void shouldImproveFragmentedQuery() {
var rewriter = RewriteQueryTransformer.builder()
.chatClientBuilder(chatClientBuilder)
.build();
// 碎片化查询:缩写多、语法不完整
Query original = new Query("embedding模型选哪个比较好 ollama上的");
Query rewritten = rewriter.transform(original);
log.info("原始查询:{}", original.text());
log.info("重写后:{}", rewritten.text());
assertThat(rewritten.text()).isNotBlank();
// 重写后应该比原始查询更结构化
log.info("原始长度:{},重写后长度:{}", original.text().length(), rewritten.text().length());
}
}运行测试,观察控制台输出:
- "spring ai的rag咋搞的" -> "Spring AI 实现 RAG(检索增强生成)的步骤"
- "embedding模型选哪个比较好 ollama上的" -> "Ollama embedding 模型推荐"
3. 多轮对话丢上下文:查询压缩
假设用户的对话是这样的:
用户:Spring AI 支持哪些向量数据库?
助手:支持 PGVector、Chroma、Milvus、Pinecone 等...
用户:第一个怎么配置?
"第一个" 指的是 PGVector,但直接拿 "第一个怎么配置" 去检索,什么也找不到。
因此,这里要用到CompressionQueryTransformer 结合对话历史,将查询压缩为自包含的问题:
CompressionQueryTransformer compressor = CompressionQueryTransformer.builder()
.chatClientBuilder(chatClientBuilder)
.build();3.1 测试查询压缩
src/test/java/com/albertstack/rag/rag/QueryCompressionTest.java
package com.albertstack.rag.rag;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.AssistantMessage;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.preretrieval.query.transformation.CompressionQueryTransformer;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Slf4j
@SpringBootTest
class QueryCompressionTest {
@Autowired
private ChatClient.Builder chatClientBuilder;
@Test
void shouldResolvePronouns() {
var compressor = CompressionQueryTransformer.builder()
.chatClientBuilder(chatClientBuilder)
.build();
// 模拟多轮对话历史
List<Message> history = List.of(
new UserMessage("Spring AI 支持哪些向量数据库?"),
new AssistantMessage("Spring AI 支持 PGVector、Chroma、Milvus、Pinecone 等多种向量数据库。")
);
// 用户的跟进问题,包含代词"第一个"
Query followUp = Query.builder()
.text("第一个怎么配置?")
.history(history)
.build();
Query compressed = compressor.transform(followUp);
log.info("原始查询:{}", followUp.text());
log.info("对话历史:{} 条消息", history.size());
log.info("压缩后:{}", compressed.text());
// 压缩后应该包含 PGVector,因为"第一个"指的是它
assertThat(compressed.text()).containsIgnoringCase("PGVector");
}
}运行测试,观察压缩结果:
原始查询:第一个怎么配置?
对话历史:2 条消息
压缩后:Spring AI 中如何配置 PGVector 向量数据库?
使用 CompressionQueryTransformer压缩后,对话历史中的上下文被正确解析,代词 "第一个 "被替换为具体实体。
4. 一次查询漏文档:多查询扩展
知识库里同一概念可能有多种表述。用户问 "Spring AI 如何实现 RAG",但文档里写的是 "检索增强生成的配置方法",单次查询可能命中不了。
为防止漏查,可以用 MultiQueryExpander 将一个查询扩展为多个不同角度的变体,分别检索后合并结果:
MultiQueryExpander expander = MultiQueryExpander.builder()
.chatClientBuilder(chatClientBuilder)
.numberOfQueries(3) // LLM 生成 3 个变体查询
.includeOriginal(true) // 保留原始查询(默认就是 true)
.build();numberOfQueries(3):让 LLM 生成 3 个不同角度的变体includeOriginal(true):默认行为,原始查询也会保留在结果列表中。最终返回的查询数 = 原始 1 个 + 变体 N 个
4.1 测试多查询扩展
src/test/java/com/albertstack/rag/rag/MultiQueryExpandTest.java
package com.albertstack.rag.rag;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.preretrieval.query.expansion.MultiQueryExpander;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import static org.assertj.core.api.Assertions.assertThat;
@Slf4j
@SpringBootTest
class MultiQueryExpandTest {
@Autowired
private ChatClient.Builder chatClientBuilder;
@Test
void shouldExpandToMultipleQueries() {
var expander = MultiQueryExpander.builder()
.chatClientBuilder(chatClientBuilder)
.numberOfQueries(3)
.build();
Query original = new Query("Spring AI 如何实现 RAG");
List<Query> expanded = expander.expand(original);
log.info("原始查询:{}", original.text());
log.info("扩展结果(共 {} 个):", expanded.size());
for (int i = 0; i < expanded.size(); i++) {
log.info(" 查询 {}:{}", i + 1, expanded.get(i).text());
}
// includeOriginal 默认为 true,所以结果数 = 原始 1 个 + 变体 3 个 = 4 个
assertThat(expanded).hasSize(4);
// 第一个应该是原始查询
assertThat(expanded.get(0).text()).isEqualTo(original.text());
}
@Test
void shouldExcludeOriginalWhenConfigured() {
var expander = MultiQueryExpander.builder()
.chatClientBuilder(chatClientBuilder)
.numberOfQueries(3)
.includeOriginal(false) // 不保留原始查询
.build();
Query original = new Query("Spring AI 如何实现 RAG");
List<Query> expanded = expander.expand(original);
log.info("excludeOriginal 模式,扩展结果(共 {} 个):", expanded.size());
for (int i = 0; i < expanded.size(); i++) {
log.info(" 查询 {}:{}", i + 1, expanded.get(i).text());
}
// 只有 LLM 生成的变体,没有原始查询
assertThat(expanded).hasSize(3);
}
}测试运行结果如下:
原始查询:Spring AI 如何实现 RAG
扩展结果(共 4 个):
查询 1:Spring AI 如何实现 RAG
查询 2:Spring AI 搭建 RAG 系统的详细教程与核心代码实现
查询 3:Spring AI 框架下的 RAG 架构设计与 Retriever 配置
查询 4:如何在 Spring AI 中集成向量存储实现检索增强生成应用
excludeOriginal 模式,扩展结果(共 3 个):
查询 1:Spring AI 实现 RAG 的完整代码示例与配置步骤
查询 2:Spring AI 架构设计:如何集成向量存储实现检索增强生成
查询 3:Spring AI 入门教程:构建检索增强生成应用的实践指南经过扩展后,查询从单个表述扩展成多个不同表述的变体,覆盖了同一主题的不同说法,分别检索后合并去重,能召回更多相关文档。
5. 检索结果不精准:文档检索器
VectorStoreDocumentRetriever 封装了 VectorStore,提供比 SearchRequest 更精细的检索控制:
VectorStoreDocumentRetriever retriever = VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.similarityThreshold(0.5) // 低于此值的文档被过滤
.topK(5) // 最多返回 5 个文档
.build();5.1 测试不同阈值的检索效果
src/test/java/com/albertstack/rag/rag/DocumentRetrieverTest.java
package com.albertstack.rag.rag;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.ai.document.Document;
import org.springframework.ai.rag.Query;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
@Slf4j
@SpringBootTest
class DocumentRetrieverTest {
@Autowired
private VectorStore vectorStore;
@BeforeEach
void setUp() {
// 准备主题差异较大的文档,方便观察阈值过滤效果
vectorStore.add(List.of(
new Document("Spring AI 支持 PGVector、Chroma、Milvus 等向量数据库",
Map.of("source", "vector-db.txt")),
new Document("Spring Boot Actuator 提供健康检查和监控端点",
Map.of("source", "actuator.txt")),
new Document("Java 21 引入了虚拟线程,大幅提升并发性能",
Map.of("source", "java21.txt"))
));
}
@Test
void shouldFilterByThreshold() {
Query query = new Query("Spring AI 的向量数据库支持");
// 低阈值:宽松匹配,可能返回"沾边"的文档
var looseRetriever = VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.similarityThreshold(0.1)
.topK(5)
.build();
List<Document> looseResults = looseRetriever.retrieve(query);
log.info("低阈值(0.1) 检索结果数:{}", looseResults.size());
looseResults.forEach(doc -> log.info(" 来源:{},内容:{}",
doc.getMetadata().get("source"),
doc.getText().substring(0, Math.min(50, doc.getText().length()))));
// 高阈值:严格匹配,只返回高度相关的文档
var strictRetriever = VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.similarityThreshold(0.8)
.topK(5)
.build();
List<Document> strictResults = strictRetriever.retrieve(query);
log.info("高阈值(0.8) 检索结果数:{}", strictResults.size());
strictResults.forEach(doc -> log.info(" 来源:{},内容:{}",
doc.getMetadata().get("source"),
doc.getText().substring(0, Math.min(50, doc.getText().length()))));
// 低阈值返回的文档数 >= 高阈值
assertThat(looseResults.size()).isGreaterThanOrEqualTo(strictResults.size());
}
}运行前,请先注释掉 AiConfig 中的 CommandLineRunner ingestDocuments,改为在测试中直接添加文档,避免文档干扰。
Calling EmbeddingModel for document id = 147c0873-cfdf-4289-b55a-97ac1b673b11
Calling EmbeddingModel for document id = 79f0b6df-2771-4634-a681-0ebe255832ac
Calling EmbeddingModel for document id = 8ddf363c-eb3b-41ee-8994-7a4d7b91dcc9
原始查询:Spring AI 的向量数据库支持
低阈值(0.1) 检索结果数:3
来源:vector-db.txt,内容:Spring AI 支持 PGVector、Chroma、Milvus 等向量数据库
来源:actuator.txt,内容:Spring Boot Actuator 提供健康检查和监控端点
来源:java21.txt,内容:Java 21 引入了虚拟线程,大幅提升并发性能
高阈值(0.8) 检索结果数:1
来源:vector-db.txt,内容:Spring AI 支持 PGVector、Chroma、Milvus 等向量数据库运行测试,观察两组结果的差异。低阈值可能把其他内容也捞回来(因为都和 Spring 沾边),高阈值只留下真正相关的 "向量数据库" 文档。
similarityThreshold 的调优建议:
| 阈值 | 效果 | 风险 |
|---|---|---|
| 0.3-0.5 | 宽松匹配,覆盖面广 | 可能引入不相关内容 |
| 0.5-0.7 | 平衡精准和覆盖,推荐起点 | - |
| 0.7-0.9 | 严格匹配,结果精准 | 可能过滤掉有用文档 |
6. 知识库没答案怎么办:上下文增强
用户的问题不一定总能在知识库中找到匹配。检索结果为空时,是直接告诉用户"没找到",还是让 LLM 用自身知识兜底?
通过 ContextualQueryAugmenter 的 allowEmptyContext 可以控制这个行为:
ContextualQueryAugmenter augmenter = ContextualQueryAugmenter.builder()
.allowEmptyContext(true)
.build();allowEmptyContext(true):没检索到文档时,仍将查询发给 LLM,模型基于自身知识回答allowEmptyContext(false)(默认):直接返回"未找到相关信息",不调用 LLM
7. 整合组件
7.1 更新 AiConfig
把各组件组装到 AiConfig 中。相比前面的 ragChatClient,这里改为注册一个 RetrievalAugmentationAdvisor Bean:
src/main/java/com/albertstack/rag/config/AiConfig.java
package com.albertstack.rag.config;
import com.albertstack.rag.service.IngestionService;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.ai.rag.preretrieval.query.transformation.RewriteQueryTransformer;
import org.springframework.ai.rag.retrieval.search.VectorStoreDocumentRetriever;
import org.springframework.ai.rag.generation.augmentation.ContextualQueryAugmenter;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.boot.CommandLineRunner;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.core.io.ClassPathResource;
import java.util.List;
@Slf4j
@Configuration
public class AiConfig {
@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel) {
return SimpleVectorStore.builder(embeddingModel).build();
}
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
return builder.build();
}
@Bean
public RetrievalAugmentationAdvisor ragAdvisor(
ChatClient.Builder chatClientBuilder, VectorStore vectorStore) {
return RetrievalAugmentationAdvisor.builder()
.queryTransformers(RewriteQueryTransformer.builder()
.chatClientBuilder(chatClientBuilder).build())
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.similarityThreshold(0.5)
.topK(5)
.build())
.queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(true)
.build())
.build();
}
@Bean
public CommandLineRunner ingestDocuments(IngestionService ingestionService) {
return args -> {
List<String> files = List.of(
"docs/spring-ai-intro.txt",
"docs/rag-concepts.txt",
"docs/ollama-guide.txt"
);
for (String file : files) {
var resource = new ClassPathResource(file);
int chunks = ingestionService.ingest(resource);
log.info("已摄入: {} -> {} 个分块", file, chunks);
}
};
}
}AiConfig 的调整总结:
- 删除了 ragChatClient:不再用
QuestionAnswerAdvisor构建专用 ChatClient - 新增 ragAdvisor:
RetrievalAugmentationAdvisor作为独立 Bean,按需挂载到任意 ChatClient 上 - chatClient 保持不变:基础 ChatClient 不绑定任何 Advisor,更灵活
7.2 更新 RagService
src/main/java/com/albertstack/rag/service/RagService.java
package com.albertstack.rag.service;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.model.ChatResponse;
import org.springframework.ai.rag.advisor.RetrievalAugmentationAdvisor;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
@Service
public class RagService {
private final ChatClient chatClient;
private final RetrievalAugmentationAdvisor ragAdvisor;
public RagService(ChatClient chatClient, RetrievalAugmentationAdvisor ragAdvisor) {
this.ragAdvisor = ragAdvisor;
this.chatClient = chatClient;
}
public String chat(String question) {
return chatClient.prompt()
.user(question)
.advisors(ragAdvisor)
.call()
.content();
}
public Flux<String> chatStream(String question) {
return chatClient.prompt()
.user(question)
.advisors(ragAdvisor)
.stream()
.content();
}
public Flux<ChatResponse> chatStreamDetail(String question) {
return chatClient.prompt()
.user(question)
.advisors(ragAdvisor)
.stream()
.chatResponse();
}
}关键变化: 不再通过 @Qualifier 注入 ragChatClient,而是注入基础 chatClient + ragAdvisor,调用时通过 .advisors(ragAdvisor) 挂载。
同一个 ChatClient 可以按需挂载不同的 Advisor,不用维护多个 ChatClient Bean。
RagController 不需要改动,它只依赖 RagService 的接口,内部实现的切换对它完全透明。
7.3 整合测试
前面分别测试了各个组件的独立行为,现在验证完整管道的端到端效果。
src/test/java/com/albertstack/rag/rag/ModularRagTest.java
package com.albertstack.rag.rag;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import com.albertstack.rag.service.RagService;
import static org.assertj.core.api.Assertions.assertThat;
@Slf4j
@SpringBootTest
class ModularRagTest {
@Autowired
private RagService ragService;
@Test
void shouldAnswerPreciseQuery() {
String question = "Spring AI 支持哪些向量数据库?";
String answer = ragService.chat(question);
log.info("精确查询 - 问题:{}", question);
log.info("精确查询 - 回答:{}", answer);
assertThat(answer).isNotBlank();
}
@Test
void shouldHandleColloquialQuery() {
// 口语化查询,关键词在但表述不规范,经过重写后应能正确检索
String question = "spring ai的rag咋搞的";
String answer = ragService.chat(question);
log.info("口语化查询 - 问题:{}", question);
log.info("口语化查询 - 回答:{}", answer);
assertThat(answer).isNotBlank();
}
@Test
void shouldStreamResponse() {
String question = "什么是 RAG?";
log.info("流式查询 - 问题:{}", question);
var thinkingText = new StringBuilder();
var contentText = new StringBuilder();
ragService.chatStreamDetail(question)
.doOnNext(chunk -> {
var result = chunk.getResult();
if (result == null || result.getOutput() == null) return;
// 思考过程在 Generation metadata 的 "thinking" 字段中
Object thinking = result.getMetadata().get("thinking");
if (thinking != null && !thinking.toString().isEmpty()) {
thinkingText.append(thinking);
System.out.print(thinking);
return;
}
// 回答内容
String text = result.getOutput().getText();
if (text != null && !text.isEmpty()) {
contentText.append(text);
System.out.print(text);
}
})
.doOnComplete(System.out::println)
.blockLast();
log.info("思考过程:{} 字符", thinkingText.length());
log.info("回答内容:{} 字符", contentText.length());
assertThat(contentText.toString()).isNotBlank();
}
}8. 加入多查询扩展
如果想进一步提升召回率,在 ragAdvisor 中加入 MultiQueryExpander:
@Bean
public RetrievalAugmentationAdvisor ragAdvisor(
ChatClient.Builder chatClientBuilder, VectorStore vectorStore) {
return RetrievalAugmentationAdvisor.builder()
.queryTransformers(RewriteQueryTransformer.builder()
.chatClientBuilder(chatClientBuilder).build())
.queryExpander(MultiQueryExpander.builder()
.chatClientBuilder(chatClientBuilder)
.numberOfQueries(3)
.build())
.documentRetriever(VectorStoreDocumentRetriever.builder()
.vectorStore(vectorStore)
.similarityThreshold(0.5)
.topK(5)
.build())
.queryAugmenter(ContextualQueryAugmenter.builder()
.allowEmptyContext(true)
.build())
.build();
}完整处理流程:
- 用户查询 ->
RewriteQueryTransformer重写 -> 语义更精确 - 重写后 ->
MultiQueryExpander扩展 -> 3 个变体查询 - 3 个查询分别经过
VectorStoreDocumentRetriever检索 -> 合并去重 - 检索结果 ->
ContextualQueryAugmenter注入 Prompt -> LLM 生成回答
9. 选型指南
不是所有场景都需要全套组件。根据业务需求选择:
| 方案 | 组件组合 | 适用场景 |
|---|---|---|
| 最简 | DocumentRetriever + QueryAugmenter | 用户查询规范、企业内部工具 |
| 推荐 | + RewriteQueryTransformer | 大多数场景,覆盖模糊查询 |
| 高召回 | + MultiQueryExpander | 知识库内容丰富但表述多样 |
| 多轮对话 | + CompressionQueryTransformer | 聊天式 RAG |
10. 本章小结
从 QuestionAnswerAdvisor 升级到了 RetrievalAugmentationAdvisor,通过业务场景逐步引入了各个模块化组件。
| 知识点 | 解决的问题 | 说明 |
|---|---|---|
| RewriteQueryTransformer | 用户查询模糊 | 用 LLM 重写查询,提升检索命中率 |
| CompressionQueryTransformer | 多轮对话丢上下文 | 结合对话历史,将查询压缩为自包含问题 |
| MultiQueryExpander | 单次查询召回不足 | 扩展为多个变体查询,分别检索后合并 |
| VectorStoreDocumentRetriever | 检索参数不够灵活 | 精细控制 topK、相似度阈值 |
| ContextualQueryAugmenter | 空结果处理不可控 | 配置检索无结果时是否兜底调用 LLM |
接下来把存储层从内存中的 SimpleVectorStore 升级到生产级的 Qdrant,解决数据持久化和性能扩展的问题。