☰
Java团队如何用LangChain4j与LangGraph4j从0到1搭建RAG知识库系统
2026/9/28 13:21:25 网站建设 项目流程

1. 为什么Java团队需要一套自己的RAG知识库系统

做Java后端的兄弟这两年应该都有一个明显感受:公司内部文档越堆越多,Confluence、语雀、飞书、PDF规范、Word需求文档、Excel配置表,散落在十几个地方。新人来了问“这个接口的鉴权逻辑在哪”,老员工自己都得翻半天。大模型出来后,大家都想搞个“问一句就能答”的知识库,但真动手时发现,Python那一套LangChain生态虽然热闹,Java团队接起来总有点别扭——服务是Spring Boot写的,部署是Jar包,结果为了一个RAG还得单独维护一套Python服务,运维成本直接翻倍。

这就是LangChain4j和LangGraph4j这两个库的价值所在。LangChain4j是LangChain的Java实现,把文档加载、切分、向量化、检索、对话记忆这些RAG核心环节都封装成了Java API,Maven一引就能用。LangGraph4j则是把Agent的状态流转图搬到了Java侧,让你能用节点和边的思路编排“检索-判断-重排-生成-反思”这种多步流程,而不是写一堆if-else。两者配合,Java团队可以在自己熟悉的技术栈里,从0到1搭出一套完整的RAG知识库系统。

这篇文章面向的是有Java基础、想落地RAG但不想切Python的开发者。我会把整个系统的设计思路、核心代码、参数选择、踩过的坑全部摊开讲。读完你至少能拿到三样东西:一套可运行的工程骨架、一份关键参数的调优参考、一张常见问题的排查表。全文基于我实际搭建内部知识库的经验,代码可以直接抄作业,但参数你得根据自己的语料调。

2. 整体架构设计与技术选型思路

2.1 为什么是LangChain4j加LangGraph4j这个组合

先说选型。市面上Java侧做RAG,绕不开三个选项:Spring AI、LangChain4j、自己撸。Spring AI的优势是和Spring生态无缝,但它的RAG抽象层相对薄,复杂流程编排能力弱,遇到“先检索再判断相关性再决定要不要二次检索”这种逻辑,写起来很别扭。自己撸的话,向量库客户端、文本切分、Prompt模板全得手写,工作量不小且容易在细节上翻车。

LangChain4j的定位很清晰:它把RAG的每个环节都做成了可替换的组件。文档加载有DocumentLoader,切分有DocumentSplitter,向量化有EmbeddingModel,存储有EmbeddingStore,检索有ContentRetriever,对话有ChatLanguageModel。你想换向量库,改一行配置;想换Embedding模型,换个Bean。这种组件化设计让系统具备很强的可演进性。

LangGraph4j解决的是“流程编排”问题。单纯的RAG是线性的:问题进、检索、拼Prompt、出答案。但真实场景往往需要多步:先判断问题类型,如果是闲聊直接答,如果是知识问题才检索;检索后判断相关性,不相关就改写query重试;生成后还要做一次事实校验。这些用LangGraph4j的StateGraph表达非常自然,每个节点是一个处理单元,边定义流转条件,整个流程可视化且可测试。

提示:如果你的场景就是最简单的单轮检索问答,其实LangChain4j自带的RetrievalAugmentor就够了,不必上LangGraph4j。上图的复杂度要匹配业务复杂度,别为了用而用。

2.2 系统分层与数据流转

我把整个系统分成四层,这样职责清晰,也方便后续替换组件。

层级职责核心组件
接入层接收用户提问、返回答案Spring Boot Controller、SSE流式输出
编排层控制RAG流程走向LangGraph4j StateGraph、条件边
检索层向量化、存储、召回、重排EmbeddingModel、EmbeddingStore、ContentRetriever
数据层原始文档的加载与切分DocumentLoader、DocumentSplitter、元数据过滤

数据流转是这样的:用户提问先进入编排层,编排层调用检索层拿到相关文档片段,检索层从数据层已经灌好的向量库里查。这里有个关键点——文档灌库和在线问答是两条独立的链路。灌库是离线的,可以慢慢跑;问答是在线的,要求低延迟。很多新手会把两者混在一起,每次问答都去重新加载文档,那性能必然崩。

2.3 向量库和Embedding模型的选型考量

向量库我推荐两个方向。如果是本地开发或者小规模部署,用Milvus的standalone模式或者Qdrant,Docker一条命令起来,LangChain4j都有对应的EmbeddingStore实现。如果是生产环境且已经有Elasticsearch,直接用ES的dense_vector字段也行,省得再维护一个中间件。我实测下来,十万级文档片段用Qdrant单机完全扛得住,召回延迟在几十毫秒。

