💡 完整可運作的範例可在 GitHub 上取得: spring-ai-document-reader-using-groupdocs-parser-java

舊方法:永遠不會進入索引的附件

Spring AI DocumentReader 是 Spring AI ETL 管線的第一階段:它將檔案轉換為 Document 物件,供 splitter 與向量儲存庫使用,因此它拋棄的任何內容都會永遠消失。想像一下您支援團隊所要求的助理。您匯出共用信箱,索引 .eml 檔案的訊息內容,進行分割、嵌入與傳送。第一個真正的問題出現時,答案引用了一封整封內容都是「請參閱附件的退款政策」的電子郵件。該政策本身(PDF 附件)從未被讀取。

下游沒有任何東西可以修復這個問題。TokenTextSplitter 只會把它收到的那句話切成區塊,向量儲存庫把它當作「我們的退款窗口是什麼?」的最相近匹配返回。每個元件都盡了本分。問題的根源出現在管線最開始的地方——決定什麼算是文件。信箱匯出是一棵樹:訊息、附件,有時還有附件內的壓縮檔。只看最上層的讀取器只會索引外層的包裝紙。

我自己的第一個版本正是這樣做的。我用最直觀的方式修正:在一個 helper 中解壓發行套件,在另一個 helper 中從訊息中抽取附件,第三個 helper 用於逐頁讀取 PDF,並在每個 helper 中自行定義中繼資料名稱。它能運作,直到第四種格式出現,這些 helper 在「頁」的命名上產生分歧。

真正的成本:在範例專案中,4 個輸入檔案中有 2 個(電子郵件與 ZIP)包含了僅以正文方式讀取時永遠不會被索引的文字。在支援語料庫中,這個比例決定了助理是否有用。

更好的方法:每種輸入只用一個讀取器

GroupDocs.Parser for Java 能在程式內部讀取超過 65 種檔案格式。它根據串流內容偵測格式,逐頁返回文字,並透過單一容器 API 列出電子郵件的附件或 ZIP 的條目。將這些呼叫包裝在 Spring AI 的 DocumentReader 介面中,管線的其他部分無法分辨差異,唯一的不同是 Document 現在攜帶了頁碼與來源關係。

Spring AI 自己的讀取器在各自的情境下仍然是好選擇:參考 ETL pipeline reference 中說明的 TikaDocumentReader(基於 Apache Tika)與 PagePdfDocumentReader(基於 Apache PDFBox)。此處的重點較窄。當附件與壓縮檔成員必須作為第一級來源時,能遍歷容器的讀取器可以省去大量 helper。

在開始之前,您需要:

  • 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。三個步驟即可完成:開啟資源、逐頁讀取,然後遍歷容器並將所有內容交給 splitter。

步驟 1 - 將任何資源作為串流開啟

讀取器接受 Spring Resource,因此檔案、classpath 條目與上傳皆走同一路徑。Parser 讀取位元組,這表示即使是沒有副檔名的 Blob 仍能被解析。

// 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 則能看出每個頁面的分界點。當我首次在真實的信箱匯出上執行時,這兩項搜尋解答的檢索品質問題,遠超過整天調整提示的效果。

對照比較:前後差異

以下列出我原本的 per‑format helper 版本與單一讀取器之間的變化:

之前(僅讀取正文加上 per‑format helper) 之後(GroupDocs.Parser DocumentReader)
Email attachments 遺失,或由單獨的 helper 抽取 以 parent_source 讀取為 Document
ZIP members 先解壓至磁碟 直接於原位讀取,source = sample.zip/sample.pdf
Citations 只有檔名 檔名加上 page_number
Code to maintain 每種格式一個 helper 一個 DocumentReader 類別
Unreadable file 依賴 helper 的實作 記錄、跳過,批次繼續執行

讀取器之後的管線本身沒有任何變動;唯一改變的是流入管線的內容。

實務範例:每夜支援索引

支援團隊每晚會匯出共用信箱,並隨每次產品發布收到一個包含更新手冊的 ZIP。其 Spring Boot 服務中的排程工作會列出新檔案,使用 GroupDocsParserDocumentReader 讀取,分割後寫入助理背後的向量儲存庫。當客服人員詢問退款時,答案現在會引用 manuals-2026-10.zip/refunds.pdf 第 4 頁,且可透過 parent_source 篩選只搜尋最新的發布套件。

上述三個步驟的程式碼可一對一對映到此工作。步驟 1 接受每個夜間匯出產生的檔案,甚至包括副檔名遺失的檔案。步驟 2 將手冊頁面分開,讓客服人員在回覆客戶前能直接開啟第 4 頁。步驟 3 讓 ZIP 與信箱成為可見的父層,而非不透明的 Blob,且 splitter 的呼叫方式與團隊已在使用的其他 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 後方,附件、壓縮檔成員與單頁都會成為助理可引用的來源,而替換它只需要一個建構子。

準備好在自己的語料庫上試試看了嗎?

其他資源