Work · Documents

document-extractor.

A PDF goes in. Structured fields come out, each one pointing back to the words on the page.

12 November 2026 v1 · 0.1.0 Python 3.11+ MIT CI

Web demo on an invoice: nine fields are extracted (invoice number NWP-2026-0412, total 5,496.68 and seven others), each with a numbered source box on the page, and the question “When is payment due?” is answered with a cited passage.
Web demo on an invoice. The recording replays results computed by Qwen2.5-1.5B-Instruct. Nine fields extracted, each with a source box; the question “When is payment due?” is answered with a cited passage.

The problem

Retyping the same nine fields, hundreds of times a week.

Imagine a finance operations desk that closes the week by retyping 400 invoices. Each one is a PDF: a number, a due date, a total, two company names. Someone opens the file, finds those nine fields, and keys them into a spreadsheet. The work is careful and slow, and a transposed digit is easy to miss until a payment comes back.

The useful result is not a model that can “read PDFs.” It is a list of fields a person can audit, because every value still knows the place on the page it came from.

What we built

A pipeline that returns a claim, and the exhibit attached to it.

The pipeline is five steps, and a sixth if you ask a question. A PDF or a scan is read into words with coordinates (Florence-2 for scanned pages). A YAML schema lists the fields you want. Each returned value is matched back to the printed words on the page, and comes back as JSON with its page, bounding box and source text. Ask a question and a retriever — bge-m3, or BM25 — picks passages; the model answers with citations.

Think of each field as a claim, and the bounding box as the exhibit attached to it. The backend can be a hosted API or open models on your own machine; the rest of the pipeline stays the same.

Pipeline: a PDF or scan is read into words with page boxes; a YAML schema drives field extraction; each value is grounded back to the printed words, producing JSON with page, bounding box and source text. A parallel path answers questions from retrieved passages with citations. The same pipeline runs on an OpenAI-compatible API or on open models from Hugging Face.
The same five steps, whether the backend is your API or open models on your own machine.
Left: tax invoice invoice-northwind-classic.pdf, from Northwind Parts Pte. Ltd. to Example Logistics Pte. Ltd., invoice NWP-2026-0412, total due SGD 5,496.68. Right: the nine extracted fields, all correct, each sourced to matching text on page 1 — invoice_number NWP-2026-0412, invoice_date 2026-03-14 (from “14 March 2026”), due_date 2026-04-13, seller and buyer names, currency SGD, subtotal 5,042.83, tax 453.85, total 5,496.68.
invoice-northwind-classic.pdf beside the extract. All nine fields correct, all nine sourced. scripts/evaluate.py.
Web demo after extraction: a table of nine fields for invoice-northwind-classic.pdf, all correct (invoice_number NWP-2026-0412, invoice_date 2026-03-14, due_date 2026-04-13, seller_name Northwind Parts Pte. Ltd., buyer_name Example Logistics Pte. Ltd., currency SGD, subtotal 5,042.83, tax 453.85, total 5,496.68), with numbered rust source boxes drawn on the invoice page.
Nine fields from invoice-northwind-classic.pdf, all correct and sourced. Numbered rust boxes on the page mark each source. Qwen2.5-1.5B-Instruct, Hugging Face backend.
Web demo after asking “When is payment due?”: the answer “Payment is due on 13 April 2026” with a cited passage, and an amber box on the invoice highlighting the due date.
Question: “When is payment due?” Answer: “Payment is due on 13 April 2026,” with a cited passage and an amber box on the due date of the same invoice.

A total without a source is a rumour. A total with a bounding box is a claim you can audit — from 5,496.68 back to the printed “SGD 5,496.68” on the page, and reject it if the box is wrong. That is the difference between an extract you retype to be safe, and an extract you can actually use.

Results

60 of 61 fields correct, every correct value sourced.

On seven PDFs — four invoice layouts, two contracts, and one image-only scan — the Hugging Face backend with Qwen2.5-1.5B-Instruct got 60 of 61 fields correct (98%). Every correct value also carried a source box whose text matches the value (60/60).

The one miss is an OCR slip on the scanned invoice: buyer_name came back as Northwind Parts Pte., Ltd. (an extra comma) instead of Northwind Parts Pte. Ltd.

Field-extraction results on seven samples, 12 November 2026.
SampleCorrectSourcedTime (s)Notes
contract-master-services.pdf 8/88/818.7—
contract-supply.pdf 8/88/89.0—
invoice-acme-scanned.pdf 8/98/822.1buyer_name: extra OCR comma
invoice-demo-freight-compact.pdf 9/99/910.5—
invoice-example-logistics-banner.pdf 9/99/99.7—
invoice-northwind-classic.pdf 9/99/910.9—
invoice-placeholder-two-page.pdf 9/99/913.6—
All60/61 (98%)60/609.0–22.1

Methodology. Seven PDFs (four invoice layouts, two contracts, one image-only scan), 61 fields in total. A field counts as correct when the typed value matches the ground truth in samples/*.truth.json (strings compared case-insensitively, extra spaces and trailing punctuation ignored; numbers within 0.005). “Sourced” counts correct values that also carry a source box whose text matches the value. Hugging Face backend, Qwen2.5-1.5B-Instruct, Florence-2 for the scanned page, 12 November 2026. Wall-clock for all seven samples was 96 seconds, including model load. Per-document times were 9.0–22.1 seconds. The raw table is in docs/eval.md. The script is scripts/evaluate.py; the samples are in samples/.

The API backend against a local llama.cpp server (Qwen2.5-1.5B-Instruct, Q4_K_M) got 52 of 61 fields correct: all 52 fields on the six text-layer PDFs, in 3.9–12.1 seconds each. The scanned invoice is scored 0/9 there, because that backend has no OCR unless a vision model is configured.

Run it yourself

Three commands, then the demo.

Quickstart
git clone https://github.com/ModelShifts/document-extractor
cd document-extractor && python -m venv .venv && source .venv/bin/activate
pip install -e ".[hf,demo]" && document-extractor serve

On a CPU-only machine, install the CPU build of PyTorch first and set MS_HF_MODEL=Qwen/Qwen2.5-1.5B-Instruct. Python 3.11 or newer.