ARTICLE DETAIL

资讯详情

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

Java从零搭建RAG知识库:LangChain4j+LangGraph4j实战

Java从零搭建RAG知识库:LangChain4j+LangGraph4j实战 1. 为什么我选择用Java从零搭一套RAG知识库先说结论这套东西我用了一个周末跑通第一版第二周开始往生产环境上靠中间踩的坑比预想的多但整体收益远超预期。标题里提到的LangChain4j LangGraph4j组合是我对比了 Spring AI、直接调大模型 API、以及 Python 侧方案之后定下来的路线。原因很直接——我的主技术栈是 Java团队里没人愿意为了一个知识库系统再维护一套 Python 服务而 LangChain4j 把 RAG 的核心链路文档加载、切分、向量化、检索、生成都封装成了 Java 原生 APILangGraph4j 则补上了多步编排和状态流转这块短板。RAGRetrieval-Augmented Generation检索增强生成说白了就是给大模型配一个外挂记忆库。大模型本身的知识是训练时冻结的你问它公司内部文档、最新产品手册、私有业务规则它要么瞎编要么说不知道。RAG 的做法是先把你的文档切块、向量化、存进向量库用户提问时先把问题也向量化去库里捞出最相关的几块原文再把这些原文塞进提示词一起发给大模型让它看着材料回答。这样既避免了重新训练模型的高昂成本又能保证答案有据可查。这套系统适合谁我认为三类人最该动手做一遍一是Java 后端想在自己的业务系统里加一个智能问答能力又不想引入异构服务二是做企业知识管理的同学手里有一堆 Word、PDF、Confluence 导出文档想让它活起来三是正在准备Java 面试的朋友——现在 RAG、Agent、向量检索这些词已经频繁出现在中高级岗位的面试题里光背八股文不够手上得有一个能讲清楚链路的项目。我下面写的内容全部基于我实际跑通的版本代码能抄参数能改坑我也标出来了。你不需要是算法工程师但得会写 Java、会用 Maven、能看懂 JSON。2. 整体架构设计与技术选型拆解2.1 为什么是 LangChain4j 而不是 Spring AI这是被问得最多的问题我自己也纠结过。Spring AI 的优势是跟 Spring 生态无缝集成Bean一配就能用如果你整个项目就是 Spring Boot上手确实快。但它的问题在于抽象层次偏高很多 RAG 的细节比如切分策略、检索后的重排序、多路召回融合你想深度定制时会发现要么得绕开它的封装要么得等社区版本更新。LangChain4j 的定位更底层一些它把 RAG 拆成了清晰的组件DocumentLoader、DocumentSplitter、EmbeddingModel、EmbeddingStore、ContentRetriever、ChatLanguageModel。每个组件你都能替换成自己的实现。我实测下来做Agentic RAG让模型自己决定要不要检索、检索几轮时LangChain4j 的灵活度明显更高。至于 LangGraph4j它是 LangGraph 的 Java 移植版核心价值是把 RAG 流程从线性管道变成状态图。普通的 RAG 是检索→拼接→生成一条直线但真实场景往往需要先判断问题类型→决定走不走检索→检索后评估相关性→不相关就改写查询重试→最后生成。这种带分支、带循环、带状态的流程用 LangGraph4j 表达起来非常自然。对比维度Spring AILangChain4j LangGraph4j上手速度快Spring 风格中等需理解组件模型定制灵活度一般高组件可替换多步编排能力弱强原生状态图社区活跃度高高更新频繁适合场景标准 RAG、快速验证复杂 RAG、Agentic 流程我的建议是如果只是做个 demo 或者标准问答Spring AI 够用但凡涉及多轮检索、查询改写、条件分支直接上 LangChain4j LangGraph4j别中途换。2.2 核心链路拆解一条数据从文档到答案的旅程整套系统的数据流我画不出图这里也不让画但可以用文字讲清楚。它分两个阶段离线索引阶段和在线检索生成阶段。离线阶段做四件事加载文档PDF、Word、Markdown、网页都行→ 切分成小块chunk→ 每块调用 Embedding 模型转成向量 → 存进向量库。这一步是一次性的文档更新时增量做。在线阶段做五件事用户提问 → 问题向量化 → 向量库相似度检索 Top-K → 可选重排序 多路召回融合 → 把检索结果和问题拼成提示词 → 大模型生成答案。LangGraph4j 介入的是在线阶段。我把整个在线流程定义成一个状态图节点包括classifyQuery判断问题类型、retrieve检索、gradeDocuments评估检索质量、rewriteQuery改写查询、generate生成答案。边则定义了流转逻辑比如gradeDocuments发现文档不相关就回到rewriteQuery重试最多重试两轮。2.3 向量库和 Embedding 模型的选型逻辑向量库我选了Milvus本地开发用 Docker 起单机版原因是它对 Java 客户端支持好、性能稳、支持标量过滤这点很重要后面讲多租户时会用到。轻量场景也可以用Chroma或PgVector如果你已经有 PostgreSQLPgVector 是最省事的不用额外维护一个中间件。Embedding 模型我用的是BGE-M3通过本地部署的推理服务调用。选它的理由中文效果好、支持多语言、维度 1024 不算太高、开源可商用。如果你不想自己部署用云厂商的 Embedding API 也行LangChain4j 对这些都有适配。这里有个关键点Embedding 模型一旦选定索引和查询必须用同一个模型否则向量空间对不上检索结果全是噪声。我见过有人索引用 A 模型、查询用 B 模型然后抱怨检索命中率极低这就是典型的踩坑。3. 环境搭建与核心依赖配置3.1 Maven 依赖清单与版本选择依赖这块我踩过版本冲突的坑所以直接把我的pom.xml关键部分贴出来。核心是langchain4j、langchain4j-open-ai或你用的模型适配包、langchain4j-milvus、langgraph4j-core。properties langchain4j.version0.35.0/langchain4j.version langgraph4j.version1.0.0/langgraph4j.version java.version17/java.version /properties dependencies dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version${langchain4j.version}/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version${langchain4j.version}/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-milvus/artifactId version${langchain4j.version}/version /dependency dependency groupIdorg.bsc.langgraph4j/groupId artifactIdlanggraph4j-core/artifactId version${langgraph4j.version}/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-document-parser-apache-pdfbox/artifactId version${langchain4j.version}/version /dependency /dependencies注意LangChain4j 的版本迭代很快0.35.0 是我写这篇时稳定的版本。升级前务必看官方 release notes它有过几次破坏性变更比如EmbeddingStore接口的方法签名调整。Java 版本我强烈建议17 或以上因为 LangGraph4j 用到了 record 和 sealed class 这些特性Java 8 跑不起来。如果你项目还锁在 Java 8那这套方案得先评估升级成本。3.2 向量库的本地启动与连接配置Milvus 我用 Docker Compose 起配置文件精简版如下version: 3.5 services: etcd: image: quay.io/coreos/etcd:v3.5.5 environment: - ETCD_AUTO_COMPACTION_MODErevision - ETCD_AUTO_COMPACTION_RETENTION1000 volumes: - ./volumes/etcd:/etcd command: etcd -advertise-client-urlshttp://127.0.0.1:2379 -listen-client-urls http://0.0.0.0:2379 --data-dir /etcd minio: image: minio/minio:RELEASE.2023-03-20T20-16-18Z environment: MINIO_ACCESS_KEY: minioadmin MINIO_SECRET_KEY: minioadmin volumes: - ./volumes/minio:/minio_data command: minio server /minio_data standalone: image: milvusdb/milvus:v2.3.3 command: [milvus, run, standalone] environment: ETCD_ENDPOINTS: etcd:2379 MINIO_ADDRESS: minio:9000 ports: - 19530:19530 - 9091:9091 depends_on: - etcd - minio启动后19530是 gRPC 端口Java 客户端连这个。连接代码MilvusEmbeddingStore embeddingStore MilvusEmbeddingStore.builder() .host(127.0.0.1) .port(19530) .collectionName(knowledge_base) .dimension(1024) .build();这里的dimension必须和 Embedding 模型输出维度一致BGE-M3 是 1024OpenAI 的 text-embedding-3-small 是 1536。填错了要么报错要么检索结果完全乱套。3.3 模型接入本地推理服务 vs 云 API我两种都试过。本地部署 BGE-M3 用的是一个轻量推理服务好处是数据不出内网、没有调用费用、延迟稳定坏处是要占 GPU 资源机器配置不够时吞吐上不去。云 API 的好处是省心坏处是数据要出去、有成本、有网络延迟。LangChain4j 接入本地服务时只要对方兼容 OpenAI 的接口格式就能直接用OpenAiEmbeddingModel把baseUrl指过去就行EmbeddingModel embeddingModel OpenAiEmbeddingModel.builder() .baseUrl(http://localhost:8000/v1) .apiKey(not-needed) .modelName(bge-m3) .build();提示很多本地推理服务默认不校验 apiKey但 LangChain4j 的 builder 要求必填随便填个字符串即可别留空。4. 文档处理与索引构建的实操细节4.1 文档加载不同格式的处理策略LangChain4j 提供了多种DocumentLoader。PDF 用ApachePdfBoxDocumentParserWord 用ApachePoiDocumentParser纯文本和 Markdown 直接读文件。我实际项目里文档来源杂所以写了一个分发器public ListDocument loadDocument(Path path) { String fileName path.getFileName().toString().toLowerCase(); DocumentParser parser; if (fileName.endsWith(.pdf)) { parser new ApachePdfBoxDocumentParser(); } else if (fileName.endsWith(.docx)) { parser new ApachePoiDocumentParser(); } else { parser new TextDocumentParser(); } return parser.parse(path); }这里有个坑PDF 解析出来的文本经常带乱码或断行。尤其是扫描件PdfBox 提取出来是空的因为它本质是图片。这种情况要么上 OCR要么在入库前人工筛一遍。我的做法是加一个校验解析后文本长度小于 50 字符的直接标记为待人工处理不进入索引。Word 文档还有个细节表格内容。Poi 解析表格时默认会把单元格内容按行拼接但表头和数据行的对应关系会丢失。如果你的知识库里有大量表格比如产品参数表建议单独处理表格转成字段名: 值的键值对文本再入库检索效果会好很多。4.2 文本切分chunk 大小和重叠度的取舍切分是 RAG 里最容易被低估的环节。切太大检索出来的块包含太多无关信息稀释了相关性切太小语义不完整模型拿到半句话没法回答。我的经验值是中文文档 chunk 大小 300-500 字重叠 50-80 字。LangChain4j 的DocumentSplitters.recursive()支持按段落、句子、字符递归切分比固定长度切分效果好DocumentSplitter splitter DocumentSplitters.recursive( 500, // maxSegmentSizeInChars 80, // maxOverlapSizeInChars new OpenAiTokenizer() ); ListTextSegment segments splitter.split(document);重叠度为什么要有因为一句话可能正好被切在边界上前半句在块 A、后半句在块 B检索时只捞到块 A语义就断了。重叠 80 字能保证边界处的语义至少在一个块里是完整的。注意OpenAiTokenizer是按 token 估算的中文一个汉字大约 1-2 个 token。如果你用字符数控制直接用DocumentSplitters.recursive(maxChars, overlapChars)那个重载别传 tokenizer否则实际切出来的块会比你预期的小。4.3 向量化与批量入库的性能优化向量化是 CPU/GPU 密集型操作逐条调用 Embedding 接口会非常慢。LangChain4j 的EmbeddingStoreIngestor支持批量处理但默认批大小可能不适合你的场景。我实测下来批大小设 32-64 比较稳太大容易触发服务端超时太小则吞吐上不去。EmbeddingStoreIngestor ingestor EmbeddingStoreIngestor.builder() .documentSplitter(splitter) .embeddingModel(embeddingModel) .embeddingStore(embeddingStore) .build(); ingestor.ingest(documents);入库时我还加了元数据metadata比如source来源文件、page页码、category分类。这些元数据在检索时能做过滤比如只在产品手册里搜能大幅提升准确率。Milvus 支持标量字段过滤LangChain4j 的MetadataFilter可以表达这类条件。5. 用 LangGraph4j 编排 Agentic RAG 流程5.1 状态图的基本概念与节点定义LangGraph4j 的核心是StateGraph。你先定义一个状态类型通常是个 Map 或自定义对象然后往里加节点Node和边Edge。节点是执行单元边决定下一步走哪。我的状态定义简化版public class RagState { private String query; // 原始问题 private String rewrittenQuery; // 改写后的问题 private ListTextSegment docs; // 检索到的文档 private String answer; // 最终答案 private int retryCount; // 重试次数 // getters/setters 省略 }节点我用函数式的方式定义每个节点接收状态、返回更新后的状态。比如检索节点NodeActionRagState retrieveNode state - { String q state.rewrittenQuery() ! null ? state.rewrittenQuery() : state.query(); ListTextSegment docs retriever.retrieve(q); return Map.of(docs, docs); };5.2 条件边让流程学会判断和回头这是 LangGraph4j 最值钱的地方。普通 RAG 检索完就直接生成但检索质量差的时候生成出来的答案就是垃圾。我加了一个gradeDocuments节点用大模型给检索到的文档打分相关/不相关然后通过条件边决定下一步graph.addConditionalEdges( gradeDocuments, state - { boolean relevant state.docs().stream() .anyMatch(d - d.metadata().get(relevant).equals(yes)); if (relevant) return generate; if (state.retryCount() 2) return generate; // 重试上限 return rewriteQuery; }, Map.of( generate, generate, rewriteQuery, rewriteQuery ) );这个评估-改写-重试的循环就是Agentic RAG和普通 RAG 的核心区别。普通 RAG 是一条道走到黑Agentic RAG 会自我纠错。我实测下来加了这一层之后复杂问题的回答准确率提升明显代价是多花一两次模型调用。5.3 查询改写节点的实现技巧查询改写不是简单地把问题换个说法而是要根据检索失败的原因做针对性调整。常见策略有三种一是扩展同义词报销流程→报销 流程 步骤 申请二是拆解复合问题A 和 B 的区别→分别检索 A 和 B三是补全上下文多轮对话里把它替换成实际指代。我用的是让大模型来做改写提示词大致是以下问题在知识库中检索效果不佳请改写为更适合向量检索的形式保留核心实体补充可能的同义词只输出改写后的问题。实测这个提示词比请改写问题效果好很多因为给了模型明确的优化目标。提示改写节点一定要设重试上限否则模型可能陷入改写→检索失败→再改写的死循环把 token 烧光。我设的是 2 次。6. 检索质量优化与常见问题排查6.1 提升命中率混合检索与重排序纯向量检索有个天然缺陷它对精确匹配不敏感。比如用户问工单编号 INC-2024-001 的状态向量检索可能召回一堆工单处理流程的文档却漏掉那条精确记录。解决办法是混合检索向量检索 关键词检索BM25两路结果融合。融合算法我用的是RRFReciprocal Rank Fusion倒数排名融合。它的逻辑很简单对每个文档把它在各路结果中的排名取倒数再求和得分高的排前面。公式是score Σ 1/(k rank)k 通常取 60。这里有个细节值得说LangChain4j 和 LangChain 的默认 RRF 实现在去重逻辑上是有差异的。LangChain 的 Python 版按文档 ID 去重而某些 Java 实现按文档内容哈希去重。如果你的文档块内容有重复比如页眉页脚被切进多个块按内容去重会把它们合并成一个导致排名计算失真。我的做法是入库时给每个块生成唯一 ID 写进 metadata融合时按 ID 去重稳得多。重排序Rerank是另一层优化。向量检索召回 Top-20然后用一个 Cross-Encoder 模型对这 20 个重新打分取 Top-5 送给大模型。Cross-Encoder 比向量相似度准但慢所以只对小候选集用。我用的重排序模型是 BGE-Reranker本地部署。优化手段提升点代价混合检索 RRF精确匹配召回率多一路检索开销Cross-Encoder 重排序Top-K 准确率推理延迟增加元数据过滤缩小检索范围需提前标注查询改写复杂问题召回多一次模型调用6.2 常见问题速查表下面这张表是我和团队实际遇到并解决的问题按出现频率排序问题现象可能原因排查与解决检索结果完全不相关Embedding 模型索引/查询不一致检查两处模型名和维度是否相同答案答非所问chunk 太大噪声多减小 chunk 到 300 字加重叠精确问题召回不到纯向量检索不敏感加 BM25 混合检索重复内容反复出现切分重叠度过大重叠降到 50 字检查去重逻辑入库速度极慢逐条调用 Embedding改批量批大小 32-64内存溢出一次性加载全部文档流式加载分批 ingest多轮对话答非所问没做查询改写加改写节点补全指代检索延迟高候选集太大先向量召回 20再重排取 56.3 数据一致性文档更新后索引怎么同步这是生产环境绕不开的问题。文档改了索引还是旧的用户就会拿到过期答案。我的方案是增量索引 版本标记每个文档块入库时带上docId和version文档更新时先按docId删除旧块再插入新块。Milvus 支持按标量字段删除LangChain4j 的EmbeddingStore.removeAll(Filter)能表达这个操作。注意删除和插入之间有个时间窗口如果此时正好有查询进来可能检索不到该文档。对一致性要求极高的场景可以用双写 切换新版本写到新 collection写完再切流量旧 collection 延迟删除。7. 我踩过的坑和几条实在建议第一个坑是版本兼容。LangChain4j 和 LangGraph4j 的版本要匹配我一开始用了 LangGraph4j 的最新版配 LangChain4j 的旧版结果StateGraph里的类型对不上编译报了一堆泛型错误。后来统一到兼容的版本组合才消停。建议你锁定版本别用LATEST。第二个坑是Embedding 服务的并发限制。本地推理服务默认并发数很低我批量入库时并发一高就超时。解决办法是在客户端加限流或者把批大小调小、串行处理。别指望服务端能扛住你无限制的并发。第三个坑是提示词里的文档拼接。检索回来的文档块直接拼进提示词如果块之间有重复内容模型会重复回答。我加了一个简单的去重按内容前 50 字做哈希重复的只保留一个。另外给每个块加上来源标注来源产品手册第 3 页模型回答时能引用出处用户信任度更高。第四个坑是评估缺失。一开始我靠感觉判断检索好不好后来发现完全不准。后来我建了一个小测试集50 个问题 标准答案每次改完参数跑一遍看命中率和答案准确率的变化。这个习惯帮我避免了好几次以为优化了其实退步了的情况。最后分享一个实用技巧给检索结果加相关性分数。LangChain4j 的EmbeddingStoreContentRetriever返回的Content里带score()你可以在生成前过滤掉分数低于阈值的块。阈值多少合适我的经验是向量相似度低于 0.6 的基本可以扔但具体值要看你用的模型和距离度量方式建议在测试集上跑一遍确定。这套系统我目前跑在内部环境日均查询量几千次响应时间 P95 在 2 秒左右含一次重排序和一次生成。后续我打算把 ontology 那套东西接进来让知识库不只是检索文本而是能理解实体之间的关系那样对复杂推理类问题的支持会更好。如果你也在做类似的事欢迎交流踩坑经验。
返回列表