让大模型读懂你的文档:Spring AI + RAG 从0到1
让大模型读懂你的文档:Spring AI + RAG 从0到1
上篇文章带你把Ollama跑起来了。但有个问题——你问它”我们公司的年假怎么算?”、”这个Bug的修复方案是什么?”,它大概率一本正经地胡说八道。
因为大模型的知识截止到训练那天,它根本不知道你公司内部的事。怎么办?
RAG。今天这篇文章,手把手带你用 Spring AI + PGVector 搭一个能读懂你自己文档的问答系统。全程可运行代码,照着抄就行。
一、RAG到底是什么?(3句话讲清楚)
别被这个缩写吓到,原理其实特别简单:
你问问题 → 系统先从你的文档里找相关内容 → 把找到的内容 + 问题一起扔给大模型 → 大模型基于这些内容回答
就像考试开卷:先翻课本找到相关页码,再组织答案。而不是凭空编。
RAG(Retrieval-Augmented Generation),拆开就三步:检索 → 增强 → 生成。
┌──────────┐ ┌──────────────┐ ┌──────────┐
│ 用户提问 │ ──→ │ 向量检索相关文档 │ ──→ │ 大模型回答 │
└──────────┘ └──────────────┘ └──────────┘
↑
文档提前被切成小块,
用Embedding模型转成向量存入向量数据库
懂了原理就行,不深究数学。下面直接开干。
二、技术选型(帮你省掉3天调研时间)
搭RAG系统,至少要选三样东西。我帮你对比好了:
2.1 大模型(负责理解和回答)
|
|
|
|
|
| Ollama本地 |
|
|
|
|
|
|
|
|
|
|
|
|
|
上篇你已经装好了Ollama + Qwen2.5,这里直接沿用。
2.2 向量数据库(负责存储和检索文档向量)
|
|
|
|
|
| PGVector |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
新手首推PGVector。如果你已经在用PostgreSQL,零额外成本。如果你没有PG,后面我教你怎么用Docker 30秒起一个。
2.3 Embedding模型(负责把文字转成向量)
这个很多人忽略了,但它直接决定检索准不准。
|
|
|
|
|
| bge-m3 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
中文场景用bge-m3,这个不用纠结。上篇文章FAQ里也提到了,效果差距是肉眼可见的。
我的推荐组合
Ollama(Qwen2.5-7B) + PGVector + bge-m3
理由就三个字:能跑通。不折腾一堆组件,本地零成本,跟得上篇直接衔接。
三、搭建最小可行RAG系统(完整代码)
3.1 前置准备:启动PGVector
用Docker一键启动,30秒搞定:
# 启动一个带PGVector扩展的PostgreSQL实例
docker run -d \
--name pgvector \
-e POSTGRES_USER=raguser \
-e POSTGRES_PASSWORD=ragpass \
-e POSTGRES_DB=ragdb \
-p 5432:5432 \
pgvector/pgvector:pg16
验证是否启动成功:
docker exec -it pgvector psql -U raguser -d ragdb -c "SELECT extname, extversion FROM pg_extension WHERE extname = 'vector';"
看到输出了 vector | x.x.x 就说明PGVector扩展已安装。
没装Docker?去 https://www.docker.com 下载一个,5分钟装好。
3.2 拉取Embedding模型
PGVector准备好之后,还需要一个Embedding模型把文字转成向量:
# 拉取bge-m3模型(约1.2GB)
ollama pull bge-m3
# 验证
ollama list
# 应该能看到 bge-m3
这个模型和Qwen2.5可以共存,Ollama会按需加载,不会同时占两份内存。
3.3 创建Spring Boot项目
pom.xml:
<?xml version="1.0" encoding="UTF-8"?>
<projectxmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0
https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>3.3.5</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>rag-demo</artifactId>
<version>1.0.0</version>
<properties>
<java.version>17</java.version>
</properties>
<dependencyManagement>
<dependencies>
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-bom</artifactId>
<version>1.1.3</version>
<type>pom</type>
<scope>import</scope>
</dependency>
</dependencies>
</dependencyManagement>
<dependencies>
<!-- Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Ollama Chat(复用上篇的Qwen2.5) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-ollama-spring-boot-starter</artifactId>
</dependency>
<!-- PGVector 向量数据库 -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pgvector-store-spring-boot-starter</artifactId>
</dependency>
<!-- PDF文档解析(支持上传PDF文件) -->
<dependency>
<groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-pdf-document-reader</artifactId>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
</plugin>
</plugins>
</build>
</project>
application.yml:
server:
port:8080
spring:
ai:
# === 大模型配置(复用上篇) ===
ollama:
base-url:http://localhost:11434
chat:
model:qwen2.5:7b
options:
temperature:0.3# RAG场景建议偏低,减少幻觉
# === Embedding模型配置 ===
embedding:
ollama:
model:bge-m3# 中文Embedding模型
# === PGVector向量数据库 ===
vectorstore:
pgvector:
initialize-schema:true# 首次启动自动建表
# === PostgreSQL连接 ===
datasource:
url:jdbc:postgresql://localhost:5432/ragdb
username:raguser
password:ragpass
driver-class-name:org.postgresql.Driver
⚠️
initialize-schema: true只在开发阶段用。生产环境建议手动执行SQL脚本建表,别让应用随便改数据库结构。
3.4 核心代码:文档上传 + 问答
主程序:
package com.example.ragdemo;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
@SpringBootApplication
publicclassRagDemoApplication {
publicstaticvoidmain(String[] args) {
SpringApplication.run(RagDemoApplication.class, args);
}
}
文档上传接口(把文档存入向量数据库):
package com.example.ragdemo;
import org.springframework.ai.document.Document;
import org.springframework.ai.embedding.EmbeddingModel;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.reader.pdf.PagePdfDocumentReader;
import org.springframework.ai.reader.pdf.config.PdfDocumentReaderConfig;
import org.springframework.ai.transformer.splitter.TextSplitter;
import org.springframework.ai.transformer.splitter.TokenTextSplitter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
import org.springframework.web.multipart.MultipartFile;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
@RestController
@RequestMapping("/api/rag")
publicclassDocumentController {
@Autowired
private VectorStore vectorStore;
/**
* 上传PDF文档,解析后存入向量数据库
*/
@PostMapping("/upload")
public ResponseEntity<Map<String, Object>> uploadDocument(
@RequestParam("file") MultipartFile file)throws IOException {
// 1. 保存上传的文件到临时目录
PathtempFile= Files.createTempFile("upload-", ".pdf");
file.transferTo(tempFile.toFile());
try {
// 2. 读取PDF文档
PdfDocumentReaderConfigconfig= PdfDocumentReaderConfig.builder()
.withPageTopMargin(0)
.withPageBottomMargin(0)
.withPageExtractedText(true)
.build();
PagePdfDocumentReaderreader=newPagePdfDocumentReader(
tempFile.toString(), config);
List<Document> documents = reader.get();
// 3. 文档切分(把整篇文档切成小块,每块500个token,重叠100个token)
TextSplittersplitter=newTokenTextSplitter(
500, // chunkSize:每块500个token
100, // chunkOverlap:块之间重叠100个token
5, // minChunkSizeChars:最小块字符数
10000, // minChunkLengthToEmbed:最小嵌入长度
true// keepSeparator:保留分隔符
);
List<Document> chunks = splitter.apply(documents);
// 4. 存入向量数据库(自动调用bge-m3生成向量)
vectorStore.add(chunks);
// 5. 返回结果
Map<String, Object> result = newHashMap<>();
result.put("fileName", file.getOriginalFilename());
result.put("totalPages", documents.size());
result.put("totalChunks", chunks.size());
result.put("message", "文档上传成功,已切分为" + chunks.size() + "个文本块");
return ResponseEntity.ok(result);
} finally {
// 清理临时文件
Files.deleteIfExists(tempFile);
}
}
}
问答接口(基于文档内容回答问题):
package com.example.ragdemo;
import org.springframework.ai.chat.client.ChatClient;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.ai.vectorstore.SearchRequest;
import org.springframework.ai.vectorstore.VectorStore;
import org.springframework.ai.document.Document;
import org.springframework.http.MediaType;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import java.util.*;
@RestController
@RequestMapping("/api/rag")
publicclassChatController {
privatefinal ChatClient chatClient;
privatefinal VectorStore vectorStore;
publicChatController(ChatClient.Builder chatClientBuilder,
VectorStore vectorStore) {
this.chatClient = chatClientBuilder.build();
this.vectorStore = vectorStore;
}
/**
* 基于文档内容的问答(同步返回)
*/
@PostMapping("/chat")
public Map<String, Object> chat(@RequestBody ChatRequest request) {
// 1. 从向量数据库中检索相关文档片段(取最相关的3条)
List<Document> relevantDocs = vectorStore.similaritySearch(
SearchRequest.builder()
.query(request.getQuestion())
.topK(3)
.build()
);
if (relevantDocs.isEmpty()) {
Map<String, Object> result = newHashMap<>();
result.put("answer", "没有找到与你的问题相关的文档内容。请先上传相关文档。");
result.put("sources", Collections.emptyList());
return result;
}
// 2. 把检索到的文档内容拼接到问题里
StringBuildercontext=newStringBuilder();
List<Map<String, String>> sources = newArrayList<>();
for (inti=0; i < relevantDocs.size(); i++) {
Stringcontent= relevantDocs.get(i).getText();
context.append("【参考文档").append(i + 1).append("】\n");
context.append(content).append("\n\n");
sources.add(Map.of(
"index", String.valueOf(i + 1),
"content", content.length() > 100
? content.substring(0, 100) + "..."
: content
));
}
// 3. 构造RAG提示词
StringsystemPrompt="""
你是一个专业的文档问答助手。请严格基于以下参考文档内容来回答用户的问题。
如果参考文档中没有相关信息,请明确告知用户"文档中没有找到相关内容"。
不要编造或推测文档之外的信息。
参考文档:
%s
""".formatted(context);
// 4. 调用大模型生成回答
Stringanswer= chatClient.prompt()
.system(systemPrompt)
.user(request.getQuestion())
.call()
.content();
// 5. 返回答案 + 来源
Map<String, Object> result = newHashMap<>();
result.put("answer", answer);
result.put("sources", sources);
return result;
}
/**
* 流式问答(逐字输出,用户体验更好)
*/
@GetMapping(value = "/chat-stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(@RequestParam String question) {
List<Document> relevantDocs = vectorStore.similaritySearch(
SearchRequest.builder()
.query(question)
.topK(3)
.build()
);
if (relevantDocs.isEmpty()) {
return Flux.just("没有找到与你的问题相关的文档内容。请先上传相关文档。");
}
StringBuildercontext=newStringBuilder();
for (inti=0; i < relevantDocs.size(); i++) {
context.append("【参考文档").append(i + 1).append("】\n");
context.append(relevantDocs.get(i).getText()).append("\n\n");
}
StringsystemPrompt="""
你是一个专业的文档问答助手。请严格基于以下参考文档内容来回答用户的问题。
如果参考文档中没有相关信息,请明确告知用户"文档中没有找到相关内容"。
不要编造或推测文档之外的信息。
参考文档:
%s
""".formatted(context);
return chatClient.prompt()
.system(systemPrompt)
.user(question)
.stream()
.content();
}
}
classChatRequest {
private String question;
public String getQuestion() { return question; }
publicvoidsetQuestion(String question) { this.question = question; }
}
核心就这些。200行不到,一个完整的RAG系统搭完了。
四、跑通验证(5步搞定)
按顺序来:
# ===== Step 1:确保Ollama服务在运行 =====
ollama list
# 确认能看到 qwen2.5:7b 和 bge-m3
# ===== Step 2:确保PGVector在运行 =====
docker ps | findstr pgvector
# 如果没启动:docker start pgvector
# ===== Step 3:启动Spring Boot =====
cd 你的项目目录
mvn spring-boot:run
# 看到 "Started RagDemoApplication" 就OK了
# ===== Step 4:上传一份PDF文档 =====
# 准备一份PDF文件,比如你的公司规章制度、技术文档什么的
curl -X POST http://localhost:8080/api/rag/upload \
-F "file=@你的文档.pdf"
# 应该返回类似:
# {"fileName":"你的文档.pdf","totalPages":12,"totalChunks":38,"message":"文档上传成功,已切分为38个文本块"}
# ===== Step 5:提问 =====
curl -X POST http://localhost:8080/api/rag/chat \
-H "Content-Type: application/json" \
-d '{"question": "文档里关于XX的规定是什么?"}'
# 测试流式接口
curl "http://localhost:8080/api/rag/chat-stream?question=请总结文档的主要内容"
如果返回的答案跟你文档里写的一致,恭喜你,RAG系统跑通了 🎉
五、几个关键点解释
为什么要切分文档?
一整篇文档太长了,大模型的上下文窗口塞不下。而且,用户问的问题通常只跟文档的某几段有关。如果整篇塞进去,相关信息会被无关内容稀释。
切分策略直接影响检索效果:
TokenTextSplittersplitter=newTokenTextSplitter(
500, // chunkSize:每块多大
100, // chunkOverlap:块之间重叠多少
...
);
-
• chunkSize太小 → 上下文碎片化,AI理解不了完整语义 -
• chunkSize太大 → 检索结果里混入太多无关内容 -
• 500+100 是个经验值,多数场景够用。下篇文章我会详细讲怎么调优
topK=3 是什么意思?
topK 控制检索多少条相关文档喂给大模型:
-
• 太小(1-2) → 信息不够,回答不完整 -
• 太大(10+) → 噪音太多,Token浪费且回答可能跑偏 -
• 3-5 是个稳妥的范围
为什么system prompt里要强调”不要编造”?
这是防幻觉的一个设计。告诉模型”只基于参考文档回答”,能降低它胡编乱造的概率。不过我直说了,这招不是万能的——下篇踩坑文章会讲遇到幻觉怎么办。
六、常见问题FAQ
Q1:报错”Connection refused”连不上PGVector?
# 检查Docker容器是否在运行
docker ps | findstr pgvector
# 如果没运行,启动它
docker start pgvector
# 如果容器不存在了(重启电脑后Docker没自启),重新创建
docker run -d \
--name pgvector \
-e POSTGRES_USER=raguser \
-e POSTGRES_PASSWORD=ragpass \
-e POSTGRES_DB=ragdb \
-p 5432:5432 \
pgvector/pgvector:pg16
Q2:报错”model bge-m3 not found”?
说明Ollama里没有这个模型:
ollama pull bge-m3
# 等下载完再重启Spring Boot应用
Q3:上传文档后问问题,回答还是不对?
三个排查方向:
① Embedding维度不匹配
bge-m3 输出维度是 1024。如果你的PGVector表建成了其他维度,检索会失效。initialize-schema: true 会自动处理这个问题,如果关掉了就要手动确认。
② chunkSize不合理
文档切得太碎或太大,都会影响检索质量。试试改成 300-200 或 800-200 看看效果差异。
③ 问题表述太模糊
“文档说了什么” 这种问法太宽泛了。尽量问具体问题:”XX功能的配置方式是什么?”、”XX错误怎么解决?”
Q4:能不能支持Word/Markdown/纯文本?
可以。Spring AI提供了多种DocumentReader:
// Markdown文件
varreader=newMarkdownDocumentReader("path/to/file.md");
// 纯文本
varreader=newTextReader("path/to/file.txt");
// Tika(万能文档解析,支持docx/xlsx/pptx等)
varreader=newTikaDocumentReader("path/to/file.docx");
把这些reader替换掉PDF那行代码就行,后面的切分、存库逻辑完全不用改。
Q5:生产环境要注意什么?
这篇是入门教程,直接拿去上生产不现实。几个我踩过的地方:
-
• ❌ initialize-schema: true关掉,用Flyway/Liquibase管理数据库 -
• ✅ 加检索结果的相关度阈值过滤(相似度太低的直接丢弃) -
• ✅ 加Token用量统计和费用控制 -
• ✅ 加异常处理和降级方案(向量库挂了怎么办?)
这些我会在后面的进阶文章里逐个展开。
写在最后
到这里,你已经搭通了一个能读懂文档的RAG系统。回顾一下整个过程:
✅ Docker启动PGVector
✅ 拉取bge-m3 Embedding模型
✅ Spring AI配置大模型 + 向量库 + Embedding
✅ 文档上传、切分、向量化存储
✅ 基于文档内容的智能问答
但说实话,这个版本只是”能跑”。从我自己的经验来看,从Demo到生产可用中间至少还有五六个坑要踩——检索不到内容、回答太慢、AI编造不存在的文档内容……每一个都能让你头疼半天。
这些真实踩坑经历,下篇文章全给你讲。
🔥 下篇预告
4月25日(周五),我会出一篇 《花了2周做的RAG,用户说还不如直接问GPT…》 ——真实项目里遇到的5个离谱问题和解决方案。不想踩同款坑的,记得关注 👇
💬 互动时间
你打算用RAG做什么类型的应用?
-
• ✅ 内部知识库/文档问答 — 公司规章制度、技术文档、FAQ -
• ⚠️ 客户服务智能客服 — 产品手册、工单处理 -
• ❌ 代码/技术文档检索 — 帮开发团队快速查资料
评论区打个标记,让我知道你的场景,后面写文章可以针对性展开。
⭐️ 关于本文
-
• 源码获取:公众号底部菜单 【获取源码】 -
• 学习路线:公众号回复 「路线图」 获取Spring AI完整学习路径 -
• 环境要求:Java 17 + Maven + Docker + 8GB以上内存 + Ollama(上篇已装) -
• 建议路径:docker启动PGVector → ollama pull bge-m3 → 拉代码 → mvn spring-boot:run → 上传PDF → 提问测试
如果这篇文章对你有帮助,点个「在看」支持下吧,这对我真的很重要 🙏
夜雨聆风