Spring AI RAG 完整实战:从零搭建企业知识库问答系统
本文是《Spring AI 实战》系列第 7 章。前面六章分别讲了文档读取(第5章)、分割(第5章)、Embedding(第6章)、VectorStore(第6章)、ChatClient 和 Advisor 机制(第4章)。本章把所有组件串起来,搭建一个完整的企业知识库问答系统------从文档上传到 AI 回答,全流程可运行。
一、开篇:把前面学的串起来
如果你跟着前面的章节一步步做了实验,你手里已经有了这些"零件":
- DocumentReader:能把 PDF 读成 Document 对象
- TokenTextSplitter:能把 Document 切成小块
- EmbeddingModel:能把文本变成向量
- VectorStore:能存向量、做相似度搜索
- ChatClient + Advisor:能调用大模型,能通过 Advisor 拦截增强
现在要做的是:把这些零件组装成一条完整的流水线。
这条流水线有两条链路:
- 文档入库链路(离线):PDF → 读取 → 分割 → 向量化 → 存入 VectorStore
- 问答检索链路(在线):用户提问 → 向量化 → 检索相关文档 → 拼入 Prompt → 大模型生成回答
本章的目标是:你复制完整代码,改一下配置,就能跑起来一个可用的知识库问答系统。
二、系统架构设计
2.1 两条链路详解
链路一:文档入库(Document Ingest Pipeline)
用户上传 PDF
↓
DocumentController 接收 MultipartFile
↓
DocumentIngestService.ingest()
├→ (1) PagePdfDocumentReader 读取 PDF → List<Document>
├→ (2) 添加元数据(file_name, department, doc_type, upload_time)
├→ (3) TokenTextSplitter 分割 → List<Document> chunks
└→ (4) VectorStore.add(chunks) → 自动 Embedding + 写入 Redis
链路二:RAG 问答(Query Pipeline)
用户提问 "公司的年假政策是什么?"
↓
KnowledgeController 接收问题
↓
RagQueryService.ask()
↓
ChatClient 调用(带 QuestionAnswerAdvisor)
├→ QuestionAnswerAdvisor 拦截请求
│ ├→ 将问题向量化
│ ├→ VectorStore.similaritySearch() 检索相关文档
│ └→ 将检索到的文档拼入 System Prompt
├→ 增强后的 Prompt 发送给大模型
└→ 大模型基于真实文档内容生成回答
↓
返回回答给用户(可附带引用来源)
2.2 模块划分
| 模块 | 类名 | 职责 |
|---|---|---|
| Controller | KnowledgeController |
接收 HTTP 请求(上传文件 + 提问) |
| 文档入库 | DocumentIngestService |
读取 + 分割 + 元数据 + 存入 VectorStore |
| RAG 问答 | RagQueryService |
调用 ChatClient + QuestionAnswerAdvisor 回答问题 |
| 配置 | RagConfig |
配置 ChatClient(带 RAG Advisor)和 VectorStore |
三、Step 1:文档入库
3.1 完整的 DocumentIngestService 代码
java
package com.example.springaidemo.service;
import org.springframework.ai.document.Document;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
import org.springframework.ai.splitter.TokenTextSplitter;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.core.io.ByteArrayResource;
import org.springframework.stereotype.Service;
import org.springframework.web.multipart.MultipartFile;
import java.io.IOException;
import java.time.LocalDateTime;
import java.time.format.DateTimeFormatter;
import java.util.List;
import java.util.Map;
/**
* 文档入库服务
*
* 职责:将用户上传的 PDF 文件处理并存入向量数据库
*
* 处理流程:
* 1. 将 MultipartFile 转为 Spring Resource
* 2. 用 PagePdfDocumentReader 读取 PDF
* 3. 为每个 Document 添加自定义元数据
* 4. 用 TokenTextSplitter 分割文档
* 5. 调用 VectorStore.add() 存入(自动 Embedding)
*/
@Service
public class DocumentIngestService {
private final VectorStore vectorStore;
public DocumentIngestService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
/**
* 处理上传的 PDF 文件并存入向量库
*
* @param file 用户上传的 PDF 文件
* @param department 所属部门(用于元数据过滤)
* @param docType 文档类型(policy/manual/guide 等)
* @return 处理结果摘要
*/
public IngestResult ingest(MultipartFile file,
String department,
String docType) {
long startTime = System.currentTimeMillis();
String fileName = file.getOriginalFilename();
try {
// ====== 步骤 1:读取 PDF ======
// 将 MultipartFile 转为 Spring 的 Resource 对象
var resource = new ByteArrayResource(file.getBytes());
var config = PdfDocumentReaderConfig.builder()
.pageExtractedTextMaxLength(10000)
.build();
var reader = new PagePdfDocumentReader(resource, config);
List<Document> documents = reader.get();
if (documents.isEmpty()) {
return new IngestResult(fileName, 0, 0, 0,
"PDF 内容为空(可能是扫描版图片 PDF)");
}
// ====== 步骤 2:添加元数据 ======
String uploadTime = LocalDateTime.now()
.format(DateTimeFormatter.ISO_LOCAL_DATE_TIME);
documents.forEach(doc -> {
Map<String, Object> meta = doc.getMetadata();
meta.put("file_name", fileName);
meta.put("department", department);
meta.put("doc_type", docType);
meta.put("upload_time", uploadTime);
});
// ====== 步骤 3:分割文档 ======
var splitter = TokenTextSplitter.builder()
.defaultChunkSize(800) // 每个 chunk 约 800 token
.defaultOverlap(100) // 相邻 chunk 重叠 100 token
.minChunkSizeChars(100) // 最小字符数
.build();
List<Document> chunks = splitter.apply(documents);
// ====== 步骤 4:存入向量库 ======
// VectorStore.add() 内部会自动调用 EmbeddingModel 向量化
vectorStore.add(chunks);
long cost = System.currentTimeMillis() - startTime;
return new IngestResult(
fileName,
documents.size(), // 原始页数
chunks.size(), // 分割后的 chunk 数
cost, // 耗时(毫秒)
"入库成功"
);
} catch (IOException e) {
throw new RuntimeException("读取上传文件失败: " + fileName, e);
}
}
/**
* 入库结果记录
*/
public record IngestResult(
String fileName,
int pageCount,
int chunkCount,
long costMs,
String message
) {}
}
3.2 关键设计说明
为什么在 Service 层处理 MultipartFile 而不是先存文件? 两种方式都可以。对于小文件(< 50MB),直接从内存读取更快。对于大文件或需要异步处理的场景,建议先存到本地/对象存储,再异步处理。这里的代码展示的是最直接的同步处理方式。
VectorStore.add() 内部做了什么? 这是 Spring AI 的核心抽象之一。add() 方法内部会:
- 遍历每个 Document
- 调用
EmbeddingModel.embed()将文本转为向量 - 将向量 + 文本 + 元数据一起写入底层存储(Redis / PG / Milvus 等)
你不需要手动调用 Embedding API------VectorStore 已经帮你封装好了。
四、Step 2:RAG 问答
4.1 QuestionAnswerAdvisor 详解
QuestionAnswerAdvisor 是 Spring AI 内置的 RAG Advisor,它的作用是自动完成检索增强:
用户问题 "年假有几天?"
↓
QuestionAnswerAdvisor 拦截
↓
1. 将问题传给 VectorStore.similaritySearch() 做语义搜索
2. 检索出 top-k 条相关文档片段
3. 将文档片段格式化后拼入 Prompt(作为上下文)
↓
增强后的 Prompt 发送给大模型
↓
大模型基于真实文档内容生成回答
你不需要手动拼接 Prompt 。QuestionAnswerAdvisor 会自动生成类似这样的增强 Prompt:
请基于以下参考信息回答用户的问题。如果参考信息中没有相关内容,请如实告知。
参考信息:
---
[文档1内容]
---
[文档2内容]
---
用户问题:年假有几天?
4.2 完整的 RagQueryService 代码
java
package com.example.springaidemo.service;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* RAG 问答服务
*
* 职责:基于向量数据库中的文档回答用户问题
*
* 核心机制:QuestionAnswerAdvisor
* - 自动将用户问题向量化
* - 自动在 VectorStore 中检索相关文档
* - 自动将检索结果拼入 Prompt
* - 大模型基于真实文档生成回答
*/
@Service
public class RagQueryService {
private final ChatClient ragClient;
public RagQueryService(ChatClient ragClient) {
this.ragClient = ragClient;
}
/**
* 基于 RAG 回答问题
*
* @param question 用户问题
* @return AI 回答(基于知识库文档)
*/
public String ask(String question) {
return ragClient.prompt()
.user(question)
.call()
.content();
}
/**
* 基于 RAG 回答问题,支持自定义搜索参数
*
* @param question 用户问题
* @param topK 返回的文档数量
* @param threshold 相似度阈值
* @return AI 回答
*/
public String askWithParams(String question, int topK, double threshold) {
// 通过 advisors 方法传入本次请求的搜索参数
return ragClient.prompt()
.user(question)
.advisors(a -> a
.param(QuestionAnswerAdvisor.FILTER_EXPRESSION, "") // 不过滤
.param(SearchRequest.TOP_K, String.valueOf(topK))
.param(SearchRequest.SIMILARITY_THRESHOLD,
String.valueOf(threshold))
)
.call()
.content();
}
/**
* 带部门过滤的 RAG 问答
*
* 场景:不同部门的用户只能搜索本部门的文档
*/
public String askWithDepartment(String question, String department) {
return ragClient.prompt()
.user(question)
.advisors(a -> a
.param(QuestionAnswerAdvisor.FILTER_EXPRESSION,
"department == '" + department + "'")
)
.call()
.content();
}
}
4.3 RAG 配置类
java
package com.example.springaidemo.config;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.client.advisor.QuestionAnswerAdvisor;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
/**
* RAG 配置类
*
* 核心配置:将 QuestionAnswerAdvisor 注册到 ChatClient
* 这样每次调用 ChatClient 时,Advisor 会自动完成检索增强
*/
@Configuration
public class RagConfig {
/**
* 配置带 RAG 能力的 ChatClient
*
* QuestionAnswerAdvisor 的工作流程:
* 1. 拦截用户问题
* 2. 调用 VectorStore 做相似度搜索
* 3. 将搜索结果拼入 Prompt
* 4. 将增强后的 Prompt 发送给大模型
*/
@Bean
public ChatClient ragClient(ChatClient.Builder builder,
VectorStore vectorStore) {
return builder
.defaultSystem("""
你是一个企业知识库问答助手。
请严格基于提供的参考信息回答问题。
规则:
1. 如果参考信息中有相关内容,基于参考信息回答,并在回答末尾标注引用来源
2. 如果参考信息中没有相关内容,如实说"知识库中未找到相关信息"
3. 不要编造参考信息中没有的内容
4. 回答要简洁、准确、有条理
""")
.defaultAdvisors(
// RAG 核心 Advisor:自动检索相关文档并拼入 Prompt
new QuestionAnswerAdvisor(vectorStore)
)
.build();
}
}
五、Step 3:Controller 层
java
package com.example.springaidemo.controller;
import com.example.springaidemo.service.DocumentIngestService;
import com.example.springaidemo.service.RagQueryService;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.util.Map;
/**
* 知识库 Controller
*
* 提供两个接口:
* 1. POST /api/knowledge/upload ------ 上传文档到知识库
* 2. POST /api/knowledge/ask ------ 基于知识库提问
*/
@RestController
@RequestMapping("/api/knowledge")
public class KnowledgeController {
private final DocumentIngestService ingestService;
private final RagQueryService queryService;
public KnowledgeController(DocumentIngestService ingestService,
RagQueryService queryService) {
this.ingestService = ingestService;
this.queryService = queryService;
}
/**
* 上传文档到知识库
*
* @param file PDF 文件
* @param department 部门(可选,默认 general)
* @param docType 文档类型(可选,默认 general)
* @return 入库结果
*/
@PostMapping(value = "/upload", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
public ResponseEntity<Map<String, Object>> upload(
@RequestParam("file") MultipartFile file,
@RequestParam(value = "department", defaultValue = "general") String department,
@RequestParam(value = "docType", defaultValue = "general") String docType) {
// 校验文件类型
String fileName = file.getOriginalFilename();
if (fileName == null || !fileName.toLowerCase().endsWith(".pdf")) {
return ResponseEntity.badRequest()
.body(Map.of("error", "仅支持 PDF 文件"));
}
// 校验文件大小(限制 50MB)
if (file.getSize() > 50 * 1024 * 1024) {
return ResponseEntity.badRequest()
.body(Map.of("error", "文件大小不能超过 50MB"));
}
// 执行入库
var result = ingestService.ingest(file, department, docType);
return ResponseEntity.ok(Map.of(
"code", 200,
"message", "入库成功",
"data", Map.of(
"fileName", result.fileName(),
"pageCount", result.pageCount(),
"chunkCount", result.chunkCount(),
"costMs", result.costMs()
)
));
}
/**
* 基于知识库提问
*
* @param request 包含 question 和可选的 department
* @return AI 回答
*/
@PostMapping("/ask")
public ResponseEntity<Map<String, Object>> ask(
@RequestBody Map<String, String> request) {
String question = request.get("question");
if (question == null || question.isBlank()) {
return ResponseEntity.badRequest()
.body(Map.of("error", "问题不能为空"));
}
String answer;
String department = request.get("department");
// 如果传了 department,则按部门过滤搜索
if (department != null && !department.isBlank()) {
answer = queryService.askWithDepartment(question, department);
} else {
answer = queryService.ask(question);
}
return ResponseEntity.ok(Map.of(
"code", 200,
"data", Map.of(
"question", question,
"answer", answer
)
));
}
}
5.1 完整测试流程
bash
# 1. 上传文档到知识库
curl -X POST "http://localhost:8080/api/knowledge/upload" \
-F "file=@/path/to/employee-handbook.pdf" \
-F "department=HR" \
-F "docType=policy"
# 返回:
# {"code":200,"message":"入库成功","data":{"fileName":"employee-handbook.pdf","pageCount":25,"chunkCount":87,"costMs":15230}}
# 2. 基于知识库提问
curl -X POST "http://localhost:8080/api/knowledge/ask" \
-H "Content-Type: application/json" \
-d '{"question": "公司的年假有几天?怎么申请?"}'
# 返回:
# {"code":200,"data":{"question":"公司的年假有几天?怎么申请?","answer":"根据《员工手册》第三章规定..."}}
# 3. 带部门过滤的提问
curl -X POST "http://localhost:8080/api/knowledge/ask" \
-H "Content-Type: application/json" \
-d '{"question": "报销流程是什么?", "department": "HR"}'
5.2 完整 Maven 依赖
xml
<dependencies>
<!-- Spring AI OpenAI(ChatModel + EmbeddingModel) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId>
</dependency>
<!-- PDF 文档读取 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>
<!-- Redis VectorStore -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-redis-store</artifactId>
</dependency>
<!-- Spring Boot Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
</dependencies>
5.3 完整 application.yml
yaml
spring:
ai:
openai:
api-key: ${OPENAI_API_KEY}
chat:
options:
model: gpt-4o-mini # 问答用 gpt-4o-mini(便宜快)
embedding:
options:
model: text-embedding-3-small # Embedding 用 small(够用且便宜)
data:
redis:
host: localhost
port: 6379
database: 0
六、优化策略
当你的 RAG 系统跑起来后,大概率会碰到"回答不准确"或"找不到相关文档"的问题。以下是 5 种经过验证的优化方案:
优化 1:调整 Chunk 大小
| Chunk 大小 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| 300-500 token | 检索精度高,噪音少 | 容易丢失上下文 | 问答类、FAQ 类文档 |
| 500-1000 token | 平衡精度和上下文 | 通用 | 大多数场景的推荐配置 |
| 1000-2000 token | 上下文丰富 | 噪音多,Token 成本高 | 技术文档、长篇分析 |
实操建议:先用 800/100 的默认配置跑起来,如果发现回答不够精准,尝试减小到 500/100。如果发现上下文丢失,增大到 1200/200。
优化 2:增加 top-K 数量
QuestionAnswerAdvisor 默认返回 4 条文档。如果你的文档库较大或问题比较宽泛,增加到 8-10 条能显著提高召回率。代价是消耗更多 Token。
优化 3:换更好的 Embedding 模型
第 6 章的对比表已经说明了:中文场景用通义 text-embedding-v3,效果明显优于 OpenAI 的模型。这一步的投入产出比很高------只改一行配置,不需要改代码。
优化 4:优化 System Prompt
System Prompt 对 RAG 的效果影响很大。关键指令:
- 要求大模型严格基于参考信息回答,不要用自己的知识
- 要求标注引用来源,方便用户验证
- 明确说"不知道",而不是编造
优化 5:混合检索
纯向量检索有盲区。比如用户搜"v3.2.1 版本的已知问题",向量搜索可能搜不到包含精确版本号的文档。混合检索(向量 + 关键词)能互补这个缺陷。第 8 章会详细讲解。
