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 落地经验,或者遇到的问题 👇
参考
Ollama Embeddings API
RAG 原论文
上一篇:Token 缓存优化