LedgerMap research · 17/10/2026 · for Khush

Scanned bank statements: how LedgerMap can read them

Today the app refuses every scanned PDF. I tested 11 ways to read scans on real Indian statements, scored every row against known values, and checked what our own validation would catch. This page gives the answer first, then the evidence.

An example with made-up figures. One model read a 6 as a 0 in a real test. The balance before, minus the debit, must equal the balance after. When it does not, we know which row to show the user.

The short answer

  1. What works: a vision-language model (VLM) that reads each page image straight into rows (date, narration, debit, credit, balance). Our own code then checks every row. The best models read 97–99% of rows exactly on real scans. With the checks, almost no wrong row got through unflagged.
  2. Where: on our server, not on the customer's PC. Open models on a CPU take 38–130 s per page, far over a 10–12 s budget for a whole statement. Our server has 4 CPU cores and no GPU, so it must send pages to an API (or to a rented GPU later), all pages of a statement at the same time.
  3. Speed: the 10–12 s target is reachable only with every page read in parallel. The models write about 120–140 tokens a second, and a page needs 700–1,500 tokens, so the slowest page sets the time: about 6–13 s of writing, plus 1–3 s to send the image. Dense pages need the shorter output format and split pages described below. My CLI-based tests could not measure the true API time (see "Speed").
  4. Which service: start with Gemini 3.8 Flash (97.9% of rows exact on real scans, about $4 per 1,000 pages). Add Claude Haiku 5.5 as a cheap second reader (97.2%, about $1–4 per 1,000 pages). Claude Opus 5.5 was the most accurate (99.3%), at about $26 per 1,000 pages. Use it for pages that fail the checks.
  5. Open source: not good enough yet. On real scans PaddleOCR-VL-1.6 read 74% of rows exactly and GLM-OCR 77% (cloud models: 97–99%). PP-OCR (Apache-2.0) is still useful: it finds 99.8% of amounts exactly in 2–7 s per page on CPU, so it is our independent second reader and gives the position of each value on the page.
  6. What does not work: adding OCR text to our current parser. Only 1 of 37 OCR'd statements validated. Tesseract misses 22–69% of amounts.

What I tested on

There were no scanned uploads in production yet (12 statements in total, all text PDFs). So I built three test sets. Two of them have exact answers.

SetWhere it came fromPagesRowsHow we know the right answerSent to cloud?
ProductionThe 6 distinct validated production statements, turned into scan-like images (tilt, blur, noise, grey paper, JPEG)22441The validated rows saved in productionNo, this PC only
31 banksOne text PDF per bank from the Drive samples (SBI, HDFC, ICICI, Axis, BoB, PNB, Kotak, co-operative banks and more), made scan-like in the same way31704Our parser's validated rows from the original text PDFYes
Real scansThe 31 scanned PDFs in the Drive samples: phone photos, office scans, passbook pages, handwriting, ticks, punched holes, cut-off edges124 (31 scored)428 scoredPage 1 of each PDF: where two models from different vendors agreed exactly and the balances reconciled (26 pages), I accepted the rows. I checked the other 5 pages by eye.Yes

A row counts as right only if its date, debit, credit and balance all match exactly. Narration is scored separately. Because the real-scan answers came partly from model agreement, they favour models that agree with Gemini and Claude Sonnet. The two other sets have no such bias.

Results

Rows read exactly right (date, debit, credit, balance)
EngineTypeReal scans31 banksProductionSeconds per pageUSD per 1,000 pagesLicense / access
Cloud APIs (tested through their command-line tools)
Claude Opus 5.5General VLM
99.3%
100%not sent17*~26Anthropic API; India processing on Bedrock
GPT-6 AstraGeneral VLM
98.8%
99.9%not sent28*~54OpenAI API; no India processing
Gemini 3.8 Flash (low thinking)General VLM
97.9%
100%not sent29*~4Google API; Vertex AI Mumbai (check model list)
Claude Haiku 5.5General VLM
97.2%
98.6%not sent20*1–4 (measured)Anthropic API
Gemini 3.8 Flash (high thinking)General VLM
86.2%
100%not sent109*~4One 46-row page came back empty
Claude Sonnet 5.5General VLM
91.8%
100%not sent15*~13Slipped a date column by one row on one page
Open models on CPU (8 threads of a Ryzen 9 9950X)
GLM-OCR (official table prompt)OCR VLM, 0.9B
77.2%
100% (2 pages)—1120MIT. Stopped after 19 of 31 real-scan pages: too slow
PaddleOCR-VL-1.6 (official pipeline)Layout + OCR VLM, 0.9B
74.3%
——380Apache-2.0. Real scans only; then stopped
Classic OCR on CPU (text only, no rows)
PP-OCRv6 via RapidOCRDetector + recognizer—amounts found exactly: 99.8%2–7 (4 cores)0Apache-2.0; runs on Windows and Linux
Tesseract 5Detector + recognizer—amounts found: 78% / 31%2–40Apache-2.0
PP-OCR words fed into our current parserOCR + recipes—58%57%+10Validated 1 of 37 statements

