💡 完整可運作的範例位於 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,頂層檔案則沒有 parent。

// 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 寫入每個區塊的一行 JSON,欄位包括 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 與試算表逐筆索引,並與投保單關聯。
  • 投訴處理:客戶郵件與其附件一起索引,郵件成為每個附件區塊的父層。

在您的理賠環境中快速上手

將範例程式碼 clone 下來,對幾封您自己的匯出郵件執行,然後開啟 output/chunks.jsonl 檢查標題路徑與 parent id,確定無誤後再接入您的嵌入作業。分階段的架構說明可參考此管線的文件使用案例,而Markdown 輸出參考則列出模式保留的格式細節。

資源