
简介面向 Java 开发者的 RAG 增强检索生成实战项目完整展示如何将知识库与语义检索能力集成到可运行的系统中适合希望掌握 RAG 落地路径、需要企业级检索场景参考的开发者。压缩包共 266 个文件以 231 个 Java 源码文件为主辅以 XML 配置、YML 环境配置、Dockerfile、SQL 脚本和 JAR 依赖等整体仅 14.32MB结构清晰便于快速部署与二次开发。已有 1651 人学习下载。项目不仅包含可运行源码还提供流程教程从知识库构建、向量存储、检索服务到 LLM 调用均有完整实现同时覆盖用户管理、图片生成等扩展能力可直接移植到企业内部知识库、在线问答等场景中使用。1. RAG 与 Java 的结合点为什么说这是增强检索最值得先跑通的项目先抛一个反直觉的结论很多团队把 RAG 做成「简历级 Demo」只需要一个周末但生产可用却卡在知识库切分和检索召回这两步上跟用什么语言写大模型调用反而关系不大。这个基于 Java 实现的 RAG 项目恰好把这两块做成了完整闭环——自带知识库模块、文档解析与切分、向量化检索、Top-K 召回最后才是与大模型的 Prompt 组装。也就是说你拿到的不是一条「调接口」的脚本而是一套从文档入库到答案生成都能在本地跑通的工程骨架。适合谁首先是在 Java 技术栈里做内部知识库问答的团队其次是想理解 RAG 全链路、但不想从零开始写分词和向量检索的工程师。项目源码里把流程教程也带上了这意味着你既能当项目抄也能当教材拆。下面按我拆这类项目的习惯从知识库构建、检索链路、生成接入、坑点排查一路讲到参数调优。2. 项目骨架与知识库构建从原始文档到可检索的语料2.1 模块划分与启动入口拿到源码后第一件事不是看代码而是先把模块边界理清楚。这个项目按 RAG 的标准三段式组织ingestion负责文档加载与切分retriever负责向量化和召回generator负责与大模型交互。另外有一个独立的config包存放知识库路径、模型地址、阈值参数全部收敛在application.yml。git clone 项目地址 rag-java cd rag-java mvn clean package -DskipTests java -jar target/rag-demo.jar --spring.profiles.activedev启动之后留意控制台日志正常会打印「知识库加载完成」和「向量索引初始化完成」两行。如果只看到前者说明检索组件没起来多数情况是向量模型路径配置错了后面避坑章节会细说。2.2 文档解析与切分策略知识库最常见的数据来源是 Word、PDF、Markdown 和纯文本这个项目里统一走DocumentParser接口再按扩展名分发到具体实现。切分策略是整条流水线里最影响检索质量的一环——切得太粗一段文本里混入多个主题召回噪声大切得太细语义被割裂很多片段单独拿出来根本读不通。// TextSplitter.java 核心片段 public ListTextChunk split(String content, SplitConfig config) { int chunkSize config.getChunkSize(); // 单块字数默认 400 int overlapSize config.getOverlapSize(); // 相邻块重叠字数默认 80 ListString sentences splitIntoSentences(content, config.getLanguage()); ListTextChunk chunks new ArrayList(); StringBuilder buffer new StringBuilder(); for (String sentence : sentences) { if (buffer.length() sentence.length() chunkSize buffer.length() 0) { chunks.add(new TextChunk(buffer.toString())); int overlapStart Math.max(0, buffer.length() - overlapSize); buffer.setLength(0); buffer.append(buffer.substring(overlapStart)); } buffer.append(sentence); } if (buffer.length() 0) { chunks.add(new TextChunk(buffer.toString())); } return chunks; }逻辑说明按句切分而不是按固定长度硬切是为了避免一句话被拦腰截断超过chunkSize的句子会单独成块保证每块都有相对完整的语义单元。overlapStart的取值是关键它把上一块的尾部 80 字带到下一块开头让跨块引用的信息能同时出现在两个 chunk 里。参数建议内部文档以术语密集为特点chunkSize可以降到 300overlapSize保持 80如果是对话记录或新闻语料chunkSize调到 500 效果更好。切忌把 overlap 设得比 chunk 的一半还大那会造成同一段内容被多次索引检索结果千篇一律。2.3 知识库存储结构设计这个项目把切分后的 chunk 存进内置的 Lucene 索引目录同时把每个 chunk 对应的原始文档路径当成元数据一并存储。这样做的直接好处是检索命中之后你能立刻知道答案来自哪份文档的哪个位置对后续人工校验非常重要。-- 索引结构示意实际为 Lucene Document 字段 -- doc_id: 唯一标识 -- content: 切分后的文本块 -- source: 原始文件名 页码 -- chunk_seq: 块在文档中的序号 -- embed: 768 维向量由 EmbeddingService 生成如果你打算改成 MySQL 存储建议不要省掉chunk_seq这个字段。之前我有一次排查「同一段答案反复出现」的问题最后发现是检索时只按相似度排序没有按文档内顺序约束导致匹配到的块都是同一篇文章里最相似的段落。加上chunk_seq做二次排序体验立刻正常。3. 检索层实现向量召回与关键词召回的双路策略3.1 文本向量化与模型接入向量化是 RAG 与普通全文检索的分水岭。这个项目里的EmbeddingService预留了两种接入方式本地加载 ONNX 格式的 Embedding 模型以及远程调用 HTTP 接口。生产环境我一般推荐远程接口因为本地模型的内存开销远比想象中大但项目默认走本地加载方便离线调试。// EmbeddingService.java public float[] embed(String text) { // 1. 文本标准化去除多余空格、统一全半角 String normalized normalize(text); // 2. 调用本地 ONNX 模型或远程接口 if (localMode) { return localEmbedder.embed(normalized); } // 3. 远程模式需要设置超时避免检索链路被外部拖死 return remoteEmbedder.embedWithTimeout(normalized, 3000); }这里有一个非常容易被忽略的细节查询语句和知识库文本必须走同一个预处理函数。如果你在入库时做了全角转半角检索时不转向量就会产生偏差。这个项目把normalize放在embed内部保证所有入参都过一遍算是比很多开源实现严谨的地方。3.2 相似度计算与 Top-K 选择向量检索的相似度计算项目里同时实现了余弦相似度和内积两种方式。默认用余弦相似度因为它不受向量模长影响对 Embedding 模型的直接输出更友好。内积适合已归一化的向量计算速度略快但语义区分度稍弱。// VectorSearch.java public ListSearchHit search(float[] queryVector, int topK) { PriorityQueueSearchHit queue new PriorityQueue(topK); for (VectorDoc doc : vectorStore.getAll()) { double score cosineSimilarity(queryVector, doc.getVector()); queue.offer(new SearchHit(doc.getDocId(), doc.getSource(), score)); if (queue.size() topK) { queue.poll(); // 淘汰最小分 } } ListSearchHit result new ArrayList(queue); result.sort(Comparator.comparingDouble(SearchHit::getScore).reversed()); return result; }逻辑说明用小顶堆做 Top-K 而不是全量排序后取前 K是工程上的常规优化——当知识库有几万条文本时全量排序的内存和耗时都不可接受。堆的大小固定为topK每次插入新元素后弹出最小分保证堆内始终是当前最大的 K 个。参数选择topK建议在 310 之间。知识库越大单块信息密度越低topK可以适当调大。但不要一上来就设 20召回太多块塞进 Prompt大模型的注意力会被稀释回答反而变差。3.3 混合检索的融合排序纯粹靠向量检索有两个典型短板专业缩写词比如「RAG」本身就是缩写的向量表达不稳定以及精确 ID 号、工单编号这类场景向量相似度远不如字符串匹配可靠。这个项目在检索模块里实现了关键词检索与向量检索的加权融合。// HybridRetriever.java public ListSearchHit hybridSearch(String query, int topK, double vectorWeight) { ListSearchHit vectorHits vectorSearcher.search(query, topK * 2); ListSearchHit keywordHits keywordSearcher.search(query, topK * 2); MapString, SearchHit merged new LinkedHashMap(); for (SearchHit hit : vectorHits) { hit.setScore(hit.getScore() * vectorWeight); merged.put(hit.getDocId(), hit); } for (SearchHit hit : keywordHits) { merged.merge(hit.getDocId(), hit, (oldHit, newHit) - new SearchHit(hit.getDocId(), hit.getSource(), oldHit.getScore() hit.getScore() * (1 - vectorWeight))); } return merged.values().stream() .sorted(Comparator.comparingDouble(SearchHit::getScore).reversed()) .limit(topK) .collect(Collectors.toList()); }逻辑说明两次召回都取了topK * 2的候选目的是给融合排序留出缓冲避免单路召回漏掉关键结果后直接没得可融。合并时同一个docId会累加两路得分这相当于给「既被向量命中又被关键词命中」的文本加权实际检索效果比单路稳定很多。vectorWeight的默认值可以设 0.7即向量召回为主、关键词兜底。如果知识库里有大量代码片段、报错日志这类文本的关键词特征远比语义特征明显把权重调到 0.5 以下会更合理。4. 生成链路把检索结果安全地送给大模型4.1 Prompt 组装与上下文窗口控制检索只是手段答案生成才是用户能感知的结果。这个项目在generator模块里把检索到的文本块组装成带编号的上下文再拼上用户问题一次交给大模型。组装顺序不是简单的拼接而是按文本块的得分从高到低排列保证模型最先看到最相关的证据。// PromptBuilder.java public String build(SearchRequest request, ListSearchHit hits) { StringBuilder context new StringBuilder(); context.append(请基于以下资料回答问题如果你不确定答案请直接说明。\n\n); for (int i 0; i hits.size(); i) { SearchHit hit hits.get(i); context.append(【资料).append(i 1).append(】) .append(来源).append(hit.getSource()).append(\n) .append(hit.getContent()).append(\n\n); } context.append(问题).append(request.getQuestion()); return context.toString(); }这段代码里有三个容易被忽视的细节。一是「来源」字段被强行带进 Prompt让模型在回答时可以引用出处二是「不确定就说明」这句限定语能显著降低模型编造答案的概率三是资料数控制在 5 条以内避免上下文过长。4.2 上下文窗口与 Token 预算上下文窗口控制是生成链路里最容易翻车的环节。很多模型对外宣称支持 8K 甚至更大的上下文但实际效果在超过一定长度后急剧下降。项目里给出了一个ContextWindowGuard工具类核心逻辑很简单估算每个文本块的 Token 数超出预算直接丢弃得分最低的块。// ContextWindowGuard.java public ListSearchHit fitToWindow(ListSearchHit hits, int maxTokens) { ListSearchHit filtered new ArrayList(); int used 0; for (SearchHit hit : hits) { int tokens estimateTokens(hit.getContent()); if (used tokens maxTokens) { break; } filtered.add(hit); used tokens; } return filtered; }为什么必须丢弃而不是截断因为截断会恰好切在某个文本块的中间大模型看到的是一段语义残缺的文字比不看这段还糟糕。丢弃低分块至少保证接进来的内容都是完整的。另一个相关部门是请求超时。调用大模型接口时生成速度受输入长度影响很大这个项目把连接超时设为 3 秒、读取超时设为 30 秒。如果你接入的是本地部署的模型读取超时可以放宽到 60 秒但连接超时建议保持短避免模型服务挂了之后请求一直挂着。5. 避坑与排查RAG 在 Java 工程里的常见问题5.1 中文乱码导致检索结果「仿佛失忆」现象知识库能加载但无论怎么搜都召不回正确的文本块甚至检索结果一片空白。原因Windows 环境下默认字符集是 GBK项目里读文件用的是Files.readAllLines且没指定 charset中文文本入库时就变成乱码向量化出来的向量也是错乱的。解决把读文件的地方统一改成显式指定 UTF-8Files.readAllLines(path, StandardCharsets.UTF_8)。我在接手任何 Java 项目时第一步就是全局搜readAllLines和FileReader看到没带 charset 的一律改掉。5.2 向量化耗时过长接口超时现象第一次启动时建索引可以接受但运行期间每来一个查询都要等好几秒才能返回。原因Embedding 服务是同步调用的查询请求里把「问题向量化」和「知识库文本向量化」串行执行了。更隐蔽的是有些实现会在每次查询时重新计算整个知识库的向量而不是复用启动时构建的索引。解决把向量索引的构建放到启动阶段查询阶段只做「问题向量化 索引搜索」。如果你改造成异步接口记得给 Embedding 调用加缓存——同一个问题短时间内重复查询没必要重新向量化。5.3 上下文溢出直接报错现象知识库单块字数设置过大检索命中的几块加起来超过模型输入限制调用时报context length exceeded之类错误。原因chunkSize设得太大比如超过 1000 字再加上 5 块一起注入Token 数轻松破万。责任不在模型在切分参数。解决把chunkSize降到 400 以下把ContextWindowGuard的maxTokens设成模型上限的 80%。留出 20% 余量因为 Prompt 模板本身、问题文本、系统提示也都要吃 Token。5.4 检索结果顺序不稳定现象同样的查询两次运行命中的内容差不多但排序不同导致生成答案的文字组织方式有差异。原因许多 Embedding 模型在计算文本向量时引入了随机性或者向量索引的排序没对得分相同的文本块做二次稳定排序。解决在hit.getScore()相同的情况下按docId升序排列。代价是结果顺序确定用户感知一致性明显提升。6. 检索效果自检三个硬指标和一套压测流程这个项目跑通并不代表它「好用」。我一般会在交付前做一轮检索质量自检三个指标就能暴露大部分问题。第一个是召回准确率从知识库里挑 30 个有明确答案的问题人工标注正确答案所在的文本块跑一遍检索链路看前 5 条召回里是否包含标注块低于 80% 就得调整切分参数或检查向量化质量。第二个是答案可溯源比例让大模型生成的答案必须带出「来源文档」字段统计能正确命中的比例。第三个是响应耗时从查询到达服务到答案完全生成这个值决定了你能不能把接口对外放出。# 压测脚本片段模拟查询并发 seq 1 50 | xargs -P 10 -I {} curl -s -X POST http://localhost:8080/api/rag/query \ -H Content-Type: application/json \ -d {question:什么是RAG增强检索} \ -o /tmp/response_{}.json压测之后重点看 P95 耗时而不是平均值——平均值会被少数慢请求拉高P95 更接近普通用户的真实体验。如果 P95 超过 5 秒优先检查 Embedding 服务的耗时如果模型生成占大头考虑降低topK或把ContextWindowGuard的预算收紧。我个人的习惯是每换一次知识库语料类型就强制走一遍上述流程并且把每一轮的自检结果提交到项目仓库里。这样做的好处是团队里任何人改过参数之后都能对比前后两轮的召回指标而不是靠感觉判断「好像变好了」。从那以后我每次接触新的 RAG 项目都会先问一句你的评估集在哪没有评估集的检索系统就是裸奔。这个 Java 项目把流程教程和源码都备齐了希望帮你在正式上线前把这一步补上。本文还有配套的精品资源点击获取