# One PDF, Twelve Documents: Splitting and Classifying a Batch Before You Extract

> Real uploads are not single documents — they are 40-page bundles holding invoices, receipts, contracts and a fax cover sheet. Here is how to find the boundaries, classify each piece, and keep the split auditable before extraction ever runs.

- Source: https://parserai.co/blog/document-splitting-classification-mixed-pdf-batches
- Published: 2026-09-17

---
Every extraction demo starts with one clean PDF holding exactly one invoice. Production never does. What arrives is a 41-page scan a client made by feeding a month of paperwork through a sheet-feeder: invoices, a couple of receipts, a signed delivery note, a contract amendment, two blank pages and a fax cover sheet — in no particular order. If your pipeline treats that file as one document, everything downstream is already wrong, and the accuracy number you measured last week will not warn you.

## The batch is the real input

There is a quiet assumption baked into most extraction pipelines: that a file and a document are the same thing. It survives the pilot, because pilots use documents someone curated. It dies the first week a real client uploads whatever their scanner produced.

The fix is not a better prompt. It is an extra stage — segmentation — that runs before extraction and answers two questions about every page: **where does one document end and the next begin**, and **what kind of document is each segment**. Only then does extraction run, once per segment, with the schema that matches that segment's type. It is the stage most [OCR and IDP pipelines](https://parserai.co/blog/ocr-extraction-in-2026-how-to-automate-document-processing-for-faster-more-accurate-workflows) leave out.

Skipping that stage does not produce an obvious error. It produces a plausible one. Hand a model forty pages and ask for an invoice, and it will return an invoice: a document number from page 2, a date from page 17, a total from page 31. Every field is well-formed. The object is fiction. That is the whole danger — merged batches fail silently, in valid JSON, and pass every structural check you wrote.

_Illustrative: field accuracy when a batch is sent whole versus split first. The gap only opens as files get messier — which is exactly the traffic a pilot never sees._

## Three ways a missing split hurts you

It helps to name the failure modes, because they need different defenses.

**Silent merge.** Two documents become one record. You lose one payable entirely and the surviving record mixes fields from both. In accounts payable that is a missed invoice plus a wrong amount on the one you do pay — a double error from a single cause.

**Silent drop.** The model settles on the first or the clearest document in the bundle and ignores the other eleven. Volume looks normal because one file produced one record. Nothing in your monitoring says that eleven documents evaporated; you only find out when a supplier calls about an unpaid invoice they know they sent.

**Cross-contamination.** The boundary is roughly right but a page lands on the wrong side. A totals page belonging to invoice B gets attached to invoice A, so both are wrong and both look complete. These are the hardest to detect after the fact, because each record is internally consistent.

A merged batch passes validation

Required-field checks, type checks and JSON-schema validation all pass on a merged record — the fields exist and look right. Only cross-document checks catch it: does the line-item sum match the total on the same page range, does one tax ID appear across the whole segment, does the page count fall inside the normal range for this document type?

## What the splitter actually decides

Boundary detection is a per-page binary question: *is this page the first page of a new document?* Framing it that way is worth more than it sounds. It turns a vague "understand the bundle" task into a labelled classification problem you can build an eval set for, measure, and improve — one row per page, one gold label per row.

The signals that answer it are mostly not semantic. They are structural, and that is good news, because structure survives bad scans better than meaning does:

| Signal | What it tells you | Where it fails |
| --- | --- | --- |
| Explicit pagination ("page 1 of 3") | Strongest single signal; gives both boundary and expected length | Absent on most receipts and one-pagers |
| Header / logo repetition | A new masthead usually means a new document | Same supplier twice in a row |
| Document identifier change | New number or access key = new document | Continuation pages that repeat the identifier |
| Totals / signature block | Marks a likely *last* page, not a first one | Subtotals mid-document |
| Layout fingerprint change | Column count, margins, font mix shift at a boundary | Multi-layout documents (invoice + attached report) |
| Blank or near-blank page | Often a separator inserted by the scanner operator | Blank backs of duplex scans — meaningless |

No single signal is sufficient, and the useful thing is to combine them into a score rather than a vote. Pagination plus an identifier change is near-certain. A layout shift alone, on a low-DPI scan, is a coin flip that should not silently become a boundary.

## Classify after you split, not before

