đź’ˇ Full working example available on GitHub: spring-ai-document-reader-using-groupdocs-parser-java
The Old Way: Attachments That Never Reach the Index
A Spring AI DocumentReader is the first stage of the Spring AI ETL pipeline: it turns a file into the Document objects that a splitter and a vector store consume, so whatever it drops is gone for good. Picture the assistant your support team asked for. You export the shared mailbox, index the message bodies of the .eml files, split, embed, and ship. The first real question comes in, and the answer cites an email whose entire body is “Please see the attached refund policy.” The policy itself, a PDF attachment, was never read.
Nothing downstream can repair that. TokenTextSplitter chunks the one sentence it was given, and the vector store returns it as the closest match to “what is our refund window?”. Every component did its job. The gap sits at the very start of the pipeline, in the decision about what counts as a document. A mailbox export is a tree: messages, attachments, and sometimes archives inside attachments. A reader that sees only the top level indexes the wrapping paper.
My own first version did exactly this. I fixed it the obvious way: unzip the release bundles in one helper, pull attachments out of messages in another, page through PDFs with a third, and invent metadata names in each. It worked, until the fourth format arrived and the helpers disagreed about what to call a page.
The real cost: in the sample project, 2 of 4 input files, the email and the ZIP, carry text that a body-only read never indexes. In a support corpus that ratio decides whether the assistant is useful.
A Better Way: One Reader for Every Input
GroupDocs.Parser for Java reads 65+ file formats in-process. It detects the format from a stream’s content, returns text per page, and lists the attachments of an email or the entries of a ZIP through one container API. Wrap those calls in Spring AI’s DocumentReader interface and the rest of the pipeline cannot tell the difference, except that the Documents now carry page numbers and lineage.
Spring AI’s own readers remain good choices for their cases: the ETL pipeline reference documents TikaDocumentReader on Apache Tika and PagePdfDocumentReader on Apache PDFBox. The point here is narrower. When attachments and archive members have to be first-class sources, a reader that walks containers saves you the helper zoo.
Before we start, you’ll need:
- Java 17 or later, the baseline for Spring AI 2.x
- GroupDocs.Parser for Java 26.10 (get a temporary license) and
org.springframework.ai:spring-ai-commons2.0.2 - An exported email with an attachment, or the four sample files from the repository
Clone the sample and run it with one command:
mvn compile exec:java
What does a Spring AI DocumentReader need to return for RAG citations?
It needs a List<Document> where each entry is small enough to cite and labelled well enough to trace. In practice that means one Document per page with a page_number, a source naming the file, and, for anything that arrived inside an email or archive, a parent_source naming the container. TokenTextSplitter copies this metadata onto every chunk, so the citation survives all the way into the vector store.
The New Way: Attachments as First-Class Documents
Here is the same job with a single class, GroupDocsParserDocumentReader. Three steps cover it: open the resource, read pages, then follow the containers and hand everything to the splitter.
Step 1 - Open Any Resource as a Stream
The reader accepts a Spring Resource, so a file, a classpath entry and an upload all take the same path. The Parser reads bytes, which means a blob with no extension still parses.
// 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<>();
}
Step 2 - Read Page by Page
Page mode is the default. I would leave it on for anything longer than a page or two, since a reviewer checking an answer wants to jump straight to page 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
}
}
Key points:
pagePerDocument:truegives every chunk apage_number; the three-argument constructor switches it off for short notes.isTextPage(): guards formats without pages; for those the reader callsgetText()once and still recordssource,file_typeandpage_count.
Step 3 - Follow the Containers, Then Split
Each attachment or archive member gets its own Parser and goes back through the same page logic, labelled with its path inside the parent.
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);
}
}
The splitter stage is stock Spring AI, and it keeps the reader’s metadata on every chunk:
TokenTextSplitter splitter = TokenTextSplitter.builder()
.withChunkSize(300) // tokens per chunk in the sample run
.build();
List<Document> chunks = splitter.apply(documents);
On the 4 sample files the reader returns 12 Documents and the splitter makes 20 chunks. The demo writes them to output/spring-ai-documents.jsonl in place of vectorStore.add(chunks), so you can read source, parent_source, page_number and the splitter’s chunk_index on every line before you build a real index.
That file is worth five minutes of your time. Search it for parent_source and you see exactly which chunks came from inside the email and the ZIP; search it for page_number and you see where each page boundary fell. When I first ran it against a real mailbox export, those two searches answered more questions about retrieval quality than a day of prompt tweaking had.
Side-by-Side: Before vs. After
Here is what changed between my helper-per-format version and the single reader:
| Before (body-only read plus per-format helpers) | After (GroupDocs.Parser DocumentReader) | |
|---|---|---|
| Email attachments | lost, or extracted by a separate helper | read as Documents with parent_source |
| ZIP members | unzipped to disk first | read in place, source = sample.zip/sample.pdf |
| Citations | file name only | file name plus page_number |
| Code to maintain | one helper per format | one DocumentReader class |
| Unreadable file | depends on the helper | logged, skipped, batch continues |
The pipeline after the reader did not change at all; only what flows into it did.
Real-World Example: A Nightly Support Index
A support team exports its shared mailbox every night and receives a ZIP of updated manuals with each product release. A scheduled job in their Spring Boot service lists new files, reads them with GroupDocsParserDocumentReader, splits and writes to the vector store behind the agent assistant. When an agent asks about refunds, the answer now cites manuals-2026-10.zip/refunds.pdf, page 4, and a filter on parent_source can restrict a search to the latest release bundle.
The code from the three steps maps onto that job one to one. Step 1 accepts whatever the nightly export produces, including files whose extension got lost on the way. Step 2 keeps manual pages separate, so the agent can open page 4 before replying to a customer. Step 3 makes the ZIP and the mailbox visible parents instead of opaque blobs, and the splitter call is unchanged from any other Spring AI pipeline the team already runs.
Short internal notes do not need a page split, so the team reads them in whole-file mode and keeps attachments on:
// One Document per note; attachments still become their own Documents
DocumentReader reader = new GroupDocsParserDocumentReader(resource, false, true);
List<Document> documents = reader.read();
What Else Can You Do with GroupDocs.Parser?
Now that attachments flow into Spring AI, a few related capabilities are one call away:
- Read from streams of any origin: the load document from stream guide covers the
Parser(InputStream)constructor the reader uses. - Work with containers directly: extract data from attachments and ZIP archives explains
getContainerandopenParser. - Read PDF pages outside Spring AI: extract text from PDF documents shows page-level text extraction on its own.
- Handle email directly: extract attachments from emails walks through attachment extraction from email messages without the Spring AI layer.
Conclusion
The fix for “see attached” turned out to live in the reader stage. With GroupDocs.Parser behind Spring AI’s DocumentReader, attachments, archive members and individual pages become sources the assistant can cite, and swapping it in is one constructor.
Ready to try it on your own corpus?
- Get a temporary license to run without evaluation limits
- Follow the integration guide in the documentation for patterns, settings and a production checklist
- Clone the sample project on GitHub and point it at a copy of your mailbox export