* Cloud times include the start-up of each command-line tool (10–20 s). A direct API call should take about 5–15 s per page, and pages of one statement can run at the same time. Costs are API list prices (October 2026) times tokens per page. Haiku was measured: 2,900–7,500 output tokens per page, because thinking is on by default and is billed as output. The other costs count only the visible rows (about 820 tokens) and are too low by the same effect: treat them as minimums. "Production" was run only with engines on this PC.

Speed against a 10–12 s budget

The budget is for a whole statement, from upload to rows. Most scanned statements in the samples have 1–4 pages; the longest has 23.

What I measuredResultWhat it means
Open models on CPU, per page38–130 sOut on any PC or on our server
Model writing speed (fitted from 400+ page calls)120–140 tokens/sHaiku, Sonnet and Gemini Flash are alike; GPT-6 Astra is about 54 tokens/s
Output per page700 median, 1,530 for dense pages (tokens)Writing alone takes 6–13 s per page
CLI start-up with no image (cost of my test method only)4.5 s Claude, 7.5 s Codex, 15 s AntigravityA direct API call does not pay this
10-page real scan, all pages at once, through the CLI22 s Haiku, 59 s GeminiIncludes the CLI overhead and a file-reading step for each page
Same statement, one line per row instead of JSON20% fewer charactersAccuracy unchanged (224–226 of 226 rows agree)
Same statement, each page cut into two overlapping halvesOutput per call down 42%All 226 rows recovered after merging the halves

Estimate for a direct API call: 1–3 s to send the image, then the writing time of the slowest page. A typical page should take about 7–9 s and a dense page about 13–15 s. With the one-line format and split pages, a dense page drops to about 5–8 s. With every page sent at once, a statement of up to about 20 pages should finish in 8–12 s. This is an estimate, not a measurement. To confirm it, I need a direct Gemini or Anthropic API key, or the GPU free for the local test.

Two more ways to make the wait feel shorter: show rows as they arrive (pages finish one by one), and start reading as soon as the upload ends, before the bank is confirmed.

The mistakes the models made

All errors on the real scans fall into five kinds. These are the cases our checks must handle.

What our checks catch

For each page I ran four checks that need no right answer:

  1. The running balance reconciles on every row (in page order or newest-first).
  2. Every amount the model read also appears in the PP-OCR reading of the same page.
  3. The dates never go backwards.
  4. Every row has a balance and exactly one of debit or credit, and no "?".
Real scans, page 1 of 31 PDFs (26 pages with transactions)
ModelWrong rowsWrong rows that passed all checksAmounts flagged by PP-OCRWrong amounts the PP-OCR check caught
Claude Opus 5.530——
GPT-6 Astra5041 of 839 (4.9%)3 of 3
Gemini 3.8 Flash91 (the punched-hole date)45 of 847 (5.3%)7 of 8
Claude Haiku 5.512037 of 847 (4.4%)—
Claude Sonnet 5.536038 of 847 (4.5%)0 of 8 (the invented paise)

On whole statements the balance check is even stronger. Gemini 3.8 Flash read all 124 pages of the 31 real scans (2,155 rows). 27 of 31 statements reconciled from the first row to the last, across page breaks. The other 4 showed 45 breaks, all on pages with heavy handwriting or ticks. Those statements would open as "to check", with the exact rows marked, as the app already does for needs_review.

The PP-OCR check is strict: on real scans it questions about 5% of correct amounts, because PP-OCR itself misreads some cells. So it should mark single cells to check, not reject the page.

Where to run it

OptionSpeed (measured)AccuracyCostVerdict
Customer's Windows PC, CPUPaddleOCR-VL-1.6: 32 s/page; GLM-OCR: 128 s/page (4 fast cores). A 10-page statement takes 5–20 minutes. Office PCs are slower.Small open models onlyFree, but a 1–2 GB download in the installerNo for reading rows. PP-OCR (about 2–7 s/page) could run here.
Customer's PC, GPUFast on a gaming GPU, but most office PCs have noneSame as the open modelsFreeNo: we cannot count on the hardware
Our server (4 vCPU EPYC 7F72, 15 GB, no GPU)About 1.5–2× slower than my PC's cores: about 60 s/page for a 0.9B modelSame as the open modelsFreeOnly PP-OCR: a VLM is too slow for the 100 s Cloudflare limit
Our server → VLM API5–15 s per page; pages run in parallel97–99% of rows exact on real scans$1–26 per 1,000 pagesYes, start here
Our server → rented GPU with an open modelNot measured: the test GPU was busy. Published: about 1 page/s per model on an A10074–77% on real scans for the open models tested (on CPU; same weights)About $0.5–1 per hour for an L4-class GPU, or per-second serverlessLater: when volume is high or data must stay with us

