拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

LangChain4j文档处理实战:从加载到分割的RAG最佳实践

LangChain4j文档处理实战:从加载到分割的RAG最佳实践

我最早接触 Langchain4j,是给一个 Java 老项目做内部知识库问答。当时团队最大的诉求是不想为了一个简单的 RAG 功能,硬生生引入一套 Python 技术栈,所以只能在 JVM 生态里找方案。Langchain4j 的出现刚好把这条链路搬到了 Java 世界,而文档处理恰恰是整条链路里最容易忽略、却最影响最终效果的第一步。

这篇文章不打算讲太多概念,只聚焦 Langchain4j 文档处理中的两个核心阶段:加载(Loading)和分割(Splitting)。我会把常见入口、解析器选择、分割参数怎么调、哪些地方容易踩坑,以及一段可以直接复制的 Java 示例代码都写清楚。无论你是在做本地知识库、企业私有文档问答,还是想把一批 PDF、Word、TXT 灌进向量库做语义检索,这篇笔记都可以拿来当一份实操参考。

1. 整体设计与思路拆解

1.1 文档处理在 RAG 链路中的位置

很多人刚开始接触知识库项目时,习惯先选一个大模型,再去研究提示词怎么写,最后才想起来还有一批文档要处理。实际上,RAG 的效果上限,很大程度由文档处理决定。加载阶段决定你能读到哪些内容,分割阶段决定模型有没有办法把内容检索出来,这两个阶段做得不好,后面即使接入 GPT 级别的模型,检索结果依然是“看上去很像,答起来全错”。

Langchain4j 把文档处理抽象成几个可插拔的组件:DocumentSource(数据源)、DocumentParser(解析器)、Document(内存中的文档对象)、DocumentSplitter(分割器)、TextSegment(分割后的文本片段)。整条流程可以理解成一条流水线:原始文件从数据源进入,被解析成统一结构的 Document,再按策略切成 TextSegment,最终变成向量库里的记录。

1.2 “加载”与“分割”为什么必须分开考虑

我在带团队时经常发现,新手容易把加载和分割混在一起做,比如读文件的同时顺便截断字符串,看起来省了一步,实际上后患无穷。文档加载解决的是“从哪拿数据、如何把不规整的二进制变成规整文本”的问题;文档分割解决的是“如何把长文本切成适合模型处理、又适合检索召回的小块”的问题。这两个问题的评价标准完全不同。

加载关心的重点是格式兼容性、编码、大文件稳定性;分割关心的重点是语义完整性、块与块之间的重叠、token 窗口利用率。如果强行在加载阶段做切割,经常会碰到 PDF 段落被拦腰截断、表格内容被拆散、一段代码被切成两半的问题。把两者分开,也方便你后续单独替换解析器或者分割策略,而不需要动整条链路的其他部分。

1.3 Langchain4j 的高低级 API 选择

LangChain4j 官方文档里经常出现“低级 API”和“高级 API”的说法。高级 API 通常指一行代码直接加载文档,比如早期的FileDocumentLoader.loadDocument(path),用起来非常方便,适合快速验证。低级 API 则是指你手动把FileDocumentSource、TextDocumentParser、DocumentLoader组合起来,每一步都自己定,适合需要在生产环境做定制化改造的场景。

以我实际的使用体验看,要真正掌握文档处理,必须过一遍低级 API。因为高级 API 屏蔽了太多细节,一旦遇到“某个 PDF 需要走特殊解析规则”“文件不在本地磁盘而在 S3 上”“希望只加载目录里最近三天更新的文件”这些需求,你迟早还是要回到组件拼接这条路。后面的示例也统一走低级 API,方便你看清每一步发生了什么。

2. 文档加载:数据入口与解析原理

2.1 三种最常见的文档来源

文档来源可以分为三类:本地文件、远程 URL、以及流式数据。本地文件是入门最常见的场景,直接用FileDocumentSource包一层 Path 就可以。远程 URL 适合抓取网页正文或者公开文档,LangChain4j 提供了UrlDocumentSource,但要记得处理超时、重定向和 SSL 证书问题。流式数据则适合对接阿里云 OSS、AWS S3、数据库 BLOB 字段或消息队列,思路是先拿到InputStream,再用自定义的DocumentSource接进框架。

