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.
| Status | Description |
|---|---|
| not_present | The 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. |
| unreadable | The 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. |
| ambiguous | The 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 problemsThe 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.
- Write the inclusive description first, in one clause. What is this field.
- Take ten documents and find every other value on the page that could plausibly be mistaken for it. On an invoice,
invoice_datehas at least three: due date, delivery date, and the date in the payment terms line. - 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 listsRechnungsnummerandFacture n°rather than saying “in any language”. - 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.
- 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.
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_dateis the Rechnungsdatum, not the Lieferdatum and not the Zahlbar-bis date. Three plausible dates on one page, and theNOTclauses are what select between them.invoice_numberis 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_grossisunreadable, not1.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. Onlyunreadabletriggers a re-render, and a re-render is exactly what this document needs.currencytakes 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_quoterises. 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_presentrises on one field only. A template changed at the source. Pull five recent failures and look at the document, not the prompt.ambiguouscollapses 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.