☰
Langchain4j文档处理实战:从加载到分割的完整指南
2026/10/2 13:31:11 网站建设 项目流程

做 RAG 项目的这段时间,我踩过最深的坑,不是向量库选型,不是 Embedding 模型调参,而是最基础的文档处理环节。文本加载不干净、分割策略不对,后面所有环节的效果都会跟着崩。Langchain4j 在 Java 生态里把文档加载(Loading)和文本分割(Splitting)封装成了完整链路,但我发现很多人在用的时候只知其然,照着 Demo 抄,一换真实场景就出问题。

这篇文章我会把 Langchain4j 文档处理从加载到分割的完整流程拆开讲透,包括 Document 对象怎么设计、 Loader 怎么选、几种分割策略到底有什么区别、真实项目中参数应该怎么定,以及我在实测中踩过的各种坑。内容围绕 Java + Langchain4j,但背后的处理思路在 Python 生态里也一样适用。适合正在做知识库问答、私有文档 QA、或者刚开始接触 Langchain4j 的开发者参考。

1. 为什么说文档处理是整个 RAG 链路的胜负手

1.1 一个容易被忽视的事实:模型再好也救不了脏数据

RAG 的完整链路是:文档加载 → 文本分割 → 向量化 → 存储 → 检索 → 生成。大多数人把精力放在后面几步,觉得 Embedding 模型选得好、向量库性能高就行。但实际情况是,如果你的原始文档在加载和分割阶段就已经引入了大量噪音,后面的检索准确率一定会被拖下水。

举一个实测例子。我之前处理一批 PDF 格式的招标文件,直接用了默认配置做加载和分割,结果问答系统回答“投标截止时间是几点”这类问题时,经常答非所问。后来排查才发现,PDF 里表格被解析成了混乱文本,长段落被生硬截断,语义完整的内容被劈成了两半,向量化之后根本检索不到有效片段。这就是文档处理环节没做好的典型代价。

从原理上讲,RAG 的效果上限由检索质量决定,而检索质量又由文本块的质量决定。模型只是把检索到的内容做归纳和生成,如果检索出来的文本块本身就是残缺的、语义断裂的、夹杂大量页眉页脚噪音的,那生成结果自然不可靠。这个因果关系很多人要踩过坑之后才真正理解。

1.2 Langchain4j 在这条链路上做了什么

Langchain4j 的设计思路是把文档处理抽象成几个可以独立替换的环节:

  • Document:统一的数据结构,包含文本内容和元数据。
  • DocumentParser:负责把不同格式的文件解析为纯文本。
  • DocumentLoader:负责从不同来源读取文档,配合 Parser 使用。
  • DocumentSplitter:负责把长文档切分成适合向量化和检索的文本块。

这四个概念对应了文档处理的完整流程,而且每一步都可以通过自定义实现来扩展。理解这套抽象之后,你会发现不管换了什么格式、什么来源,核心的处理逻辑都是通用的。

这样的设计对 Java 开发者特别友好。Python 生态里 LangChain 虽然功能更全,但文档处理部分依赖大量第三方库,版本冲突时有发生。Langchain4j 相对轻量,同时保持了足够的扩展空间。它没有把文档处理做成一个黑盒,而是拆成了几个你可以完全控制的环节,这在实际排障时能省很多时间。

2. 环境准备:依赖引入和版本选择

2.1 Maven 依赖怎么加

我先说明一点,Langchain4j 的模块拆分在不同版本里变化比较大。我下面用 0.35.0 版本为例,这是我在生产环境里验证过的组合,API 稳定,文档也比较齐全。

Maven 配置:

<dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j</artifactId> <version>0.35.0</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-document-parser</artifactId> <version>0.35.0</version> </dependency> <dependency> <groupId>dev.langchain4j</groupId> <artifactId>langchain4j-document-transformer</artifactId> <version>0.35.0</version> </dependency>

如果你用 Gradle,对应写法:

