BlogExtraction7 min read

A schema that fails loudly beats a blob that fails silently

The single cheapest change to an extraction pipeline, and why free-text output is a decision to discover your bugs from customers.

The cheapest improvement available to most extraction pipelines is not a better model or a better prompt. It is refusing to accept output that does not fit a shape you declared in advance.

The failure this prevents

A pipeline asks a model to extract an invoice and gets back free text, or JSON with no enforced shape. Downstream code reads result.total and gets undefined, or "1,240.50" where it expected a number, or null because the model hedged.

None of that throws. It writes. The bad value lands in the database looking exactly like a good one, and surfaces weeks later when a customer disputes an amount.

Without a schema, a malformed extraction and a correct extraction are the same kind of object. That is the entire bug.

What the schema has to do

Declaring types is the easy half. The half that matters:

// Not this — every field optional, nothing enforced,
// undefined sails straight through into the ledger.
type Invoice = {
  total?: number
  currency?: string
  lineItems?: unknown[]
}

// This. Required fields are required. Values are constrained.
// Confidence rides along with the value it describes.
type Invoice = {
  total: Money           // { amount: number; currency: ISO4217 }
  issuedOn: PlainDate    // not a string that might be MM/DD or DD/MM
  lineItems: LineItem[]  // must be non-empty
  confidence: Record<keyof Invoice, number>
  source: { documentHash: string; page: number }
}

Three things are doing work there.

Money rather than number. A bare number invites a currency bug that is invisible until an invoice arrives in euros. Making currency non-optional means the extractor has to have an answer.

PlainDate rather than string. 03/04/2026 is two different dates depending on which side of the Atlantic produced it. A parsed date type forces that ambiguity to be resolved at the boundary, where you still have the document to look at.

confidence keyed to the field names. Confidence for the whole document is nearly useless — a document is rarely uniformly good. Per-field confidence is what lets you accept the date and question the total.

Then make it actually fail

A schema that logs a warning and carries on is a schema-shaped decoration. The validation has to have somewhere to send failures:

  1. Validate at the boundary, immediately after extraction, before anything downstream sees the object.
  2. Route failures to a queue, not to the table of record. A person resolves them.
  3. Keep the raw output alongside the failure. When you are debugging why the extractor produced nonsense, the nonsense is the evidence.

The one that catches the most

Add cross-field arithmetic as part of validation:

const summed = invoice.lineItems.reduce((n, li) => n + li.amount, 0)
if (!closeEnough(summed + invoice.tax, invoice.total)) {
  return review(invoice, "line items do not sum to total")
}

This single check catches a large share of numeric misreads, and it costs nothing — no extra model call, no latency. A hallucinated figure almost never happens to satisfy the arithmetic, so the constraint does the detection for you.

Most pipelines we look at have no version of this, and it is consistently the highest-value thing we add.


More on this in when extraction accuracy collapses and how we build extraction.

Something here

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