RAG 系统的"数据基础"已经就位:
- Embedding 模型:通过 Ollama 运行 qwen3-embedding:8b,把文本转为向量
- 向量存储:SimpleVectorStore 负责存储和检索向量
- ETL 管道:TextReader 读取文件 -> TokenTextSplitter 分块 -> VectorStore 存储
知识库准备就绪,是时候把 检索 和 生成 串联起来,构建第一个完整的 RAG 问答系统了。
1. QuestionAnswerAdvisor
Spring AI 提供了 QuestionAnswerAdvisor,它是实现 RAG 最快的方式。它的工作原理:
- 用户发送问题给
ChatClient QuestionAnswerAdvisor拦截请求,提取用户问题- 用问题在
VectorStore中进行相似度检索 - 将检索到的文档作为上下文,附加到发给 LLM 的 Prompt 中
- LLM 基于上下文生成回答
整个过程对调用者透明,你只需要正常调用 chatClient.prompt().user(question),Advisor 会自动完成检索和上下文注入。
1.1 扩展 AiConfig
在前面创建的 AiConfig 基础上,新增一个带 QuestionAnswerAdvisor 的 ragChatClient,以及启动时自动摄入知识库的 CommandLineRunner:
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.chat.client.advisor.vectorstore.QuestionAnswerAdvisor;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.SimpleVectorStore;
import org.springframework.ai.vectorstore.SearchRequest;
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 {
// 内存向量存储,开发阶段够用,后续会替换为 Qdrant
@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel) {
return SimpleVectorStore.builder(embeddingModel).build();
}
// 基础 ChatClient,不带任何 Advisor
@Bean
public ChatClient chatClient(ChatClient.Builder builder) {
return builder.build();
}
// 带 RAG 能力的 ChatClient
@Bean
public ChatClient ragChatClient(ChatClient.Builder builder, VectorStore vectorStore) {
return builder
.defaultAdvisors(QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder()
.topK(5)
.build())
.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);
}
};
}
}四个 Bean 各司其职:
vectorStore:SimpleVectorStore内存实现,后续会替换为 QdrantchatClient:基础版,对比测试中用它演示"无 RAG"的效果ragChatClient:带QuestionAnswerAdvisor,自动检索知识库并注入上下文。先不设similarityThreshold,让所有检索结果都参与,后面"调参"小节再讲阈值控制ingestDocuments:应用启动时把知识库文件摄入向量存储,后续增加文件直接在这里追加
2. 准备知识库
为了让 RAG 的效果更明显,准备几份主题明确的知识库文件。
src/main/resources/docs/spring-ai-intro.txt
(沿用上一章创建的文件,介绍 Spring AI 的核心功能。)
src/main/resources/docs/rag-concepts.txt
RAG(Retrieval-Augmented Generation)是一种将信息检索与文本生成相结合的技术。它的核心思想是:在生成回答之前,先从外部知识库中检索相关信息,然后将这些信息作为上下文提供给语言模型。
RAG 系统的关键组件包括:
1. 文档摄入管道:负责将原始文档处理成可检索的格式。这包括文档读取、文本分块、向量化和存储四个步骤。
2. 向量数据库:存储文档的向量表示。常见的向量数据库包括 PGVector、Qdrant、Chroma、Milvus、Pinecone 等。向量数据库支持基于语义相似度的快速检索。
3. Embedding 模型:将文本转换为高维向量。好的 Embedding 模型能够捕捉文本的语义信息,使得语义相近的文本在向量空间中距离更近。
4. 检索策略:决定如何从向量数据库中获取最相关的文档。包括 Top-K 检索、相似度阈值过滤、元数据过滤等策略。
5. Prompt 工程:将检索到的文档与用户问题组合成有效的 Prompt。好的 Prompt 模板能显著提升回答质量。
RAG 相比直接使用 LLM 的优势:
- 知识实时更新:只需更新知识库,无需重新训练模型
- 减少幻觉:回答基于真实文档,而非模型的参数记忆
- 可追溯:可以标注回答的来源文档
- 成本低:不需要微调模型,普通硬件即可运行src/main/resources/docs/ollama-guide.txt
Ollama 是一个本地运行大语言模型的工具,让你无需云端 API 就能在自己的电脑上使用 LLM。
Ollama 的主要特点:
1. 简单易用:一条命令即可下载并运行模型。例如 ollama run llama3 就能启动 Meta 的 LLaMA 3 模型进行对话。
2. 模型丰富:支持 LLaMA 3、Mistral、Gemma、Qwen 等主流开源模型,以及 qwen3-embedding、nomic-embed-text 等 Embedding 模型。
3. API 兼容:提供 REST API 接口,兼容 OpenAI API 格式,方便集成到各种应用中。默认运行在 http://localhost:11434。
4. 资源管理:根据模型大小自动分配 CPU 或 GPU 资源。支持 Apple Silicon 的 Metal 加速和 NVIDIA CUDA 加速。
5. 模型自定义:通过 Modelfile 可以自定义模型参数、系统提示词等。
在 Spring AI 中使用 Ollama 非常简单,只需要在配置文件中指定模型名称和服务地址即可。Spring AI 的 Ollama 集成支持 Chat 和 Embedding 两种模型类型。3. 问答服务
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.beans.factory.annotation.Qualifier;
import org.springframework.stereotype.Service;
import reactor.core.publisher.Flux;
@Service
public class RagService {
private final ChatClient ragChatClient;
public RagService(@Qualifier("ragChatClient") ChatClient ragChatClient) {
this.ragChatClient = ragChatClient;
}
/**
* 同步问答:等待完整回答后一次性返回
*/
public String chat(String question) {
return ragChatClient.prompt()
.user(question)
.call()
.content();
}
/**
* 流式问答:逐 Token 返回纯文本,适合 SSE 接口
*/
public Flux<String> chatStream(String question) {
return ragChatClient.prompt()
.user(question)
.stream()
.content();
}
/**
* 流式问答(详细版):返回完整的 ChatResponse,可提取思考过程和元数据
*/
public Flux<ChatResponse> chatStreamDetail(String question) {
return ragChatClient.prompt()
.user(question)
.stream()
.chatResponse();
}
}通过 @Qualifier("ragChatClient") 注入带 RAG 能力的 ChatClient。Advisor 在构建时就绑定了,所以 RagService 不需要任何检索逻辑。
chatStream():返回Flux<String>,纯文本 token 流,给 Controller 的 SSE 接口用chatStreamDetail():返回Flux<ChatResponse>,包含完整 metadata,可以从中提取模型的思考过程
4. 问答接口
src/main/java/com/albertstack/rag/controller/RagController.java
package com.albertstack.rag.controller;
import com.albertstack.rag.service.RagService;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import java.util.Map;
@RestController
@RequestMapping("/api/rag")
public class RagController {
private final RagService ragService;
public RagController(RagService ragService) {
this.ragService = ragService;
}
@PostMapping("/chat")
public Map<String, String> chat(@RequestBody Map<String, String> request) {
String question = request.get("question");
String answer = ragService.chat(question);
return Map.of("question", question, "answer", answer);
}
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(@RequestParam String question) {
return ragService.chatStream(question);
}
}POST /api/rag/chat:同步接口,接收 JSON 请求体,返回完整回答GET /api/rag/chat/stream:SSE 流式接口,逐 Token 推送回答
5. 效果对比
用一个测试类直观感受 RAG 带来的差异。构建两个 ChatClient:一个带 QuestionAnswerAdvisor,一个不带,对同一个问题分别提问,对比回答质量。
src/test/java/com/albertstack/rag/RagCompareTest.java
package com.albertstack.rag;
import lombok.extern.slf4j.Slf4j;
import org.junit.jupiter.api.Test;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.boot.test.context.SpringBootTest;
import static org.assertj.core.api.Assertions.assertThat;
@Slf4j
@SpringBootTest
class RagCompareTest {
@Autowired
private ChatClient chatClient; // 基础版,无 Advisor
@Autowired
@Qualifier("ragChatClient")
private ChatClient ragChatClient; // 带 QuestionAnswerAdvisor
@Test
void compareWithAndWithoutRag() {
String question = "Spring AI 支持哪些向量数据库?";
// 无 RAG:模型凭训练数据回答,可能不准确
String plainAnswer = chatClient.prompt()
.user(question)
.call()
.content();
log.info("无 RAG 回答:\n{}", plainAnswer);
// 有 RAG:基于知识库文档回答
String ragAnswer = ragChatClient.prompt()
.user(question)
.call()
.content();
log.info("有 RAG 回答:\n{}", ragAnswer);
// RAG 版本应该包含知识库中的具体内容
assertThat(ragAnswer).satisfiesAnyOf(
a -> assertThat(a).containsIgnoringCase("SimpleVectorStore"),
a -> assertThat(a).containsIgnoringCase("PGVector"),
a -> assertThat(a).containsIgnoringCase("Chroma")
);
}
@Test
void ragShouldAnswerSpecificFacts() {
// 知识库中明确写了 http://localhost:11434
String answer = ragChatClient.prompt()
.user("Ollama 默认运行在哪个端口?")
.call()
.content();
log.info("回答:{}", answer);
assertThat(answer).contains("11434");
}
}在 IDEA 中运行测试,对比控制台输出的两个回答:
| 无 RAG | 有 RAG | |
|---|---|---|
| 回答内容 | 可能列出一些向量数据库,但信息不一定准确 | 准确列出 SimpleVectorStore、PGVector、Chroma、Milvus 等(来自知识库原文) |
| 可靠性 | 模型"猜测",可能编造不存在的集成 | 基于实际文档,信息可追溯 |
| 详细程度 | 笼统的描述 | 结合知识库中的具体描述,更有针对性 |
6. SearchRequest 调参
QuestionAnswerAdvisor 的检索效果很大程度上取决于 SearchRequest 的参数配置。
6.1 topK 的影响
// topK = 1:只检索最相关的 1 个文档块
SearchRequest.builder().topK(1).build()
// topK = 10:检索最相关的 10 个文档块
SearchRequest.builder().topK(10).build()| topK 值 | 效果 | 风险 |
|---|---|---|
| 1 | 回答高度聚焦,但可能遗漏重要信息 | 如果最相关的那个块恰好不含答案,就会失败 |
| 3-5 | 平衡精准度和覆盖面,推荐起点 | - |
| 10+ | 覆盖面广,适合复杂问题 | 引入噪声,可能超出 LLM 上下文窗口 |
6.2 similarityThreshold 的影响
// 低阈值:宽松匹配
SearchRequest.builder().similarityThreshold(0.5).build()
// 高阈值:严格匹配
SearchRequest.builder().similarityThreshold(0.9).build()| 阈值 | 效果 | 风险 |
|---|---|---|
| 0.5 | 宽松匹配,可能返回一些"沾边"的内容 | 引入不相关的信息,降低回答质量 |
| 0.7 | 适中,推荐起点 | - |
| 0.9 | 只返回高度相关的内容 | 可能过于严格,导致没有检索结果,LLM 无法回答 |
7. 自定义 Prompt 模板
QuestionAnswerAdvisor 默认使用英文的 Prompt 模板。对于中文 RAG 应用,自定义中文模板能显著提升回答质量。
@Bean
public ChatClient ragChatClient(ChatClient.Builder builder, VectorStore vectorStore) {
return builder
.defaultAdvisors(QuestionAnswerAdvisor.builder(vectorStore)
.searchRequest(SearchRequest.builder()
.topK(5)
.similarityThreshold(0.7)
.build())
.promptTemplate(new PromptTemplate("""
基于以下上下文信息回答用户的问题。
如果上下文中没有相关信息,请明确告知用户"根据现有资料无法回答该问题",不要编造答案。
回答时请引用相关的上下文内容来支持你的观点。
上下文:
{question_answer_context}
用户问题:{query}
"""))
.build())
.build();
}几个自定义模板的要点:
- 明确"不知道"的行为:最重要的一条。如果不指示模型在缺乏上下文时拒绝回答,它会基于自己的训练数据"编"一个答案,这就失去了 RAG 的意义
- 要求引用来源:让模型标注回答基于哪部分上下文,增强可信度
- 使用中文模板:对中文 LLM(如 Qwen)来说,中文指令的遵循效果通常更好
{question_answer_context}和{query}:这两个是QuestionAnswerAdvisor固定的占位符,分别会被填充为检索到的文档内容和用户问题。变量名必须完全匹配,否则会报Not all variables were replaced错误
8. 测试 RagService
编写端到端测试,验证整个 RAG 管道:
src/test/java/com/albertstack/rag/service/RagServiceTest.java
package com.albertstack.rag.service;
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 static org.assertj.core.api.Assertions.assertThat;
@Slf4j
@SpringBootTest
class RagServiceTest {
// AiConfig 中的 CommandLineRunner 会在测试启动时自动摄入知识库
@Autowired
private RagService ragService;
@Test
void shouldAnswerFromKnowledgeBase() {
String question = "Spring AI 支持哪些向量数据库?";
String answer = ragService.chat(question);
log.info("问题:{}", question);
log.info("回答:{}", answer);
assertThat(answer).satisfiesAnyOf(
a -> assertThat(a).containsIgnoringCase("PGVector"),
a -> assertThat(a).containsIgnoringCase("SimpleVectorStore"),
a -> assertThat(a).containsIgnoringCase("Chroma")
);
}
@Test
void shouldAnswerAboutOllama() {
String question = "Ollama 支持哪些模型?";
String answer = ragService.chat(question);
log.info("问题:{}", question);
log.info("回答:{}", answer);
assertThat(answer).satisfiesAnyOf(
a -> assertThat(a).containsIgnoringCase("LLaMA"),
a -> assertThat(a).containsIgnoringCase("Mistral"),
a -> assertThat(a).containsIgnoringCase("Qwen")
);
}
@Test
void shouldStreamWithThinking() {
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();
}
}- 日志 + 断言:
log.info()让你在控制台直接看到模型的完整回答,断言验证回答是否包含预期内容。只看断言通过与否不够直观,日志能帮助判断回答质量 satisfiesAnyOf:LLM 的回答不是确定性的,用宽松断言,包含关键词之一即可- 流式测试:
doOnNext逐 token 打印到控制台,运行时能直观看到文字逐步输出的效果。blockLast()等待流结束后再做断言
9. 本章小结
RAG 问答系统,的数据流程图如下:
| 知识点 | 说明 |
|---|---|
| QuestionAnswerAdvisor | Spring AI 的 RAG Advisor,自动完成检索和上下文注入 |
| 有无 RAG 对比 | 有 RAG 时模型基于文档回答,无 RAG 时只能凭训练数据猜测 |
| SearchRequest 调参 | topK 控制召回数量,similarityThreshold 过滤低质量结果 |
| 自定义 Prompt 模板 | 用中文模板引导模型输出格式和风格,提升回答质量 |
| 端到端测试 | 用宽松断言验证 LLM 的非确定性输出,覆盖同步和流式接口 |
目前只支持纯文本文件,但现实世界的知识库充满了 PDF、Markdown、Word 文档。接下来解决多格式文档的处理问题。