实际项目里,我见过很多人只把“加载”理解为“读文件”。这是不对的。现代知识库的素材来源非常杂,有网页、IM 聊天记录导出、企业 Wiki 备份、邮件附件,甚至数据库字段拼接。你需要先把所有来源统一进 Document 这个中间结构,后续的解析和分割才能复用同一套逻辑。

2.2 PDF、Word、文本文件的解析器选择

Langchain4j 对不同格式提供了不同解析器。纯文本文件用TextDocumentParser,它基本上就是把InputStream按 UTF-8 或指定编码读成字符串,逻辑很简单,不容易出问题。PDF 文件要引入langchain4j-document-parser-pdf依赖,底层由 Apache PDFBox 负责,能够提取出文本层的内容。Word 文档则需要额外的解析模块,常见做法是配合 Apache POI 把.docx里的段落提取出来。

这里有一个特别容易踩的坑:PDF 分成“文本型 PDF”和“扫描型 PDF”。文本型 PDF 可以直接用 PDFBox 提取文字,比如系统导出、打印预览生成的 PDF;扫描型 PDF 本质上是图片,PDFBox 提取出来的内容是空白或者乱码,必须接入 OCR 引擎才能得到文本。Langchain4j 本身不内置 OCR,但你可以先用 OCR 服务把扫描件转成文本,再丢进文档处理链路。

2.3 自定义加载器与异构存储接入

当你需要对接公司的对象存储或者数据库时,写自定义加载器是绕不开的。流程不复杂:实现DocumentSource接口,核心是提供inputStream()方法;再实现DocumentParser,核心是提供parse(InputStream)方法;最后用DocumentLoader.load(source, parser)组装起来。

我记得有一次对接某个内部文档平台,底层是把文档以 JSON 结构存在 MongoDB 里的。我直接把查询结果拼成一个临时文本,再封装成Document,绕过了繁琐的文件落地过程,省了不少 IO。这种抽象能力才是 Langchain4j 文档处理真正的价值所在——它并不关心你的文档原来长在哪里,只关心你能不能把它变成Document。

3. 文档分割:分块策略与关键参数

3.1 不分割直接灌向量的后果

如果你把整篇 2 万字的文档直接丢给 Embedding 模型,大概率会遇到两个问题:一是模型输入长度超限,请求直接报错;二是即使没超限,整篇文档的向量是一个高维平均化的结果,语义糊成一团。当用户提问某个具体细节时,检索系统很难从这种“大而全”的向量里捞到精确片段。

分割的真正意义是缩小检索单元。想象一本字典,如果你把整本字典存成一个词条,查“苹果”就得翻完整本书;如果你按字、按词、按解释条目切好,查询就能直接命中那一页。文本分割本质上就是在做这种“重新编目”的工作,它决定了知识库里最小但有意义的索引单位是什么。

3.2 分割器选型:递归分割、段落分割、句子分割与 Markdown 分割

Langchain4j 提供了DocumentSplitters工具类,里面常见的分割策略有四种。

第一种是固定窗口分割,按照字符数或 token 数机械切分,适合结构不明显的日志、纯文本流水账。第二种是递归分割,它按分隔符优先级迭代切割,优先保留段落结构,再退化为句子,最后退化为固定长度,个人使用体验最好,适合大部分通用文档。第三种是句子分割,它从标点、换行符入手,尽量让每个块都是完整句子,适合法律条款、技术文档这类句子独立性强的文本。第四种是 Markdown 分割,它能感知#、##、列表、代码块等结构,适合处理 README、技术博客、Wiki 页面。

选型时不要迷信某一个策略。我见过有人对一篇产品说明书用了 Markdown 分割器,结果因为原文没有标准标题层级,切割结果还不如普通递归分割稳定。分割器的选择始终要跟源文档的结构对齐,这是最省心的调参思路。

3.3 参数计算与调优思路

分割器最重要的两个参数是maxSegmentSize和maxOverlapSize。前者决定单个片段的最大长度,后者决定相邻片段之间重叠多少个字符或 token。重叠的意义是防止关键信息正好落在两个块的边界上导致检索遗漏,但重叠太多又会显著增加向量库的存储量和请求成本。

