Skip to content

Extraction Prompts for Messy Documents

13 min read · updated August 4, 2026

The hard part of extraction is not pulling out the fields that are there. It is knowing what happened when a field is not — and a schema whose only answer is null cannot tell you, so nothing downstream can decide whether to retry, escalate or accept.

The prompt

Extract the fields in <schema> from the document below.

For each field return an object:
{"value": <the value, or null>,
 "quote": "<verbatim substring of the document, or null>",
 "status": "found" | "not_present" | "unreadable" | "ambiguous",
 "alternatives": [{"value": ..., "quote": "..."}]}

Rules:
- "quote" must be an exact substring of the document, copied character for
  character including case, punctuation and line breaks. It must be the
  shortest span that contains the value.
- "value" must appear inside "quote". If it does not, you have paraphrased;
  fix the quote or set status to "ambiguous".
- status "not_present": the field does not appear anywhere in the document.
  value and quote are null.
- status "unreadable": the field is there but you cannot read it — a cut-off
  table, an illegible scan, a truncated line. value is null; quote is the
  span you could see.
- status "ambiguous": the field appears more than once with different values,
  or the document does not determine which of two readings is meant. Put the
  first occurrence in value and every other reading in alternatives, each
  with its own quote. Do not choose between them.
- Never compute a field. If a total is not printed, its status is
  "not_present", even when the line items would sum to it.
- Copy numbers and dates exactly as printed. Do not strip currency symbols,
  do not normalise 1.234,56 into 1234.56, do not turn 03/04/2026 into an ISO
  date. Normalisation happens after this step, not in it.
- Return every field in <schema>, in the order given, even when not present.

<schema>
invoice_number  The document's own identifier. Usually labelled Invoice No.,
                Rechnungsnummer, or Facture n°. NOT the order number, NOT the
                customer number, NOT a delivery note number.
invoice_date    The date the invoice was issued. NOT the due date, NOT the
                delivery date, NOT a date inside a line item description.
supplier_name   The legal entity issuing the invoice, as printed in the header
                or footer block. NOT a trading name in the logo unless it is
                the only name given.
total_gross     The final amount payable including tax, as printed. If the
                document prints several totals, this is the one labelled as
                payable or due.
currency        As printed: the symbol or the ISO code, whichever appears.
vat_number      The supplier's VAT identifier. NOT the customer's.
</schema>

<document>
{{document}}
</document>

Why every value carries a span

The span is what turns an extraction from an assertion into a claim you can check without reading the document. Three consequences follow, and the third is the one that matters most.

  • You get a free verifier. A substring test on the quote and a containment test of value inside quote catch a large class of failures with no model call. The code is in the verifier section below.
  • Human review becomes viable. Reviewing a hundred extracted invoices by opening a hundred PDFs is a day. Reviewing a hundred rows of field, value and span is twenty minutes, and a highlighted span in your UI is a character offset away.
  • It suppresses the confident fabrication. The most damaging extraction failure is a plausible value for a field that was never in the document — an invoice date that is the delivery date, a total that was computed. Requiring a span makes fabrication require a second fabrication, and the fabricated span fails the substring test.

“Shortest span that contains the value” is a small but load-bearing phrase. Without it the model returns the paragraph, and a paragraph-length span passes every test while proving nothing.

Three kinds of missing

This is the part most extraction prompts get wrong, and it is not a matter of taste. The three statuses need different responses from your pipeline, and one null cannot distinguish them.

StatusDescription
not_presentThe document genuinely does not contain the field. Correct behaviour downstream: accept it. Retrying costs money and will produce the same answer, or worse, a different one. If the field is required, this is a document problem, not a model problem — route it to whoever sent it.
unreadableThe field is there and could not be read. Correct behaviour: retry, and retry differently — a higher-resolution render, a page crop, a vision model instead of a text extraction, a human. This is the only status where a retry has a reason to succeed.
ambiguousThe document supports more than one answer. Correct behaviour: a person decides, and they decide fastest when both candidate values arrive with their spans. Never let the model resolve this silently — a silently resolved ambiguity is a wrong answer with no trace.

The rate of each status is also your best diagnostic. High unreadable is an input-quality problem — scanning, rendering, the wrong OCR path. High ambiguous on one field is a schema problem: the description does not exclude the neighbour it is being confused with. High not_present on a field you know is always there means the field description no longer matches your documents, typically after a supplier changed their template.

For the pipeline that surrounds this — validation, repair, retry budgets — extracting structured data from messy documents covers the architecture, and field-level confidence covers getting a calibrated number rather than a status. This page is the prompt those two assume.

The verifier

import unicodedata

def canon(s: str) -> str:
    "Normalise the things a model changes without meaning to."
    s = unicodedata.normalize("NFKC", s)
    return " ".join(s.split())

