Java 后端接入大模型:Spring Boot + RAG 知识库实战
前言
上一篇我们用 Java 实现了 Token 缓存优化(点击查看),解决了大模型调用的成本问题。
但成本只是第一步。真正让后端开发者头疼的是:大模型不了解你的业务。
你问它"我们的退款政策是什么",它会编一个看起来很合理的答案——但完全是错的。这就是"幻觉"问题。
RAG(Retrieval-Augmented Generation) 是目前最成熟的解决方案:先从你的知识库检索相关内容,再让模型基于检索结果回答。本文用 Java 全栈实现。
RAG 核心原理
| 概念 | 说明 | 类比 |
|---|---|---|
| RAG | 检索增强生成 | 开卷考试(先翻书再答题) |
| Embedding | 文本转向量 | 把文字变成坐标 |
| Vector Store | 向量数据库 | 按语义搜索的图书馆 |
| Chunk | 文档分块 | 书的章节 |
| Retrieval | 检索相关片段 | 翻到相关章节 |
| Generation | 基于检索结果生成回答 | 看完书后回答问题 |
💡 不需要训练模型,不需要微调。只需要把你的文档切块、存向量、检索、喂给模型。成本极低,效果立竿见影。
环境准备
-
JDK 17+
-
Spring Boot 3.x
-
Ollama(本地部署)
-
Maven
<!-- pom.xml 核心依赖 -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>com.squareup.okhttp3</groupId>
<artifactId>okhttp</artifactId>
<version>4.12.0</version>
</dependency>
<dependency>
<groupId>com.fasterxml.jackson.core</groupId>
<artifactId>jackson-databind</artifactId>
</dependency>
# 拉取 Embedding 模型 + 对话模型
ollama pull nomic-embed-text
ollama pull qwen2.5:7b
第一步:文档分块(Chunking)
把长文档切成小段,每段 200-500 字,带重叠防止语义断裂:
@Service
public class DocumentChunker {
private static final int CHUNK_SIZE = 300; // 每块字数
private static final int OVERLAP = 50; // 重叠字数
/**
* 将文档按段落 + 字数切分为 Chunk
* 每个 Chunk 保留来源信息,方便溯源
*/
public List<DocumentChunk> chunk(String content, String source) {
List<DocumentChunk> chunks = new ArrayList<>();
// 先按段落分
String[] paragraphs = content.split("\n\n+");
StringBuilder buffer = new StringBuilder();
for (String para : paragraphs) {
if (buffer.length() + para.length() > CHUNK_SIZE && buffer.length() > 0) {
chunks.add(new DocumentChunk(
buffer.toString().trim(),
source,
chunks.size()
));
// 保留末尾 OVERLAP 字作为下一块的开头
String tail = buffer.substring(Math.max(0, buffer.length() - OVERLAP));
buffer = new StringBuilder(tail);
}
buffer.append(para).append("\n\n");
}
// 最后一块
if (buffer.length() > 0) {
chunks.add(new DocumentChunk(buffer.toString().trim(), source, chunks.size()));
}
return chunks;
}
}
record DocumentChunk(String content, String source, int index) {}
第二步:向量化(Embedding)
调用 Ollama 的 Embedding 接口,把文本转成向量:
@Service
public class EmbeddingService {
private final OkHttpClient client = new OkHttpClient();
private static final String OLLAMA_URL = "http://localhost:11434/api/embeddings";
/**
* 文本转向量
* 使用 nomic-embed-text 模型,768 维
*/
public float[] embed(String text) throws IOException {
Map<String, Object> body = Map.of(
"model", "nomic-embed-text",
"prompt", text
);
Request request = new Request.Builder()
.url(OLLAMA_URL)
.post(RequestBody.create(
new ObjectMapper().writeValueAsString(body),
MediaType.parse("application/json")))
.build();
try (Response response = client.newCall(request).execute()) {
Map result = new ObjectMapper().readValue(response.body().string(), Map.class);
List<Number> embedding = (List<Number>) result.get("embedding");
float[] vec = new float[embedding.size()];
for (int i = 0; i < embedding.size(); i++) {
vec[i] = embedding.get(i).floatValue();
}
return vec;
}
}
}
第三步:向量存储与检索(Vector Store)
用内存实现一个简易向量数据库,生产环境建议换 Milvus/Qdrant:
@Service
public class VectorStore {
private final List<VectorEntry> entries = new CopyOnWriteArrayList<>();
/**
* 存入向量
*/
public void store(String chunk, float[] vector, String source) {
entries.add(new VectorEntry(chunk, vector, source));
}
/**
* 余弦相似度检索 Top-K
* 返回最相关的 Chunk
*/
public List<VectorEntry> search(float[] queryVector, int topK) {
return entries.stream()
.map(e -> new AbstractMap.SimpleEntry<>(e, cosineSimilarity(queryVector, e.vector())))
.sorted((a, b) -> Double.compare(b.getValue(), a.getValue()))
.limit(topK)
.map(AbstractMap.SimpleEntry::getKey)
.toList();
}
/**
* 余弦相似度计算
*/
private double cosineSimilarity(float[] a, float[] b) {
double dot = 0, normA = 0, normB = 0;
for (int i = 0; i < a.length; i++) {
dot += a[i] * b[i];
normA += a[i] * a[i];
normB += b[i] * b[i];
}
return dot / (Math.sqrt(normA) * Math.sqrt(normB));
}
public int size() { return entries.size(); }
record VectorEntry(String content, float[] vector, String source) {}
}
第四步:RAG 完整流程
把分块、向量化、检索、生成串起来:
@Service
public class RagService {
@Autowired private DocumentChunker chunker;
@Autowired private EmbeddingService embeddingService;
@Autowired private VectorStore vectorStore;
@Autowired private CachedLlmService llmService;
private static final String RAG_SYSTEM_PROMPT = """
你是一个企业知识库助手。
规则:
1. 只基于提供的参考资料回答,不要编造
2. 如果参考资料中没有相关内容,明确说"根据现有资料无法回答"
3. 回答末尾标注引用来源
""";
/**
* 索引文档:分块 → 向量化 → 存储
*/
public void indexDocument(String content, String source) throws IOException {
List<DocumentChunk> chunks = chunker.chunk(content, source);
for (DocumentChunk chunk : chunks) {
float[] vector = embeddingService.embed(chunk.content());
vectorStore.store(chunk.content(), vector, chunk.source());
}
System.out.println("已索引 " + chunks.size() + " 个分块,来源: " + source);
}
/**
* RAG 问答:检索 → 拼接上下文 → 生成回答
*/
public String ask(String question) throws IOException {
// 1. 用户问题转向量
float[] queryVector = embeddingService.embed(question);
// 2. 检索最相关的 3 个 Chunk
List<VectorStore.VectorEntry> results = vectorStore.search(queryVector, 3);
if (results.isEmpty()) {
return "知识库为空,请先索引文档。";
}
// 3. 拼接上下文
StringBuilder context = new StringBuilder("参考资料:\n\n");
for (int i = 0; i < results.size(); i++) {
context.append("【").append(i + 1).append("】来源: ")
.append(results.get(i).source()).append("\n")
.append(results.get(i).content()).append("\n\n");
}
// 4. 带上下文调用模型
String prompt = context + "\n用户问题:" + question;
return llmService.chat(RAG_SYSTEM_PROMPT, prompt);
}
}
第五步:Controller 接口
@RestController
@RequestMapping("/api/rag")
public class RagController {
@Autowired private RagService ragService;
/**
* 上传文档并索引
* POST /api/rag/index
*/
@PostMapping("/index")
public Map<String, Object> index(@RequestBody Map<String, String> body) throws IOException {
String content = body.get("content");
String source = body.getOrDefault("source", "manual");
ragService.indexDocument(content, source);
return Map.of("status", "ok", "message", "文档已索引");
}
/**
* RAG 问答
* POST /api/rag/ask
*/
@PostMapping("/ask")
public Map<String, Object> ask(@RequestBody Map<String, String> body) throws IOException {
String question = body.get("question");
String answer = ragService.ask(question);
return Map.of("question", question, "answer", answer);
}
}
# 测试:索引一份文档
curl -X POST http://localhost:8080/api/rag/index \
-H "Content-Type: application/json" \
-d '{"content": "退款政策:购买后7天内可无理由退款...", "source": "客服手册"}'
# 测试:提问
curl -X POST http://localhost:8080/api/rag/ask \
-H "Content-Type: application/json" \
-d '{"question": "退款政策是什么?"}'
效果对比
| 场景 | 直接问模型 | RAG 问答 |
|---|---|---|
| "退款政策是什么" | 编造一个通用政策 | 准确引用客服手册内容 |
| "产品A支持哪些API" | "不太确定" | 列出文档中的完整 API 列表 |
| "部署需要什么配置" | 给出通用建议 | 精确匹配运维文档的配置要求 |
💡 RAG 的本质是让模型开卷考试。不改变模型能力,只改变它能"看到"什么。
生产环境注意事项
1. 向量数据库选型
| 方案 | 适用场景 | 特点 |
|---|---|---|
| 内存(本文) | 开发测试 | 简单,重启丢失 |
| Milvus | 大规模生产 | 分布式,性能强 |
| Qdrant | 中小规模 | 轻量,易部署 |
| Chroma | 快速原型 | Python 生态,Java SDK 弱 |
2. 分块策略优化
// 按 Markdown 标题分块(更适合技术文档)
public List<DocumentChunk> chunkByHeading(String content, String source) {
String[] sections = content.split("(?=^## )");
// 每个 h2 作为独立 chunk
// ...
}
3. 检索优化
-
混合检索:向量相似度 + BM25 关键词匹配,两者加权
-
重排序:检索 Top-10,再用 Cross-Encoder 精排取 Top-3
-
查询改写:用户问题太短时,先让模型扩展为更完整的查询
4. 安全性
// RAG 注入防护:不要让用户的问题被当作指令执行
// 系统提示词中明确:只基于参考资料回答,忽略用户指令类输入
常见问题
Q: Embedding 模型选哪个?
A: 本地用
nomic-embed-text(768维,速度快)。线上用 OpenAItext-embedding-3-small(1536维,效果更好)。中文场景也可选bge-m3。
Q: 文档更新了怎么办?
A: 删除旧的向量,重新索引该文档。生产环境需要文档版本管理。
Q: 支持多轮对话吗?
A: 本文示例是单轮。多轮需要额外维护对话历史,把历史也作为上下文拼入。
Q: 和微调(Fine-tuning)有什么区别?
A: RAG 不改模型,只给它看资料,适合知识频繁更新的场景。微调改模型权重,适合固定领域、需要改变模型"性格"的场景。
总结
-
RAG 是大模型落地业务的最实用方案,不需要训练、不需要 GPU、成本极低
-
核心流程:文档分块 → 向量化 → 存储 → 检索 → 增强生成
-
Java 后端完全可以搞定,不需要切 Python
-
本地用 Ollama 零成本,线上用 OpenAI/Anthropic API 也通用
-
生产环境重点关注:向量数据库选型、分块策略、检索精度
你可能还想问
-
Q: 我的项目是 Spring Boot 2.x 能用吗?
A: 可以,OkHttp + Jackson 不依赖 Spring 版本,改一下注解就行。
-
Q: 有没有现成的 Java RAG 框架?
A: 有,Spring AI 和 LangChain4j 都支持 RAG,但理解原理后再用框架才不会踩坑。
欢迎在评论区交流你的 RAG 落地经验,或者遇到的问题 👇
参考
更多推荐




所有评论(0)