97% accurate is not a number
Accuracy averaged over fields hides the one field that matters. What to measure per field, and why precision and recall answer different business questions.
"The pipeline is 97% accurate."
Accurate at what? Averaged how? A single accuracy figure for a document extraction system is nearly always the mean of per-field correctness across a test set, and that mean has a specific, predictable distortion in it: the easy fields outnumber the hard ones.
A twelve-field invoice schema where eleven fields are trivially readable and the total is genuinely difficult produces 97% while getting the total wrong one time in three. Ninety-seven per cent, and the only field anybody cares about is at 67.
Averaging across fields assumes the fields are worth the same. Nobody believes that, and every summary metric asserts it.
Measure per field, weighted by consequence
select
field,
count(*) as n,
avg((extracted = expected)::int) as accuracy,
-- Of the times we produced a value, how often was it right?
avg((extracted = expected)::int)
filter (where status = 'found') as precision,
-- Of the times a value existed, how often did we produce it?
count(*) filter (where status = 'found' and extracted = expected)::float
/ nullif(count(*) filter (where expected is not null), 0) as recall,
-- The refusals. Not errors, and they cost review time.
avg((status <> 'found')::int) as refusal_rate
from eval_results
group by field
order by accuracy asc;
Sorted ascending, so the worst field is the first row. That ordering is the report — a table sorted by field name buries the answer.
Precision and recall answer different questions
For extraction, the distinction is not academic. It maps directly onto two different business failures:
Precision — when we output a value, is it right? Low precision means wrong data entering the system. In accounts payable this is a wrong payment.
Recall — when a value exists, do we find it? Low recall means gaps. In accounts payable this is an invoice that never gets paid.
Which one to optimise is a domain question with a real answer:
| Domain | Prefer | Because |
|---|---|---|
| Financial postings | Precision | A wrong number moves money. A gap is visible. |
| Compliance flagging | Recall | A missed violation is the failure. False alarms cost review time. |
| Search and enrichment | Balanced | Both degrade the product roughly equally. |
| Medical extraction | Recall, then human | Missing a value is unacceptable; a person checks the rest. |
This is where the option to refuse becomes measurable. Refusal trades recall for precision, deliberately. A pipeline at 99% precision and 82% recall may be strictly better than one at 94%/94% for payables — and a single accuracy number scores the second higher.
The case where 99% is worse than 94%
Two pipelines on the same 1,000 invoices:
Pipeline A. 99% accurate. Ten wrong values, written silently into the ledger, indistinguishable from the 990 correct ones.
Pipeline B. 94% accurate. Twenty wrong values and forty refusals. The refusals go to review and are corrected in four seconds each, so the corrected output has twenty errors — and the review pass, which puts a human in front of the hardest documents, catches roughly half of those too.
B ends with about ten to twelve errors, and — critically — knows which documents were hard. A costs less to operate and gives you no idea where the ten errors are.
The metric that captures this is not accuracy. It is escaped error rate: wrong values that reached the system of record without being flagged.
-- The number that actually matters. Errors that nobody caught.
select
count(*) filter (where extracted <> expected and status = 'found'
and confidence >= auto_accept_threshold)::float
/ count(*) as escaped_error_rate
from eval_results;
Report that alongside accuracy. It is the number a customer is actually buying, and it is the one that moves when you change a confidence threshold.
Match the comparison to the field
extracted = expected is wrong for most field types, and a naive equality check
makes a good pipeline look bad:
const COMPARE: Record<string, (a: unknown, b: unknown) => boolean> = {
// Money: exact, in minor units. No tolerance. It is either the amount or not.
total: (a, b) => (a as Money).minor === (b as Money).minor
&& (a as Money).currency === (b as Money).currency,
// Dates: compare the resolved date, and count "correctly flagged as
// ambiguous" as correct rather than as a miss.
issuedOn: (a, b) => sameDate(a, b) || bothAmbiguousWithSameCandidates(a, b),
// Names: normalised. "ACME Ltd" and "Acme Limited" are the same supplier
// and scoring them as a miss measures your normaliser, not your extractor.
supplier: (a, b) => normaliseSupplier(a) === normaliseSupplier(b),
// Free text: exact match is meaningless. Bounded edit distance.
description: (a, b) => similarity(a, b) > 0.9,
}
Getting these wrong distorts in both directions. Exact string matching on supplier names understates accuracy; loose matching on money overstates it, which is far more dangerous.
Segment as well as split by field
Per-field is one axis. Per-stratum is the other, and the interesting information is usually in the cell where they cross:
native pdf scan photo
total 0.99 0.96 0.71
issuedOn 0.99 0.94 0.62
supplier 1.00 0.99 0.93
lineItems 0.97 0.88 0.44
That grid tells you what a single number cannot: the pipeline is excellent on native PDFs, adequate on scans, and should probably not be attempting line items on photographs at all. The action — route photographs to review, or to a stronger model — is visible in the grid and invisible in the average.
Building it requires the golden set to be stratified in the first place, which is the argument for doing that at the start.
What to put in front of a customer
Three numbers, not one:
- Escaped error rate, per material field. What gets through wrong.
- Review rate. What it costs to operate, in human seconds.
- Coverage. What proportion of documents complete without a person.
Those three describe a system honestly and they trade against each other visibly. A single accuracy figure describes none of them and invites the reader to assume the flattering interpretation, which is a bad way to start an engagement that will eventually involve someone finding an error.
More in how to evaluate an LLM pipeline, your golden set is your happy path, and when extraction accuracy collapses.