ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Java RAG实战:LangChain4j与LangGraph4j构建Agentic知识库

Java RAG实战:LangChain4j与LangGraph4j构建Agentic知识库 Java 生态里做 RAG过去很长一段时间是个尴尬事。Python 那边 LangChain 已经把链路跑通了Java 这边要么自己手写向量检索加拼 Prompt要么在 Spring AI 和各类 SDK 之间反复横跳。LangChain4j 出来之后情况变了它把 LLM 调用、Embedding、向量库、文档切分这些基础件做成了统一的 Java 抽象而 LangGraph4j 补上了另一块——把 RAG 从一次检索一次生成升级成带状态、可循环、可分支的 Agentic RAG。这篇东西就是把我从零搭一套 Java RAG 知识库的完整过程摊开讲包括选型时纠结的点、切块参数怎么定、检索召回率上不去时怎么排查、多轮对话的状态怎么管。适合已经会写 Java、想把这套东西落到自己项目里的人也适合被到底用 Spring AI 还是 LangGraph4j这个问题卡住的同学。1. 为什么 Java 做 RAG 值得单独拎出来讲1.1 Java RAG 的真实处境不是能不能做是链路怎么串先说结论Java 做 RAG 在能力上没有任何短板短板一直在胶水层。RAG 的本质链路其实很朴素——文档进来、切块、向量化、存库、查询时召回、拼进 Prompt、交给 LLM 生成。这条链路里真正跟语言强相关的只有调 LLM 的 HTTP 客户端和向量库的 SDK剩下的都是工程问题。而工程问题恰恰是 Java 的主场线程池、连接管理、事务、可观测性、部署运维这些 Java 生态沉淀了二十年。问题出在过去没有一层统一的抽象。你要接 OpenAI 的 Embedding写一套换成本地部署的模型再写一套向量库从内存版换到 Milvus接口又得改。LangChain4j 的价值就在这——它定义了EmbeddingModel、EmbeddingStore、ChatLanguageModel、DocumentSplitter这些接口换实现只改依赖和配置业务代码不动。这一点对 Java 团队特别重要因为 Java 项目最怕的就是换个组件动全身。LangGraph4j 则是另一个维度的补充。LangChain4j 解决的是组件标准化LangGraph4j 解决的是流程编排。传统 RAG 是线性的query 进来检索生成结束。但真实场景里用户的问题往往需要多跳推理、需要判断检索结果够不够好、不够要不要改写 query 再检一次、需要根据问题类型走不同的检索分支。这些用 if-else 硬写会迅速变成一团乱麻LangGraph4j 用状态图StateGraph把这些节点和边显式建模出来流程可读、可调试、可扩展。1.2 和 Spring AI 的关系不是二选一是分层不同热词里反复出现现在到底用 Spring AI 还是 LangGraph4j这个问题本身问得有点偏。Spring AI 和 LangChain4j 是同一层的竞品都是LLM 应用的基础抽象层LangGraph4j 是更上层的编排层。所以真正的选择是基础抽象层用 Spring AI 还是 LangChain4j编排层要不要上 LangGraph4j。我选 LangChain4j 的原因很实际它的 RAG 相关抽象更完整EmbeddingStore的实现覆盖更广文档切分器DocumentSplitter开箱即用而且和 LangGraph4j 同属一个作者体系配合起来没有阻抗。Spring AI 的优势在于和 Spring Boot 的自动配置集成更顺如果你的项目本来就是 Spring Boot 全家桶、团队又不想引入新概念Spring AI 也完全够用。但如果你的 RAG 要做成 Agentic 的、要有多轮状态、要有条件分支LangGraph4j 这层基本绕不开那基础层选 LangChain4j 会更顺。提示不要为了技术先进硬上 LangGraph4j。如果你的场景就是用户问一句、检索一次、答一句线性 RAG 完全够用上状态图反而是过度设计。判断标准很简单你的流程里有没有根据中间结果决定下一步走哪的逻辑有就上没有就别上。1.3 这套组合能解决的具体问题把话说具体点LangChain4j LangGraph4j 这套组合能落地这几类需求企业私有知识库问答把内部文档、Wiki、产品手册灌进去员工用自然语言问答案带出处。多轮对话式检索用户第一句问报销标准是多少第二句问那出差呢系统要能理解那指的是报销场景把上下文带进检索。Agentic RAG检索结果不理想时自动改写 query、换检索策略、甚至调用外部工具补充信息再生成。带评估的 RAG生成完答案后用一个评估节点判断答案是否忠实于检索到的文档不忠实就重试。这几类需求里后三类都天然需要状态和循环这正是 LangGraph4j 的用武之地。2. 环境搭建Maven 依赖与模型接入的取舍2.1 依赖清单与版本对齐LangChain4j 的模块拆得很细好处是按需引入坏处是版本必须对齐否则会出现NoSuchMethodError这种运行时才炸的问题。我的做法是用 BOM 统一管理版本。dependencyManagement dependencies dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-bom/artifactId version0.35.0/version typepom/type scopeimport/scope /dependency /dependencies /dependencyManagement dependencies !-- 核心抽象 -- dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId /dependency !-- 接入具体模型按需选 -- dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId /dependency !-- 向量库先用内存版跑通 -- dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-embeddings-all-minilm-l6-v2/artifactId /dependency !-- 文档解析PDF/Word 等 -- dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-document-parser-apache-pdfbox/artifactId /dependency !-- 编排层 -- dependency groupIdorg.bsc.langgraph4j/groupId artifactIdlanggraph4j-core/artifactId version1.0.0/version /dependency /dependencies这里有个坑要提前说langchain4j-embeddings-all-minilm-l6-v2是本地推理的 Embedding 模型它会把模型文件打包进依赖第一次跑会下载几百 MB 的模型权重。如果你在内网环境这一步会卡住需要提前把模型文件放到本地路径用AllMiniLmL6V2EmbeddingModel的构造参数指定。生产环境我更建议用独立的 Embedding 服务把模型推理和业务进程解耦避免业务 JVM 被模型推理拖垮。2.2 模型接入本地模型 vs 远程 API 的决策逻辑Embedding 和 Chat 两个模型选型逻辑不一样。Embedding 模型的核心指标是语义区分度和成本。本地小模型如 all-MiniLM-L6-v2384 维胜在免费、无网络依赖、延迟低但语义区分度一般对中文的支持也偏弱。如果你的知识库是中文为主本地小模型的效果会明显打折这时候要么换中文优化的本地模型如 bge-small-zh要么直接上远程 Embedding API。远程 API 的代价是每次检索都要发网络请求延迟和费用都要算进预算。Chat 模型的选择更看场景。如果只是做知识库问答中等规模的模型足够如果要做 Agentic RAG 里的 query 改写、结果评估这些元任务模型能力要求会更高因为这些任务本身就需要推理能力。// 本地 Embedding适合内网、成本敏感场景 EmbeddingModel embeddingModel new AllMiniLmL6V2EmbeddingModel(); // 远程 Chat 模型 ChatLanguageModel chatModel OpenAiChatModel.builder() .apiKey(System.getenv(API_KEY)) .baseUrl(System.getenv(BASE_URL)) .modelName(gpt-4o-mini) .temperature(0.2) // RAG 场景温度要低减少胡编 .timeout(Duration.ofSeconds(60)) .build();注意RAG 场景的temperature一定要压低0.1 到 0.3 之间。温度高了模型会发挥把检索到的文档内容改得面目全非这在知识库问答里是致命的。我见过有人用默认温度 0.7 跑 RAG结果答案里一半是模型自己编的还怪检索不准。2.3 向量库选型从内存版到生产级的迁移路径开发阶段我强烈建议用InMemoryEmbeddingStore零依赖、启动快、方便调试。但它的数据在 JVM 内存里重启就没了而且没有持久化和并发优化绝对不能上生产。生产级的选型看几个维度数据量、是否需要分布式、运维成本。数据量在百万级以内、单机可扛的PgVector 是最省心的选择——你本来就有 PostgreSQL加个扩展就行不用额外维护一套向量数据库。数据量上千万、需要水平扩展的再考虑 Milvus、Qdrant 这类专用向量库。迁移的关键是接口不变。LangChain4j 的EmbeddingStore接口是统一的从内存版换到 PgVector业务代码里的embeddingStore.add()和embeddingStore.search()一行都不用改只换实现类的构造。// 开发内存版 EmbeddingStoreTextSegment store new InMemoryEmbeddingStore(); // 生产PgVector接口完全一致 EmbeddingStoreTextSegment store PgVectorEmbeddingStore.builder() .host(localhost) .port(5432) .database(ragdb) .user(rag) .password(System.getenv(DB_PASSWORD)) .table(embeddings) .dimension(384) // 必须和 Embedding 模型维度一致 .build();这里有个必须强调的点dimension必须和 Embedding 模型的输出维度严格一致。all-MiniLM-L6-v2 是 384 维bge-small-zh 是 512 维OpenAI 的 text-embedding-3-small 是 1536 维。维度对不上插入时不会报错但检索结果会完全乱套——这是最隐蔽的坑之一。3. 文档切块RAG 效果的地基3.1 切块为什么是 RAG 里最容易被低估的环节很多人搭 RAG把精力全花在选模型、调 Prompt 上切块随便按固定长度切一切就完事。结果检索召回率上不去回头怪模型不行。实际上切块决定了检索的最小单元是什么切得不好再好的模型也救不回来。举个具体的例子。一份产品手册里有一段本产品支持三种模式标准模式、节能模式、高性能模式。标准模式适合日常使用功耗约 15W。如果你按固定 100 字切很可能把三种模式和后面的解释切到两个块里。用户问标准模式功耗多少检索到的块里只有标准模式适合日常使用没有15W这个关键数字答案自然就错了。切块的核心矛盾是块太大检索精度下降一个块里混了太多主题向量被平均掉了块太小上下文丢失关键信息被切断模型拿不到完整语义。这个平衡点没有万能公式必须结合你的文档类型来定。3.2 按文档类型选切块策略我的经验是按文档结构分三类处理文档类型推荐策略块大小重叠理由结构化文档手册、规范按标题层级递归切分300-500 字50 字保留章节语义完整性半结构化FAQ、工单按问答对切分一问一答为一块无天然语义单元不需要再切非结构化长文、报告递归字符切分500-800 字100 字平衡精度和上下文LangChain4j 提供了DocumentSplitters.recursive()它按段落、句子、字符的优先级递归切分比单纯按字符数切要合理得多。DocumentSplitter splitter DocumentSplitters.recursive( 500, // 每块最大字符数 100 // 块间重叠字符数 ); ListDocument documents FileSystemDocumentLoader.loadDocuments( Paths.get(/data/knowledge), new ApachePdfBoxDocumentParser() ); ListTextSegment segments splitter.splitAll(documents);重叠overlap这个参数很多人会忽略。它的作用是防止关键信息正好落在切分边界上被切断。比如一句话被切成两半前一半在块 A 末尾后一半在块 B 开头如果没有重叠两个块都拿不到完整语义有了重叠这句话会完整出现在其中一个块里。重叠一般设块大小的 10% 到 20%。3.3 中文切块的特殊处理中文和英文在切块上有个本质差异英文有天然的空格分词中文没有。如果切块器按空格切中文会被当成一个超长 token切出来的块要么过大要么在奇怪的地方断开。LangChain4j 的递归切分器默认按\n\n、\n、。、、这些标点切对中文基本可用。但如果你的文档里有大量专业术语、代码片段、表格默认切分器会切得很碎。我的做法是自定义分隔符优先级把中文标点和英文标点都加进去DocumentSplitter splitter DocumentSplitters.recursive( 500, 100, new DocumentSplitterOptions() // 伪代码示意实际用自定义实现 );更实际的做法是继承DocumentSplitter自己实现把。\n作为一级分隔符,、作为二级空格作为三级。这样中文文档切出来的块边界更自然。提示切块效果没法靠肉眼判断必须用检索测试来验证。准备 20 到 30 个真实问题看正确答案所在的块能不能被召回。召回率低于 80%先回去调切块别急着换模型。4. 检索链路从向量召回走向混合检索4.1 纯向量检索的失效场景向量检索的原理是把 query 和文档都映射到同一个向量空间算余弦相似度。它的强项是语义匹配——用户问怎么退款文档里写申请退货流程字面不重合但语义相近向量检索能召回。但它的弱项同样明显专有名词、型号、编号用户问X200 的保修期向量检索可能召回一堆讲保修政策的文档但就是漏掉那个只提了一次X200的块因为型号这种低频词在向量空间里权重很低。精确匹配需求用户问某个具体的错误码向量检索会召回语义相近但错误码不同的内容。否定和限定用户问不支持哪些功能向量检索容易召回支持哪些功能的内容。这些场景下传统的关键词检索BM25反而更准。所以生产级 RAG 基本都走混合检索向量召回 关键词召回两路结果融合。4.2 RRF 融合与去重逻辑的坑两路结果怎么融合最常用的是 RRFReciprocal Rank Fusion倒数排名融合。它的思路很简单不看绝对分数因为向量相似度和 BM25 分数不在一个量纲上只看排名每个文档的得分是1 / (k rank)k 一般取 60。两路排名都靠前的文档融合后得分最高。RRF 的公式看着简单但实现时有几个坑去重逻辑同一个文档可能被两路都召回融合时必须按文档 ID 去重否则同一个块会出现两次占满上下文窗口。去重时要注意不同来源的文档 ID 生成方式可能不同如果 ID 不稳定去重会失效。k 值的选择k 越大排名靠后的文档得分衰减越慢融合结果越平均k 越小头部文档优势越明显。默认 60 是个经验值但如果你的召回列表很短比如只召回 5 条k 应该相应调小。权重有些实现会给两路不同的权重比如向量 0.7、关键词 0.3。这取决于你的场景——语义查询为主就偏向量精确查询为主就偏关键词。// RRF 融合的简化实现 MapString, Double fusedScores new HashMap(); int k 60; for (int rank 0; rank vectorResults.size(); rank) { String id vectorResults.get(rank).id(); fusedScores.merge(id, 1.0 / (k rank 1), Double::sum); } for (int rank 0; rank keywordResults.size(); rank) { String id keywordResults.get(rank).id(); fusedScores.merge(id, 1.0 / (k rank 1), Double::sum); } ListString finalIds fusedScores.entrySet().stream() .sorted(Map.Entry.String, DoublecomparingByValue().reversed()) .limit(10) .map(Map.Entry::getKey) .toList();注意热词里提到langchain 和 langchain4j 的默认 rrf 实现去重逻辑存在缺陷这个提醒很实在。默认实现里如果文档 ID 生成依赖内容哈希而两路召回返回的同一文档内容有细微差异比如空白字符不同哈希就会不同去重失效。稳妥的做法是用文档的稳定标识如文件路径 块序号作为 ID而不是内容哈希。4.3 重排序把最相关的顶到最前面混合检索召回 20 条但上下文窗口塞不下 20 条只能取前 5 条。这时候前 5 条是不是最相关的就至关重要。向量和 BM25 都是粗排精度有限加一层重排序Rerank能显著提升最终质量。重排序模型如 bge-reranker的思路是把 query 和每个候选文档拼在一起让模型直接判断相关性输出一个精确分数。它比向量检索慢但只对少量候选做成本可控。流程是混合检索召回 20 条 → 重排序打分 → 取前 5 条 → 拼进 Prompt。// 伪代码重排序节点 ListTextSegment candidates hybridRetrieve(query, 20); ListTextSegment reranked reranker.rerank(query, candidates, 5);重排序的收益在长文档、专业领域特别明显。我实测过一个技术文档库不加重排序时 Top5 命中率约 65%加了之后到 85% 以上。这个提升幅度值得多引入一个模型。5. 用 LangGraph4j 把 RAG 编排成状态图5.1 为什么线性 RAG 不够用线性 RAG 的流程是死的query → 检索 → 生成。但真实对话里这个流程经常需要回头。比如用户问我们公司年假多少天检索回来的文档讲的是请假流程而不是年假天数这时候理想的做法是识别出检索结果不相关 → 改写 query 为年假天数规定 → 重新检索 → 再生成。这个识别不相关和改写重试就是线性流程做不到的。LangGraph4j 用状态图建模这类流程。图里有节点Node执行具体逻辑和边Edge决定下一步走哪。节点之间通过共享的 State 传递数据。这样检索 → 评估 → 不达标则改写 → 再检索就变成一个带环的图逻辑清晰。5.2 状态设计State 里该放什么State 是 LangGraph4j 的核心概念它是在节点间流转的数据载体。设计 State 的原则是放节点间需要共享的数据不放节点内部的临时变量。一个 RAG 图的 State 通常包含public class RagState { private String originalQuery; // 用户原始问题 private String currentQuery; // 当前用于检索的 query可能被改写 private ListTextSegment retrieved; // 检索结果 private String answer; // 生成的答案 private int retryCount; // 重试次数防止死循环 private ListChatMessage history; // 多轮对话历史 }retryCount这个字段特别重要。带环的图如果没有退出条件会无限循环。每次改写重试就加一超过阈值比如 2 次就强制走生成节点用现有结果作答。5.3 节点拆解检索、评估、改写、生成把 RAG 拆成四个节点RetrieveNode拿currentQuery去混合检索结果写进retrieved。EvaluateNode判断retrieved是否足够回答问题。可以用 LLM 判断也可以用简单的相似度阈值。LLM 判断更准但更慢阈值判断快但容易误判。RewriteNode如果评估不通过用 LLM 改写 query写回currentQueryretryCount加一。GenerateNode把retrieved和originalQuery拼成 Prompt生成答案。StateGraphRagState graph new StateGraph(RagState::new) .addNode(retrieve, retrieveNode) .addNode(evaluate, evaluateNode) .addNode(rewrite, rewriteNode) .addNode(generate, generateNode) .addEdge(START, retrieve) .addEdge(retrieve, evaluate) .addConditionalEdges(evaluate, state - state.retryCount() 2 ? generate : rewrite, Map.of(generate, generate, rewrite, rewrite)) .addEdge(rewrite, retrieve) .addEdge(generate, END); CompiledGraphRagState compiled graph.compile();这段代码里addConditionalEdges是关键——它根据 State 里的retryCount决定走生成还是改写。这就是根据中间结果决定下一步的典型场景也是 LangGraph4j 相对线性 RAG 的核心价值。5.4 多轮对话的状态管理多轮对话的难点是指代消解。用户第二句说那出差呢系统得知道那承接的是上一轮的报销话题。做法是在 State 里保留history在检索前先用 LLM 把当前 query 和 history 结合生成一个独立可检索的 query。// 指代消解节点 String standaloneQuery chatModel.generate( 根据对话历史把用户最新问题改写成不依赖上下文的独立问题。\n 历史 formatHistory(state.history()) \n 最新问题 state.originalQuery() );这一步做完后续检索就用standaloneQuery多轮对话的检索质量会明显提升。代价是多一次 LLM 调用延迟增加但对体验的提升值得。6. 实测中的坑与调优经验6.1 检索召回率上不去的排查顺序召回率低是最常见的问题但原因可能出在链路的任何一环。我的排查顺序是固定的先看切块把没召回的问题对应的原文找出来看它被切成了什么样。如果关键信息被切断问题在切块。再看 Embedding如果切块没问题把 query 和正确文档块分别向量化算相似度。相似度很低说明 Embedding 模型不适合你的领域考虑换模型或微调。然后看检索策略纯向量不行就上混合检索混合还不行就加重排序。最后看 Prompt如果检索结果里有正确答案但模型没用好问题在 Prompt检查是不是上下文太长把关键信息淹没了。这个顺序不能乱。很多人一上来就调 Prompt但如果是切块问题调 Prompt 是白费功夫。6.2 上下文窗口塞不下怎么办检索召回 10 条每条 500 字就是 5000 字加上对话历史和系统 Prompt很容易超出模型上下文窗口。处理方式有三种截断只取 Top N 条简单粗暴但可能丢掉关键信息。压缩用一个 LLM 把召回内容压缩成摘要再拼进 Prompt。多一次调用但信息密度高。分块生成对每个召回块分别生成局部答案再汇总。适合多文档综合类问题。我一般用截断 重排序的组合重排序保证 Top N 是最相关的截断控制长度。压缩适合文档特别长、信息特别分散的场景。6.3 幻觉抑制让答案有据可查RAG 的核心价值是答案有出处但模型经常忍不住自己发挥。抑制幻觉有几个手段Prompt 里明确要求告诉模型只根据提供的文档回答文档里没有的信息就说不知道。要求引用来源让模型在答案里标注每个结论来自哪个文档块这样用户能核对也逼着模型基于文档。生成后校验用一个评估节点判断答案是否忠实于检索文档不忠实就重新生成或降级为未找到相关信息。String prompt 你是一个知识库助手。请严格根据以下文档回答问题。 如果文档中没有相关信息直接回答根据现有资料无法回答该问题不要编造。 回答时请标注信息来源。 文档 %s 问题%s .formatted(formatSegments(state.retrieved()), state.originalQuery());提示temperature压低 明确不知道就说不知道 要求引用来源这三招组合下来幻觉能压掉一大半。剩下的靠生成后校验兜底。6.4 性能与成本的平衡RAG 链路的每一环都有成本Embedding 调用、向量检索、重排序、LLM 生成、评估、改写。全开的话一次问答可能五六次模型调用延迟和费用都上去了。优化的思路是分级简单问题走快路径复杂问题走全流程。怎么判断简单复杂可以用一个轻量的分类节点或者用检索结果的相似度分数——分数高说明检索很确定直接生成分数低说明不确定才走改写重试。另一个优化是缓存。相同或相似的 queryEmbedding 结果和检索结果可以缓存。Embedding 缓存尤其有效因为同一段文档的向量是固定的没必要每次重算。7. 从 Demo 到生产还差什么7.1 文档更新的增量处理Demo 阶段通常是一次性灌入所有文档。生产环境里文档会持续更新需要增量处理新增文档要切块入库修改的文档要删旧块插新块删除的文档要清理对应向量。难点在修改——你得知道哪些块属于被修改的文档。做法是在块的元数据里存文档 ID更新时先按文档 ID 删除旧块再插入新块。LangChain4j 的EmbeddingStore支持按元数据过滤删除用这个能力实现。7.2 可观测性RAG 链路怎么监控RAG 出问题时你需要知道是哪一环出的问题。所以要给每个节点打点检索耗时、召回数量、重排序前后排名变化、生成耗时、token 消耗。这些指标能帮你快速定位瓶颈。更进阶的是记录检索到的块和最终答案定期人工抽查看检索质量有没有退化。RAG 系统最怕的是悄悄变差——模型没变、代码没变但文档更新后检索质量下降了没有监控根本发现不了。7.3 评估体系的搭建RAG 评估有两个维度检索质量和生成质量。检索质量看召回率、命中率、MRR平均倒数排名生成质量看忠实度答案是否基于文档、相关性答案是否回答了问题。评估需要标注数据成本不低。务实的做法是先攒一批真实用户问题人工标注正确答案作为回归测试集。每次改动换模型、调切块、改 Prompt都跑一遍看指标有没有退化。没有评估集所有优化都是盲调。我在实际项目里踩过最深的坑是早期完全靠感觉调 RAG——今天觉得答案不错就上线明天用户反馈答错了就改 Prompt改来改去没有方向。后来老老实实建了 50 条问题的评估集每次改动跑一遍才发现之前一半的优化其实是负优化。评估集这个东西建的时候嫌麻烦用起来是真香。最后分享一个切块的小技巧如果你的文档里有表格别让切分器把表格切散。表格的语义是整体性的切散了模型根本读不懂。做法是在切块前先把表格转成 Markdown 或自然语言描述作为一个不可分割的块处理。这个细节不起眼但对含表格多的文档库效果提升很明显。
返回列表