💡 完整可運作的範例可在 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-commons2.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,以下幾項相關功能只需一次呼叫即可取得:
- Read from streams of any origin:load document from stream 指南說明了
Parser(InputStream)建構子如何使用。 - Work with containers directly:extract data from attachments and ZIP archives 介紹
getContainer與openParser。 - Read PDF pages outside Spring AI:extract text from PDF documents 展示了獨立的頁面文字抽取。
- Handle email directly:extract attachments from emails 說明如何在不使用 Spring AI 層的情況下從電子郵件抽取附件。
結論
「see attached」的修正最終落在讀取器階段。將 GroupDocs.Parser 放在 Spring AI 的 DocumentReader 後方,附件、壓縮檔成員與單頁都會成為助理可引用的來源,而替換它只需要一個建構子。
準備好在自己的語料庫上試試看了嗎?
- 取得臨時授權 以免受評估限制
- 依照文件中的 integration guide in the documentation 了解模式、設定與上線檢查清單
- 克隆 sample project on GitHub 並指向您的信箱匯出副本