Spring AI 检索增强生成(RAG)实战:让模型掌握你的私有知识
大模型经过万亿级语料训练,确实知识渊博,但它天生有两个缺陷:训练数据有截止日期 ,以及对私有业务数据一无所知。你可以问"Spring Boot 3 新特性是什么?"模型能回答得头头是道;但你问"我们公司上个月的订单异常率是多少?"模型只能一脸茫然,然后给你编一个数字。
解决这个问题的标准方案就是 RAG(Retrieval-Augmented Generation,检索增强生成) 。它的核心思想非常朴素:在模型回答前,先从你的知识库中找到最相关的文档片段,把它们作为参考上下文注入提示词,让模型"带着答案阅读"并生成回复。
本文将从 RAG 的完整流程出发,结合 Spring AI 提供的向量化、存储、检索组件,详细讲解如何构建一个可用的私有知识问答系统。
一、为什么需要 RAG?
先列举直接微调(Fine-tuning)不如 RAG 的理由:
| 维度 | 微调(Fine-tuning) | RAG |
|---|---|---|
| 知识更新 | 每次重新训练,成本高周期长 | 增删文档即可,秒级生效 |
| 私有知识 | 需要大量标注数据 | 直接把文档切碎存起来就能用 |
| 可解释性 | 黑盒 | 可返回引用来源,便于溯源 |
| 成本 | GPU 训练费用昂贵 | 只需向量化与检索,成本低 |
| 幻觉问题 | 仍会胡编乱造 | 有检索结果作为约束,大幅降低幻觉 |
当然 RAG 也不是银弹,它依赖于检索质量。如果检索不到相关资料,模型仍然无法正确回答。因此,构建高质量的 RAG 管道是工程重点。
RAG 的整体流程:
markdown
原始文档 → 文档加载(DocumentReader)→ 文档切分(DocumentSplitter)
→ 向量化(EmbeddingModel)→ 向量入库(VectorStore)
↑
用户提问 → 问题向量化 → 语义检索 TopK → 拼接上下文 + 问题 → 大模型生成回答
下面分步详解。
二、文档加载与切片
2.1 文档加载器
Spring AI 提供了丰富的文档读取器:
PagePdfDocumentReader:读取 PDF;TikaDocumentReader:基于 Apache Tika,支持 PDF、Word、PPT、HTML 等;JsonReader:读取 JSON 文件;TextReader:读取纯文本文件;UrlDocumentReader:从 URL 加载内容。
一个典型的加载方式:
ini
@Value("classpath:/docs/company-handbook.md")
private Resource handbookResource;
TikaDocumentReader reader = new TikaDocumentReader(handbookResource);
List<Document> documents = reader.read();
Document对象包含 id、text(正文)、metadata(元数据,如文件路径、页码、标题等)。元数据在检索过滤中非常有用,例如按部门、文章类别过滤。
2.2 文档切分
加载进来的文档可能是一整本几十页的手册,直接向量化会导致:
- embedding 向量太长,语义被稀释;
- 超出模型上下文限制;
- 检索返回整篇文档,token 消耗巨大。
因此必须切片(Splitter) 。Spring AI 提供多种切分器:
| 切分器 | 策略 | 适用场景 |
|---|---|---|
TokenTextSplitter |
按 token 数切分,带重叠 | 最通用,适合保持语义边界 |
ParagraphTextSplitter |
按段落切分 | 结构性较强的文档 |
SentenceTextSplitter |
按句子切分 | 短文本知识库 |
TextSplitter抽象 |
自定义实现 | 特殊格式 |
示例:使用 TokenTextSplitter,每个切片 500 token,重叠 100 token:
ini
TextSplitter splitter = TokenTextSplitter.builder()
.withChunkSize(500)
.withChunkOverlap(100)
.build();
List<Document> chunks = splitter.apply(documents);
为什么需要重叠?因为关键句子可能跨越两个切片的边界,适当重叠可避免信息被切断。建议 chunk size 300~800,overlap 10%~20%。
三、向量化与向量存储
3.1 EmbeddingModel
切片后的文本无法直接进行语义比较,需要转换为向量。Spring AI 的 EmbeddingModel抽象了对各厂商 embedding API 的调用:
typescript
@Configuration
public class EmbeddingConfig {
@Bean
public EmbeddingModel embeddingModel() {
// 这里以 OpenAI 为例,Spring AI 会自动读取 spring.ai.openai.api-key
return new OpenAiEmbeddingModel(OpenAiApi.builder()
.apiKey(System.getenv("OPENAI_API_KEY"))
.build());
}
}
如果是本地 Ollama:
typescript
@Bean
public EmbeddingModel embeddingModel() {
return new OllamaEmbeddingModel(OllamaApi.builder()
.baseUrl("http://localhost:11434")
.build());
}
Embedding 的选择对检索质量影响巨大,建议使用与模型同生态的向量模型。测试阶段可以直接用 OpenAI text-embedding-3-small,成本低效果不错。
3.2 VectorStore 接口
Spring AI 提供了统一的 VectorStore接口,核心方法是:
arduino
public interface VectorStore {
void add(List<Document> documents);
Optional<Boolean> delete(List<String> idList);
List<Document> similaritySearch(SearchRequest request);
}
SearchRequest允许设置查询文本、topK、相似度阈值、元数据过滤等。Spring AI 支持的向量库存储类型很多:
SimpleVectorStore:内存实现,适合开发测试;PgVectorStore:基于 PostgreSQL + pgvector 插件;RedisVectorStore:基于 Redis Search 模块;MilvusVectorStore、QdrantVectorStore、ChromaVectorStore等。
以 PgVector 为例,先创建数据库表:
sql
CREATE EXTENSION IF NOT EXISTS vector;
然后配置:
scss
@Bean
public VectorStore vectorStore(JdbcTemplate jdbcTemplate, EmbeddingModel embeddingModel) {
return PgVectorStore.builder(jdbcTemplate, embeddingModel)
.dimensions(1536) // 与 embedding 模型维度一致
.distanceType(VectorStore.DistanceType.COSINE_DISTANCE)
.initializeSchema(true) // 自动建表
.build();
}
SimpleVectorStore最适合入门:
typescript
@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel) {
return SimpleVectorStore.builder(embeddingModel).build();
}
3.3 文档入库
将加载、切分后的文档保存到向量库:
ini
@Service
public class KnowledgeBaseService {
private final VectorStore vectorStore;
private final TextSplitter textSplitter = TokenTextSplitter.builder()
.withChunkSize(500)
.withChunkOverlap(100)
.build();
public KnowledgeBaseService(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
public void ingest(String resourcePath) {
TikaDocumentReader reader = new TikaDocumentReader(
new ClassPathResource(resourcePath)
);
List<Document> docs = reader.read();
List<Document> chunks = textSplitter.apply(docs);
vectorStore.add(chunks);
System.out.println("已入库切片数: " + chunks.size());
}
}
vectorStore.add内部会对每个切片调用 EmbeddingModel 生成向量,与原始文本一起保存。
四、相似度检索
用户提问后,我们需要在向量库中找到最相关的几个切片。检索 API:
less
@RestController
public class SearchController {
private final VectorStore vectorStore;
public SearchController(VectorStore vectorStore) {
this.vectorStore = vectorStore;
}
@GetMapping("/search")
public List<String> search(@RequestParam String query) {
List<Document> results = vectorStore.similaritySearch(
SearchRequest.builder()
.query(query)
.topK(5)
.similarityThreshold(0.5)
.build()
);
return results.stream().map(Document::getText).toList();
}
}
参数解释:
topK:返回最相似的 K 个文档,通常 3~5;similarityThreshold:相似度阈值,过滤低质量命中;- 可以加
filterExpression做元数据过滤,比如只查询某部门的文档:
ini
.filterExpression("department == '技术部'")
检索结果按相似度降序排列。如何选择 TopK?K 太小可能漏掉关键信息,K 太大则上下文膨胀、干扰模型判断,还可能超 token 限制。可以先取 4 个切片,每个 500 token,正好约 2000 token 的上下文,平衡效果与成本。
五、注入 Prompt:让模型基于检索结果回答
有了检索到的上下文,接下来把它拼入 System/User 消息,要求模型仅依据上下文回答。不要先给模型看问题,再让它自由发挥。正确姿势:
ini
@Service
public class RagChatService {
private final ChatClient chatClient;
private final VectorStore vectorStore;
public RagChatService(ChatClient chatClient, VectorStore vectorStore) {
this.chatClient = chatClient;
this.vectorStore = vectorStore;
}
public String ask(String question) {
// 1. 检索
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.builder()
.query(question)
.topK(4)
.build()
);
// 2. 拼接上下文
String context = docs.stream()
.map(Document::getText)
.reduce((a, b) -> a + "\n---\n" + b)
.orElse("未找到相关资料");
// 3. 注入 Prompt
String systemPrompt = """
你是企业内部知识助手。请仅根据以下资料回答问题。
如果资料中找不到答案,请明确回答"资料库中没有相关信息",不要编造。
参考资料:
%s
""".formatted(context);
// 4. 调用模型
return chatClient.prompt()
.system(systemPrompt)
.user(question)
.call()
.content();
}
}
这里的关键是把上下文放入 System 消息,而不是 User 消息。这样能明确告诉模型"这些是权威参考",降低它被用户自由文本干扰的可能性。
5.1 带来源引用的回答
为了让回答可追溯,可以在检索文档时保留元数据,并在 Prompt 中要求模型标注来源:
ini
String context = docs.stream()
.map(doc -> "来源:%s\n内容:%s".formatted(
doc.getMetadata().get("source"),
doc.getText()))
.reduce((a, b) -> a + "\n" + b)
.orElse("无");
String systemPrompt = """
根据以下资料回答问题,并在回答末尾注明"资料来源:xxx"。
%s
""".formatted(context);
模型可能输出:"根据公司手册第3章的规定,请假需提前一天申请。(资料来源:company-handbook.pdf)"。
5.2 检索后重排序(Rerank)
基础向量检索偶尔会返回语义相近但与问题不完全相关的结果。可以在向量检索后增加重排序模型,例如 Cohere Rerank 或 BGE-Reranker。Spring AI 目前不内置,但可以自行调用外部重排序 API,将候选文档重排后选择 TopK。这也是一种进阶优化手段。
六、Spring AI 中的 Advisors:更优雅的 RAG 集成
Spring AI 在 ChatClient 中提供了 Advisor 概念,用于拦截请求和响应。其中 QuestionAnswerAdvisor内置了 RAG 核心逻辑:自动检索向量库,并注入 Prompt。
使用方式:
typescript
@Configuration
public class RagConfig {
@Bean
public ChatClient ragChatClient(ChatModel chatModel, VectorStore vectorStore) {
return ChatClient.builder(chatModel)
.defaultAdvisors(new QuestionAnswerAdvisor(vectorStore))
.build();
}
}
之后,直接调用:
arduino
@Service
public class RagService {
private final ChatClient ragChatClient;
public RagService(ChatClient ragChatClient) {
this.ragChatClient = ragChatClient;
}
public String ask(String question) {
return ragChatClient.prompt()
.user(question)
.call()
.content();
}
}
QuestionAnswerAdvisor做了哪些事?
- 将用户问题转化为搜索请求;
- 从 VectorStore 检索 TopK 文档;
- 将文档内容拼接到 System Prompt 的默认模板中;
- 调用模型;
- 在响应中附加检索信息的元数据,便于追踪。
默认模板大致为:
markdown
上下文信息如下,请基于此回答问题:
---------------------
{context}
---------------------
它保证了最小的侵入性,你不需要手动管理上下文拼接。但如果你需要自定义 Prompt 格式,或者对上下文加入严格规则(如"不要编造"),建议手动实现,像前一节那样。
七、完整示例:私有知识问答系统
下面整合一个从文档导入到问答的完整示例。
7.1 配置
typescript
@Configuration
public class RagApplicationConfig {
@Bean
public VectorStore vectorStore(EmbeddingModel embeddingModel) {
return SimpleVectorStore.builder(embeddingModel).build();
}
@Bean
public ChatClient chatClient(ChatModel chatModel) {
return ChatClient.builder(chatModel).build();
}
}
7.2 文档导入接口
kotlin
@RestController
@RequestMapping("/api/kb")
public class KnowledgeBaseController {
private final VectorStore vectorStore;
private final TextSplitter textSplitter;
public KnowledgeBaseController(VectorStore vectorStore) {
this.vectorStore = vectorStore;
this.textSplitter = TokenTextSplitter.builder()
.withChunkSize(400)
.withChunkOverlap(80)
.build();
}
@PostMapping("/ingest")
public String ingest(@RequestParam String filePath) {
TikaDocumentReader reader = new TikaDocumentReader(
new FileSystemResource(filePath)
);
List<Document> docs = reader.read();
List<Document> chunks = textSplitter.apply(docs);
vectorStore.add(chunks);
return "成功导入 " + chunks.size() + " 个切片";
}
}
7.3 问答接口
less
@RestController
@RequestMapping("/api/rag")
public class RagController {
private final VectorStore vectorStore;
private final ChatClient chatClient;
public RagController(VectorStore vectorStore, ChatClient chatClient) {
this.vectorStore = vectorStore;
this.chatClient = chatClient;
}
@GetMapping("/ask")
public String ask(@RequestParam String question) {
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.builder()
.query(question)
.topK(4)
.build()
);
String context = docs.stream()
.map(doc -> doc.getMetadata().getOrDefault("source", "未知") + ": " + doc.getText())
.reduce((a, b) -> a + "\n\n" + b)
.orElse("无资料");
return chatClient.prompt()
.system("""
你是一个只能依据给定资料回答的智能助手。
如果资料不足以回答问题,请直接说"资料库中未找到相关信息",不要自行编造。
资料:
%s
""".formatted(context))
.user(question)
.call()
.content();
}
// 流式回答
@GetMapping(value = "/ask/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> askStream(@RequestParam String question) {
List<Document> docs = vectorStore.similaritySearch(
SearchRequest.builder()
.query(question)
.topK(4)
.build()
);
String context = docs.stream()
.map(Document::getText)
.reduce((a, b) -> a + "\n\n" + b)
.orElse("无资料");
return chatClient.prompt()
.system("基于资料回答,资料中未提到就说不知道。资料如下:\n" + context)
.user(question)
.stream()
.content();
}
}
7.4 测试
先导入一篇文档:
arduino
curl -X POST "http://localhost:8080/api/kb/ingest?filePath=/data/员工手册.pdf"
再提问:
arduino
curl "http://localhost:8080/api/rag/ask?question=年假是怎么规定的?"
输出应该引用手册内容,而不是凭空回答。
八、优化 RAG 效果的核心技巧
| 问题 | 优化手段 |
|---|---|
| 切片边界切断关键信息 | 增大 overlap;按标题/章节结构切分 |
| 检索结果不相关 | 调低 topK 阈值、尝试动态 topK;使用混合检索 |
| 检索结果太冗余 | 使用 Rerank 模型重排序;为元数据加过滤条件 |
| 模型仍幻觉 | 在 System Prompt 中强制"无信息禁止回答";提高模板的约束力 |
| token 超限 | 减小 chunk size、限制 topK、压缩上下文 |
| 英文/中文嵌入效果不一 | 选择支持中英文的 embedding 模型,如 bge-large-zh |
| 知识更新不及时 | ingest 时对旧文档执行 delete 再 add;或按版本管理 |
8.1 混合检索
语义检索擅长理解意图,但关键词精确匹配有时更可靠(例如产品型号"iPhone 15 Pro Max")。可以把向量检索与 BM25 文本检索结合,融合分数。Spring AI 未内置混合检索,但可以自行实现:
- 用 Elasticsearch 或 PostgreSQL 全文检索获得关键词命中;
- 用 VectorStore 获得向量命中;
- 按权重合并,取 TopK。
8.2 问答链路中加入引用
为了让用户信任结果,回答中应提供来源。将元数据中的文件名、页码拼入响应。为此,可以采用自定义 Advisor 拦截响应,将检索到的来源附加在回答之后。
九、总结
RAG 是当前大模型落地最快、最实用的私域知识解决方案。在 Spring AI 中实现 RAG 只需要六步:
- 加载文档 :使用
TikaDocumentReader等读取多种格式; - 切分文档 :
TokenTextSplitter生成合适大小的切片; - 向量化 :
EmbeddingModel将文本转为向量; - 存储 :
VectorStore存入向量库(内存、PostgreSQL、Redis 等); - 检索 :用户提问后
similaritySearch取回 TopK 相关文档; - 注入:将上下文拼入 System Prompt,调用 ChatClient 生成答案。
这六步构成了 RAG 的最小闭环。在此基础上,你还可以结合前面章节的知识:
- 用 ChatMemory 保留多轮对话上下文;
- 用 ChatClient 输出 JSON 让 RAG 返回结构化结果;
- 用 @Tool 将 RAG 封装为模型可调用的工具,实现"知识查询"技能。