implementation 'dev.langchain4j:langchain4j:0.35.0' implementation 'dev.langchain4j:langchain4j-document-parser:0.35.0' implementation 'dev.langchain4j:langchain4j-document-transformer:0.35.0'

这里有个容易混淆的地方:核心包 langchain4j 里其实已经包含了 Document、TextSegment 这些基础类,但 PDF、DOCX 这些格式的解析器是放在独立模块里的。如果你只引入了核心包,调用 ApachePdfBoxDocumentParser 时就会报 ClassNotFoundException。

2.2 版本选择:别盲目追新,也不建议停在太老的版本

Langchain4j 迭代速度很快。早期版本里很多 API 是实验性的,可能升一个小版本就 deprecated 了。我在项目里吃过这个亏:用 0.31.0 写好的代码,升到 0.35.0 之后,DocumentSplitters 类的方法签名发生了变化,导致编译报错。

我的建议是:新项目直接选当前稳定的最新版本,但锁死在 pom 里,不要频繁升级。如果你是在维护老项目,升级前先看 Release Notes 里的 breaking changes,重点检查这四类 API 是否变动:

模块升级重点检查项
langchain4j-coreDocument、TextSegment、Metadata 的构造方式
document-parser各类 Parser 的类名、包路径
document-transformer分割器的工厂方法和参数顺序
各集成模块向量库、嵌入模型的客户端初始化方式

2.3 JDK 版本要求

Langchain4j 要求 JDK 8 以上,但如果你用的是 JDK 8,很多高级特性用不了。我在本地开发用的是 JDK 17,生产环境也是 17,实测没遇到兼容性问题。如果你的项目还在 JDK 8 上,建议至少确认一下依赖树里有没有引入需要 JDK 11 以上才能运行的传递依赖。

提示:文档处理本身不依赖 AI 服务,所以这一阶段可以完全离线运行。调试时不需要配 API Key,你可以在本地写单元测试直接验证加载和分割的结果。

3. 文档加载:从文件、URL 到各类格式的接入姿势

3.1 先理解 Document 数据结构

在 Langchain4j 里,一个文档被抽象为 Document 对象,核心就两个部分:

  • text():文档全文文本。
  • metadata():文档的元数据,比如文件名、来源 URL、页码、标题等。

元数据这一点容易被忽视,但它非常关键。分割后的每个文本块都会继承所属文档的元数据,后续做检索时,你可以根据元数据过滤来源、定位页码,甚至做权限控制。我见过不少人在加载阶段完全不管 Metadata,等到要按来源过滤时才发现无法实现,只能回头改代码。

创建 Document 最简单的方式:

Document doc = Document.from("项目背景...全文内容...", Metadata.from(Map.of( "source", "招商公告.txt", "category", "公告" )));

3.2 从文件系统加载:FileSystemDocumentLoader

最常用的入口是 FileSystemDocumentLoader。它接受路径和 Parser,返回 Document 列表。

Path path = Paths.get("/data/docs/"); List<Document> documents = FileSystemDocumentLoader.loadDocumentsRecursively(path);

这个方法会自动递归扫描目录,并按照文件扩展名匹配对应的 Parser。默认情况下它支持 txt、html、pdf、docx 等常见格式。如果文件格式不常见,或者你想强制指定解析器,可以这样写:

List<Document> documents = FileSystemDocumentLoader.loadDocumentsRecursively( path, new ApachePdfBoxDocumentParser() );

这里有个细节:loadDocumentsRecursively 加载的是目录下所有可解析文件,而 loadDocument 是加载单个文件。如果你只想处理特定前缀或后缀的文件,建议先自己过滤文件列表,再逐个加载。

3.3 其他来源:URL、类路径

除了文件系统,Langchain4j 还提供了 UrlDocumentLoader,可以从 HTTP 地址加载文档。这个在做实时文档同步时很好用:

Document doc = UrlDocumentLoader.load( URI.create("https://example.com/document.html").toURL(), new HtmlTextDocumentParser() );

