BlogExtraction8 min read

Page two assumes you read page one

Documents are written for a reader who works front to back. Pipelines that process pages independently throw away the context every later page depends on.

The natural way to build a document pipeline is one page at a time. Pages parallelise, they fit comfortably in a context window, they retry independently, and the per-page cost is easy to reason about.

It also throws away the thing that makes a document a document. Page two was written for somebody who has read page one, and it omits everything that person already knows.

A page is a unit of paper. It is not a unit of meaning, and processing pages independently assumes it is.

What page two leaves out

Look at the second page of any multi-page invoice or statement. It is missing:

  • The header. Supplier, invoice number, date, currency. Usually replaced by a short "Invoice 4471 — page 2 of 4" strip, or nothing at all.
  • Column meanings. The table header — Description, Qty, Unit, Amount — was printed once, on page one. Page two is bare rows.
  • The subject. A statement's account number, a contract's parties.
  • The units. "All amounts in thousands of EUR" appears once, under the first table.

That last one is the expensive example. A page-independent extractor reads 1,240 on page three and records €1,240. The document said €1,240,000 and it said so on page one.

The continuation row

The sharpest version of the problem is a table row split across a page break.

--- page 2, last row -------------------------------
  4471-9  Replacement hydraulic seal kit, 40mm,
--- page 3, first row ------------------------------
          including gasket set          2   118,40

Processed independently, page two yields a row with a description and no quantity or amount, and page three yields a row with numbers and a fragment of a description. Two broken rows instead of one correct one — and neither is malformed enough to fail a schema check, because both are plausible rows.

Downstream, line items no longer sum to the total. If you have the arithmetic check from a schema that fails loudly you at least catch it. If not, it lands silently.

Carry a context object forward

The fix is to process pages in order and thread a small, explicit context through them:

type PageContext = {
  /** Established on page 1, valid for the whole document. */
  header: {
    documentId: string
    supplier: string
    currency: string
    issuedOn: PlainDate
  }
  /** Column layout, so bare rows on later pages can be interpreted. */
  columns: ColumnSpec[]
  /** "All amounts in thousands" and similar. Rarely present, expensive to miss. */
  scaleNote?: { factor: number; appliesTo: "amounts" }
  /** A row that started on the previous page and did not finish. */
  pendingRow?: PartialRow
  /** Running total, for the arithmetic check at the end. */
  runningSubtotal: bigint
}

async function extractDocument(pages: Page[]): Promise<Document> {
  let ctx = initialContext()
  const rows: Row[] = []

  for (const page of pages) {
    const result = await extractPage(page, ctx)   // ctx goes INTO the prompt
    if (result.completedRow) rows.push(result.completedRow)
    rows.push(...result.rows)
    ctx = advance(ctx, result)                    // and comes back updated
  }

  return assemble(ctx, rows)
}

Two properties matter here.

The context goes into the prompt. Not just into your code. The model extracting page three is told the currency, the column layout, the scale note, and the partial row it needs to complete. It cannot infer any of that from the pixels in front of it.

The context is small and typed. The temptation is to pass the previous page's full text. That inflates cost linearly with page count and buries the four facts that matter in several thousand tokens of noise. A structured context object stays roughly constant in size regardless of document length.

Handling the pending row explicitly

Ask the extractor to tell you when a row is incomplete, rather than inferring it:

type PageResult = {
  rows: Row[]
  /** Set when the last row on this page has no amount — it continues overleaf. */
  trailingPartial?: PartialRow
  /** Set when the first row on this page had no description — it started overleaf. */
  leadingContinuation?: { text: string }
}

Then joining is mechanical: a trailingPartial on page n merges with a leadingContinuation on page n+1. If one appears without the other, that is a real signal — either the split was not what you thought, or a page is missing — and it belongs in the review queue rather than being quietly dropped.

The cost, and why it is smaller than it looks

Sequential processing gives up page-level parallelism, which sounds expensive and usually is not:

  • Documents still parallelise. Pages within a document are sequential; documents are not. At any real volume that is all the parallelism you need.
  • The context adds few tokens. A structured context object is a few hundred tokens against a page image costing thousands.
  • Rework falls. The failures this prevents are the ones that produce a review queue item or a reprocess, and both cost more than the latency.

Where it genuinely hurts is single very long documents on a tight interactive deadline — a 400-page contract a user is waiting on. The hybrid there: extract the header and column layout from page one, then fan out the remaining pages in parallel with that context attached. You lose continuation-row handling and keep everything else, which is the right trade for a document type where rows do not split.

Detecting that you have this problem

Two checks against documents you have already processed, both cheap:

-- Line items that do not sum to their document total. In a corpus with
-- multi-page documents, continuation rows are a leading cause.
select d.id, d.total, sum(li.amount) as summed, d.page_count
from documents d join line_items li on li.document_id = d.id
group by d.id, d.total, d.page_count
having abs(sum(li.amount) - d.total) > 1
order by d.page_count desc;

If mismatches cluster in documents with more than one page, you have this bug. The second check is simpler still: compare extracted field completeness on page one against later pages. A currency present on 100% of first pages and 40% of later pages is the header problem, stated as a number.


More in when extraction accuracy collapses, a schema that fails loudly, and how we build extraction pipelines.

Something here

the audit is the cheapest way to find out for certain.