实际调参时,我一般先看 Embedding 模型的最大输入长度,然后留出 20% 的余量。例如模型支持 512 tokens,我就把 maxSegmentSize 设为 400 左右;如果模型支持 1024 tokens,可以取 800。重叠部分通常取 maxSegmentSize 的 10%~20%,不要一味往大了调。用公式估算,一个片段的实际新增 token 大概是maxSegmentSize - maxOverlapSize,向量库总量可以按这个值粗略预估。

4. 实操:一个完整的“加载-分割”代码示例

4.1 环境准备与依赖引入

我用的是当前比较稳定的 LangChain4j 0.31.0 版本,虽然 API 一直在演进,但核心思路没有变。先建一个普通的 Maven 工程,Java 版本建议 17 以上。在 pom.xml 里引入基础依赖和 PDF 解析依赖。

<dependencies> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>0.31.0</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-document-parser-pdf</artifactId> <version>0.31.0</version> </dependency> </dependencies>

如果只是处理 TXT 或 Markdown 文件,可以不引入后者。但 PDF 几乎是知识库项目的标配,所以示例里我直接带上。

4.2 从本地目录加载多个文档

假设你的知识库目录里混合了.txt和.pdf文件,下面的示例会遍历目录,按扩展名选择不同的解析器,把所有内容加载成统一的Document列表。

import dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.DocumentLoader; import dev.langchain4j.data.document.DocumentParser; import dev.langchain4j.data.document.parser.TextDocumentParser; import dev.langchain4j.data.document.parser.PdfDocumentParser; import dev.langchain4j.data.document.source.FileDocumentSource; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.util.ArrayList; import java.util.List; import java.util.stream.Stream; public class DocumentLoaderDemo { public static List<Document> loadAll(Path rootDir) throws Exception { List<Document> documents = new ArrayList<>(); try (Stream<Path> paths = Files.walk(rootDir)) { List<Path> files = paths.filter(Files::isRegularFile).toList(); for (Path file : files) { DocumentParser parser = selectParser(file); if (parser == null) { System.out.println("跳过不支持的文件: " + file.getFileName()); continue; } FileDocumentSource source = new FileDocumentSource(file); Document document = DocumentLoader.load(source, parser); documents.add(document); System.out.println("已加载: " + file.getFileName() + ",字符数=" + document.text().length()); } } return documents; } private static DocumentParser selectParser(Path file) { String name = file.getFileName().toString().toLowerCase(); if (name.endsWith(".txt") || name.endsWith(".md")) { return new TextDocumentParser(); } else if (name.endsWith(".pdf")) { return new PdfDocumentParser(); } return null; } public static void main(String[] args) throws Exception { List<Document> docs = loadAll(Paths.get("/data/kb")); System.out.println("共加载文档数: " + docs.size()); } }

这里的关键点是selectParser方法。生产环境里文件类型往往比想象中复杂,后缀名不一定可靠,所以我还会再检查文件头的魔数,比如 PDF 文件通常以%PDF开头。加载阶段多留一个心眼,后面解析阶段就能少报一个错。

4.3 按固定 Token 窗口切分并产出 TextSegment

文档加载完成后,下一步是分割。这里为了展示通用思路,我直接使用基于 token 的递归分割器。如果你没有接任何大模型厂商的 tokenizer,也可以用基于字符数量计算的递归分割器,参数含义是一致的。

import dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.DocumentSplitter; import dev.langchain4j.data.document.splitter.DocumentSplitters; import dev.langchain4j.data.segment.TextSegment; import dev.langchain4j.model.Tokenizer; import java.util.List; public class DocumentSplitterDemo { public static List<TextSegment> splitDocument(Document document, Tokenizer tokenizer) { DocumentSplitter splitter = DocumentSplitters.recursive( 400, 60, tokenizer ); return splitter.split(document); } }

我习惯把maxSegmentSize设为 400,maxOverlapSize设为 60,是按照 OpenAI 第二代 Embedding 模型的 512 token 上限来预留的。如果你们用的是开源模型比如 BGE,有些模型的输入上限也是 512,这个参数可以直接套用;如果是中文场景,token 计算方式会跟英文不一样,400这个值建议线上压测后小幅调整。

4.4 将分割结果写入向量库前的检查

