ADR-004: EasyOCR as the shipped OCR fallback engine¶
Status: Accepted (decided by the OCR benchmark, 2026-07-11)
Context¶
The PDF parser's OCR fallback (ADR-003 §5)
made the engine pluggable (OCR_ENGINE → Docling ocr_options factory) and
shipped EasyOCR as an explicitly provisional default — the plan required
the shipped default to be decided by benchmark data, not preference. The
eval ocr harness measures, per engine: CER/WER against the OCR
fixtures' known ground truth (data/eval/ocr_truth/), pages/sec
wall-clock (warmed engines, CPU), and — the project-relevant definition of
"better" — retrieval recall on the scanned-document golden queries with
the engine as the only variable (CONTEXTUALIZE off).
Benchmark results¶
Run 20260711T172924Z-ocr.json (3 fixture pages: a 1-page scanned memo, a
2-page mixed digital/scanned survey; 4 scanned-doc golden queries):
| Engine | CER | WER | Pages/s | recall@{5,10,20} (all 3 methods) |
|---|---|---|---|---|
| easyocr | 0.0000 | 0.0000 | 0.102 | 1.000 |
| tesseract | 0.0014 | 0.0161 | 0.551 | 1.000 |
- Tesseract's errors are concentrated on the scanned memo (WER 0.0526 there — two dropped/garbled words); it was error-free on the mixed survey.
- Both engines' chunk boundaries resolved every golden ref (no drift).
- Retrieval recall saturates at 1.000 for both — on this tiny corpus the engine choice is not a retrieval-quality signal either way.
Decision¶
EasyOCR ships as the default (OCR_ENGINE=easyocr). Tesseract remains
one config flip away.
Rationale¶
The data reduces this to a quality-vs-throughput call, and v1's usage profile weights quality:
- The fallback's whole purpose is recovering text for retrieval, and OCR noise hits BM25 keyword matching hardest — a dropped word is a permanently unmatchable keyword. Tesseract produced real word errors on exactly the kind of low-fidelity scan the fallback exists for; EasyOCR was error-free.
- Throughput is the wrong thing to optimize here: only textless documents trigger OCR, ingestion is offline batch work, and 5.4× faster (0.55 vs 0.10 pages/s) only matters at a scanned-document volume v1 doesn't have. If that volume arrives, the plan's escalation path is GPU/VLM OCR serving — not a lossier CPU engine.
- Zero migration cost: EasyOCR was already the provisional default and its weights are already in the cache volume.
Consequences¶
OCR_ENGINE=easyocrstays the default inconfig.py/.env.example, now marked as benchmark-decided rather than provisional.- Set
OCR_ENGINE=tesseractwhen throughput matters more than fidelity for a specific batch (e.g. a large, high-quality scan backlog) — the Tesseract system binary ships in the app image, so it always works. - The benchmark is re-runnable (
uv run --group eval main.py eval ocr); revisit this decision if the fixture set grows toward real-world scan quality or a new engine (e.g. RapidOCR — one factory line) is added.