我们直接用Java做完了一整套RAG知识库系统,这中间踩的坑比想象中多得多。如果你也在Java生态里做检索增强生成,想用LangChain4j和LangGraph4j而不是天天开Python服务,这篇文章应该能帮你少走很多弯路。我会从依赖选型讲到图编排,再落到切块、去重、多轮对话这些实际工程问题,全程给出可复现的代码和参数。
1. 为什么在Java生态里自己做RAG:LangChain4j与LangGraph4j到底解决什么问题
先交代背景。大多数RAG教程都是Python写的,LangChain、LlamaIndex、向量数据库一把梭。但很多团队的后端核心是Java,尤其是老一点的企业项目,为了接入大模型去额外维护一个Python微服务,还要处理跨语言调用、部署运维,成本并不低。LangChain4j的出现让这件事可以完全在Java/JVM内闭环,加上LangGraph4j把可控的Agent流程也带到了Java生态,才真正值得正经聊一聊。
1.1 从Python到Java的RAG之痛
我最初试过用Python写一个独立的RAG服务,再让Java业务系统通过HTTP调用。方案能跑通,但引入了两个服务之间的网络开销、鉴权、超时和日志追踪问题。而且Python侧依赖管理比较随性,打包成镜像体积也不小,团队里还要有人专门维护那套环境。
换到Java侧后,最明显的感受是:文档解析、数据库访问、事务控制、监控埋点这些能力直接复用现有技术栈。比如我要把Word、PDF里的内容灌进知识库,用Java本身的POI配合其他解析库就行,不需要再开一个Python子服务。LangChain4j恰好提供了统一的文档加载、拆分、嵌入、存储、检索API,而LangGraph4j则在流程编排层面补上了状态机和条件路由的能力。
1.2 LangChain4j的定位与边界
LangChain4j不是简单地把Python LangChain翻译成Java。它的核心抽象包括Document、DocumentSplitter、EmbeddingModel、EmbeddingStore、ContentRetriever、ChatMemory、AiServices等。你可以把它理解成一个“胶水层”,把不同厂家的模型和向量库以同一方式接入。
但它也有边界:单轮的“检索-生成”链路通过AiServices很容易实现,可一旦你要做多轮对话中的查询改写、判断检索结果是否足够、不够就再查一次这类有状态流程,AiServices本身是不够的。这种场景LangGraph4j就更合适。
1.3 LangGraph4j:把流程变成有向图
LangGraph4j的核心理念是让LLM应用中的逻辑流程变成一张显式的图。你定义节点和边,每个节点是一个函数,边决定下一步走向。节点之间通过一个全局状态对象传递数据,支持并行、分支和循环。
这个设计让“Agentic RAG”落地变得可控。传统RAG是“问一次、查一次、答一次”,而Agentic RAG可以在回答前先判断问题是否需要二次检索、要不要改写查询词、有没有多个子问题需要分别查询。把这些判断逻辑画成一张图,你就知道自己每一次调用模型花在什么地方了。
2. 动手前的地基:项目依赖、Embedding模型选择与本地向量库
先说依赖。我用的是Maven,Spring Boot 3.2.x,Java 17。LangChain4j版本用到了官方BOM来统一管理,避免子模块版本漂移。
2.1 Maven依赖怎么加
<properties> <java.version>17</java.version> <langchain4j.version>1.0.0-beta2</langchain4j.version> </properties> <dependencyManagement> <dependencies> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-bom</artifactId> <version>${langchain4j.version}</version> <type>pom</type> <scope>import</scope> </dependency> </dependencies> </dependencyManagement> <dependencies> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> </dependency> <!-- 按需引入模型和向量库 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-open-ai</artifactId> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-chroma</artifactId> </dependency> <!-- LangGraph4j --> <dependency> <groupId>org.bsc.langgraph4j</groupId> <artifactId>langgraph4j-core</artifactId> <version>1.0</version> </dependency> <dependency> <groupId>org.bsc.langgraph4j</groupId> <artifactId>langgraph4j-langchain4j</artifactId> <version>1.0</version> </dependency> </dependencies>建议直接用BOM,不要自己写一堆版本号。我见过好几个项目因为open-ai和chroma模块版本不一致,运行时直接NoSuchMethodError。
2.2 Embedding模型的选择
Embedding模型决定检索质量的上限。如果公司有OpenAI的Key,直接用OpenAiEmbeddingModel最省事:
EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder() .apiKey(System.getenv("OPENAI_API_KEY")) .modelName("text-embedding-3-small") .build();如果出于成本、隐私或合规考虑想本地部署,可以选择Ollama或者ONNX Runtime加载本地模型。LangChain4j有OllamaEmbeddingModel,我本地测试常用nomic-embed-text这种小模型。实测下来,本地模型维度低一些,检索效果相对OpenAI的embedding会有差距,但知识库如果以专业术语为主,本地模型的向量仍然够用。
2.3 向量存储的选型对比
向量存储的选择会影响后续的扩展性和运维复杂度。我整理了一张表:
| 方案 | 部署成本 | 持久化 | 适合场景 |
|---|---|---|---|
| InMemoryEmbeddingStore | 零部署 | 否 | 原型验证、测试 |
| Chroma | 本地进程 | 是 | 中小型项目、单机 |
| PgVector | 需PostgreSQL插件 | 是 | 复用已有PG,事务和向量统一 |
| Milvus | 独立集群 | 是 | 百万级以上向量、高并发 |
我项目里最终选了Chroma,因为部署简单,不用单独维护一套存储服务。如果你公司已经有用PG,PgVectorEmbeddingStore也值得试,这样可以用一套数据库同时管业务数据和向量,还能用SQL做过滤。
3. 核心链路拆解:加载-切块-向量化-检索-生成
不管用不用LangGraph4j,RAG基础链路是绕不开的。这部分串起来的是数据准备和单轮问答,也是后续一切流程的地基。
3.1 文档加载与切块策略
原始文档可能是PDF、Word、Markdown。LangChain4j内置了Document.fromFile和Document.fromInputStream,但Word和PDF需要额外配合解析库。我建议先解析成纯文本再交给LangChain4j的DocumentSplitter。毕竟嵌入模型输入的是文本,解析成干净文本比在复杂文档结构上做切块更稳定。
切块策略直接决定检索精度。我用的是DocumentSplitters.recursive:
DocumentSplitter splitter = DocumentSplitters.recursive( 500, 100, new NaiveTextSplitter() ); List<TextSegment> segments = splitter.split(document);这里recursive的意思是先按段落切,再按句子切,最后按固定窗口切,尽量保证语义完整性。500是目标块大小,100是重叠部分。块大小要根据你的embedding模型和业务问题长度来调。如果块太小,检索到的内容没有上下文支撑;太大,带入Prompt的噪声多且浪费token。
以我的经验:一般技术文档用400~600字比较合适。如果问答偏向“某段话讲了什么”,300字就够;如果偏向“总结这一章”,可以调到800~1000。
3.2 构建Embedding并入库
切块之后,遍历每个TextSegment生成向量,存入库中。这里有一个容易忽略的点:如果你要用Chroma,最好把文档ID或来源URI作为元数据一并存进去,否则后续增量更新数据时,你不知道哪些向量对应旧文档。
List<Embedding> embeddings = embeddingModel.embedAll(segments).content(); embeddingStore.addAll(embeddings, segments);如果是上线项目,建议把这段写成一个批处理任务,用文档ID做幂等。不要图省事每次构建知识库都全量重刷,后期文档多了性能扛不住。
3.3 检索器与Prompt组装
检索器负责根据用户问题找到最相关的片段。LangChain4j的EmbeddingStoreContentRetriever直接可用:
ContentRetriever retriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(4) .minScore(0.6) .build();minScore是相似度阈值,设置太低会把不相关内容塞进来。我一般设0.6~0.7,具体看embedding模型的分布。这里要注意:不同embedding模型的相似度分数范围不一致,OpenAI的基本在0.7~1.0,本地模型可能分散一些,一定要先看看实际分布再定阈值。
Prompt组装是容易被低估的环节。最简单的是:
PromptTemplate promptTemplate = PromptTemplate.from( """ 你是知识库助手。请基于以下资料回答用户问题。 资料内容: {{info}} 用户问题:{{question}} 如果资料中没有明确答案,请直接说不知道,不要编造。 """ );注意要把检索到的文本用分隔符明确隔开,并且加上“不要编造”的约束,能显著减少幻觉。
3.4 用AiServices把问答接口串起来
LangChain4j的AiServices可以省掉你手写模型调用和Prompt拼装的代码。先定义一个接口:
interface Assistant { String answer(@UserMessage String userMessage); }然后装配:
Assistant assistant = AiServices.builder(Assistant.class) .chatLanguageModel(chatLanguageModel) .contentRetriever(retriever) .chatMemory(MessageWindowChatMemory.withMaxMessages(10)) .build();调用assistant.answer("什么是RRF?")即可。ContentRetriever会自动把检索结果注入Prompt。这一套跑通后,单轮RAG就能用了。
4. 引入LangGraph4j:从单轮问答到Agentic RAG
只靠AiServices做固定管道,处理复杂问题会很吃力。我遇到的实际场景是用户连续追问、问题模糊、或需要多个文档交叉验证,这时候要把流程变成图。
4.1 为什么简单的“检索+生成”不够
举一个真实例子:用户问“我们公司的报销政策是什么?请对比不同部门的额度差异”。如果你只做一次检索,可能会得到大量关于报销政策的片段,但很难同时命中所有部门的额度描述。更合理的方式是先识别出这是一个对比型问题,然后分别检索“A部门报销额度”“B部门报销额度”,再汇总答案。
这就是Agentic RAG要做的:让LLM参与流程决策,而不是只当最后的生成器。LangGraph4j允许你把“改写查询-并行检索-判别结果-生成回答”画成一张图,每个环节都可控。
4.2 LangGraph4j核心概念:状态、节点、边
LangGraph4j中最关键的是StateGraph。你需要定义状态类型,然后注册节点,最后设置边的跳转条件。
StateGraph<RagState> graph = new StateGraph<>(RagState::new); graph.addNode("rewrite_query", this::rewriteQuery); graph.addNode("retrieve_docs", this::retrieveDocs); graph.addNode("generate_answer", this::generateAnswer); graph.addNode("check_relevance", this::checkRelevance); graph.setEntryPoint("rewrite_query"); graph.addEdge("rewrite_query", "retrieve_docs"); graph.addEdge("retrieve_docs", "check_relevance"); graph.addConditionalEdge("check_relevance", state -> state.isRelevant() ? "generate_answer" : "rebuild_query", Map.of("generate_answer", "generate_answer", "rebuild_query", "rewrite_query") ); graph.setFinishingPoint("generate_answer");这里的状态类可以包含用户原始问题、改写后的问题、检索到的片段、生成结果等字段。每个节点方法接收状态,返回部分状态更新。
4.3 一个带查询改写和自愈评测的RAG图怎么搭
我的实现思路是:收到用户问题后,先让LLM判断问题是否需要重写。比如“它的实施步骤是什么?”这种指代不明的,需要结合历史对话改写为“Java项目中集成LangChain4j的实施步骤是什么”。改写后去做检索。
检索结果回来后,再加一个“相关性评估”节点,让LLM判断检索到的资料是否足以回答当前问题。如果不够,则触发二次检索或换查询词重新走一遍。这个“重试”机制就是LangGraph4j相比固定管道最大的优势——你可以在图上显式建模循环。
下面是一个简化的节点实现:
public Map<String, Object> rewriteQuery(RagState state) { String rephrased = chatModel.generate( "请把这个问题改写成适合检索的查询词,只输出改写结果,不要解释:\n" + state.getQuestion() ); return Map.of("finalQuery", rephrased); }checkRelevance节点实现类似,用LLM输出一个布尔评分。要注意控制重试次数,我拿一个计数器存在状态里,最多重试两次,否则容易陷入死循环。
实测下来,这种带自愈评测的流程对复杂问题的准确率提升明显,但延迟会上升,因为多了一次甚至几次LLM调用。如果你在乎响应速度,可以只在用户开启“深度查询”时走完整图,默认走简单链路。
5. 实测踩坑记录:切块大小、去重逻辑、上下文溢出与响应质量
这一节全是实战中遇到的问题,每一个都是我花时间调过的。
5.1 切块大小对答案质量的影响
之前我图省事把所有文档切成了固定1000字,结果检索出来的片段经常跨章节。用户问“LangGraph4j的状态如何定义”,返回的内容可能包含了节点和边的定义,但关键的StateGraph初始化代码被切到了下一个块里,导致答案缺胳膊少腿。
后来改成递归切块+重叠100字后,情况好了很多,但还不够。我发现对代码教程类内容,最好在切块时保留代码块完整性。LangChain4j的DocumentSplitters.recursive支持自定义分界符,我把```代码块标记也加入分界符列表,尽量不让代码块被拦腰切断。
5.2 去重逻辑的坑
热词里提到“langchain 和 langchain4j 的默认 rrf 实现,去重逻辑存在缺陷”,我也踩过。当多个检索器(比如向量检索和BM25关键词检索)合并结果时,RRF算法会给出综合排序。LangChain4j默认结果集如果包含相同文本片段的不同chunk,可能会同时返回两段高度重复的内容。
我的处理方案是在检索结果合并后、组装Prompt之前,增加一个去重步骤:
List<TextSegment> deduplicated = retrievedSegments.stream() .collect(Collectors.toMap( seg -> normalize(seg.text()), Function.identity(), (a, b) -> a, LinkedHashMap::new )) .values() .stream() .toList();normalize函数去掉空格和换行,把语义上相同的文本视为重复。这个方法简单但有效。另外,RRF里的k参数默认是60,如果你觉得结果排序不够准,可以调小试一下,我调到30后精确度感觉更好。
5.3 多轮对话中的上下文管理
用MessageWindowChatMemory.withMaxMessages(10)简单粗暴地保留最近10轮,但这样会带来一个问题:中间的历史消息可能包含与当前问题无关的内容,且每次都把完整历史塞给模型,token消耗大。
RAG场景更适合的做法是:仅把第一轮用户明确描述的上下文作为历史压缩记忆,检索时使用当前轮改写后的查询。我在LangGraph4j状态里单独保存了originQuestionHistory,当前轮的问题由LLM结合历史改写后再去检索。这样既保留了指代消解能力,又不会让历史消息污染检索权重。
5.4 性能与成本:延迟与token消耗
一次完整RAG响应的延迟主要来自三块:embedding检索、LLM生成、额外LLM调用。embedding检索一般是毫秒级,LLM生成才是大头。如果用了LangGraph4j的自愈流程,延迟会变成多次串行LLM调用之和。我在实际线上环境中做过分流策略:普通问题走AiServices快速链路,复杂问题走LangGraph4j完整链路。判断逻辑就是一个简单的关键词+长度规则,没有额外模型调用,成本可控。
另外,OpenAiEmbeddingModel跑批处理时一定要控制并发和批次大小,不然会被限流。我习惯把“文档切片→embedding→入库”写成独立的Job,不在用户请求链路里做实时入库。
6. Spring AI还是LangGraph4j?我的选型建议
很多Java程序员会纠结:现在官方有Spring AI,社区有LangChain4j和LangGraph4j,到底选哪个?我不能替你决定,但可以把我的观察讲清楚。
6.1 两者定位区别
Spring AI更像是一个“Spring官方对AI能力的抽象层”。它倾向于和Spring生态无缝整合,提供了ChatClient、EmbeddingModel等接口,但它在Agent编排和复杂流程上目前还比较基础。它的特点是稳、标准化,适合已经在Spring全家桶里深耕的团队。
LangGraph4j则更接近于Python LangGraph的思路:把应用逻辑画成图,节点之间显式传状态。你可以精细控制循环、分支、并行。代价是要自己理解图执行引擎的细节,学习成本比单纯用Spring AI的RestTemplate高不少。
6.2 不同场景的取舍
如果你的需求是快速给业务加一个“智能客服”,简单检索+固定提示词就够了,那么Spring AI或LangChain4j的AiServices都合适。真正需要LangGraph4j的时候,往往具备以下特征:需要对检索过程做多次改写和验证,需要根据用户意图动态选择不同工具,需要并行处理多个检索子问题。
我的项目最终是LangGraph4j和LangChain4j混用:底层文档处理和向量操作用LangChain4j,上层复杂流程编排用LangGraph4j。两者不冲突,langgraph4j-langchain4j这个模块就是为桥接而生的。
6.3 从维护成本和团队能力看
选型也要看团队的Java能力。LangGraph4j的图模型要求开发者对状态机、异步处理有概念,写起来比普通CRUD更像在写算法。如果团队是刚转Java的,建议还是先从LangChain4j的AiServices起步,跑通了再上LangGraph4j。否则一旦流程复杂,调试状态流转就够折腾一壶。
我在实际中写LangGraph4j节点时,每个节点尽量保持纯函数风格,只依赖传入的状态、只返回需要修改的字段,这样单元测试非常好写。这也是我想分享的最有价值的一条实践。
最后再分享一个小技巧:给RAG系统加上缓存和反馈
线上跑了两个月后,我发现很多用户的问题其实是重复问法。与其每次都走一遍LLM和检索,不如给RAG加一层结果缓存。缓存键用归一化后的问题文本,缓存值用生成结果,命中缓存直接返回。知识库更新时需要手动或者定时清理相关缓存,不然老答案会一直驻留。
另外,推荐在答案里带上引用的文档ID和片段文字,方便用户核对。这一点在RAG系统里极其重要——用户信任度和答案准确性一样重要。
搭建这套系统的过程让我印象最深的一点是:真正决定RAG上线效果的不是用什么模型、用什么向量库,而是你对数据切分、流程控制和错误处理的细致程度。用Java统一技术栈确实能省掉很多跨服务协调的麻烦,但工程细节一个都绕不过去。希望上面这些记录,能让你少踩几个我踩过的坑。