💡 完整的工作示例可在 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-commons 2.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,以下几个相关能力只需一次调用即可实现:

结论

对 “see attached” 的修复实际上位于读取器阶段。将 GroupDocs.Parser 放在 Spring AI 的 DocumentReader 背后,附件、压缩包成员和单独页面都成为助手可以引用的来源,而替换它只需一个构造函数。

准备好在自己的语料库上尝试了吗?

附加资源