Embedding模型的选择更关键,它直接决定检索质量。中文场景我建议用BGE系列或者M3E,这两个在中文语义相似度上表现稳定。如果你走的是云服务API,那用厂商提供的embedding接口也行,但要注意维度和向量库配置对齐。这里有个坑:不同Embedding模型产出的向量维度不同,换模型必须重新灌库,否则检索结果全是乱的。我见过有人换了模型没重建索引,排查了一整天才发现是维度不匹配。

<!-- LangChain4j核心依赖,Maven引入 --> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>0.35.0</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-qdrant</artifactId> <version>0.35.0</version> </dependency> <dependency> <groupId>org.bsc.langgraph4j</groupId> <artifactId>langgraph4j-core</artifactId> <version>1.0.0</version> </dependency>

版本号这块要注意,LangChain4j迭代很快,0.3x版本之间API有变动。建议锁定一个稳定版本,别频繁升级,升级前先看changelog。

3. 文档灌库链路的核心细节与实操

3.1 文档加载:别小看格式兼容问题

灌库第一步是把各种格式的文档读进来。LangChain4j提供了FileSystemDocumentLoader和ApachePoiDocumentLoader等,Word、PDF、Markdown、纯文本都能处理。但实际用起来,格式兼容是个大坑。

PDF是最麻烦的。扫描版PDF没有文字层,加载出来是空的,这种必须先做OCR。即使是文字版PDF,表格和分栏排版也经常被解析得乱七八糟。我的做法是:PDF先转成Markdown再灌库,用一些转换工具把结构保留下来,比直接解析PDF质量高很多。Word文档相对好处理,但要注意.doc和.docx是两种格式,老版本的.doc需要额外依赖。

// 加载单个文档目录下的所有文件 List<Document> documents = FileSystemDocumentLoader.loadDocuments( Paths.get("/data/knowledge-base"), new ApachePoiDocumentParser() // 支持docx、pptx等 );

加载时一定要保留元数据。每个Document对象除了文本内容,还能挂Metadata,比如来源文件名、章节标题、更新时间。这些元数据在检索时能做过滤,比如“只搜最近半年的文档”,或者“只搜某个产品线的资料”。没有元数据,后面想做精细化检索就抓瞎了。

3.2 文本切分:chunk大小和重叠的取舍

切分是RAG里最容易被低估的环节。切太大,检索出来的片段包含太多无关信息,干扰大模型;切太小,语义不完整,检索命中率下降。LangChain4j提供了DocumentSplitters.recursive(),按段落、句子、字符逐级切分,比固定长度切分合理得多。

我的经验参数是:chunk size 500到800字符,overlap 100到150字符。这个范围对中文技术文档比较友好。overlap的作用是防止一句话被切断后语义丢失,比如“该接口的鉴权逻辑是……”被切到两个chunk里,有重叠就能保证至少一个chunk包含完整语义。

