ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

Langchain4j实战:文档加载与分割,筑牢Java RAG应用地基

Langchain4j实战:文档加载与分割,筑牢Java RAG应用地基 Langchain4j 这个库我陆陆续续用了大半年中间踩过不少坑也重构过好几版文档处理管线。如果你刚接触它或者正准备用 Java 写一套 RAG 应用那“文档加载”和“文档分割”这两个环节绝对值得你花时间吃透。很多人一上来就去调大模型接口、调向量库结果文档喂进去之后检索效果稀烂问题多半就出在入口这一段。我要说的这套流程核心就是围绕Document的加载与分割展开怎么把文件、URL、文本变成框架能识别的Document再做分块得到TextSegment为后续嵌入、检索打好基础。这是整套 RAG 系统的地基地基不稳后面再怎么调 prompt、换模型都是白搭。下面这份实战记录既讲原理也讲操作适合正在用 Java 做 LLM 应用、或者准备从零搭建知识库问答的开发者。1. 先把 Langchain4j 文档处理模块的定位讲清楚1.1 它到底解决了什么问题在 Langchain4j 里文档处理属于低级 API 的一部分。低级 API 是框架的地基直接面向Document、TextSegment、Embedding这些核心数据结构而高级 API 的AiServices则是站在这些地基之上帮你组装 RAG 管道的。你做知识库问答核心链路就是“文档 - 分割 - 嵌入 - 检索 - 生成”这里面最前面两步就是低级 API 中DocumentLoader和DocumentSplitter的活。和 Python 生态的 LlamaIndex、LangChain 相比Langchain4j 在 Java 世界里做了一件很关键的事把文档加载和分割的接口收敛得足够简单同时又保留了一定灵活性。Document是一个统一的数据结构里面包含文档文本和元数据不管你的源文件是 PDF、Word、Markdown 还是纯文本加载进来都会规范化为这个结构后续的分割、嵌入、存储就只管对着Document和TextSegment操作不用再关心来源差异。1.2 核心数据结构Document 与 TextSegmentDocument这个类很简单通常我就用Document.from(String text)或者Document.document(String text, Metadata metadata)来创建。Metadata是键值对你可以在里面塞文件名、URL、页码、章节标题这类信息它会在后续的分割过程中被复制到每一个子块上。为什么要带元数据因为 RAG 检索之后需要溯源检索结果返回时你总得知道这段内容来自哪个文件、哪一页否则用户没法验证答案系统也没法做引用展示。分割之后得到的是TextSegment你可以把它理解为“带元数据的文本块”。每个TextSegment都有唯一的segmentId同时完整继承了来源Document的元数据。做向量化的时候向量库里存的是一条条TextSegment的向量而不是整个文档的向量。所以我们平时说的“让模型基于知识库回答”本质上是找出与问题最相关的若干文本块再把它们拼进 prompt。TextSegment 的粒度直接决定了检索精度这也是分割参数为什么重要的原因。注意Document是不可变对象分割操作不会修改原文档而是生成新的TextSegment列表。所以你可以放心地对同一个Document尝试不同的分割策略对比效果。2. 文档加载的几种方式与底层选型逻辑2.1 从文件系统加载最常用也最需要小心的路径FileSystemDocumentLoader是我日常用得最多的加载器。它的用法很直白一行代码就能把文件变成Documentimport dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.loader.FileSystemDocumentLoader; import java.nio.file.Path; // 加载单个文件 Path path Paths.get(docs, 产品手册.txt); Document document FileSystemDocumentLoader.loadDocument(path); // 加载某个目录下所有文件 ListDocument documents FileSystemDocumentLoader.loadDocuments(Paths.get(docs));这里有个关键机制loadDocument会去读取文件扩展名然后根据扩展名自动选择合适的DocumentParser。比如.txt和.md默认走纯文本解析.pdf会走 PDF 解析器.html会走 HTML 解析器。所以如果你加载 PDF 或者 Word 出了问题多半不是框架的锅而是缺了对应的 parser 依赖。我踩过的一个坑是编码问题。FileSystemDocumentLoader默认按 UTF-8 读取如果你的文件是 GBK 编码国内很多老系统导出文件都是 GBK读出来就是乱码。解决办法是指定字符集Document document FileSystemDocumentLoader.loadDocument(path, StandardCharsets.GBK);还有一个更隐蔽的问题加载目录时如果目录里有临时文件或者隐藏文件可能会解析失败导致整个批处理中断。我现在的做法是先过滤一遍扩展名只加载需要的文件类型ListDocument documents FileSystemDocumentLoader.loadDocuments( Paths.get(docs), path - path.toString().endsWith(.md) || path.toString().endsWith(.pdf) );2.2 从 URL 加载适合处理网页和在线文档UrlDocumentLoader可以加载远程文档这在处理企业 Wiki、在线帮助文档时非常有用import dev.langchain4j.data.document.loader.UrlDocumentLoader; Document doc UrlDocumentLoader.loadDocument( URI.create(https://example.com/help.html).toURL(), new HtmlDocumentParser() );URL 加载的核心在于你要显式告诉它用哪个 parser。因为 URL 本身没有文件扩展名或者扩展名和内容类型对不上UrlDocumentLoader没法自动判断。对于网页我通常用HtmlDocumentParser它会用 Jsoup 做解析把网页正文提取成纯文本。这里有一个很大的坑网页里通常包含导航栏、页脚、广告、脚本标签如果直接解析大量噪声会混进文档分割之后产生一堆垃圾文本块检索时特别容易被这些无关片段干扰。我的经验是先用 Jsoup 跪取.main-content或者article标签内的内容再做一次清洗最后才交给UrlDocumentLoader。2.3 直接构造 Document处理文本、JSON 和运行时数据有些场景下文档不是来自文件系统而是来自数据库、消息队列或者其他程序内部。这时候直接用Document.from()构造就行Document doc Document.from(这是一段来自数据库的文本内容。); // 如果还要带上下文信息就加上 Metadata Document doc Document.document( 这是一段来自数据库的文本内容。, Metadata.from(source, order_remarks) .put(userId, u_1024) .put(createdAt, 2025-01-15) );这种直接构造的方式在事件驱动架构里很常见。比如客服工单系统会把用户反馈实时写入消息队列消费端拿到文本之后直接Document.from()建文档再走分割和向量化。这种方式与框架解耦代码上最灵活。还有一个小技巧如果你要加载 JSON 文件Document.from(Files.readString(path))就够了JSON 的解析和清洗逻辑你可以在外部处理好不用非往框架里塞。2.4 DocumentParser 的体系与依赖选型DocumentParser是加载过程的底层解析器接口FileSystemDocumentLoader和UrlDocumentLoader最终都会调用它。Langchain4j 提供了多种实现但有一个细节要注意有些 parser 位于独立模块里需要单独引入依赖。我实际用下来比较常见的依赖组合是这样dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version0.31.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-document-parser-apache-pdfbox/artifactId version0.31.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-document-parser-jsoup/artifactId version0.31.0/version /dependency需要注意的是PDF 的解析有两种典型实现一种是基于 Apache PDFBox 的文本解析适用于有文本层的电子版 PDF一种是基于 OCR 的解析比如通过 Tesseract适用于扫描件。如果你的 PDF 是扫描图片直接走 PDFBox 解析出来全是空白这时候就需要 OCR 方案或者提前用外部工具把 PDF 转成图片再走视觉模型。这块没有银弹得根据你的文档来源做选型。3. 文档分割的原理与三种主流分割器对比3.1 为什么说分割是 RAG 效果的命门分割看起来就是个“切文本”的动作但它的意义比表面复杂得多。模型和向量检索都有上下文窗口限制你不可能把整本手册一股脑塞进 prompt也不可能把整本手册的向量作为一个点去检索。分割的粒度直接影响两个指标一个是检索的召回精度一个是生成时的上下文完整性。如果分割块太小比如固定 100 个字符单个块表达的信息不完整语义容易丢失用户问一个需要跨块推理的问题时检索结果就拼不齐答案。如果分割块太大比如一整章作为一个块向量化之后这个块的语义过于宽泛检索时命中率可能很高但真正相关的细节被淹没在一大段文字里模型反而抓不住重点。所以分割参数需要根据文档类型和目标模型的窗口来调而不是随便抄一个默认值。3.2 递归字符分割器我的首选默认方案DocumentSplitters.recursive()是 Langchain4j 内置的递归分割器也是我项目里默认使用的方案。它的思路是优先按照语义较完整的边界比如段落\n\n来切切出来的块如果还超过最大长度就退一步按句子边界、再退到单词边界去切直到满足长度要求。import dev.langchain4j.data.document.splitter.DocumentSplitters; import dev.langchain4j.data.segment.TextSegment; DocumentSplitter splitter DocumentSplitters.recursive( 300, // 每个文本块的最大 token 数 30 // 相邻块之间的重叠 token 数 ); ListTextSegment segments splitter.split(document);为什么我说它是默认方案因为递归分割器会优先保留文本的语义结构。段落的语义完整度高于句子句子的语义完整度高于短语切割时优先选择破坏语义最小的方式这对后续 embedding 的效果友好。Langchain4j 在识别文档类型后还会自动选择合适的边界集合。比如 HTML 文档会优先按p、div这类标签边界切Markdown 会优先按标题边界切源码会按方法、类结构切。3.3 按固定大小分割简单粗暴但适合特定场景如果你嫌递归分割器的逻辑太重DocumentSplitters.slidingWindow()可以按固定窗口大小切分同样支持重叠。这种方式实现最简单、行为最可预测但问题也很明显它完全不考虑文本语义可能一句话被拦腰截断一个表格被从中间劈开。我一般只在处理非常规范的短文本时用比如每条都是独立记录的日志文件、或者固定格式的交易流水。如果处理自然语言文档还是递归分割器稳。两种方式的核心参数有两个maxSegmentSizeInTokens和maxOverlapSizeInTokens。第一个是目标块大小第二个是相邻块之间重叠的大小。重叠的意义是为了缓冲切口影响如果一个知识点恰好落在上一块的结尾和下一块的开头没有重叠的话两块都不完整检索时就都搜不到。加了重叠之后这个知识点至少会完整出现在某个块里。3.4 基于模型的智能分割效果最好但成本也高如果你的预算充足、且对效果要求极高可以试试OpenAiDocumentSplitter。它不是按字符或 token 规则切而是让模型来判断哪些内容应该放在同一个块里。这种分割器的质量上限很高尤其适合语义结构复杂的文档但它每次分割都要调用大模型 API成本和延迟都会明显上升。我目前的经验是中小规模项目用递归分割器足够除非你的文档结构极其松散、且对检索精度有极致要求否则没有必要上模型分割。几种分割器的选择可以参考这个对照分割器原理优点缺点适用场景recursive()按段落、句子、词逐级递归切分语义破坏小效果稳定参数需要调超大块无法再细切通用文档默认首选slidingWindow()固定长度滑窗切分简单、速度极快可能截断语句语义质量差日志、流水等结构化短文本OpenAiDocumentSplitter调用 LLM 智能判断切点语义边界精准质量高成本高、延迟高、依赖外部 API复杂文档、高精度知识库注意无论用哪种分割器核心参数都是 token 数不是字符数。因为 embedding 模型和对话模型都按 token 计算输入上限按字符切分容易超出模型限制。如果你不清楚文本的 token 数需要借助分词器做估算这会在后面实操部分展开。4. 从加载到分割的完整实操代码含参数计算4.1 先搭好 Maven 环境和依赖我们用标准的 Java 工程来跑通整个流程。除了langchain4j核心包还需要引入文档解析器和 embedding 模型相关的依赖。这里以 OpenAI 的 embedding 为例dependency groupIddev.langchain4j/groupId artifactIdlangchain4j/artifactId version0.31.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-document-parser-apache-pdfbox/artifactId version0.31.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-document-parser-jsoup/artifactId version0.31.0/version /dependency dependency groupIddev.langchain4j/groupId artifactIdlangchain4j-open-ai/artifactId version0.31.0/version /dependency选0.31.0这个版本是因为它是我验证过最稳定的一个迭代接口变化也比较大和 AI4J 的组件划分已经比较清晰。如果你后续要接 Milvus 做混合检索还需要单独引入langchain4j-milvus的依赖但那是检索侧的事不在今天的流程范围内。4.2 加载并把元数据补全我们用一个混合场景加载.md文件同时给它补上来源和业务标签。你在生产环境里可能是从知识库系统导出的一批 Markdown 文档每条文档可能还有部门、分类、权限等级等属性。这些属性都可以在加载之后塞进 Metadataimport dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.Metadata; import dev.langchain4j.data.document.loader.FileSystemDocumentLoader; import java.nio.file.Path; import java.nio.file.Paths; import java.time.LocalDate; Path path Paths.get(docs, release-notes.md); Document document FileSystemDocumentLoader.loadDocument(path); // 给文档补充业务元数据 Metadata metadata Metadata.from(source, release-notes.md) .put(category, 产品公告) .put(publishDate, LocalDate.now().toString()) .put(permission, internal); document Document.document(document.text(), metadata);这样做的意义在于分割之后每个TextSegment都会带着这些元数据向量化之后存进向量库每条被检索出来的记录天然带有来源、时间、权限信息。后续做权限过滤或时间过滤时你不需要去查原始文档直接从TextSegment.metadata()里取就行。4.3 计算分割参数从模型窗口倒推最大块大小怎么定我的做法不是拍脑袋而是根据目标模型的上下文窗口倒推。假设你用的对话模型上下文窗口是 8000 token那么在 RAG 场景下系统 prompt、用户问题、历史对话、检索到的文本块都会占用窗口。一般经验是检索结果的总量不要超过整个窗口的 30% 到 50%给生成部分留足空间。举个例子8000 窗口按 30% 算检索结果总共约 2400 token。如果每次检索召回 4 个文本块那每个块 600 token 左右比较合理。如果模型窗口只有 4000那就得压缩到每块 300 token 左右。我通常会再留一点余量因为 Embedding 模型本身也有输入上限比如text-embedding-3-small支持 8191 token但text-embedding-ada-002按官方说明也是 8191实践中我们很少会顶着上限跑512 到 1024 是比较稳妥的区间。重叠大小的话我一般取块大小的 10% 到 20%。比如最大块是 600 token重叠就取 50 到 100 token。重叠太大会导致存储冗余成倍增加太小的重叠又起不到保护语义的作用。这里没有绝对标准最好的办法是搭好流程后做一次小规模实验用几篇有代表性的文档分别跑 300、500、800 三档参数然后抽几个典型问题测检索效果。4.4 分割并输出结果验证分割代码本身不长import dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.splitter.DocumentSplitters; import dev.langchain4j.data.segment.TextSegment; DocumentSplitter splitter DocumentSplitters.recursive(600, 80); ListTextSegment segments splitter.split(document); System.out.println(分割得到的文本块数量: segments.size()); for (int i 0; i segments.size(); i) { TextSegment segment segments.get(i); System.out.println(----- Segment i -----); System.out.println(字符数: segment.text().length()); System.out.println(来源: segment.metadata().getString(source)); System.out.println(segment.text().substring(0, Math.min(100, segment.text().length()))); }这里有个值得注意的细节maxSegmentSizeInTokens是目标值而不是硬性上限。如果文档里有一段很长的代码或一个超大表格递归分割器已经找不到更细的分隔符了最终输出的块仍可能大于 600 token。如果你想严格控制上限需要在分割之后做一次过滤把超长块单独拎出来再做二次分割或者干脆根据文档类型自定义一个专用分割器。分割完成之后下一步就是把每个TextSegment做 embedding 了import dev.langchain4j.model.embedding.EmbeddingModel; import dev.langchain4j.model.openai.OpenAiEmbeddingModel; EmbeddingModel embeddingModel OpenAiEmbeddingModel.builder() .apiKey(System.getenv(OPENAI_API_KEY)) .modelName(text-embedding-3-small) .build(); ListTextSegment segments splitter.split(document); ListEmbedding embeddings embeddingModel.embedAll(segments).content();embedAll返回的Embedding列表与segments一一对应之后你就可以把这组向量连同TextSegment的元数据批量写入向量库。向量入库时我会额外保存两个字段segmentId和documentId这样后续做知识库版本更新时可以按documentId精准删除旧向量而不是整库重建。5. 常见问题与排查技巧实录5.1 PDF 文档解析出来是乱码或者空白这是 PDF 加载里最典型的坑。如果你的 PDF 是从 Word 等工具导出的电子版通常有文本层PDFBox 能正常提取文字。如果是扫描件或图片型 PDF解析出来就是空白。另一个常见问题是 PDF 内部使用了非标准编码PDFBox 提取出的字符是乱码。排查思路是先用 PDF 阅读器打开文件看看能不能用鼠标选中文字。如果根本选不中那说明是图片型 PDF只能走 OCR如果能选中但框架解析乱码可以试试用系统自带的文本提取工具先转一遍再喂给 Langchain4j。5.2 中文文档分割后丢了语义递归分割器在处理中文时默认的边界集合可能不理想。英文章节和句子通常以句号、换行分隔而中文的句子边界不明显长段落里可能没有足够的\n\n导致分割器退到字符级去硬切。我的两个经验一是在加载文档前做一次预处理把中文的句号、分号、感叹号后面的内容合理分段比如按每 3 到 5 个句子插入一个换行二是适当调大maxSegmentSizeInTokens让更多的句子留在同一个块里减少硬切概率。你还可以给recursive()方法传入自定义的分隔符集合把中文标点加进去。5.3 HTML 转文本后全是导航和广告噪声如果你用UrlDocumentLoader加载网页得到的结果里往往混着大量导航菜单、推荐链接、页脚版权信息。这些噪声不但浪费 token更严重的是会让检索结果偏离主题。我的做法是在交给框架之前先用 Jsoup 做一轮正文提取import org.jsoup.Jsoup; import org.jsoup.nodes.Document; org.jsoup.nodes.Document jsoupDoc Jsoup.connect(https://example.com/help).get(); String mainContent jsoupDoc.select(article, .main-content, #content).first().text(); Document langchainDoc Document.from(mainContent, Metadata.from(url, https://example.com/help));这样处理之后我们交给 Langchain4j 的文本已经是被清洗过的正文分割和向量化的效果会干净很多。对于多页面抓取建议再写一个简单的过滤规则把“上一篇”“下一篇”“相关阅读”“版权声明”这类固定文本直接过滤掉。5.4 加载目录时单个文件出错导致全批失败FileSystemDocumentLoader.loadDocuments(Path)在遇到某个文件解析失败时会抛出异常中断整个批处理。如果知识库里有几十个文件一个打不开的损坏文件就能让全部流程停摆。我的习惯是在循环里逐个加载对每个文件做 try-catch 并记录错误而不是直接用批量方法ListPath paths Files.walk(Paths.get(docs)) .filter(Files::isRegularFile) .filter(p - p.toString().endsWith(.md)) .collect(Collectors.toList()); ListDocument docs new ArrayList(); for (Path path : paths) { try { docs.add(FileSystemDocumentLoader.loadDocument(path)); } catch (Exception e) { // 记录出错的路径但不要中断整个任务 log.error(加载文档失败: {}错误: {}, path, e.getMessage()); } }这样做还有一个好处你可以在日志里清楚地看到每批失败了哪些文件然后针对性修复而不是被一个坏文件挡住整批数据。5.5 分割后块数太多或太少如果你发现分割出来的块数远超预期通常是最大块大小设置得太小切割过于细碎如果块数太少往往是文档本身比较短小或者最大块设置太大。这两个极端都会让检索失真。我一般会根据文档平均长度和目标块大小做一个预判假设一份文档 3000 字符中文大约一个字符对应不到一个 token3000 字符大约 2000 token 上下按 600 token 一块预期会产出四到五块。如果实际结果偏差很大就要检查文档里是不是有大量空白字符或者递归分割器没按预期工作。最后再分享一个小技巧分割参数没有一劳永逸的答案。我建议你把分割器配置独立成一个配置类不同的文档类型用不同的参数组合甚至可以把参数外置到配置文件里。上线之后定期抽检一批真实用户的提问看看哪些文档片段被高频检索到、哪些始终检索不到反向调整块大小和重叠值。这个调优过程是持续的也是 RAG 效果真正拉开差距的地方。
返回列表