Step 1.4 introduces region-aware OCR to AgentContextOS. This document covers the design choices that the reference doc doesn't spell out — why the SPI evolved the way it did, how engines with very different output shapes are normalised, and how OCR sits in the ingest pipeline alongside parsers and PolicyEngine.
The Step 0.3 OCR SPI returned only (text, confidence, page_number) — a
single flat string per image. That worked for proof-of-concept ingest but
breaks down for two real workloads:
- Structure-aware chunking (Step 1.5) wants natural split points.
Lines and paragraphs in a scanned page are the equivalent of
Blocks in a parsed Word doc — the chunker needs them to decide where to break chunks. Without per-region bboxes, every page becomes one undifferentiated text blob. - Faithfulness scoring & UI overlays. Per-region coordinates let downstream consumers highlight the exact span of a cited chunk on the source image. The hallucination guard (Step 4.3) and Status GUI (Step 3.11) both depend on this.
So Step 1.4 evolves the SPI to carry a regions: list[OCRRegion] payload.
The legacy text / aggregate confidence fields remain so callers that
only care about full-page text continue to work.
The two shipped engines emit very different output shapes; we normalise on the way out so downstream code never has to special-case the backend.
| Concern | Tesseract (via pytesseract) | PaddleOCR | Normalised form |
|---|---|---|---|
| Granularity | per-word | per-line | informational only — both flow through OCRRegion |
| Coordinates | (left, top, width, height) axis-aligned |
4-point quadrilateral (supports rotated text) | BoundingBox(x0, y0, x1, y1) axis-aligned |
| Confidence | per-word 0–100 | per-line 0.0–1.0 | per-region 0.0–1.0 |
| Aggregate confidence | not provided | not provided | text-length-weighted mean (1.0 when no regions) |
| Empty input | empty data dict | [None] |
OCRResult(text="", regions=[], confidence=1.0) |
PaddleOCR returns each detection as a quadrilateral — four (x, y) points
— so it can represent rotated text. The Step 1.4 SPI deliberately collapses
this to the enclosing axis-aligned BoundingBox because:
- The first downstream consumer (Step 1.5 chunker) operates on text + offsets, not pixels. Rotation is invisible to it.
- The second consumer (Step 3.11 Status GUI region overlay) renders simple pixel rectangles in v0; axis-aligned bboxes are sufficient.
- Tesseract has no rotation output. Carrying rotation in the SPI would make consumers handle two cases (rotated / not) without a use case yet.
If a future consumer needs rotation, the path is: add quad as an optional
attribute on OCRRegion and have PaddleOCR populate it. The current bbox
normalisation keeps the contract uniform until then.
When an OCR pass finds no text, returning confidence=0.0 would look like
an engine failure to consumers. confidence=1.0 reads correctly as "fully
confident that there is no text here". Engine failures raise IngestionError
instead — there's no ambiguity at the SPI surface.
Connector → Parser → OCR → Chunker → Enricher → PII → Embedder → Store
↑
Only invoked when Parser flags an image-bearing block
(scanned PDF page, image-only DOCX shape, raw image
file from a Connector).
OCR sits after the parser and before the chunker:
- Parsers emit
Blocks withBlockType.otherandattributes={"image": ...}for image regions they can't text-extract. OCR processes those images and inserts OCR'dBlocks back into the document at the right offsets. (Wiring lands in Step 1.10 — the OCR SPI is ready now.) - The chunker (Step 1.5) sees OCR'd blocks identically to natively-parsed
blocks, with the added per-region bbox metadata in
attributes.
OCR runs upstream of PolicyEngine in the ingest direction, like
parsers and connectors. The bytes haven't become Chunks yet, so there's
no ACL or PII to enforce on them. The Step 1.10 ingest pipeline is the
enforcement point — per-document quota check before OCR (so a quota-busted
tenant doesn't burn an OCR call), per-chunk ACL + PII after chunking.
The rag-ocr package therefore depends only on rag-core, not on
rag-policy. The PolicyEngine coverage linter (tests/policy/coverage.py)
allowlists OCR plugins for this reason.
OCR is the slowest stage in the ingest pipeline by a wide margin (tens to hundreds of milliseconds per image vs. sub-millisecond for parsers). performance.md classifies OCR as a cold path — Pydantic validation at the SPI boundary is fine; the engines themselves dominate the latency budget.
The only hot-path concern is the per-region loop in _data_to_regions /
_raw_to_regions. Both use plain dicts/lists with no Pydantic overhead in
the loop body — OCRRegion is constructed once per detection and the
construction itself dwarfs the surrounding dict access.
The default rag-ocr install is pure-Python (rag-core + Pillow). Both
engine extras have cross-platform stories:
- Tesseract — system binary, install via
brew(macOS),apt(Linux), or the UB Mannheim installer (Windows).pytesseractis pure-Python and has no native deps beyond Pillow. - PaddleOCR —
paddlepaddleships wheels for all three OS+arch combos in the CI matrix. Heavy (~500 MB installed) so it stays optional.
Unit tests stub both engines at the sys.modules boundary, so the test
suite is hermetic — no system binaries or heavy wheels needed in CI.
- ADR-0005 (PolicyEngine PDP) — defines the egress enforcement boundary that OCR sits upstream of.
- ADR-0007 (tiered storage with BlobRef) — OCR'd text follows the same trust-level rules as parsed text; OCR doesn't grant additional trust.
- ADR-0009 (vector index strategy + quantization) — OCR'd chunks are embedded identically to parsed chunks; no special path.