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 (measured through the direct API): with thinking off and every page sent at once, Claude Haiku 5.5 read whole statements in 5.4 s (median) and at most 12.5 s, over all 31 real scans (1 to 23 pages). Thinking off does not lower accuracy. It only removes the hidden thinking tokens.
  4. Which service: Claude Haiku 5.5 with thinking off (97.0% of rows exact on real scans, 100% on the 31 banks, $1.2 per 1,000 pages, measured). Read every page with Claude Sonnet 5.5 with thinking off at the same time (97.4%, $24 per 1,000 pages), and use Sonnet's page only where Haiku's page fails the checks. Together, 28 of 31 real statements reconciled end to end in at most 13.5 s, for about ₹2 a page. Gemini 3.8 Flash (97.9%) and Opus 5.5 (99.3%) were tested only through their CLIs, so their true speed is not known.
  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
Direct Anthropic API, measured (usage and time from the API itself)
Claude Haiku 5.5, thinking off, low effortGeneral VLM
97.0%
100%not sent5.11.20 thinking tokens; 1,290 output tokens per page
Claude Haiku 5.5, thinking off, high effortGeneral VLM
97.2%
100%not sent5.51.2Same as low effort
Claude Haiku 5.5, thinking on, low effortGeneral VLM
97.2%
98.6%not sent6.01.3Thought only on some pages; slowest page 18.5 s
Claude Sonnet 5.5, thinking off, low effortGeneral VLM
97.4%
100%not sent7.624No date-column shift this time; 28 of 31 whole statements reconcile
Claude Sonnet 5.5, thinking off, high effortGeneral VLM
97.7%
98.6%not sent7.824Swapped debit and credit on one bank page (caught)
Cloud APIs tested through their command-line tools (times include the tool)
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
Claude Haiku 5.5 (low effort)General VLM
96.0%
100%not sent15*~2 (measured)Same errors as medium, all caught by the checks; 3 of them were honest "?" for hidden dates
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

* Times through a command-line tool include its start-up and file-reading step (10–20 s); the direct API rows do not. Costs are API list prices (October 2026) times tokens per page. The direct API rows use the tokens the API reported: about 5,200 input tokens per page image and 1,300 output tokens. The CLI rows for Gemini, GPT and Opus count only the visible rows and assume fewer input tokens, so treat them as minimums. Through the CLI, Claude thinking cannot be turned off (I tried both settings); the API accepts thinking: {type: "disabled"} for Haiku 5.5 and {type: "between_tools"} for Sonnet 5.5, at effort high or below. "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
Direct Anthropic API, all 31 real statements (124 pages), every page of a statement sent at once
Haiku 5.5, thinking offmedian 5.4 s, 90% within 12.5 s, slowest 16.0 s4 of 31 statements over 12 s, each because of one slow reply
Haiku 5.5, thinking off, second copy of a page sent after 8 smedian 5.4 s, 90% within 9.4 s, slowest 12.5 sMeets the budget. 17 of 124 pages needed the second copy. The slowest is a page with 46 dense rows
Sonnet 5.5, thinking off, second copy after 10 smedian 8.1 s, slowest 20.5 s7 of 31 statements over 12 s
Haiku and Sonnet at once, Sonnet's page used only where Haiku's fails the checksmedian 6.0 s, slowest 13.5 s16 of 124 pages came from Sonnet; 3 statements just over 12 s
Pages cut into two halves, through the APIslowest page 9.2 s (was 11.4 s)No: rows exact on real scans fell from 97.0% to 91.1%. The lower half has no column headings, so the model put some debits in the credit column

Measured with the direct API: Haiku 5.5 with thinking off writes about 250–300 tokens a second, about twice the rate seen through the CLI. A typical page takes 4–7 s and a dense 46-row page about 11–12 s. The time of a statement is the time of its slowest page, not the number of pages: the 23-page statement took 5.6 s. Gemini was not measured through its API (no Gemini key).

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)

Two more checks, added after the API tests: (5) every date must also appear in the PP-OCR reading of the page, and (6) no row may be named B/F, brought forward, opening, carried forward or closing balance. With them, no wrong row passed for any Claude run, including the guessed dates and the B/F row above. The cost is more pages to look at: on the 31 banks, 21 instead of 29 pages pass by themselves, because PP-OCR misreads some dates. The date check should mark the one row, not the page.

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 41 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. Through the direct API with thinking off: Haiku reconciled 22 of 31 statements (64 breaks), Sonnet 28 of 31 (9 breaks), and Haiku with Sonnet's page where Haiku's fails 28 of 31 (7 breaks).

