乐于分享
好东西不私藏

让大模型读懂你的文档:Spring AI + RAG 从0到1

让大模型读懂你的文档: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本地
免费无限制、数据不出本机
吃硬件、推理速度慢
开发测试、内网环境
OpenAI API
效果最好、不用管部署
收费、数据出境
不差钱、对效果要求高
国产模型API
中文好、合规、价格低
需要逐个对接
国内生产环境

上篇你已经装好了Ollama + Qwen2.5,这里直接沿用。

2.2 向量数据库(负责存储和检索文档向量)

方案
优点
缺点
推荐度
PGVector
PostgreSQL插件,不引入新组件
大数据量性能不如专用库
⭐⭐⭐⭐⭐
Milvus
专治大数据量、性能强
部署重、学习成本高
⭐⭐⭐
Elasticsearch
你可能已经部署了
向量能力偏弱
⭐⭐⭐
Chroma
轻量、Python友好
不适合生产环境
⭐⭐
Redis Stack
功能相对少
⭐⭐

新手首推PGVector。如果你已经在用PostgreSQL,零额外成本。如果你没有PG,后面我教你怎么用Docker 30秒起一个。

2.3 Embedding模型(负责把文字转成向量)

这个很多人忽略了,但它直接决定检索准不准。

模型
中文能力
维度
推荐度
bge-m3
⭐⭐⭐⭐⭐
1024
⭐⭐⭐⭐⭐
bge-large-zh
⭐⭐⭐⭐
1024
⭐⭐⭐⭐
nomic-embed-text
⭐⭐⭐
768
⭐⭐⭐
text-embedding-3-small
⭐⭐⭐⭐
1536
⭐⭐⭐(收费)

中文场景用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(0100) + "..."
                            : 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 → 提问测试

如果这篇文章对你有帮助,点个「在看」支持下吧,这对我真的很重要 🙏