Java 后端接入大模型:Spring Boot + RAG 知识库实战

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 缓存优化