At today's volume an API is far cheaper than any GPU. 10,000 scanned pages a month cost about $40 with Gemini Flash plus about $6 for a Haiku second reading. A GPU that is always on costs $400–700 a month.

Proposed design

The design keeps the rule that already protects the app: a statement is validated only when its numbers prove themselves, and otherwise it is needs_review with the rows marked.

1. UploadThe desktop app sends the PDF as it does now. The parser says scanned_pdf instead of failing.
2. RenderThe server renders each page at 200–300 DPI with PDFium.
3. Read twiceA VLM API reads each page into rows (all pages in parallel). PP-OCR reads the same page on our CPU and gives word positions.
4. CheckBalance chain across all pages, opening and closing balance, PP-OCR agreement per amount, dates in order, no "?". Rows that fail go to a stronger model (Opus) once.
5. ShowAll pass: validated. Else needs_review with the rows to check, and Alt+O shows the place on the page from the PP-OCR boxes.

Steps to build it

  1. Ask the user (the accountant) for consent to send page images to the chosen provider. Today they consent only to sending extracted text to OpenAI.
  2. Add a scan path on the server: render pages, call Gemini 3.8 Flash per page with a fixed JSON schema and amounts as strings, at temperature 0.
  3. Run PP-OCR (RapidOCR, ONNX) on the server for the cross-check and the word boxes.
  4. Reuse our validation: balance chain, totals, needs_review reasons, plus new reasons such as ocr_disagrees and unreadable_value.
  5. Retry failed rows once with Claude Opus 5.5 (or Haiku as a cheap second reader), then show the rest to the user.
  6. Make long statements asynchronous (a job with progress), because 20+ pages can pass Cloudflare's 100 s limit.
  7. Track every scan outcome in tracking_events, so we can measure real accuracy.

External services

From web research on 17/10/2026. I tested only the general VLMs above; the rest are from the vendors' pages.

ServiceWhat it givesUSD per 1,000 pagesProcessing in IndiaFit for us
Gemini 3.8 Flash (Google)Rows straight from the image (tested)~4 (doubles on 01/01/2027)Vertex AI Mumbai for listed models; check 3.8First choice
Claude Haiku / Opus 5.5 (Anthropic)Rows straight from the image (tested)~0.6 / ~26Amazon Bedrock India profileSecond reader / hard pages
GPT-6 Astra (OpenAI)Rows (tested); most honest about unreadable values~54Storage only, no processingToo costly
Mistral OCR 4 / 4.1Markdown and tables, block boxes, word confidence4 (2 in batch)Not statedNot tested: no API key
Google Document AI OCRWords with positions~1.5MumbaiSecond reader option
AWS Textract (tables)Words and table cells with positions15MumbaiSecond reader option
Azure Document Intelligence (Layout)Words and tables with positions~10Central IndiaIts bank-statement model reads US statements only
Perfios, FinBox, Signzy, HyperVerge, Arya.aiBank statement analysis for lenders; accept scansQuote onlyIndiaNo: built for credit reports, not rows for accountants
LlamaParse, Reducto, Unstructured, Nanonets, DocsumoGeneral document parsing1–300Not statedNo advantage over a VLM plus our checks

Open-source models

The newest public ranking (OmniDocBench v1.6, September 2026) is led by small open models. None of the benchmarks uses Indian bank statements. I ran PaddleOCR-VL-1.6 and GLM-OCR on our real scans (74% and 77% of rows exact). The others are downloaded and ready for a GPU test.

ModelSizeLicenseOmniDocBench v1.6Positions of valuesNotes
TeleOCR1.2BApache-2.096.9Layout blocksBuilt for photos of documents
OvisOCR20.8BApache-2.096.5Images only
PaddleOCR-VL-1.60.9BApache-2.096.3Text spottingBest on skew and photos (Real5 benchmark)
MinerU2.5-Pro1.2BApache-2.0 (model)95.8Layout blocksReady CPU install in MinerU 4
GLM-OCR0.9BMIT95.2No
Chandra 24BPaid above $2M revenue—Layout blocksBest on old scans (olmOCR-Bench); not tested
Qwen3-VL 4B / 8B4–8BApache-2.0—On requestGeneral model: follows our JSON prompt
PP-OCRv5 / v6smallApache-2.0—Words and charactersNot generative: cannot invent a row

Privacy

Limits of this study

How I ran the tests

All work is in the main checkout's ignored folder .local/ocr-research/ (scripts, sets, results). scripts/build_sets.py makes the sets, run_cli.py runs cloud models, run_vllm.py and run_openai_api.py run local ones, evaluate.py scores rows, gate.py measures the checks, doc_check.py checks whole statements, and probe_docs.py feeds PP-OCR words into the current parser. The earlier experiment in bank-parser/docs/ocr-sample-036-benchmark.md reached the same result for OCR plus the current parser.

Every model got the same prompt: return rows as JSON, copy digits exactly, never calculate, write "?" for unreadable characters, and ignore handwriting and stamps. Document models got their own table prompt, and I turned their tables into rows by their column headers.

Sources