def verify(document: str, fields: dict) -> list[tuple[str, str]]:
    """Return (field, problem) for every field that fails a structural check."""
    doc = canon(document)
    problems = []
    for name, f in fields.items():
        status = f.get("status")
        quote, value = f.get("quote"), f.get("value")

        if status not in {"found", "not_present", "unreadable", "ambiguous"}:
            problems.append((name, "bad_status"))
            continue
        if status == "not_present" and (quote or value is not None):
            problems.append((name, "not_present_with_content"))
        if status in {"found", "ambiguous"}:
            if value is None:
                problems.append((name, "status_without_value"))
            if quote is None:
                problems.append((name, "status_without_quote"))
        if quote is not None and canon(quote) not in doc:
            problems.append((name, "quote_not_in_document"))
        if value is not None and quote is not None:
            if canon(str(value)) not in canon(quote):
                problems.append((name, "value_not_in_quote"))
        for alt in f.get("alternatives") or []:
            if canon(alt.get("quote", "")) not in doc:
                problems.append((name, "alternative_quote_not_in_document"))
    return problems

The canon function is not optional and it is where most people lose a day. Models normalise non-breaking spaces, collapse runs of whitespace across a line break, and convert typographic quotes. Without NFKC and whitespace collapsing on both sides you will see a double-digit false-failure rate on documents that were extracted perfectly.

Use the result as a gate. A field with value_not_in_quote should be dropped rather than stored, because it is precisely the fabrication case. A field with quote_not_in_document after canonicalisation is a genuine hallucinated span and is worth alerting on: it is rare, and when it starts happening in volume something upstream has changed.

Writing the field descriptions

The NOT clauses in the schema are the highest-value words in the entire prompt. Every field in a real document has a near neighbour that looks like it, and the near neighbour is what you actually extract when you do not exclude it.

  1. Write the inclusive description first, in one clause. What is this field.
  2. Take ten documents and find every other value on the page that could plausibly be mistaken for it. On an invoice, invoice_date has at least three: due date, delivery date, and the date in the payment terms line.
  3. Add each as an explicit NOT. Name it the way it is labelled on the document, in every language your documents come in — this is why the example lists Rechnungsnummer and Facture n° rather than saying “in any language”.
  4. Say where on the document it appears when position disambiguates — “in the header or footer block”. Layout is information the model has if you name it.
  5. Do not add a format constraint. “An ISO date” in a field description makes the model convert, and conversion is the step you explicitly moved after extraction.
Keep the schema in one file and version it separately from the prompt. A field description change invalidates your evaluation set in exactly the same way a label definition change does, and it is the change most likely to be made by somebody who has never seen the eval set.

A worked invoice

A deliberately awkward fragment — a German invoice with a cut-off total, two candidate dates and a missing VAT line — and the output the schema produces for it.

-- the document (as extracted from the PDF) ---------------------------------
Muster Logistik GmbH
Rechnungsnummer: 2026-00418        Bestellnummer: PO-77219
Rechnungsdatum: 03.04.2026         Lieferdatum: 28.03.2026
...
Zwischensumme                1.234,56 EUR
Zahlbar bis 17.04.2026
Gesamtbetrag                 1.4[cut off at page edge]

-- the output ---------------------------------------------------------------
{"invoice_number": {"value": "2026-00418", "status": "found",
                    "quote": "Rechnungsnummer: 2026-00418"},

 "invoice_date":   {"value": "03.04.2026", "status": "found",
                    "quote": "Rechnungsdatum: 03.04.2026"},

 "supplier_name":  {"value": "Muster Logistik GmbH", "status": "found",
                    "quote": "Muster Logistik GmbH"},

 "total_gross":    {"value": null, "status": "unreadable",
                    "quote": "Gesamtbetrag                 1.4"},

 "currency":       {"value": "EUR", "status": "found",
                    "quote": "1.234,56 EUR"},

 "vat_number":     {"value": null, "status": "not_present", "quote": null}}

Four behaviours to check in that output, each of which a weaker schema would have got wrong.

  • invoice_date is the Rechnungsdatum, not the Lieferdatum and not the Zahlbar-bis date. Three plausible dates on one page, and the NOT clauses are what select between them.
  • invoice_number is not the Bestellnummer. The order number sits on the same line and is the more familiar format. Naming it as an exclusion is the only thing that keeps it out.
  • total_gross is unreadable, not 1.234,56. The subtotal is right there, readable, and nearly correct — which is what makes it dangerous. And the “never compute” rule stops the other tempting move, which is summing the lines. Only unreadable triggers a re-render, and a re-render is exactly what this document needs.
  • currency takes its quote from the subtotal line. Legitimate — the field is “as printed” and EUR is printed there. The value is inside the quote, so the verifier passes it.

Run verify() on this output and it returns an empty list. That is the point of the shape: a document that was only two-thirds extractable produces a structurally valid result in which the missing third is labelled with what to do about it.

When it stops working

  • value_not_in_quote rises. The model has started paraphrasing spans. Re-read your prompt for a recently added instruction that asks for any kind of normalisation — they interact badly with the verbatim rule.
  • not_present rises on one field only. A template changed at the source. Pull five recent failures and look at the document, not the prompt.
  • ambiguous collapses to zero. Suspicious rather than good. It usually means the model has started picking, which is the failure this schema exists to prevent. Test with a document you know has two candidate totals.
  • Quotes get longer. Track median quote length. Drift upward means the shortest-span instruction is losing, and a long span hides a wrong value inside a correct-looking region.