docs: add hybrid OCR→vision pipeline design brief

Capture the design for a future `ANALYSIS_BACKEND=hybrid`: ocrs extracts
text, the vision LLM does grounding (tags/scene/safety), with the two kept
memory-exclusive on the Pi by reusing the vision-manager's existing
crawl-mutex pattern (OCR is a third RAM-competing, higher-priority
workload). Design only — not implemented.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-06-26 07:15:03 +02:00
parent 82264c74cd
commit 2a978aa333

136
HYBRID-OCR-VISION.md Normal file
View File

@@ -0,0 +1,136 @@
# Hybrid OCR→Vision analysis pipeline — design brief for the dev agent
**Status: design only (not implemented).** Forward-looking model for a future
`ANALYSIS_BACKEND=hybrid`, on top of the shipped `ocr` (default) and `vision`
backends. Constraint that shapes it: `ocrs` and the vision LLM must **not** be
resident at the same time (Pi RAM ceiling). OCR has priority; vision is a
preemptible, only-when-OCR-is-drained background phase.
## The key realization: this is the crawl mutex, again
The repo already runs a **`vision-manager`** sidecar
([vision-manager/manager.sh](vision-manager/manager.sh),
[VISION-AUTOSCALE.md](VISION-AUTOSCALE.md)) that owns the `mangalord-vision`
(llama.cpp, ~4 GiB) container lifecycle via a scoped docker-socket-proxy — the
backend gets no Docker access. It already:
- **Starts** vision when `pending_analysis()` > 0 (counts `analyze_page` jobs).
- **Idle-stops** it after `STOP_DEBOUNCE` (anti-thrash) once the queue drains.
- **Defers starting** vision while `crawl_running()` > 0 — the **"RAM mutex"**:
the crawler's browser and the LLM can't both fit, so vision waits for crawl.
- **Memory-yield**: SIGTERMs a running vision at a HIGH host-RAM watermark,
blocks starting at a LOW watermark (see [VISION-MEMORY-YIELD.md](VISION-MEMORY-YIELD.md)).
The OCR↔vision relationship is the relationship that already exists between
crawl and vision. OCR is just a third RAM-competing, higher-priority workload
that vision must defer to. Extend the existing arbiter rather than build a new one.
## Job model: split one queue into two
Today there is one job kind, `analyze_page`. Split it:
- **`ocr_page`** — the in-process ocrs pass (cheap, fast, no container).
- **`ground_page`** — the vision grounding pass (needs the LLM container).
**Pipeline (hybrid mode):** upload → enqueue `ocr_page`. The OCR daemon runs
ocrs, writes `page_ocr_text` + `search_doc` (text searchable *immediately*), then
enqueues `ground_page` for that page. The grounding daemon later adds
tags/scene/safety. Each kind gets its own dedup index (mirror migration 0031).
Two daemons, two queues.
## Arbitration: OCR priority + memory exclusivity
Responsibilities split cleanly between the in-backend daemons and the manager:
**Backend — OCR daemon** (already built): leases `ocr_page`, always allowed to
run (ocrs is small/local). This is the priority phase.
**Backend — grounding daemon:** leases `ground_page`, but gates leasing on
**both**:
1. `VisionReadiness` (the existing `/health` gate — vision container up), AND
2. **`ocr_backlog == 0`** (a cheap `count(ocr_page WHERE state IN pending,running)`).
Gate #2 is the whole priority mechanism, in-process, no aborts: the instant OCR
work appears, the grounding daemon **finishes its current page** (the in-flight
lease completes normally) and then **stops leasing** new pages and parks — "it
finishes, then yields." It resumes only when OCR has fully drained.
**vision-manager:** two one-line changes to the existing logic:
1. `pending_analysis()` counts **`ground_page`** (not `ocr_page`) — vision only
starts when there is *grounding* work.
2. Add an **OCR start-block mutex** identical to `RESPECT_CRAWL_MUTEX`: defer
starting vision while `ocr_page` backlog > 0. (`RESPECT_OCR_MUTEX=1`.)
**Why this yields memory exclusivity:** while OCR backlog > 0, the grounding
daemon won't lease → vision goes idle → the manager's idle-debounce stops the
container (and never restarts it under the OCR mutex). Only ocrs (small) is
resident. When OCR drains, grounding resumes, `pending_analysis()` > 0 again, the
manager starts vision. Only the *big* consumer (the LLM container) is ever
mutually exclusive with active OCR — which is the actual RAM constraint.
**The one overlap window:** if OCR work arrives mid-grounding-page, ocrs (small,
in-process) briefly coexists with the one in-flight vision page before the
grounding daemon parks. ocrs's footprint makes this a non-issue in practice. If a
deployment needs *hard* exclusivity even there, the OCR daemon can additionally
wait for `vision_running == false` before its first dispatch. No deadlock: OCR's
wait is transient (one grounding page, bounded by `job_timeout`), while vision's
deferral on OCR backlog is the persistent, priority-respecting side.
**Anti-thrash:** the manager's `STOP_DEBOUNCE` already prevents a stray OCR page
from cold-cycling the 4 GiB model. Chapter uploads arrive as bursts, so OCR
drains a whole batch in one phase before vision resumes — the natural good case.
## Data-model split (the real refactor)
`persist_analysis` currently writes OCR + tags + scene + safety in one
transaction and derives `search_doc` from the OCR rows (+ scene weight C). Two
passes means splitting it, sharing the tsvector builder:
- **`persist_ocr(page, lines)`** — writes `page_ocr_text`, sets
`search_doc` from OCR (A/B/D buckets), marks the page text-searchable. Status
reflects OCR completion.
- **`persist_grounding(page, tags, scene, safety)`** — writes auto-tags,
warnings, scene; **recomputes** `search_doc` to fold in the scene (weight C) on
top of the existing OCR buckets. Records grounding completion (e.g. a
`grounded_at` column or tags-present sentinel).
This keeps text search live after the cheap OCR phase, with semantic search/tags
arriving after the (deferred) grounding phase.
## Reusable pieces
- OCR side: the shipped `OcrsEngine` + `OcrAnalyzeDispatcher`
([backend/src/analysis/ocr.rs](backend/src/analysis/ocr.rs)) — retarget to
`ocr_page`, and on success enqueue `ground_page`.
- Vision side: factor `VisionClient::ground(image, mime, ocr_text)` out of
`analyze()`'s Pass-B block
([backend/src/analysis/vision.rs](backend/src/analysis/vision.rs) ~240261) —
pure refactor, existing tests cover it. The grounding daemon is the existing
analysis daemon retargeted to `ground_page` with the extra `ocr_backlog==0`
lease gate.
- Manager: `pending_analysis()` kind swap + `RESPECT_OCR_MUTEX` block, mirroring
the crawl-mutex branch already at [vision-manager/manager.sh](vision-manager/manager.sh).
## Scope / sequencing
Larger than an inline hybrid dispatcher: job-kind split + dedup migrations,
`persist_*` split + search_doc-builder extraction, pipeline enqueue (OCR→ground),
grounding daemon lease gate, and the manager mutex. No new privileged surface
(reuses the docker-socket-proxy). Suggested order:
1. Split `persist_analysis``persist_ocr` / `persist_grounding` (+ tests).
2. Split job kinds + dedup migrations; retarget the OCR daemon to `ocr_page`.
3. `VisionClient::ground()` refactor + grounding daemon (`ground_page`, gated on
`ocr_backlog==0`).
4. OCR→ground enqueue on OCR success (hybrid mode only).
5. vision-manager: `ground_page` counting + `RESPECT_OCR_MUTEX`.
Pure-`ocr` (shipped default) and `vision` (legacy) backends are unaffected; this
is the `hybrid` backend's runtime model.
## Open decision
Hard exclusivity in the one overlap window (OCR daemon waits for `vision_running
== false` before its first dispatch) — needed only if ocrs's few-hundred-MB
footprint can't coexist with a single in-flight grounding page on the target
Pi's RAM. Default: don't gate (rely on the grounding daemon parking fast).