A common shortcut is to classify page by page and group the runs of identical labels. It is tempting because it is one pass instead of two, and it is wrong for a specific reason: two invoices from the same supplier back to back produce one uninterrupted run of*invoice* pages, and you will merge them every time.

Split first on structure, then classify the segment as a whole. Classifying a segment is also easier than classifying a page — the first page carries the header, the last carries the totals, and the type is usually obvious from the pair. Page-level type labels are still useful as a signal feeding the boundary score; they just should not be the thing that draws the boundary.

_Illustrative composition of a month's uploads from a single mid-size client. The 9% of pages that are cover sheets and blanks are pure cost if they reach extraction._

## Route by boundary confidence, not just type confidence

Most pipelines carry a confidence score for the extracted fields and none for the split. That is backwards in terms of blast radius: a wrong field costs one correction, a wrong boundary costs an entire document and quietly corrupts a second one.

Emit both, and route on the weaker of the two. Three tiers are enough in practice: automatic when both scores are high and the segment's page count is normal for its type; human review of the *boundaries only* (a thumbnail strip where a person drags the split points, which takes seconds) when the split is uncertain but the types are clear; and full manual handling when the batch looks nothing like anything you have seen.

Add two cheap structural tripwires that catch what confidence scores miss. A segment much longer than the 95th percentile for its type is almost always an unsplit run of several documents. Two different tax IDs inside one segment means you merged documents from different issuers. Both are one line of code and both catch the expensive case.

_Illustrative distribution of segmentation errors found in review. Merges dominate, and they are the costly kind — an over-split is annoying, a merge loses a document._

## Make the split a first-class artifact

The segmentation stage should produce a manifest you can store, diff and replay — not a transient decision hidden inside a prompt chain. When someone asks in November why an invoice was never paid, the manifest is the answer: it shows the batch, the segment, the confidence, and the route the segment took.

Split manifest — the output of segmentation, before any extraction runs

```
{
  "batch_id": "batch-2026-09-17-0042",
  "source_file": "uploads/cliente-setembro-scan.pdf",
  "page_count": 41,
  "segments": [
    {
      "segment_id": "seg-01",
      "pages": [1, 1],
      "doc_type": "cover_sheet",
      "type_confidence": 0.97,
      "boundary_confidence": 0.99,
      "action": "discard",
      "signals": ["no_tax_id", "single_page", "handwritten_note"]
    },
    {
      "segment_id": "seg-02",
      "pages": [2, 4],
      "doc_type": "invoice",
      "type_confidence": 0.96,
      "boundary_confidence": 0.94,
      "action": "extract",
      "signals": ["access_key_44_digits", "page_1_of_3_footer", "totals_block_on_last_page"]
    },
    {
      "segment_id": "seg-03",
      "pages": [5, 5],
      "doc_type": "receipt",
      "type_confidence": 0.71,
      "boundary_confidence": 0.62,
      "action": "human_review",
      "signals": ["low_dpi", "no_header_match", "amount_without_document_number"]
    },
    {
      "segment_id": "seg-04",
      "pages": [6, 41],
      "doc_type": "unknown",
      "type_confidence": 0.34,
      "boundary_confidence": 0.21,
      "action": "human_review",
      "signals": ["segment_longer_than_p95", "multiple_tax_ids_detected"]
    }
  ],
  "unassigned_pages": [],
  "warnings": ["seg-04 spans 36 pages — likely an unsplit run"]
}
```

Three details in that structure earn their place. **Page ranges are inclusive and explicit**, so a segment is reproducible from the original file without re-running the model. **Unassigned pages are listed rather than dropped** — if the ranges do not cover every page, that is a bug you want loud, and an empty array is a cheap invariant to assert. And **the signals that drove each decision are recorded**, which is what makes a bad split debuggable instead of mysterious.

A split you cannot replay is a split you cannot debug. Store the page ranges, not just the pieces.

Then carry that provenance into the extraction result. Every extracted document should know which file and which pages it came from, and ideally which page each field was read from. When a reviewer disputes a total, you want to open page 4 of the original scan, not search a 41-page PDF by hand.

Extraction result carrying split provenance