What the last 7 breaks are (checked by eye on the page images):

So of 2,147 rows, the only wrong values were two ₹1 rows and one amount on a repeated row. All three were flagged.

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.

Confidence per row and the Sonnet fallback

Measured on 18/10/2026 through the direct API, on the 62 pages with exact answers (1,135 rows).

QuestionResult
What does a confidence score (0–100) per row cost?+5% output tokens: Haiku $1.23 → $1.27 per 1,000 pages. No slower when run side by side (median 5.7 s with it, 6.0 s without). Accuracy unchanged.
Does a low score mark the wrong rows?Partly. Haiku: below 80 flags 4.7% of rows and catches 19 of 22 wrong rows; below 90 catches all 22 but flags 25%. Sonnet is better calibrated: below 90 flags 4.4% and catches 10 of 10.
Does it add to our own checks?Yes. Our row checks (balance, PP-OCR, dates, labels, "?") caught 14 of Haiku's 22 wrong rows. The 8 they missed (invented paise, guessed hidden dates) all had a score below 80. Checks + score below 80: 0 wrong rows passed, at the cost of 19 more correct rows flagged.
Who decides the flagged rows?A second model. Of 241 flagged rows, Sonnet read 219 the same way, and none of those were wrong. The other 22 went to the user, and all 22 were really wrong or unreadable. Gemini Flash as the second reader gave the same result. A second Haiku did not: it agreed with 4 wrong rows, because it makes the same mistakes.
Re-read only the problem region, or the whole page?The whole page. Re-reading the whole page fixed all 7 pages it was used on. Re-reading only a region around the break fixed 4 of 9 pages and made 2 worse, because the cut loses the row context. A region call is faster (about 3.5 s against 5–15 s), but less reliable.
How many pages need the second reader?26 of 57 pages had at least one flagged row, mostly because PP-OCR misreads some dates. Run Sonnet on every page at the same time as Haiku: about $26 per 1,000 pages, slowest statement 13.5 s. Or run it after Haiku only on flagged pages: about $13 per 1,000 pages, but 7 of 31 statements then take 15–22 s.

The score threshold (80) was chosen on the same pages it was tested on, and there are only 22 wrong rows. Treat it as a starting point. Measure it again on real scanned uploads and track it in tracking_events.

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 $12 with Haiku alone, or about $250 with Haiku and Sonnet together (measured tokens). 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. ReadClaude Haiku 5.5 and Sonnet 5.5, thinking off, read every page into rows (all pages in parallel; a second copy after 8 s). 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 "?". Where Haiku's page fails, use Sonnet's page.
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 Claude Haiku 5.5 and Sonnet 5.5 per page with thinking off, a fixed JSON schema and amounts as strings.
  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. Use Sonnet's page where Haiku's page fails the checks, then show the rest to the user with the rows marked.
  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.8Strong option; API speed not measured
Claude Haiku / Opus 5.5 (Anthropic)Rows straight from the image (tested)1.2 Haiku / 24 Sonnet (measured) / ~26 OpusAmazon Bedrock India profileFirst choice (Haiku + Sonnet, thinking off)
Nanonets free bank-statement converter (tested, no log-in)Upload a PDF, get an Excel sheet. Tested on 7 real scans: page 1 rows exact 50.8% (95 of 187) against 99.5% for Haiku with thinking off. Read only page 1 of a 6-page scan (45 of 224 rows, 2 right). Returned nothing for a 4-page IDBI scan. On a punched-hole page every amount was changed but the balances still added up, so a balance check would not catch it. Good on clean office scans (HDFC 10 pages: 225 rows, 1 break)freeNot statedNo: about half the rows wrong on our scans
Nanonets OCR-3 APIMarkdown and HTML tables with a box and a confidence score for each element; /extract fills a schema. 35B mixture-of-experts model, API only (not tested here)10Not stated (US company)Worth one test: tables still need our row mapping and checks; 49.6 on the "old scans" part of olmOCR-bench
Nanonets workflow platformNo-code workflows billed per step: $0.02 simple, $0.10 standard AI, $0.30 extraction. $50 free credits; volume plans by quote300–2,000Not statedNo: 25–150 times the cost of Haiku with Sonnet
Nanonets-OCR2-3B (open weights)Qwen2.5-VL-3B fine-tune, page to markdown and HTML tables0 + GPUOur own GPULater: same class as the open models tested above (74–77%)
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