我个人很少直接用 UrlDocumentLoader,因为真实业务中网页抓取往往需要处理登录、超时、重定向等问题,这些它都管不了。更好的做法是自己写一个简单的抓取逻辑,拿到 HTML 字符串后再交给 Parser 处理。比如把 HttpClient 请求到的响应体字符串,直接构造成 Document,再走后续的清洗和分割流程。

还有一种常见需求是从 classpath 或项目资源目录加载文件。这其实不用专门的方法,直接用 FileSystemDocumentLoader 指到 resources 目录即可。或者你用 Java 自身的方式读取资源流,再转成字符串后构造 Document。

3.4 格式解析:PDF、DOCX、CSV 怎么选 Parser

不同格式需要不同的 DocumentParser。Langchain4j 提供了一系列开箱即用的 Parser:

格式Parser 类备注
TXTTextDocumentParser直接读文本
HTMLHtmlTextDocumentParser去除标签,提取正文
PDFApachePdfBoxDocumentParser基于 PDFBox
DOCXApachePoiDocumentParser基于 POI
CSVCsvDocumentParser逐行拆分
XLSXApachePoiDocumentParser表格形式

我实测下来,PDF 是最容易出问题的格式。ApachePdfBoxDocumentParser 对扫描版 PDF(纯图片)无能为力,解析出来是空文本;对复杂排版(多栏、表格、页眉页脚)的解析结果也不理想。如果你遇到的 PDF 是扫描版,必须先加 OCR 环节,或者换用其他文档预处理方案。

DOCX 相对好一些,Apache POI 对 Word 文档的段落结构解析得比较完整。但如果文档里有复杂的嵌套表格,也会出现内容顺序错乱的问题。遇到这类文档,我一般先手动打开看看结构,再决定是不是要用自定义 Parser 做后处理。

3.5 自定义 DocumentParser 的场景

Langchain4j 的 Parser 接口很简单,核心就是文档输入流转 Document:

public interface DocumentParser { Document parse(InputStream inputStream); }

自定义 Parser 主要出现在两种场景:

一是专有格式。比如你有一套 .tmpl 模板文件,内容是自定义语法,需要定制解析规则。

二是需要做预处理。比如解析 PDF 时把页眉页脚去掉、把图表说明和正文分离,这些逻辑放在 Parser 里最合适。

public class CustomPdfParser implements DocumentParser { private final ApachePdfBoxDocumentParser delegate = new ApachePdfBoxDocumentParser(); @Override public Document parse(InputStream inputStream) { Document document = delegate.parse(inputStream); String cleanedText = cleanText(document.text()); return Document.from(cleanedText, document.metadata()); } private String cleanText(String text) { // 去掉页眉页脚、合并多余空行、修正全角字符 return text; } }

这里的原则是:Parser 层做的是“从原始格式到干净文本”的转换,不要在这里做业务相关的筛选逻辑。业务筛选放到后面加载完成之后统一处理,才能保持职责清晰。

4. 文档分割:决定问答效果好坏的核心环节

4.1 为什么要分割,以及分割的评判标准

一个大模型有上下文窗口限制,不可能把整本手册一次塞进去。即使某些模型的上下文窗口大到可以装下整本书,检索时也会因为文本粒度过粗而命中大量无关内容。

分割质量直接影响两个指标:

