💡 完整的工作示例可在 GitHub 上获取:
document-to-markdown-rag-chunks-java

理赔柜台问题:为何 RAG 摄取在真实文件上会出错

本地 RAG 摄取管道是一个文档解析层,它将团队已有的文件转换为可搜索、可引用的文本,而无需将它们发送给第三方。保险业务能够说明这为何重要。理赔员会向内部助手询问“调整员在这份保单的水损害方面说了什么?”,答案藏在去年三月的一封电子邮件中,邮件附带的 PDF 报告的标题为 “Findings”。只有当摄取读取了附件、保留了标题并记住报告对应的邮件时,助手才能找到答案。

大多数摄取作业都会在以下三点中出错。纯文本提取器只返回邮件正文,忽略附件。固定的 512 token 分割器会在 “Findings” 中间截断报告。而托管解析器可以解决前两者,但前提是先将包含个人和医疗信息的理赔文件上传到他人的服务器,并按页计费。

团队真正需要的是:在已有数据的 Java 服务中读取 Word、PDF、EML 和 ZIP 文件,保持其结构为 Markdown,在作者的标题处拆分,并为每个块附上来源和父级信息。

市场概览:我们审阅的 2026 年 “RAG 文档解析” 指南将开源 Python 解析器与按页计费的云 API 进行比较。运行在自有硬件上的 Java 库在该讨论中几乎没有出现。

为什么 RAG 管道需要 Markdown 而不是纯文本?

Markdown 保留了文档中指示检索器何处结束一个想法、何处开始下一个想法的部分。标题会变成 # 行,表格保持为行列,列表保留其项目。块拆分器可以依据这些标题进行拆分,LLM 在读取块时看到的结构与人类看到的提纲相同。纯文本会丢失这些提纲,只剩下大小作为唯一的拆分信号。

为什么通用方案在这里行不通

团队通常在构建正式的摄取层之前尝试以下三种做法:

  • 纯文本提取 + token 分割器:设置快速,但块会混合章节并失去可引用的标题。
  • 云解析 API:结构良好,但文件会离开网络,且每重新索引一页都会产生费用。
  • 自行编写的每种格式解析器:为 DOCX、PDF、邮件的 MIME 以及 java.util.zip 各准备一个库,每个库都有自己的边缘情况。

GroupDocs.Parser for Java 在一个 Parser 类后面统一支持所有四种格式,并且在进程内运行。

解决方案:在理赔摄取工作流中使用 GroupDocs.Parser

GroupDocs.Parser for Java 读取文档并可通过 getFormattedText 将其文本以 Markdown 形式返回。电子邮件和压缩包被视为容器,其内部项目可以作为新的 Parser 实例打开,因此附在邮件中的 PDF 会像磁盘上的 PDF 那样被处理。示例项目将此封装在六个静态方法中,并将结果写入 JSON Lines,任何嵌入作业或向量存储都可以加载。整个过程不依赖任何 RAG 框架;输出格式即为集成点。

要跟随本示例,需要:

  • JDK 11 或更高(用于提供的 pom.xml,代码本身仅使用 Java 8 特性)
  • GroupDocs.Parser for Java 26.10(获取临时许可证)
  • 若干 DOCX、PDF、EML 或 ZIP 文件

项目的 pom.xml 声明了 https://releases.groupdocs.com/java/repo/ 仓库以及唯一的依赖项,构建只需一条命令:

mvn compile exec:java

步骤式实现摄取层

以下步骤以一个理赔文件从收件箱到索引为例。每一步对应 RagChunksDemo.java 中的一两个方法。我们特意将它们保持为普通的静态方法,以便你可以将其中任意一个直接迁移到已有的工作进程中,而无需采用其余代码。

步骤 1 - 打开每个文件并路由

每个上传的文件都会获得自己的 Parser。文件名作为 source id,顶层文件没有父级。

// One call per uploaded claim document
String sourceId = new File(path).getName();
try (Parser parser = new Parser(path)) {
    return chunkParser(parser, sourceId, null, 0);  // null parent: top-level file
} catch (UnsupportedDocumentFormatException ex) {
    // An unknown format is logged and skipped, the batch keeps going
    System.out.println("skipped (unsupported format): " + sourceId);
    return new ArrayList<>();
}

chunkParser 随后提取正文、拆分块,并在深度小于 2 时递归处理附件。

步骤 2 - 请求 Markdown,接受纯文本

提取步骤会检查格式支持的功能,而不是硬编码扩展名列表。

if (parser.getFeatures().isFormattedText()) {
    // Headings, lists and tables survive as Markdown
    FormattedTextOptions options =
            new FormattedTextOptions(FormattedTextMode.Markdown);
    try (TextReader reader = parser.getFormattedText(options)) {
        if (reader != null) {
            return new ExtractedText("markdown", reader.readToEnd());
        }
    } catch (IOException ex) {
        throw new RuntimeException(ex);
    }
}
try (TextReader reader = parser.getText()) {
    if (reader != null) {  // fallback for formats without formatted text
        return new ExtractedText("plain", reader.readToEnd());
    }
} catch (IOException ex) {
    throw new RuntimeException(ex);
}
return new ExtractedText("none", "");  // e.g. a photo with no OCR