```
{
  "document_id": "doc-2026-09-17-0042-02",
  "provenance": {
    "batch_id": "batch-2026-09-17-0042",
    "source_file": "uploads/cliente-setembro-scan.pdf",
    "source_pages": [2, 3, 4],
    "segment_id": "seg-02",
    "split_version": "splitter-2026.08"
  },
  "schema": "invoice.v4",
  "extracted": {
    "document_number": "4181-A",
    "issue_date": "2026-09-02",
    "supplier_tax_id": "12345678000195",
    "total_amount": 18450.90,
    "line_item_count": 12
  },
  "field_provenance": {
    "total_amount": { "page": 4, "bbox": [318, 640, 470, 662] },
    "document_number": { "page": 2, "bbox": [56, 94, 188, 112] }
  }
}
```

2

stages before extraction: split, then classify

1

binary question per page — is this a first page?

100%

of pages accounted for in the manifest, or it is a bug

## Ordering, duplicates and attachments

Three practical wrinkles show up once the basic split works.

**Pages out of order.** Sheet-feeders jam and operators re-feed. If page 3 of an invoice ends up after the next document, a purely sequential splitter produces two broken segments. Detect it with the identifier: pages carrying the same document number but not contiguous are a re-order candidate, not a new document. Reassemble by identifier when you can, and route to review when you cannot — never silently drop the stray page.

**Duplicates inside one batch.** The same invoice scanned twice is common, and downstream it is a duplicate-payment risk. Deduplicate at the segment level on the document identifier plus issuer plus amount, and keep both segments in the manifest with one marked as a duplicate — deleting one loses the evidence that the batch contained it.

**Attachments that belong to the document.** A delivery note stapled behind an invoice is structurally a separate document and logically part of the same transaction. Model that as a parent-child link between segments rather than forcing a single boundary decision; otherwise you either merge two document types into one broken schema or lose the connection entirely.

Blank pages are data

Do not strip blank pages before segmentation. Operators often insert them deliberately as separators, and a duplex scan's empty backs tell you the bundle was scanned two-sided — which changes how you read the page sequence. Drop them after the split, not before.

## The pre-flight checklist

- Segmentation runs as its own stage, with its own output, before any extraction call.
- Boundary detection is framed as a per-page "is this a first page?" label you can evaluate.
- Classification happens on segments, not pages — two same-supplier documents in a row must still split.
- Every segment carries a boundary confidence and a type confidence; routing uses the lower one.
- Segments longer than the p95 page count for their type are flagged automatically.
- More than one issuer tax ID inside a segment is treated as a merge until proven otherwise.
- The manifest accounts for 100% of pages — unassigned pages are an error, not a silent drop.
- Extraction results carry source file, page range and split version as provenance.
- Your eval set includes real multi-document batches, blanks and out-of-order pages — not curated single files.
- Reviewers can fix a boundary without re-uploading the file or re-keying any field.

## Common questions

### Can I just ask the model to return an array of documents?

For short bundles of two or three documents, often yes — and it is a reasonable starting point. It degrades badly with length, because the model has to hold the whole file in context and stay consistent about where it decided the boundaries were while also extracting dozens of fields. You also get no separate confidence for the split and no artifact to audit. A cheap dedicated segmentation pass over page-level features scales further and fails more visibly.

### How many pages of context does boundary detection need?

Less than people expect. The decision for page *n* is mostly answered by page*n*'s header region and page *n−1*'s footer region. Feeding whole pages of body text tends to add cost and noise rather than accuracy. Start with a narrow window and widen only where your eval set shows it helps.

### What about files that really are one document?

They pass through the same stage and come out as a single segment covering all pages, with high confidence and no warnings. The cost is small and the uniformity is worth it — you never want two code paths where one of them assumes a property of the input that nobody validated.

### Should reviewers fix the split or the fields?

The split, first and separately. Correcting a boundary is a few seconds of dragging on a thumbnail strip and it fixes every field in both affected documents at once. Correcting fields on a merged document is minutes of work that leaves the underlying record still wrong.

## Where this leaves you

Extraction quality is usually discussed as a model question, and by the time the model runs the most damaging decision has already been made silently: what counted as a document. A bundle split wrong cannot be rescued by a better prompt, a bigger context window or a stricter schema — the input to extraction was already a fiction.

Treat segmentation as its own stage with its own output, its own confidence, its own eval set and its own review path. It is the cheapest accuracy work available in a document pipeline, because it fixes whole documents at a time instead of fields, and because the failures it prevents are precisely the ones your validation layer was never going to catch.

Parser

## See it on your own documents.

In the demo we process your documents live — no slides, no fluff.

[Book a demo](https://parserai.co/demo)