  • 召回率:重要信息有没有被切出来,并被正确向量化。
  • 精准率:检索到的文本块是不是真的包含答案,而不是夹杂大量无关信息。

一个好的分割,应该尽量保持语义完整性。也就是说,一个文本块尽量是一个完整的话题、一个完整的段落、最好是一个完整的意思单元。从工程角度讲,还要考虑块的大小是否适合向量化——太大检索效果差,太小又丢失上下文。

我见过一个典型反例:有人把一份操作手册按 100 字切块,结果“打开配置文件”和“修改参数项”这种前后依赖的动作被切到了不同块里,问答系统回答“怎么修改参数”时,只能检索到后半部分,完全不知道要先打开配置文件。这就是分割粒度过细导致的语义断裂。

4.2 Langchain4j 里的分割器体系

Langchain4j 里,DocumentSplitter 和 TextSegment 是分割环节的核心。DocumentSplitter 负责把 Document 切成多个 TextSegment,每个 TextSegment 包含一段文本和对应的元数据。

内置的分割器都通过 DocumentSplitters 这个工厂类创建。最常用的是 recursive:

DocumentSplitter splitter = DocumentSplitters.recursive( 300, // 每个块的字符数 50 // 相邻块之间的重叠字符数 );

这个接口签名很好理解——目标块大小和重叠大小。recursive 的意思是:它会尝试按段落、句子、词的顺序逐级寻找切割点,优先在语义边界比较自然的地方切,而不是在硬编码的固定位置暴力截断。

4.3 不同分割策略的适用场景对比

策略特点适用场景
recursive兼顾语义和长度,按段落/句子/词逐级切泛用性最强,默认推荐
按固定字符数实现简单,块大小非常均匀对块大小有严格限制的场景
按段落保持段落完整,语义边界清晰段落较短的说明文档、条款
按句子保留句子完整性,适合短文本拼接FAQ、原句回答类任务

以我自己的项目经验,recursive 在大多数场景下是最好用的。它不是那种“数够 300 字就一刀切”的粗暴逻辑,而是会去文档里找段落换行、句号等自然边界,尽量让每个文本块读起来像一段完整的话。

当然,recursive 也不是银弹。如果你的文档本身段落很短(比如条款类文档,每条就一两句话),按段落切可能比 recursive 更合适。反之,如果你的文档是连续的大段文字,recursive 会在句子边界处切分,效果也不错。

4.4 参数到底怎么定

这是被问得最多的问题:块大小和重叠到底应该设多少?

没有标准答案,但我可以给出一个参考思路。先把选型维度拆清楚:

  • Embedding 模型的影响:不同的 Embedding 模型对输入长度有限制,通常以 token 数计算。比如某些模型最大输入是 512 token,那就意味着你的文本块不能超过这个上限。中文字符和 token 的换算比例大约是 1 个汉字 ≈ 1~2 个 token,具体要看模型。
  • 检索目标的影响:如果你的系统是让用户做文档问答,块可以稍大一些,比如 500~800 字符,这样上下文信息足。如果做的是片段匹配、短句检索,块宜小,比如 100~200 字符。
  • 重叠量的影响:重叠是为了避免信息恰好落在切分边界上导致丢失。一般取块大小的 10%~20% 即可。比如块大小 500 字符,重叠 50~100 字符。

我之前做过一次对照实验。同一批 PDF 文档,一组用 300/50 切分,一组用 1000/200 切分,问了 30 个业务问题,结果前者准确率明显更高。原因是这批文档本身就是问答段落结构,小一点的分块恰好能保留完整的问答对。换了另一批长文档,300/50 的反而不如 800/100。所以我的结论是:参数没有银弹,但按照“先按语义边界切,再按长度约束合并”的思路来调,效率会高很多。

4.5 从 Document 到 TextSegment 之后会发生什么

分割完成之后得到的是 List 。每个 TextSegment 拥有 text() 和 metadata(),metadata 来自原始 Document,同时还会自动打上索引信息。

实际项目中,接下来一般就是把 TextSegment 送入 Embedding 模型生成向量,然后存入向量库。这里有一个容易被忽略的点:TextSegment 的文本长度应该和 Embedding 模型的输入限制匹配,否则超长截断会破坏语义。

List<TextSegment> segments = splitter.split(document); for (TextSegment segment : segments) { List<Float> vector = embeddingModel.embed(segment.text()).content(); // 存入向量库 }

我自己是在这个阶段统一做文本清洗的,比如去掉纯空白段落、合并过短的分块、过滤掉长度小于 20 字符的无效片段。因为分割器产生的粒度不一定完美,后处理能校正一部分问题。

5. 完整链路:PDF 导入到分块入库的实战走通

5.1 业务场景和预期效果

用一个我实际做过的场景:一个政策文件库,输入一批 PDF 政策文件,最终实现的效果是用户可以提问“某政策对小微企业补贴额度是多少”,系统自动从文档中检索并生成回答。

我这里不接入真实的大模型和向量库,而是把链路走到“分割完成并打印分块信息”这一步,方便你在本地直接复现。实际项目里把最后一段替换成 Embedding + 向量库即可。

5.2 完整代码

import dev.langchain4j.data.document.Document; import dev.langchain4j.data.document.DocumentSplitter; import dev.langchain4j.data.document.Metadata; import dev.langchain4j.data.document.loader.FileSystemDocumentLoader; import dev.langchain4j.data.document.parser.apache.pdfbox.ApachePdfBoxDocumentParser; import dev.langchain4j.data.document.splitter.DocumentSplitters; import dev.langchain4j.data.segment.TextSegment; import java.nio.file.Path; import java.nio.file.Paths; import java.util.List; public class DocumentProcessDemo { public static void main(String[] args) { // 1. 加载目录下的所有 PDF 文件 Path pdfDir = Paths.get("/data/policy_docs"); ApachePdfBoxDocumentParser parser = new ApachePdfBoxDocumentParser(); List<Document> documents = FileSystemDocumentLoader.loadDocumentsRecursively(pdfDir, parser); System.out.println("加载文档数量:" + documents.size()); // 2. 遍历每个文档,做基础清洗后分割 DocumentSplitter splitter = DocumentSplitters.recursive(500, 80); for (Document doc : documents) { // 打印文档基本信息 System.out.println("文档来源:" + doc.metadata().getString("file_name")); System.out.println("文档总字数:" + doc.text().length()); // 分割 List<TextSegment> segments = splitter.split(doc); System.out.println("切分块数:" + segments.size()); // 3. 打印每个分块的前 100 个字符,方便直观检查切分质量 for (int i = 0; i < segments.size(); i++) { TextSegment segment = segments.get(i); String preview = segment.text().length() > 100 ? segment.text().substring(0, 100) + "..." : segment.text(); System.out.printf("[%d] 长度=%d, 预览=%s%n", i, segment.text().length(), preview.replace("\n", " ")); } } } }

5.3 运行结果和效果分析

我在本地用 3 份 PDF 政策文件跑过这个流程。加载环节没有问题,3 份 PDF 都被正确识别。分割环节用了 500/80 的参数:

  • 其中一份文件全文 3800 多字,被切成 9 块。
  • 每一块基本对应文档中的一个完整章节内容。
  • 有些段落比较长,被跨块切开了,但重叠的 80 个字符保证了下文起始信息和上文有衔接。

直观效果可以看输出里的长度和预览,重点是检查有没有出现“一块里面前半部分是上一章、后半部分是下一章”的情况。如果出现,说明段落边界没有正确识别,这时候我会先去检查 PDF 解析出的原始文本里换行符是否完整——很多 PDF 导出的文本换行符位置和视觉排版不一致,会导致分割器找不到正确的段落断点。

5.4 接入向量库的路子

上面的例子到分割就结束了。实际项目里,接下来的代码一般是:

EmbeddingModel embeddingModel = OpenAiEmbeddingModel.builder() .apiKey(System.getenv("OPENAI_API_KEY")) .modelName("text-embedding-3-small") .build(); EmbeddingStore<TextSegment> store = new InMemoryEmbeddingStore<>(); for (TextSegment segment : segments) { Embedding embedding = embeddingModel.embed(segment.text()).content(); store.add(embedding, segment); }

这里只是示意,真正的生产环境要考虑 EmbeddingStore 的持久化、批量处理的限流、失败重试等。但核心逻辑就是这样:分割之后直接走向量化,没有多余步骤。

6. 实测下来最值得注意的几个坑

6.1 中文编码问题

这是最基础的坑,但出现频率极高。加载文本文件时,如果文件是 GBK 编码,而默认的 TextDocumentParser 按 UTF-8 读取,就会出现乱码。

解决办法有两个:一是把源文件统一转成 UTF-8;二是自定义 Parser,在读取输入流时指定字符集:

DocumentParser parser = inputStream -> { String text = new String(inputStream.readAllBytes(), StandardCharsets.UTF_8); return Document.from(text); };

注意上面这个实现忽略了 Metadata。实际用的时候建议加上来源信息。另外一个技巧是:写完自定义 Parser 之后,先用一段包含生僻字、全角标点的样本文本做单元测试,防止“看起来能跑但实际有乱码”的问题。

6.2 PDF 解析出来的文本可能“不是你以为的样子”

PDFBox 解析的结果是多个文本片段按照内部结构拼接的,而不是按人的阅读顺序重排。常见问题包括:

  • 表格内容被横向拼接到正文里,读起来像乱码。
  • 页眉页脚重复出现在每个文本块里,污染检索结果。
  • 多栏排版的文档,左右栏内容交错在一起。
  • 扫描版 PDF 根本没有文本层,解析结果为空字符串。

应对方式:对于页眉页脚,可以在加载后用字符串匹配的方式批量去掉;对于扫描版,只能走 OCR;对于多栏、复杂表格,最省事的办法是优先让业务方提供 Word 版本或带书签的数字化 PDF,虽然这个建议听起来很“需求侧”,但在实际落地中确实是最有效的。

6.3 分割之后信息丢了一半

很多人会遇到一个问题:分割前 Document 里有 Metadata,分割后 TextSegment 里的 Metadata 也还在,但自己在查询阶段想按来源过滤时发现 filter 条件不生效。

排查步骤是先打印 TextSegment 的 metadata 内容。Langchain4j 在分割时会把继承自 Document 的 Metadata 复制到每个 Segment,但如果你在构造 Document 时就没有设置 metadata,这里自然为空。另外有些集成模块在往向量库写入时,需要手动指定哪些 metadata 要存入索引,否则查询时拿不到。

我的建议是:加载文档后,先统一补充元数据,比如文档名、类别、上传时间,再走分割。这个习惯能省掉后面很多麻烦。

6.4 空白文本和超短分块

数据处理中最隐形的杀手是空白文本。PDF 解析失败返回空文本时,后面的分割、向量化不会报错,但会生成一个零向量或者噪音向量,拉低整体检索准确率。

我的做法是在分割后的循环里加过滤:

List<TextSegment> validSegments = segments.stream() .filter(seg -> seg.text().trim().length() >= 20) .toList();

这个 20 字符是经验值。太短的文本块基本没有语义,向量化之后和其他文本的相似度也不稳定,留着只会添乱。

6.5 调试技巧:本地先跑通,再接外部服务

文档处理阶段不应该依赖任何外部 AI 服务。我在开发时经常做的一件事是,把加载和分割写成一个简单的测试用例,用本地文件作为输入,直接打印分块结果,肉眼检查切分质量。这样迭代速度很快,而且不消耗任何 API 调用。

等到切分质量稳定了,再接入 Embedding 和向量库。这个顺序符合“先保证输入质量,再做下游优化”的原则,也能避免把问题定位到错误层次。

就我自己的使用体验来说,Langchain4j 的文档处理链路设计得足够清晰,但越清晰的框架越考验使用者的数据处理意识。加载和分割看似只是管线前两步,实际上决定了整个 RAG 应用能在多大程度上发挥价值。同一个模型、同一个向量库,在文档处理上花一小时和花一秒,最终效果可能天差地别。

最后分享一个我在项目中养成的习惯:每次拿到一批新文档,我不会直接开写代码,而是先手动抽样看两三份,搞清楚格式、排版、噪声源在哪里,再决定用哪个 Parser、怎么清洗、分割参数取多少。这一步看起来费时间,实际上是排掉最多坑的投入。

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

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

立即咨询