
前言上一篇我们用 Java 实现了 Token 缓存优化点击查看解决了大模型调用的成本问题。但成本只是第一步。真正让后端开发者头疼的是大模型不了解你的业务。你问它我们的退款政策是什么它会编一个看起来很合理的答案——但完全是错的。这就是幻觉问题。RAGRetrieval-Augmented Generation是目前最成熟的解决方案先从你的知识库检索相关内容再让模型基于检索结果回答。本文用 Java 全栈实现。RAG 核心原理概念说明类比RAG检索增强生成开卷考试先翻书再答题Embedding文本转向量把文字变成坐标Vector Store向量数据库按语义搜索的图书馆Chunk文档分块书的章节Retrieval检索相关片段翻到相关章节Generation基于检索结果生成回答看完书后回答问题 不需要训练模型不需要微调。只需要把你的文档切块、存向量、检索、喂给模型。成本极低效果立竿见影。环境准备JDK 17Spring Boot 3.xOllama本地部署Maven!-- pom.xml 核心依赖 -- dependency groupIdorg.springframework.boot/groupId artifactIdspring-boot-starter-web/artifactId /dependency dependency groupIdcom.squareup.okhttp3/groupId artifactIdokhttp/artifactId version4.12.0/version /dependency dependency groupIdcom.fasterxml.jackson.core/groupId artifactIdjackson-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 ListDocumentChunk chunk(String content, String source) { ListDocumentChunk 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 { MapString, 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); ListNumber embedding (ListNumber) 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/QdrantService public class VectorStore { private final ListVectorEntry entries new CopyOnWriteArrayList(); /** * 存入向量 */ public void store(String chunk, float[] vector, String source) { entries.add(new VectorEntry(chunk, vector, source)); } /** * 余弦相似度检索 Top-K * 返回最相关的 Chunk */ public ListVectorEntry 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 { ListDocumentChunk 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 ListVectorStore.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 MapString, Object index(RequestBody MapString, 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 MapString, Object ask(RequestBody MapString, 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 ListDocumentChunk 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-text768维速度快。线上用 OpenAItext-embedding-3-small1536维效果更好。中文场景也可选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 APIRAG 原论文上一篇Token 缓存优化