分割完成后,不要急着把TextSegment全部塞进向量库。我会先做三件事:第一,检查每个片段的文本长度是否过短,小于 20 个字符的片段多半是无意义内容,可以直接过滤掉;第二,检查是否出现空片段或者只有标点符号的片段;第三,把片段按原文档顺序重新拼接一遍,看看是否存在明显的内容丢失。

Langchain4j 的TextSegment还支持附带元数据。我在分割后会把来源文件名、页码、章节标题塞进 metadata,这一招在后续检索时非常有用,能直接告诉用户答案来自哪一份文档的哪一页。分词结果写入向量库时,比如接入 Milvus,LangChain4j 也提供了对应的 EmbeddingStore 实现,整体集成成本比我预想的低很多。

5. 常见问题与排查技巧实录

5.1 PDF 中文乱码和扫描件识别

文本型 PDF 的中文乱码,绝大多数不是 Langchain4j 的问题,而是 PDF 内部的字体编码不规范。有些国产软件导出的 PDF,文字信息虽然存在,但缺少正确的 ToUnicode 映射,PDFBox 提取时就会变成乱码。这种情况没有特别优雅的解法,现实里我遇到过,最终是换用另一个 PDF 生成器重新导出,或者退而求其次用 OCR 兜底。

扫描件识别也一样,别在语言层面硬刚,直接接入 OCR 服务是性价比最高的方案。新建一个OcrDocumentParser,把InputStream转成图片,再交给云端 OCR 或者本地 PaddleOCR 识别,最终返回识别出的文本。这样DocumentSource、DocumentLoader这一层完全不用改,只替换 parser 即可。

5.2 文件读取乱码与编码不一致

TXT 文件最坑的地方是编码不统一。Windows 下常见 GBK,Linux 下常见 UTF-8,老旧的系统还可能出 UTF-8 BOM。TextDocumentParser默认按 UTF-8 解析,遇到 GBK 文件就会乱码。

我的排查经验是:先准备一个编码探测工具,读取文件前几个字节判断是否有 BOM;没有 BOM 再用常见编码列表挨个尝试解码,谁成功率高就选谁。Langchain4j 里也可以通过自定义 parser 实现这套逻辑,并不复杂,但能显著减少中文文档的“天书”问题。

5.3 大文档内存溢出与流式处理

一次加载几百 MB 的 PDF,内存容易报警。我最早做测试时,直接把 1GB 的日志文本塞进了Document,结果 JVM 直接 OOM。后来学乖了,处理超大文件前先做两层分流:第一层按文件大小排队,超过阈值的文件单独走异步任务;第二层解析时不要一次性读进内存,而是按页或按章节分批转换,再逐批切分。

Langchain4j 的Document本质上是文本字符串,内存占用跟文本长度正相关。如果你需要真正支撑超大规模文档库,建议预处理阶段直接把大文档拆成多个中间文件,而不是让一个Document承载太大体积。

5.4 分割质量差的几种表现与调整方法

分割质量差通常有几种表现:块之间语义断裂、检索召回率低、答案引用的片段不完整。遇到这种情况不要盲目调大 overlap,先看源文档结构。

如果原文是经典的分段式文章,优先用递归分割,并且把分隔符优先级设置为“段落换行 > 句子结束符 > 逗号 > 字符”。如果原文是产品 FAQ 或技术问答,每一条本身就是一个独立语义单元,可以用自定义分隔符按问题编号切割。如果原文是代码仓库里的 Markdown,那 Markdown 分割器才是正解。一句话,分割策略要跟随内容形态走,而不是让内容适应固定的分块函数。

6. 我个人的一些经验与小结

做完了这么多加载和分割的实验,我最大的体会是:这两步看起来都是“脏活累活”,但恰恰是决定知识库能不能落地的关键。加载决定你能“读什么”,分割决定你能不能“用好”。很多项目最后效果不好,既不是模型选得差,也不是向量库性能不行,而是文本在进入向量库之前就已经乱七八糟了。

最后再分享一个我常用的习惯:每次调整分割参数,我都会把同一个测试集跑一遍,记录下每个问题能召回哪些片段、最终答案是否正确。参数这个东西,网上能查到不少推荐值,但最可信的还是你自己文档集上的效果。Langchain4j 留给人的操作空间很大,先花半小时把加载链路和分割链路跑通,再慢慢优化索引质量,这比一上来就追求完整 RAG 架构要稳妥得多。

返回列表