đź’ˇ Full working example available on GitHub: document-to-markdown-rag-chunks-java

The Claims Desk Problem: Why RAG Ingestion Breaks on Real Files

An on-premise RAG ingestion pipeline is a document parsing layer that turns the files a team already has into searchable, citable text without sending them to a third party. Insurance operations show why that matters. A claims handler asks the internal assistant “what did the adjuster say about water damage on this policy?”, and the answer lives in an email from last March, inside a PDF report attached to it, under a heading called “Findings”. The assistant can only find it if ingestion read the attachment, kept the heading and remembered which email the report came with.

Most ingestion jobs get one of those three wrong. A plain-text extractor returns the email body and ignores the attachment. A fixed 512-token splitter cuts the report through the middle of “Findings”. And a hosted parser solves both, but only after the claim file, with its personal and medical details, has been uploaded to someone else’s servers and billed per page.

What the team needs is narrow and concrete: read Word, PDF, EML and ZIP files inside the Java service that already owns the data, keep their structure as Markdown, split at the author’s headings, and attach the source and parent to every chunk.

Market snapshot: the 2026 guides on “document parsing for RAG” that we reviewed compare open-source Python parsers with cloud APIs billed per page. A Java library running on your own hardware is mostly missing from that conversation.

Why Do RAG Pipelines Need Markdown Rather Than Plain Text?

Markdown keeps the parts of a document that tell a retriever where one idea ends and the next begins. Headings become # lines, tables stay as rows and columns, and lists keep their items. A chunker can split on those headings, and an LLM reading the chunk sees the same outline a person would. Plain text throws that outline away, leaving size as the only signal for splitting.

Why Generic Solutions Don’t Work Here

Teams usually try one of three things before building a proper ingestion layer:

  • Plain-text extraction plus a token splitter: fast to set up, but chunks mix sections and lose the heading you would cite.
  • A cloud parsing API: good structure, but files leave the network and the bill grows with every page re-indexed.
  • A home-grown parser per format: one library for DOCX, another for PDF, a MIME parser for email and java.util.zip for archives, each with its own edge cases.

GroupDocs.Parser for Java covers all four formats behind one Parser class and runs in-process.

The Solution: GroupDocs.Parser in a Claims Ingestion Workflow

GroupDocs.Parser for Java reads a document and can return its text as Markdown through getFormattedText. Emails and archives are exposed as containers whose items open as new Parser instances, so a PDF attached to an email is processed exactly like a PDF on disk. The sample project wraps that in six static methods and writes the result as JSON Lines, which any embedding job or vector store can load. No RAG framework is involved; the output format is the integration point.

To follow along you need:

  • JDK 11 or later for the shipped pom.xml (the code itself sticks to Java 8 features)
  • GroupDocs.Parser for Java 26.10 (get a temporary licence)
  • A handful of DOCX, PDF, EML or ZIP files

The project’s pom.xml declares the https://releases.groupdocs.com/java/repo/ repository and the single dependency, so a build is one command:

mvn compile exec:java

Implementing the Ingestion Layer Step by Step

The steps below follow one claim file from the inbox to the index. Each step maps to one or two methods in RagChunksDemo.java. We kept them as plain static methods on purpose, so you can lift any one of them into an existing worker without adopting the rest.

Step 1 - Open Each File and Route It

Every uploaded file gets its own Parser. The file name becomes the source id, and a top-level file has no 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 then extracts the body, chunks it, and descends into attachments while the depth is below 2.

Step 2 - Ask for Markdown, Accept Plain Text

The extraction step probes what the format supports instead of hard-coding a list of extensions.

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

Why these settings matter for claims data:

  • FormattedTextMode.Markdown: the adjuster’s “Findings” heading reaches the chunker as ## Findings, so it can become a split point and a citation.
  • isFormattedText(): in the sample run the DOCX and the email come back as Markdown while the PDF falls back to plain text, and the format field records which happened.
  • The none result: claim photos inside a ZIP produce no chunks without OCR, which is honest and easy to monitor.

Step 3 - Split at the Adjuster’s Headings

The chunker keeps a stack of the current heading at each of the six Markdown levels. A new heading closes the running section and resets everything below its level.

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');
}

Sections over 1500 characters are cut at paragraph breaks, so a long findings section becomes several chunks that share one heading path. On the bundled Word file this produces paths such as Heading 1 > Heading 1.2 > Heading 1.2.1.

Step 4 - Follow Attachments and Write JSON Lines

This is the step a claims desk cares about most. getContainer() lists email attachments and ZIP entries; each item opens as a child document and inherits the parent’s 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 then writes one UTF-8 line per chunk with id, source, parent, heading_path, format and text. With the four sample inputs the run produces 22 lines. The chunk from the sample email’s attachment is email-with-attachment.eml/attachment.txt#0, and its parent is email-with-attachment.eml. That pair is what lets the assistant say “from the report attached to the 14 March email” instead of “from somewhere in the archive”.

Real Workflow: A Weekly Claims Re-Index

Picture a claims operations team that exports the week’s closed-claim mailboxes every Friday as EML files and ZIP bundles of supporting documents. A Java worker already moves those exports into the document store. Adding ingestion means calling chunkDocument for each file and appending the JSON Lines output to the embedding queue.

Step 4 is what keeps a reserve report attached to the email that delivered it. Step 3 means that when the assistant quotes “Findings”, the citation names the file and the heading instead of a bare chunk number. My rule of thumb for this kind of index: if a chunk cannot be cited back to a file and a heading, it should not be in the index at all.

Business Impact: What This Changes

Aspect Typical setup before With the GroupDocs.Parser pipeline
Where files are parsed Third-party endpoint Inside your own JVM
Parsing cost model Per page, on every re-index No per-page cloud fee
Chunk boundaries Fixed token windows Author’s headings, 1500-character cap
Email attachments Often skipped or flattened Parsed as child documents
Citation data File name at best source, parent and heading_path
Runtime dependencies SDK plus network access One Maven artifact

The compound effect is an index the compliance team can sign off on and an assistant whose answers point at a real document and section.

Other Insurance Scenarios Where GroupDocs.Parser Fits

Once the ingestion layer exists, the same code serves other document-heavy work:

  • Policy wording search: Word policy documents split by clause headings, so answers quote the exact section.
  • Broker submissions: ZIP uploads with mixed PDFs and spreadsheets indexed member by member, each tied to the submission.
  • Complaint handling: customer emails and their attachments indexed together, with the email as the parent of every attachment chunk.

Getting Started in Your Claims Environment

Clone the sample, run it against a few of your own exported emails, and open output/chunks.jsonl to check the heading paths and parent ids before wiring it into your embedding job. The stage-by-stage architecture notes are in the documentation use case for this pipeline, and the Markdown output reference lists which formatting the mode keeps.

Resources