DocumentSplitter splitter = DocumentSplitters.recursive( 600, // 每个chunk最大字符数 120 // 相邻chunk重叠字符数 ); List<TextSegment> segments = splitter.splitAll(documents);

注意:切分前最好做一次文本清洗,把多余的空格、换行、页眉页脚去掉。我遇到过PDF解析出来每行都带页码,切分后每个chunk都混着“第3页”这种噪声,检索质量直线下降。

还有一个进阶技巧:按语义结构切分。技术文档通常有明确的标题层级,可以先用正则识别出##、###这种标题,把同一小节的内容聚在一起再切。这样每个chunk的语义更内聚。LangChain4j的DocumentByParagraphSplitter和DocumentByLineSplitter可以组合使用,效果比纯递归切分好。

3.3 向量化与入库:批量处理和幂等性

向量化就是把文本片段转成向量。这一步的耗时取决于Embedding模型,本地模型慢但免费,云API快但有成本。灌库时建议批量处理,一次传几十个片段给Embedding模型,比一个个传效率高得多。

EmbeddingModel embeddingModel = new BgeSmallZhEmbeddingModel(); EmbeddingStore<TextSegment> embeddingStore = QdrantEmbeddingStore.builder() .host("localhost") .port(6334) .collectionName("knowledge_base") .build(); // 批量向量化并入库 List<Embedding> embeddings = embeddingModel.embedAll(segments).content(); embeddingStore.addAll(embeddings, segments);

这里必须强调幂等性。灌库脚本要能重复跑而不产生重复数据。做法是给每个片段生成一个唯一ID,基于文档路径加内容哈希,入库前先查这个ID是否存在。Qdrant支持upsert语义,相同ID会覆盖,这就天然幂等了。如果没有这个机制,每次重跑灌库脚本,向量库里就多一份重复数据,检索时同一段内容出现好几次,浪费上下文窗口。

灌库完成后,建议做一次抽样验证:随机取几个问题,看检索出来的top片段是否相关。这一步能提前发现切分或向量化的问题,比等到线上问答出问题再排查强。

4. 在线问答链路与LangGraph4j流程编排

4.1 基础RAG链路:检索加生成

最基础的问答链路就两步:拿用户问题去向量库检索,把检索结果拼进Prompt让大模型生成答案。LangChain4j的RetrievalAugmentor把这两步封装好了,几行代码就能跑通。

ContentRetriever retriever = EmbeddingStoreContentRetriever.builder() .embeddingStore(embeddingStore) .embeddingModel(embeddingModel) .maxResults(5) // 召回top5片段 .minScore(0.6) // 相似度阈值 .build(); RetrievalAugmentor augmentor = DefaultRetrievalAugmentor.builder() .contentRetriever(retriever) .build();

maxResults和minScore这两个参数要重点调。maxResults太大,Prompt里塞太多内容,大模型容易“迷失在中间”,反而忽略关键信息;太小又可能漏掉答案。5到8是个比较稳的范围。minScore是相似度阈值,低于这个分数的片段直接丢弃,防止无关内容污染Prompt。这个阈值跟Embedding模型有关,BGE系列一般0.6左右起步,需要根据实际语料微调。

4.2 用LangGraph4j编排多步RAG流程

基础链路能解决60%的问题,但剩下40%需要更复杂的流程。比如用户问“对比A方案和B方案的优劣”,单次检索可能只召回A或只召回B,需要查询改写成两个子问题分别检索。再比如检索回来的内容相关性不高,需要判断后重试。这些用LangGraph4j表达就很清晰。

StateGraph<AgentState> graph = new StateGraph<>(AgentState::new) .addNode("classify", this::classifyQuestion) // 判断问题类型 .addNode("retrieve", this::retrieveDocuments) // 检索 .addNode("grade", this::gradeRelevance) // 评估相关性 .addNode("rewrite", this::rewriteQuery) // 改写query .addNode("generate", this::generateAnswer) // 生成答案 .addEdge(START, "classify") .addConditionalEdges("classify", this::routeByType, Map.of("knowledge", "retrieve", "chat", "generate")) .addEdge("retrieve", "grade") .addConditionalEdges("grade", this::routeByRelevance, Map.of("relevant", "generate", "irrelevant", "rewrite")) .addEdge("rewrite", "retrieve") .addEdge("generate", END);

这个图里,classify节点判断问题是知识型还是闲聊型,闲聊直接走生成,省一次检索。grade节点评估检索结果的相关性,如果都不相关就触发rewrite改写query再检索一次。这个“检索-评估-改写”的循环最多跑两轮,防止死循环。

AgentState是个自定义的状态对象,贯穿整个流程,携带用户问题、检索结果、改写次数等信息。LangGraph4j的节点方法接收state返回更新后的state,这种函数式风格让流程可测试性很强——每个节点都能单独写单元测试。

4.3 查询改写与多路召回

查询改写是提升召回率的关键手段。用户的问题往往口语化、有指代,直接拿去检索效果差。改写策略有几种:同义扩展(把“咋用”改成“如何使用”)、指代消解(把“它”替换成上文提到的具体对象)、子问题拆分(把复合问题拆成多个简单问题)。

private AgentState rewriteQuery(AgentState state) { String original = state.query(); String prompt = """ 请将以下问题改写为更适合向量检索的形式, 保留核心语义,去除口语化表达,输出改写后的问题即可: 原问题:%s """.formatted(original); String rewritten = chatModel.generate(prompt); return state.withQuery(rewritten).incrementRewriteCount(); }

多路召回是另一个技巧:同一个问题,用不同的改写方式生成多个query,分别检索后合并去重。这样能覆盖更多表达方式,召回率明显提升。代价是检索次数翻倍,延迟增加,所以要在召回率和延迟之间权衡。我的做法是:首轮单query检索,如果相关性评估不通过,第二轮才启用多路召回。

提示:改写用的Prompt要控制输出格式,最好让模型只输出改写后的问题,不要带解释。否则改写结果里混着“好的,我来帮你改写”这种废话,检索直接跑偏。

5. 检索质量调优与常见问题排查

5.1 提升召回率的几个实操手段

召回率低是RAG最常见的抱怨:“明明文档里有答案,就是搜不出来”。排查思路从后往前:先看检索出来的片段是否相关,再看向量化是否正常,最后看切分是否合理。

混合检索是提升召回率最有效的手段之一。纯向量检索擅长语义匹配,但对精确关键词(比如错误码、函数名)不敏感。把向量检索和BM25关键词检索结合,两路结果做RRF融合,召回率能提升一大截。LangChain4j本身对混合检索的支持还在演进,实践中可以自己实现:向量库查一路,ES或Lucene查一路,然后用RRF公式合并排序。

RRF的公式很简单:每个文档的得分等于它在各路人马中排名的倒数之和。排名越靠前,得分越高。这个算法不需要调权重,对异构检索结果的融合很鲁棒。

重排是另一个利器。检索出来的top20片段,用一个交叉编码器(cross-encoder)重新打分排序,取top5。交叉编码器比向量相似度更准,因为它能同时看到query和文档的交互。代价是慢,所以只对少量候选做重排。LangChain4j可以集成ScoringModel做重排,或者调用专门的重排API。

5.2 常见问题速查表

现象可能原因排查方向解决手段
检索结果完全不相关Embedding模型与向量库不匹配检查向量维度是否一致换模型后重建索引
答案里有文档没有的内容大模型幻觉检查Prompt是否要求“仅根据上下文回答”强化Prompt约束,加引用来源
同一段内容重复出现灌库脚本非幂等检查片段ID生成逻辑用内容哈希做ID,upsert入库
检索延迟高向量库索引未优化检查是否用了暴力检索建HNSW索引,调ef参数
长文档答案不完整chunk切分过大或过小抽样看chunk内容调整size和overlap,按语义切
中文检索效果差Embedding模型不适配中文换中文优化模型用BGE或M3E系列

5.3 我踩过的几个坑

第一个坑是元数据过滤失效。我在检索时想按文档类型过滤,结果发现过滤后召回为零。排查发现是灌库时元数据的字段名和检索时用的不一致,一个叫doc_type一个叫type。这种低级错误在跨模块协作时特别容易出,建议元数据的key统一定义成常量。

第二个坑是Prompt长度超限。检索top10片段拼进Prompt,加上系统提示和对话历史,直接超过模型上下文窗口。表现是模型报错或者答案被截断。解决办法是动态控制召回数量,根据模型窗口和平均片段长度算一个安全值。或者用滑动窗口保留最近几轮对话,别把全部历史都塞进去。

第三个坑是流式输出与检索的时序。用SSE做流式输出时,检索是同步阻塞的,用户会感觉“卡了一下才开始出字”。优化方案是把检索和生成解耦,检索完成后先返回一个“正在生成”的状态,再流式推答案。体验上会好很多。

6. 工程化落地与后续扩展方向

6.1 配置外置与多环境管理

RAG系统里有一堆参数:向量库地址、Embedding模型路径、chunk大小、召回数量、相似度阈值。这些绝对不能硬编码。用Spring Boot的@ConfigurationProperties把它们抽到yaml里,不同环境用不同profile。

rag: embedding: model: bge-small-zh dimension: 512 splitter: chunk-size: 600 overlap: 120 retriever: max-results: 5 min-score: 0.6 vector-store: type: qdrant host: ${QDRANT_HOST:localhost} port: 6334

这样做的好处是,调参不用改代码重新打包,改配置重启即可。而且不同环境可以用不同的向量库,开发用本地Qdrant,生产用集群版。

6.2 效果评估:别凭感觉说“好用”

RAG系统最怕的就是“感觉还行”。要量化评估,得有一套测试集。我的做法是:从真实用户问题里挑50到100个,人工标注每个问题的标准答案和应该召回的文档片段。然后跑评估脚本,算两个指标:召回率(应该召回的片段有多少被召回了)和答案准确率(生成的答案和标准答案的匹配度)。

召回率好算,答案准确率可以用大模型做裁判,让一个强模型判断生成答案是否正确。这套评估流程跑一次大概十几分钟,但能客观反映调参的效果。每次改切分参数或换Embedding模型,都跑一遍评估,用数据说话。

6.3 后续可以扩展的方向

基础版跑通后,有几个方向可以继续深挖。一是多模态,把图片、表格也纳入知识库,LangChain4j对多模态的支持在逐步完善。二是Agent化,让系统不只是问答,还能调用工具,比如查数据库、发邮件,LangGraph4j的图编排天然适合做这个。三是权限控制,不同用户能检索的文档范围不同,这需要在元数据里加权限标签,检索时做过滤。

还有一个容易被忽略的点是知识库的更新机制。文档是会变的,灌库不能只跑一次。我的做法是给每个文档记录最后修改时间,定时任务扫描变更,只重新灌变更的文档。全量重灌虽然简单,但文档多了之后耗时太长,增量更新是必须的。

最后分享一个我在实际使用中的体会:RAG系统的效果,七分靠数据质量,三分靠技术调优。文档本身如果结构混乱、内容过时,再好的检索算法也救不回来。所以在动手写代码之前,先花时间把知识库的文档整理一遍,该合并的合并,该废弃的废弃,这一步的投入产出比远高于调参。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询