OCR pipeline — current state + target architecture (2026-06-09)
Design doc. Captures the OCR rework requested 2026-06-09: decompose OCR into scalable, single-concern web services, separate OCR from text enrichment, and return a structured per-page JSON envelope. Not yet built — proposal for review.
Problem with today's pipeline
recover-ocr (enrich-tasks task) → processor/src/extract/ocr-recover.ts →
processor/src/ocr.ts runs the entire cascade in-process inside the
enrich-runner:
- fetch PDF (local
/dataor SeaweedFS filer) pdftoppmrasterize every page to PNG in the runner's memory- tika (text layer) → glm/VL (pool-ocr) → tesseract sidecar, per page
- merge into a single
ocr_textblob; writeocr_text + body + ocr_method + ocr_confidence
Consequences:
- OOM: rasterizing 56–79-page PDFs ×N concurrent docs in the runner spiked memory and crash-looped the whole runner fleet (node-eighteen = SPOF). 2026-06-09.
- No structured output: per-page text-layer / tesseract / VL results are collapsed into one blob — can't compare engines, can't re-run one stage.
- Mixed concerns: OCR orchestration lives next to (and competes with) the LLM enrichment tasks in the same process.
What already exists (reusable)
| Service | Replicas | Endpoint | Role |
|---|---|---|---|
tika / tika_tika |
3/3 + 2/2 | :9998 |
text-layer extraction |
postcrime-heuristics-tesseract-sidecar |
6/6 | :8082 |
per-page tesseract (scaled, splittable) |
pool-ocr (gpumon ingress) |
— | gpumon-ingress:4001 |
VL OCR (NIM nemotron-vl + spark qwen2.5-vl) |
gpumon-ocr-api / -worker / -cache |
1/1 · 2/2 · 1/1 | gpumon overlay | PDF→OCR gateway + worker + cache (edgar uses it via submitPdf()) |
The building blocks are services already. Only the orchestration is misplaced.
Target architecture
OCR = only rasterize + tesseract + VL OCR + embeddings. Text enrichment (bluebook, summaries, scheme, money, …) is downstream and separate, consuming the OCR JSON.
PDF ─▶ ocr-intake (orchestrator)
│ poppler: pdf → per-page PNG "envelopes" (pdftoppm/pdftotext)
│ + extract text-layer (tika) ── text_layer[page]
├─▶ tesseract-svc (per-page PNG → text) ── tesseract[page] (scale N)
├─▶ vl-ocr-svc (per-page PNG envelope → VL via pool-ocr) ── vl_ocr[page]
└─▶ embed-svc (page text → vector) ── embedding[page]
│
▼
OCR JSON { pages:[ {n, text_layer, tesseract, vl_ocr, confidence} ],
merged_text, page_count, methods[] }
│
▼
── separate enrichment services consume the JSON ──
bluebook-cite · summarize(5-sentence/sentence/paragraph) · scheme · money · …
Design rules:
- Each stage is its own web service taking an envelope (PDF or per-page PNG)
over HTTP and returning JSON. The orchestrator never rasterizes in the caller's
memory —
ocr-intake(or the existinggpumon-ocr-api) owns that, mem-capped- horizontally scaled.
- tesseract is split-scalable (already 6/6) — it scales independently of VL.
- VL OCR takes a per-page PNG envelope and routes through
pool-ocr— one page per call so a 79-page doc fans out instead of one giant request. - OCR returns all three engines per page (text-layer, tesseract, VL) + confidence, so callers pick/diff and stages are independently re-runnable.
- Enrichment is downstream: bluebook citation, the 5 summary variants, scheme/money/embeddings run as separate services/tasks keyed off the OCR JSON — NOT inside the OCR path.
Decision needed (build vs wire)
Two viable paths — needs your call before implementation:
- Wire to existing
gpumon-ocr-api— if its API already does PDF→per-page tesseract+VL+JSON, change fraud'srecover-ocrtosubmitPdf()(like edgar) and stop the in-process cascade. Smallest change. Open: confirm gpumon-ocr-api returns the structured per-page JSON (text_layer+tesseract+vl) we want, or only merged text. - New
fraud-ocr-orchestratorservice — a dedicated, mem-capped Bun service that owns rasterize + fan-out to tesseract-svc / vl-ocr-svc / tika and returns the JSON envelope. More work but full control of the schema + scaling knobs.
DB shape change either way: add per-engine columns or an ocr_pages_json
(per-page text_layer/tesseract/vl/confidence) so the structured output is preserved,
not collapsed to ocr_text.
Chosen path (2026-06-09): wire to gpumon-ocr-api
gpumon-ocr-api (gpu-federation-monitor/services/ocr-api) is an async job
gateway, already used by edgar:
POST /jobs{ filename, bytes, backend?: "vision"|"tesseract", … } →{ id }GET /jobs/:id→ status +total_pages/pages_doneGET /jobs/:id/result.{txt,md,json}→ output text- client lib:
shared/ocr-api-client(submitPdf,getJob,getResultText) - edgar reference:
filing-ingester.tssubmits + recordsocr_job_id/ocr_status;ocr-reaper.tspolls finished jobs → writes text. Mirror this exactly.
Wire-up plan (fraud side):
- Schema: add
ocr_job_id/ocr_status/ocr_attemptstopostcrime.documents(idempotent; same columns edgar added). - Replace
recover-ocr's in-processocr.tscascade withsubmitPdf(filename, bytes, { backend: "vision" })for image-only docs (tesseract fallback); recordocr_job_id, ACK immediately. No rasterization in the runner. - Add an
ocr-reaper(in-process poll loop, like edgar's) that reads finished jobs → writesocr_text+ flips the OCR sentinel. - Retire the local
pdftoppm/tesseract cascade from the runner once verified (the poppler stopgap below can then be dropped).
Known gap vs the target JSON: gpumon-ocr-api runs ONE backend per job
(vision XOR tesseract) and returns merged text — NOT text_layer+tesseract+vl
per page. To get the full per-page 3-engine envelope, follow-on work is needed:
either (a) extend gpumon-ocr-api to fan a job across text-layer(tika)+tesseract+vl
and emit per-page JSON, or (b) fraud submits two jobs (vision+tesseract) + runs
tika and merges per page. Decide after the basic wire-up is live and OCR is off
the runner.
Immediate stopgap (already shipped 2026-06-09)
poppler-utils added to the enrich-runner image so the in-process path at least
functions; OCR recovery is parked (see TODO #3) and must NOT run unbounded on
the shared runner (OOM risk) until this architecture lands.