💡 完整的工作示例可在 GitHub 上获取: spring-ai-document-reader-using-groupdocs-parser-java
旧方法:附件从未进入索引
Spring AI DocumentReader 是 Spring AI ETL 流水线的第一阶段:它将文件转换为 Document 对象,供拆分器和向量存储使用,因此它丢弃的内容将永远消失。想象一下你们支持团队想要的助手。你导出共享邮箱,索引 .eml 文件的消息正文,进行拆分、嵌入并发布。第一个真实的问题出现了,答案引用了一封邮件,其整个正文是 “Please see the attached refund policy.”(请参阅附件的退款政策)。该政策本身是一个 PDF 附件,却从未被读取。
下游没有任何东西可以修复这一点。TokenTextSplitter 将它收到的那句话切块,向量存储把它作为对 “what is our refund window?”(我们的退款窗口是什么?)的最接近匹配返回。每个组件都完成了自己的工作。问题出在流水线的最开始,即对什么算作文档的决策。邮箱导出是一个树结构:消息、附件,有时还有附件内部的压缩包。只看到顶层的读取器只会索引包装纸。
我自己的第一个版本正是如此实现的。我用显而易见的方式修复它:在一个帮助类中解压发布包,在另一个帮助类中从消息中提取附件,在第三个帮助类中逐页读取 PDF,并在每个帮助类中自行创建元数据名称。它工作得很好,直到第四种格式出现,帮助类之间对页面的命名产生了分歧。
真实成本:在示例项目中,4 个输入文件中有 2 个(电子邮件和 ZIP)包含正文读取永远不会索引的文本。在支持语料库中,这个比例决定了助手是否有用。
更好的方法:为每种输入提供一个读取器
GroupDocs.Parser for Java 在进程内读取65+ 种文件格式。它根据流的内容检测格式,按页返回文本,并通过一个容器 API 列出电子邮件的附件或 ZIP 的条目。将这些调用包装在 Spring AI 的 DocumentReader 接口中,流水线的其余部分无法分辨差异,唯一的区别是 Document 现在携带页码和血缘信息。
Spring AI 自带的读取器仍然是它们各自场景的好选择:文档中的ETL 管道参考介绍了基于 Apache Tika 的 TikaDocumentReader 和基于 Apache PDFBox 的 PagePdfDocumentReader。这里的重点更窄。当附件和压缩包成员必须作为一等来源时,能够遍历容器的读取器可以省去大量辅助工具。
在开始之前,你需要:
- Java 17 或更高版本,Spring AI 2.x 的基线
- GroupDocs.Parser for Java 26.10(获取临时许可证)和
org.springframework.ai:spring-ai-commons2.0.2 - 一个带附件的导出邮件,或仓库中的四个示例文件
克隆示例并使用一条命令运行:
mvn compile exec:java
Spring AI DocumentReader 需要返回什么以用于 RAG 引用?
它需要一个 List<Document>,其中每个条目足够小以便引用,并且标记得足够清晰以便追溯。实际上,这意味着每页一个 Document,包含 page_number、标识文件的 source,以及对于任何来自电子邮件或压缩包内部的内容,还要有标识容器的 parent_source。TokenTextSplitter 会将这些元数据复制到每个块上,从而使引用能够一直保留到向量存储。
新方法:将附件视为一等文档
下面展示了使用单个类 GroupDocsParserDocumentReader 完成相同任务的方式。三个步骤涵盖全部:打开资源、读取页面,然后遍历容器并将所有内容交给拆分器。
步骤 1 - 将任意资源作为流打开
读取器接受 Spring Resource,因此文件、类路径条目和上传都走同一条路。Parser 读取字节,这意味着即使是没有扩展名的二进制块也能解析。
// Format detection works on content, so the file name is only a label
try (InputStream stream = resource.getInputStream();
Parser parser = new Parser(stream)) {
List<Document> documents = new ArrayList<>(readBody(parser, source, null));
if (includeAttachments) {
documents.addAll(readAttachments(parser, source)); // the fix for "see attached"
}
return documents;
} catch (UnsupportedDocumentFormatException ex) {
// Log the unreadable file and return an empty list; the batch keeps going
System.out.println("skipped (unsupported format): " + source);
return new ArrayList<>();
}
步骤 2 - 按页读取
页面模式是默认设置。对于超过一两页的内容,我会保持开启,因为审阅者在检查答案时希望直接跳到第 31 页。
if (pagePerDocument && pageCount > 0 && parser.getFeatures().isTextPage()) {
for (int page = 0; page < pageCount; page++) {
String text = readText(parser.getText(page));
Map<String, Object> metadata =
metadata(source, parent, fileType, pageCount);
metadata.put(METADATA_PAGE, page + 1); // people count pages from 1
addIfNotBlank(documents, text, metadata); // blank pages never get embedded
}
}
关键点:
pagePerDocument:true为每个块提供page_number;三参数构造函数在短笔记时会关闭此功能。isTextPage():用于防护没有页面概念的格式;对于这些格式,读取器只调用一次getText(),仍然记录source、file_type和page_count。
步骤 3 - 跟随容器,然后拆分
每个附件或压缩包成员都会获得自己的 Parser,并再次走相同的页面逻辑,使用其在父容器中的路径进行标记。
Iterable<ContainerItem> items = parser.getContainer(); // null for plain files
if (items == null) {
return documents;
}
for (ContainerItem item : items) {
String childSource = source + "/" + item.getFilePath(); // "mail.eml/policy.pdf"
try (Parser child = item.openParser()) {
documents.addAll(readBody(child, childSource, source)); // parent_source = source
} catch (UnsupportedDocumentFormatException ex) {
System.out.println("skipped (unsupported format): " + childSource);
}
}
拆分阶段是标准的 Spring AI,并且它会在每个块上保留读取器的元数据:
TokenTextSplitter splitter = TokenTextSplitter.builder()
.withChunkSize(300) // tokens per chunk in the sample run
.build();
List<Document> chunks = splitter.apply(documents);
在 4 个示例文件上,读取器返回 12 个 Document,拆分器生成 20 个块。演示将它们写入 output/spring-ai-documents.jsonl,代替 vectorStore.add(chunks),因此在构建真实索引之前,你可以在每行中读取 source、parent_source、page_number 以及拆分器的 chunk_index。
该文件值得你花五分钟时间。搜索 parent_source 可以准确看到哪些块来自邮件或 ZIP 内部;搜索 page_number 可以看到每个页面边界所在位置。当我首次在真实邮箱导出上运行时,这两项搜索回答了比整整一天的提示调优更多的检索质量问题。
对比:前后对照
下面展示了我的每种格式辅助程序版本与单一读取器之间的变化:
| 之前(仅正文读取加每种格式的辅助程序) | 之后(GroupDocs.Parser DocumentReader) | |
|---|---|---|
| Email attachments | 丢失,或由单独的辅助程序提取 | 读取为带有 parent_source 的 Document |
| ZIP members | 先解压到磁盘 | 就地读取,source = sample.zip/sample.pdf |
| Citations | 仅文件名 | 文件名加 page_number |
| Code to maintain | 每种格式一个辅助程序 | 一个 DocumentReader 类 |
| Unreadable file | 取决于辅助程序 | 已记录,跳过,批处理继续 |
流水线在读取器之后没有任何改变;唯一变化的是流入的内容。
真实案例:每夜支持索引
支持团队每晚导出共享邮箱,并随每次产品发布收到一份更新手册的 ZIP。它们在 Spring Boot 服务中安排的作业列出新文件,使用 GroupDocsParserDocumentReader 读取,拆分后写入代理助手背后的向量存储。当代理询问退款时,答案现在会引用 manuals-2026-10.zip/refunds.pdf 第 4 页,并且可以通过 parent_source 过滤仅搜索最新发布包。
这三个步骤的代码与该作业一一对应。步骤 1 接受每个夜间导出产生的文件,包括扩展名在传输过程中丢失的文件。步骤 2 将手册页面分开,以便代理在回复客户前打开第 4 页。步骤 3 使 ZIP 和邮箱成为可见的父级,而不是不透明的二进制块,且拆分调用保持不变,和团队已经运行的其他 Spring AI 流水线完全相同。
短内部笔记不需要页面拆分,因此团队以整体文件模式读取它们,并保留附件:
// One Document per note; attachments still become their own Documents
DocumentReader reader = new GroupDocsParserDocumentReader(resource, false, true);
List<Document> documents = reader.read();
还有什么可以用 GroupDocs.Parser 做的?
现在附件已经流入 Spring AI,以下几个相关能力只需一次调用即可实现:
- 从任意来源的流读取:文档中的从流加载文档指南涵盖了读取器使用的
Parser(InputStream)构造函数。 - 直接操作容器:文档中的从附件和 ZIP 存档中提取数据解释了
getContainer和openParser。 - 在 Spring AI 之外读取 PDF 页面:文档中的从 PDF 文档中提取文本展示了独立的页面级文本提取。
- 直接处理电子邮件:文档中的从电子邮件中提取附件演示了在没有 Spring AI 层的情况下从邮件中提取附件的过程。
结论
对 “see attached” 的修复实际上位于读取器阶段。将 GroupDocs.Parser 放在 Spring AI 的 DocumentReader 背后,附件、压缩包成员和单独页面都成为助手可以引用的来源,而替换它只需一个构造函数。
准备好在自己的语料库上尝试了吗?
- 获取临时许可证以在没有评估限制的情况下运行
- 按照文档中的集成指南了解模式、设置和生产检查清单
- 克隆GitHub 上的示例项目并指向你的邮箱导出副本