这些设置对理赔数据的重要性:

  • FormattedTextMode.Markdown:调整员的 “Findings” 标题会以 ## Findings 形式传递给块拆分器,从而成为拆分点并可被引用。
  • isFormattedText():在示例运行中,DOCX 和邮件返回 Markdown,而 PDF 回退为纯文本,format 字段记录了实际使用的方式。
  • none 结果:ZIP 中的理赔照片若没有 OCR 将不产生块,这样的结果诚实且易于监控。

步骤 3 - 在调整员的标题处拆分

块拆分器为每个 Markdown 的六个层级维护一个当前标题栈。新标题会关闭之前的章节并重置其以下所有层级。

for (String line : markdown.split("\\r?\\n", -1)) {
    Matcher heading = HEADING.matcher(line);   // "#" to "######" + title
    if (heading.matches()) {
        // Close the previous section under the old path
        addSection(chunks, section.toString(), sourceId, parentId, path, format);
        section.setLength(0);
        int level = heading.group(1).length();
        stack[level - 1] = heading.group(2).trim();
        for (int i = level; i < stack.length; i++) {
            stack[i] = null;                     // forget deeper headings
        }
        path = joinPath(stack);                  // "Heading 1 > Heading 1.2"
    }
    section.append(line).append('\n');
}

超过 1500 字符的章节会在段落换行处截断,因此冗长的 Findings 部分会被拆成多个块,共享同一个标题路径。对示例 Word 文件而言,会生成类似 Heading 1 > Heading 1.2 > Heading 1.2.1 的路径。

步骤 4 - 处理附件并写入 JSON Lines

这一步是理赔柜台最关心的。getContainer() 列出邮件附件和 ZIP 条目;每个条目都会作为子文档打开,并继承父级的 id。

Iterable<ContainerItem> items = parser.getContainer();
if (items == null) {
    return chunks;                    // not an email or archive
}
for (ContainerItem item : items) {
    // "claim-0412.eml/report.pdf" style ids keep the lineage readable
    String childId = sourceId + "/" + item.getFilePath();
    try (Parser child = item.openParser()) {
        chunks.addAll(chunkParser(child, childId, sourceId, depth + 1));
    } catch (UnsupportedDocumentFormatException ex) {
        System.out.println("skipped (unsupported format): " + childId);
    }
}

writeJsonl 随后为每个块写入一行 UTF-8 文本,包含 id、source、parent、heading_path、format 与 text。使用四个示例输入运行后会产生 22 行。示例邮件附件的块 id 为 email-with-attachment.eml/attachment.txt#0,其 parent 为 email-with-attachment.eml。正是这对信息让助手能够说 “来自 3 月 14 日邮件的报告附件”,而不是 “来自某个归档中的位置”。

实际工作流:每周理赔重新索引

设想一个理赔运营团队每周五将已关闭的理赔邮箱导出为 EML 文件和包含支持文档的 ZIP 包。已有的 Java 工作进程会将这些导出移动到文档存储中。加入摄取层后,只需对每个文件调用 chunkDocument,并将 JSON Lines 输出追加到嵌入队列。

步骤 4 确保保留报告与发送它的邮件之间的关联。步骤 3 使得助手在引用 “Findings” 时,引用的既是文件又是标题,而不是单纯的块编号。我的经验法则是:如果一个块无法追溯到文件和标题,就不应出现在索引中。

商业影响:此举带来的变化

方面 之前的典型设置 使用 GroupDocs.Parser 流水线后
文件解析位置 第三方端点 在您自己的 JVM 内部
解析费用模型 按页计费,每次重新索引都收费 无按页云费用
块边界 固定 token 窗口 作者标题,1500 字符上限
邮件附件 常被跳过或扁平化 作为子文档解析
引用数据 最多文件名 source、parent 与 heading_path
运行时依赖 SDK + 网络访问 单一 Maven 构件

综合效果是:合规团队可以签署的索引,以及答案能够指向真实文档和章节的助手。

其他适用于保险场景的 GroupDocs.Parser 用例

一旦摄取层搭建完成,同一套代码即可服务其他文档密集型业务:

  • 保单条款搜索:按条款标题拆分 Word 保单文档,答案可引用精确章节。
  • 经纪人投递:ZIP 上传的混合 PDF 与电子表格按成员逐一索引,每个块都关联到对应的投递。
  • 投诉处理:客户邮件及其附件一起索引,邮件成为每个附件块的父级。

在您的理赔环境中快速上手

克隆示例代码,对几封导出的邮件进行运行,并打开 output/chunks.jsonl 检查标题路径和父级 id,然后再将其接入您的嵌入作业。分阶段的架构说明请参阅 此流水线的文档用例,Markdown 输出参考 列出了该模式保留的格式。

资源

  • [用例:在 Java 中为 RAG 准备文档][USE_CASE_URL]
  • [GitHub 上的源代码][GITHUB_URL]
  • [从文档中提取格式化文本][FORMATTED_TEXT_URL]
  • [从电子邮件中提取附件][EMAIL_ATTACHMENTS_URL]
  • [从 ZIP 档案文件中提取文本][ZIP_URL]
  • [GroupDocs.Parser for Java 文档][DOCS_URL]
  • [API 参考][API_REFERENCE_URL]
  • [免费试用许可证][TEMP_LICENSE_URL]
  • [支持论坛][FORUM_URL]