Getting a model to quote its source is straightforward. Getting the reader to the exact words on the exact page, so a citation can be checked in a second rather than read for in a minute, is a different problem — and it is the one that decides whether anyone trusts the answer. We shipped it, watched it point at the wrong paragraph, and found three unrelated bugs stacked on top of each other.

The rule we started from has not changed: never ask the model for coordinates. A model asked where on the page something sits will produce numbers, and they will look reasonable. The rectangle has to come from the document’s own word geometry. So the quote is matched back against the page, and the words that match are what gets boxed.

Fault one: three words is not a phrase

The first implementation anchored on the quote’s first three words, found them on the page, and drew a box around however many words followed. It never checked that what followed resembled the rest of the quote.

On an insurance form that is fatal, because insurance forms repeat themselves by design. “Print name date” appears in the header, in the instructions, and again at the execution block. “Any person who” opens the fraud warning for every state on the page — five or six times in a column. A three-word anchor lands on the first occurrence, confidently, and the reader is sent to a warning notice when they asked who signed.

What replaced it is order-free and weighted. A window slides over the page and each position is scored by how much of the whole quote it accounts for, with each word weighted by how rare it is on that page. Common words still contribute to the box; they cannot carry a match on their own. That single change is what tells a signature block apart from a paragraph that happens to mention signing.

A word the page does not contain has to count against the match

An early version scored only over words that were present, which meant every quote fitted somewhere. “The quick brown fox jumps over the lazy dog” scored a perfect match on a proposal form, because only “the”, “over” and “and” were ever counted. Absent words now carry full weight in the denominator, so a fabricated quote is refused instead of placed.

Fault two: forms are quoted out of order, and that is correct

Here is the case that broke the second implementation too. Asked who signed, the model answered:

“PRINT NAME: Amelia Hartwell  Executive Director  DATE: 03/28/2026  SIGNATURE OF EXECUTIVE DIRECTOR: Amelia Hartwell”

Every one of those words is on the page. None of them are in that order. The form prints the values on one line — the signature, the typed name, the date — and the labels on the line beneath them. The model has read the form the way a person reads a form, pairing each printed label with the value written against it, and then written down what it means. That is the right way to read a form. It is also why the quote exists nowhere on the page as a contiguous phrase.

A sequential matcher has two options here and both are bad: refuse to place it, or follow a word out of order and drag the highlight down the page. Ours did the second, which is how a signature citation ended up boxing three lines of fraud warning.

Order-free matching handles it directly. The words are all in one area; the score does not care what sequence they arrived in.

But then: a citation points at a place

Order-free matching brought its own problem. The scoring window is longer than the quote, so it reaches into neighbouring text — and on this form “Executive Director” appears in the fraud clause four inches above the signature block as well as in the block itself. Both matched. Both got boxed. The reader is sent to the earlier one, which is precisely the complaint we set out to fix.

So matched lines are grouped into vertical bands of adjacent lines, and only the band carrying the evidence is drawn. A second band survives when it is comparably strong — which is exactly what a labels-here-values-there form citation looks like. And the highlight is one rectangle per line, never one box from the first matched word to the last: a quote spanning three lines drawn as a single rectangle covers the full column width and everything sitting between those lines.

Fault three: the rectangles were right and the frame was wrong

With all of that fixed, the highlights still landed about a third of a page above their words. We had spent an hour on the matcher and the matcher was, by then, correct.

The overlay was positioned in percentages against its container. The container was a flex item, and the flex row had stretched it: 1200 × 725, against a page canvas of 760 × 983. Every percentage resolved against the wrong box. The horizontal positions still looked plausible — the columns lined up — which is exactly why it read as a text-matching bug rather than a layout one, and why we kept looking in the wrong file.

It was found by measuring the DOM instead of reasoning about it. Two numbers, ten seconds, after an hour of plausible theories. The fix is to measure the painted canvas and position in pixels.

The general lesson, which we keep relearning

When output is wrong and the pipeline has several stages, the instinct is to debug the clever stage. The clever stage is usually fine. Measure the boring one first — the container, the coordinate space, the units — because a layout bug and a matching bug produce the same screenshot.

What it does now, measured

Sampled across every PDF in our test corpus, with the deliberately degraded variants a model actually produces:

QuoteRight placeWrong placeNot located
Verbatim page lines109 / 10900
Word order shuffled58 / 5800
30% of words dropped53 / 5401
Assembled from two places57 / 5801
Fabricated (not in the document)0 of 29 placed anywhere

The number that matters is the middle column. Nothing is placed in the wrong spot; the residual failure mode is “no highlight”, and the viewer says so plainly. That asymmetry is deliberate. A missing highlight costs a reader ten seconds of scanning. A confident highlight on the wrong paragraph costs them the habit of checking at all — and once an underwriter stops clicking citations, every guarantee further up the stack is decorative.

Scanned pages

None of the above works on a scan, because a scanned page has no text layer to match against. The geometry does exist — OCR produced a box for every word on the way in — it was simply being rendered into a text blob and discarded, which is a pattern we have hit repeatedly: the data the code needs, thrown away one step before the code that needs it. It is now kept, stored for the pages that need it when the folder is prepared, and the same matcher runs over it unchanged.