💡 完整可運作的範例位於 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 輸出參考則列出模式保留的格式細節。