
做RAG知识库Java开发者这两年多少有点“看着隔壁吃自助自己还得等上菜”的感觉。Python生态里LangChain、LlamaIndex卷到飞起Java这边能打的完整方案一直掰着手指头数得过来。LangChain4j是这一年多Java社区里最重要的破局者它把文档加载、切块、向量化、检索、对话补全整套RAG链路都标准化了而LangGraph4j紧接着补上了“有状态编排”这块拼图让Java也能画Agent流程图、跑多轮检索、做条件分支。这篇文章想记录的是我从0到1用这两个框架搭一套企业内部知识库问答系统的一手过程和踩坑实录。重点不是把API粘一遍而是把为什么这么设计、哪些位置容易翻车、最后怎么调通讲清楚。如果你正准备用Java搭RAG知识库或者已经在用LangChain4j做检索问答但总觉得流程太死板、想往Agent方向走这篇内容应该能帮你少走不少弯路。1. 项目拆解要解决的问题和选型背后的逻辑1.1 私域知识问答到底难在哪先说业务背景。我接到的需求是给团队内部搭建一个“公司章程产品手册项目Wiki”的问答机器人。文档形式很杂有Word、PDF也有纯Markdown总量大概几百份不是海量数据但足够让传统关键词搜索抓狂问了“今年年假政策变动了哪些”如果文档里写的是“2025年起年假计算方式调整”普通检索系统会很难命中。这类需求落到技术层面就是标准的RAG问题先把文档离线切块并做向量化再把用户问题也向量化从向量库中召回相关片段最后把片段作为上下文交给大模型生成回答。听起来不难但真做起来至少有三个坑文档格式解析不干净Markdown里嵌表格、PDF扫描件、Word里的图片目录全都能让切块质量塌方。切块策略直接影响召回率切太碎语义断裂切太大上下文冗余中文场景尤其敏感。单轮检索解决不了复杂问题比如用户问“年终奖免税政策违反劳动法怎么办”需要先检索年终奖条款再检索劳动法相关条文最后综合回答这是典型的“多跳检索”。如果用传统的关键词系统这三个问题哪一个都得单独开发很久。用LangChain4j第一和第二问题基本有现成组件用LangGraph4j第三个问题可以做成有状态的流程图让系统自己判断该走单步检索还是多步检索。1.2 为什么选LangChain4j和LangGraph4j的组合选择LangChain4j而不是Spring AI我当时的判断是这样的Spring AI的强项是深度绑定Spring Boot生态但RAG组件和检索抽象的成熟度在2024年下半年到2025年这个时间窗口LangChain4j明显走得更快尤其是对PDF、Word的解析封装、EmbeddingStore的适配种类以及对流式、工具调用这些细节的覆盖直接拿来就能出活。LangGraph4j的作用更纯粹它是给LangChain4j配一个“流程图引擎”。LangChain4j本身解决的是“单次RAG任务”但真实系统的流程往往是判断问题类型→决定是否改写问题→检索一轮还是多轮→判断上下文是否足够→决定直接回答还是再检索→生成。这中间有分支、有循环、有状态。LangGraph4j把这些写成了节点和边状态在节点之间传递调试时能肉眼看到流程走哪条边这对复杂Agent的实现是决定性的。提示这两个项目目前都还在快速迭代。写这篇文章时用的API跟下一个minor版本可能已经有差异所以我下面的示例代码会标注清楚“基于当前稳定版”你接手时一定要以官方文档和当前版本实际导出的类为准。2. 整体架构与工作流设计2.1 拆成离线流水线和在线检索两条链路我最终的架构是两条独立流水线。离线流水线负责“喂数据”源文档统一读入→格式解析→按标题和段落语义切块→向量化→写入EmbeddingStore。这条链路不需要大模型参与只要嵌入模型跑一趟成本很低可以挂在CI/CD里文档变更时自动重建对应索引。在线链路负责“回答问题”用户提问→判断是否需要检索→向量检索召回TopK片段→片段重排→拼装Prompt→大模型生成回答→流式返回。这里才是LangGraph4j的主场。整个在线链路被我用LangGraph4j画成了状态图每一个环节都是一个节点节点之间传递一个自定义的AgentState对象。为什么要拆成两条链路因为离线过程和在线过程对延迟和可靠性的要求完全不一样。离线可以慢工出细活解析失败就重试在线链路必须在几百毫秒内完成检索而且要优雅处理模型超时。拆开以后两边的故障处理、日志采集和部署策略都能独立调整。2.2 状态图到底在编排什么LangGraph4j给我的第一个直观感受是它的心智模型很像“有限状态机”。你需要定义一个State类节点函数接收当前状态处理后返回新的状态字段图的边决定状态流转方向。典型的核心状态长这样public class AgentState { private String question; // 原始问题 private String rewrittenQuestion; // 改写后的问题 private ListDocument documents; // 召回文档 private String answer; // 生成回答 private int retrievalCount; // 当前检索轮数 private boolean needRetry; // 是否需要重新检索 }每个节点只做一件事改写节点改写问题检索节点填充documents生成节点消费documents产出answer判断节点修改needRetry字段。节点之间用条件边连接。比如生成前检查retrievalCount是否超过上限超过就走“直接回答”路径避免无限循环。这样做的好处是业务逻辑被拆成了可单测的小函数每个节点都能单独调试、单独mock。我项目里的检索节点注入了EmbeddingStore和EmbeddingModel生成节点注入了ChatModel单元测试时只需要替换注入对象不用启动整个Spring容器。2.3 单轮RAG和Agentic RAG的选择时机初版系统我只做了“检索→生成”的单轮流程上线后很快发现用户的问题远没那么规整。有用户直接问“页面上这个报错什么意思”但他没说哪个页面也有用户问“员工离职后社保怎么处理”其实需要把“离职流程”“社保转移”“工资结算”三块知识拼起来。这时候就需要把流程图升级成带路由和循环的Agentic RAG。我的方案是先让大模型对用户问题做一个“意图分类”判断是闲聊、单轮知识检索还是多主题的复杂问题。分类结果决定走哪条边闲聊直接走普通对话节点单轮知识走一次检索复杂问题走子问题分解多轮检索。这里LangGraph4j的循环能力就派上用场了它让整个流程可以“检索完发现不够再检索一次”。3. 从0到1的核心实现细节3.1 项目骨架和依赖配置我用的项目结构是Maven多模块。这里特别建议把“文档解析”和“在线推理”拆成两个Maven模块因为文档解析依赖的体积很大POI、PDFBox一进来就是小几十MB在线推理更适合轻装上阵部署时两者可以分开打包。核心依赖按模块加别一股脑全塞进父POM!-- 文档解析模块 -- dependency groupIdorg.apache.pdfbox/groupId artifactIdpdfbox/artifactId version3.0.3/version /dependency dependency groupIdorg.apache.poi/groupId artifactIdpoi-ooxml/artifactId version5.4.0/version /dependency !-- RAG核心模块 -- dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-embeddings-all-mini-lm-l6-v2/artifactId /dependency dependency groupIdcom.langgraph4j/groupId artifactIdlanggraph4j/artifactId /dependency注意LangChain4j和LangGraph4j的版本一直在往前走写死版本号不如用BOM或dependencyManagement统一管理。我推荐用LangChain4j BOMLangGraph4j的版本另行指定。原因很简单这两个框架的API变动频繁embedding模型类名换了、方法签名升级了都是随时可能发生的用BOM至少能把LangChain4j内部的版本冲突压住。嵌入模型这里我提一句初级选择如果你的业务不涉及特别强的语义距离要求用本地模型all-mini-lm-l6-v2就够了它跑在本地没有网络往返知识库规模在几千片以内完全没问题。但要是想达到更好的中文效果我后面会专门讲embedding选型。3.2 文档加载与切块中容易被忽略的细节LangChain4j里文档加载的入口是Document.from(File)或者各种DocumentLoader但真正的难点在切块。切块器我踩过一个很实际的坑直接用固定长度的ParagraphSegmentizer切中文长文本很容易在中间截断句子语义残缺导致检索召回质量很差。后来我改成按标题层级优先的切法先解析出文档的标题结构遇到一级标题就开新块遇到正文内容累积到接近上限再截断。这样切出来的块基本对应一个“话题单元”检索命中率有肉眼可见的提升。切块的关键参数我在公司内部知识库上试出来的经验值参数我的配置理由maxSegmentSize800字符中文单块约覆盖两三段语义完整maxOverlapSize120字符保证跨块内容不漏切分单元标题 段落比纯字符切分语义更完整注意切块长度不能拍脑袋。如果一个知识库里的文档平均每段都很长800字符可能是合理的如果文档是短条款式的法务内容400字符更合适。我建议做成配置项上线后通过看“答非所问”的比例来反向调优。另外POI解析Word文档时有个常见问题Word里的表格会被拆成零散的文本节点如果你直接把表格丢弃检索系统就丢了大量结构化信息。我的做法是自定义一个TableTextExtractor把Word表格的行列用分隔符转成文本再拼回段落流。内容不多但没这层处理问“报销比例表里差旅标准是多少”这类问题基本召回不到。3.3 嵌入模型选型和向量库的取舍嵌入模型是RAG效果的天花板我当时在这块纠结了很久。all-mini-lm-l6-v2是英文模型中文效果能跑但不算最优。测试之后发现“年假调整”“五险一金基数”这类常见业务短句它跟“员工假期政策”的相关度还凑合但跟“薪酬福利调整”就差得很远因为显然它把“福利”和“约定”混起来了。中文场景里更稳的选择是BGE系列或M3E这类中文优化模型。我用的是bge-m3LangChain4j的EmbeddingModel接口可以对接HuggingFace上导出的ONNX模型也可以通过本地推理服务暴露OpenAI兼容接口接入。如果你的团队已经有GPU服务器建议直接部署一个中文embedding服务端检索质量提升是质变。向量库的选择同样取决于规模。我内部知识库只有几万片InMemoryEmbeddingStore就够了但要考虑将来横向扩展、多服务共享索引迟早得切换到真正独立的向量数据库。我现在的选型是基于PostgreSQL的扩展方案因为团队已有PostgreSQL运维经验加一个扩展就能把向量检索和业务元数据关联起来少维护一套中间件。这里有一个LangChain4j细节EmbeddingStore接口实现类都是EmbeddingStoreTextSegment的泛型形式你要在存储时把切好的TextSegment和嵌入向量一起放进去。检索时EmbeddingSearchRequest要指定maxResults和minScoreminScore这个阈值很关键设太低会带回一堆噪声设太高又可能漏召回。我通常先设0.5再根据实际召回结果调整。3.4 检索增强生成的串联实现离线阶段完成后在线检索生成的核心代码并不长。我最早一版不经过LangGraph4j直接用LangChain4j的EasyRag甚至手动写就调通了// 检索 EmbeddingSearchRequest request EmbeddingSearchRequest.builder() .queryEmbedding(embeddingModel.embed(question).content()) .maxResults(5) .minScore(0.5) .build(); EmbeddingSearchResultTextSegment result embeddingStore.search(request); // 拼装上下文 String context result.matches().stream() .map(m - m.embedded().text()) .collect(Collectors.joining(\n\n)); // 生成 String prompt 你是一个企业知识库问答助手。请仅根据下面提供的资料回答问题。 如果资料中没有相关信息请直接说明资料库中未找到相关知识。 资料 %s 问题%s .formatted(context, question); String answer chatModel.chat(prompt);这段代码看着简单但它把RAG的每一步都交代清楚了。真正要注意的拼接细节minScore阈值和maxResults要组合用当召回结果太少时不要硬答可以在Prompot里让模型主动承认没有资料。我见过不少初版RAG因为模型强行“脑补”而被用户质疑系统是瞎编的Prompt里加一句“未找到请明确说明”是最便宜的防幻觉手段。生成阶段还有一个实用技巧用StreamingChatModel替代ChatModel这样回答文字是流式吐出的用户觉得“系统在思考”体验完全不一样。调用方式也很直接chatModel.stream(prompt)返回一个Stream按token拼回字符串后通过WebSocket推给前端。4. Agentic RAG实战把检索流程变得有脑子4.1 查询路由与问题改写第一版流程图我保留了一个“路由”节点核心逻辑是让大模型判断问题是否需要检索class RouterNode { State apply(State state) { String prompt 请判断下面的用户问题是否需要从企业知识库检索资料。 如果问题本身就是闲聊或通用知识回答no 如果问题涉及公司内部流程、政策、产品文档回答yes。 只回答yes或no。 用户问题%s .formatted(state.question()); String decision chatModel.chat(prompt).trim(); return state.withNeedRetrieval(yes.equalsIgnoreCase(decision)); } }这个节点的意义是过滤闲聊。如果不加过滤用户一句“你好”系统也会跑去向量库搜一遍浪费资源还容易把无关内容当上下文导致大模型生硬地“你好根据资料……”。查询改写更适合放在检索之前。用户的问题是“今年公司年假政策是什么样的”这句话直接拿去向量检索跟文档里的“假期管理办法”字段匹配度并不高。我让大模型在检索前先做一次“检索Query改写”把口语化问题转换成关键词更密集的检索串。实测下来改写后的检索结果准确率提升在10%~15%左右代价是每次多一次大模型调用大概多几百毫秒延迟。4.2 多跳检索与子问题分解多跳检索的需求我用一个用户问题来举例“新员工试用期表现不佳被辞退能拿赔偿金吗”这个问题涉及试用期条款、辞退流程、赔偿规定三个主题。单次检索要么找到试用期要么找到赔偿很难一把召齐。在LangGraph4j里我的流程是node(decompose) node(retrieve) node(judge) node(generate) edge(START, decompose) edge(decompose, retrieve) edge(retrieve, judge) edge(judge, generate, condition ctx - ctx.state().documents().isEmpty() || ctx.state().needMoreRetrieval()) edge(judge, retrieve, condition ctx - !ctx.state().documents().isEmpty() ctx.state().needMoreRetrieval()) edge(generate, END)decompose节点把复杂问题拆成两到三个子问题retrieve节点对每个子问题各自检索judge节点把召回结果合并后判断上下文够不够回答原始问题不够就再返回retrieve并带上“还有哪些主题没查到”的提示。这里的循环是关键普通线性代码做循环要写一堆if-else在状态图里只需要一条条件边。我强烈建议retrievalCount上限设成2最多3超过了就直接回答“资料检索不充分”。因为每多一轮搜索都意味着用户多等一秒多而大多数问题两轮检索已经足够。4.3 自我纠错与结构化输出我还加了最后一个“反思”节点它在生成回答前检查检索内容如果检索到的内容跟问题主题完全不相关就标记为“bad retrieval”回退重检。这个节点本质上是个评分Prompt。它不是百分之百可靠但能明显拦截掉一部分“检索结果网页快照过旧”导致的回话质量暴跌问题作为兜底手段值得上一份。结构化输出这块我用LangChain4j的AiServices能力把回复解析成对象比如判断路由时直接输出枚举类型而不是解析裸字符串。这样代码更干净也不会被模型的“yes。也有部分情况是no”这类废话干扰。5. 常见问题与排查实录5.1 检索质量不行的几个隐藏原因遇到“答非所问”的时候我建议先别怀疑模型先查检索通道。最容易出问题的是这几个点切块破坏语义比如在表格中间切断或者标题和正文被切到不同块。解决办法是按结构切块必要时自定义分段器。嵌入模型与问题风格不匹配如果知识库是偏口语的Wiki却用了一个适合新闻文本的embedding效果就稀碎。换模型前先抽样比对。检索阈值设置不对minScore太高漏召回太低有噪声。用测试集看召回率曲线不要靠感觉。没有做重排向量检索召回了Top10但有一些相似度很高、实际不相关的块排在了前面。我在生产环境接了一个rerank节点在langgraph图里生成前做一次粗排微调这个操作对最终答案质量的提升非常明显。5.2 中文场景的切块和编码坑中文切块跟英文有个本质差异英文按空格和标点分词通常不会碎中文长句一旦在中间被截断语义直接破裂。我遇到过一次“错误提示信息中的英语”被硬切到两个块导致检索完全无法命中。解决方法是切块时增加一个“停在标点”的约束只在句号、问号、分号处截断如果一直没有这些标点再退化为按长度截断。另一个隐蔽问题是字符编码。PDF解出来的文本经常带乱码或把全角半角混在一起POI读Word表格时偶尔会有不可见字符。我在写文本清洗函数时会把Unicode控制字符和零宽空格全部过滤掉同时把中文双引号、英文单引号统一成半角字符这一步能显著减少“明明文档里有这个词却检索不到”的诡异问题。5.3 会话状态与工具调用的坑LangChain4j支持ChatMemory但是如果你把LangGraph4j的State也同时存就要注意“双写状态”的问题。我最初的实现是图内State保存检索历史图外又开了一个MessageWindowChatMemory保存对话记录结果同一轮对话在两边不一致回答时会把上一轮的问题重复说一遍。后来统一成“LangGraph4j的State里附带一个List 字段作为唯一会话源”图内的检索节点只从State拿历史图外的ChatMemory不再单独使用。工具调用这块文档解析或代码执行这类工具要格外小心死循环。LangGraph4j虽然能做循环但每次循环都消耗token和延迟我的工具调用都加了最大步数限制并且要求工具返回必须是结构化JSON不然解析失败会导致Agent自己跟自己绕圈子。5.4 实际项目的出现频率问题再补一个生产环境常踩的坑EmbeddingStore的全局唯一ID。用InMemoryEmbeddingStore时我自己做ID自增没有太大问题切换到PG向量存储后如果不指定唯一ID每次重新索引文档就会生成重复记录导致检索出大量过期内容。我现在把文档块的MD5指纹作为ID重建索引时“先删再插”保持知识库的干净。6. 项目复盘与几条个人经验这套系统从搭建到上线我个人的体会是技术栈本身并没有太高的门槛LangChain4j把RAG组件的细节都封装好了LangGraph4j让复杂流程可视化、可控化真正的难点全在数据侧和流程编排上。数据侧难在“文档千奇百怪”流程侧难在“你要让系统知道什么时候该停下来”。最后分享几个做这类项目时我总结下来的经验希望能给你省点时间先从最小的闭环开始。不要一上来就设计复杂Agent图。先用裸RAG把单轮问答跑通把文档解析、切块、检索质量调到你满意再通过LangGraph4j逐步加上路由、多跳、反思这些能力。循序改进的效果远好于第一版就做个大而全的框架。每个节点都要可独立验证。graph里的每个节点最好暴露成一个可单测的函数不要只在图里能跑。否则某次检索质量下降你都不知道是切块变了、embedding结果变了还是prompt把模型带歪了。把Prompt当成代码管理。路由Prompt、改写Prompt、防幻觉Prompt全部分门别类放到配置文件里改一个字都要走代码评审。你会惊讶地发现系统行为的大幅波动经常源于一句“你再想想”被加进了Prompt。日志一定要记录图的完整流转轨迹。LangGraph4j允许你在State上打标签我每轮推理都会把路由结果、检索次数、每轮召回的文档ID、最终是否触发兜底路径以JSON行形式记录排查问题时作用非常大。这套知识库系统上线之后团队内部的检索命中率从最初的“能搜到但不准”逐步提升到了日常办公基本可用。但我也要说句实话RAG和Agent这条路远没有到“装完就好”的阶段。好在工具链已经愿意给Java开发者一个像样的机会了LangChain4j和LangGraph4j的组合值得每一个用Java做内部知识服务的团队认真试一试。