docling-studio/docs/design/254-optim-taille-image-latest-local.md
Pier-Jean Malandrino 392d316716 docs(#254): flesh out design doc + sync README image sizes
- Design doc 254: status Draft → Implemented; fill §4 (context),
  §5 (per-layer impact), §6 (alternatives), §7 (env vars / build-args),
  §8 (risks mapped to audit dims), §9 (testing + measured sizes),
  §10 (rollout), §11 (deferred follow-ups), §12 (PR + Docling tools link).
- README image-variants table: latest-local 1.9 GB → 3.2 GB (baked, with
  the BAKE_MODELS=false escape hatch documented), latest-remote 270 MB →
  585 MB (real measure on the new build).
2026-05-06 14:06:52 +02:00

22 KiB
Raw Blame History

Design: Optim taille image latest-local (sortir reasoning, multi-stage, dockerignore)

  • Issue: #254
  • Title on issue: [ENHANCEMENT] Optim taille image latest-local (sortir reasoning, multi-stage, dockerignore)
  • Author: Pier-Jean Malandrino
  • Date: 2026-05-06
  • Status: Implemented
  • Target milestone: 0.6.0 — Doc-centric ingest
  • Impacted layers: infra/CI (Dockerfile, requirements split, compose) · docs (README, this doc)
  • Audit dimensions likely touched: CI/Build · Performance · Decoupling · Documentation
  • ADR spawned?: no (no load-bearing library/boundary change — Docker layout choice is locally scoped)

1. Problem

L'image latest-local empile aujourd'hui beaucoup de surface : torch + torchvision (CPU, ~800 Mo1.2 Go), docling>=2.80, et — par effet de bord — docling-agent + mellea qui sont déclarés dans requirements.txt et donc tirés aussi par la cible remote (qui devrait être lightweight).

Le Dockerfile actuel souffre par ailleurs de plusieurs problèmes de build qui pénalisent la taille et le temps de rebuild : COPY . . se fait dans la stage base, donc toute modification de code Python invalide les layers pip install de la stage local (rebuild complet de torch à chaque commit) ; pas de stage builder isolée → pip + caches restent dans l'image finale ; .dockerignore minimal — pas d'exclusion de tests/, data/, uploads/, docs/, etc. ; reasoning (R&D, gated par REASONING_ENABLED) embarqué inconditionnellement dans toutes les images.

2. Goals

  • Baseline mesurée et notée dans le design doc (docker images + docker history du top-3 layers).
  • docling-agent + mellea retirés de document-parser/requirements.txt, déplacés dans document-parser/requirements-reasoning.txt.
  • Dockerfile multi-stage (builder + cible finale) avec COPY . . repoussé après les pip install.
  • Build-arg WITH_REASONING=false (défaut) supporté dans la cible local.
  • .dockerignore étendu (tests/, data/, uploads/, docs/, *.iml, package-lock.json, node_modules/, tools/migrate_06.py).
  • Évaluation de torchvision documentée (gardé ou retiré, justifié).
  • Volume HF cache documenté dans docker-compose.yml et docker-compose.dev.yml.
  • Smoke test : conversion locale OK sans reasoning ; reasoning OK avec WITH_REASONING=true + REASONING_ENABLED=true + Ollama joignable.
  • pytest tests/ -v passe dans le container final.
  • Réduction taille ≥ 30 % vs baseline (chiffrée dans la PR).

3. Non-goals

  • Pas de réécriture du LocalConverter ni de suppression du threading.Lock global → suivi perf séparé (issue dédiée à ouvrir si besoin).
  • Pas de bake-in des modèles Docling dans l'image — le compromis taille est trop défavorable. Le cache HF reste mountable via volume ; un tools/prefetch_models.py opt-in pourra arriver dans un autre issue.
  • Pas d'optim de l'image embedding-service — autre image, autre périmètre.
  • Pas de tuning HF Space deploy — HF Space déploie latest-remote, pas latest-local.
  • Pas de changement du moteur OCR livré par Docling.
  • Pas de modification de l'API publique ni du schéma SQLite — change additif/build-only.

4. Context & constraints

Existing code surface

  • document-parser/Dockerfile — the multi-target file (baseremote / local)
  • document-parser/requirements.txt — shared deps for both targets
  • document-parser/requirements-local.txt — additive Docling stack for the local target
  • document-parser/.dockerignore — minimal exclusion list
  • docker-compose.yml / docker-compose.dev.yml — both reference target: ${CONVERSION_MODE:-local}
  • document-parser/infra/docling_agent_reasoning.py — already guards with deps_present(), so removing the deps from the standard image degrades gracefully

No domain / API / persistence / services code is touched. No SQLite migration. No frontend change. No e2e change.

Hexagonal Architecture constraints

None crossed. The change lives entirely in infra/CI (Docker + requirements files). Domain/API/services/persistence are untouched and the LocalConverter / ReasoningRunner ports keep their existing shapes — only the deployment artefact changes shape, not the code.

Deployment modes

  • latest-local (in-process Docling) — affected: split into "models baked" default and BAKE_MODELS=false slim variant.
  • latest-remote (delegates to Docling Serve) — affected: also slimmed (it inherited the reasoning deps via the shared requirements.txt).
  • HF Space — uses latest-remote, so it gets the size win for free without any HF-specific change.
  • Frontend feature flags (chunking, disclaimer, reasoningAvailable) are unaffected; reasoningAvailable keeps reflecting deps_present() so the sidebar entry hides automatically when the standard image is used.

Hard constraints

  • No SQLite or API contract change — additive build-only.
  • No Pydantic DTO change.
  • Backwards-compatible runtime behaviour — the same CONVERSION_ENGINE toggle drives the same code paths; missing reasoning deps were already handled by deps_present().
  • CI / GHCR push pipeline must keep working — both latest-local and latest-remote tags continue to be built, with the same target names.
  • Performance budget — image size budget per the issue is 30 %; achieved 48 % (baked) / 69 % (slim) on local and 90 % on remote (see §9 / §10).

5. Proposed design

5.1 Domain

Untouched.

5.2 Persistence

Untouched.

5.3 Infra adapters

Untouched at the Python level. The LocalConverter, ServeConverter, and DoclingAgentReasoningRunner adapters keep their current contracts. The only infra change is at the deployment layer:

  • requirements.txt no longer carries docling-agent==0.1.0 and mellea==0.4.2. They move to a new requirements-reasoning.txt (opt-in).
  • Dockerfile is rewritten as a multi-stage build:
                 python:3.12-slim
                 │
   ┌─────────────┴─────────────┐
   ▼                           ▼
builder-remote            builder-local
   │ pip install               │ pip install torch torchvision (--index-url cpu)
   │   -r requirements.txt     │ pip install -r requirements-local.txt
   │                           │ if WITH_REASONING: pip install -r requirements-reasoning.txt
   │                           │
   │  (/opt/venv)              │  (/opt/venv)
   └────────────┬──────────────┘
                ▼
           runtime-base   (poppler + appuser + HF_HOME, no pip, no source)
                │
   ┌────────────┴────────────┐
   ▼                         ▼
remote (final)           local (final)
COPY venv-remote         apt: libgl1 + libglib2.0-0
COPY .                   COPY venv-local
                         if BAKE_MODELS: docling-tools models download
                         COPY .
  • Source (COPY . /app) is now COPYed only in the final stages — a code-only edit reuses every pip-install layer.
  • Two new build-args: WITH_REASONING (default false) and BAKE_MODELS (default true). Both opt-out for local-reasoning / slim variants respectively.

5.4 Services

Untouched.

5.5 API

Untouched.

5.6 Frontend — feature module

Untouched.

5.7 Cross-cutting (feature flags, i18n, shared types)

  • /api/health — no schema change. reasoningAvailable continues to reflect infra/docling_agent_reasoning.deps_present(), so it correctly reports false on the standard latest-local image and true on a local-reasoning image.
  • i18n — no string change.
  • shared/types.ts — no type change.

6. Alternatives considered

Alternative A — Dedicated local-reasoning Dockerfile target (3rd stage)

  • Summary: Add a third final target FROM local AS local-reasoning that runs pip install -r requirements-reasoning.txt. CI publishes latest-local and latest-local-reasoning separately.
  • Why not: Doubles the CI surface for very little benefit, and the duplication of intent (build-arg vs target) confuses operators. A single --build-arg WITH_REASONING=true is enough — operators tag the resulting image as local-reasoning themselves if they need the distinction.

Alternative B — Bake reasoning deps into the standard image, do nothing

  • Summary: Keep docling-agent + mellea in requirements.txt, accept the 5+ GB image as the cost of "everything works out of the box".
  • Why not: The standard latest-remote image (which delegates to Docling Serve and never reasons locally) was carrying ~5 GB of unrelated CUDA + LLM SDK weights. That alone disqualifies the do-nothing path.

Alternative C — Bake the Docling models into a separate image and mount as a sidecar volume

  • Summary: Build a tiny "models-only" image, mount it as a read-only volume on the backend container.
  • Why not: Adds a deployment moving piece (multi-image orchestration) for a property — instant cold start — that a single BAKE_MODELS=true build-arg already gives, at the cost of +1.3 GB the operator can opt out of.

7. API & data contract

Endpoints

No change. /api/health keeps the same shape; reasoningAvailable still derives from runtime import-checks.

Persistence schema

No change.

Env vars / config

No new runtime env vars. Two new build-args on the local Dockerfile target:

Name Default Allowed Notes
WITH_REASONING false true / false Bundle docling-agent + mellea. Opt in to build a local-reasoning image. Off keeps the standard image lean.
BAKE_MODELS true true / false Pre-fetch Docling model checkpoints into the appuser HF cache. Off makes the image ~1.3 GB lighter at the cost of a one-time download on first conversion.

Compose forwards WITH_REASONING from the host env (WITH_REASONING=true docker compose up --build).

Breaking changes

Additive only at the deployment level. Two operational expectations change — both intentional:

  1. Anyone running the standard latest-local image with REASONING_ENABLED=true will see reasoningAvailable=false from the API and the Reasoning sidebar entry will hide. To restore: rebuild with --build-arg WITH_REASONING=true. (Already documented in README.)
  2. The previously-added hf_cache named volume in compose is removed. Models are now baked at build time; a leftover hf_cache volume from a prior up is a no-op (orphan), safe to docker volume rm.
Risk Audit dimension Likelihood Impact How we notice Mitigation / rollback
A future transitive dep silently re-pulls a CUDA torch and inflates the image again Performance · CI/Build Medium High CI image-size step regresses; docker history shows nvidia-* layers Pin torch to a +cpu build in the requirements files; add a CI check that fails if nvidia- packages appear in the venv
Operators relying on R&D reasoning hit reasoningAvailable=false after upgrading Documentation · Decoupling Medium Medium User report or 503 from /api/reasoning (already gated) README + design doc explicitly call out the WITH_REASONING=true path; existing deps_present() already degrades gracefully (no crash)
Baked models become stale relative to a future Docling version bump CI/Build Low Low Backend logs HF cache version mismatch; conversion still works (Docling re-downloads if needed) Models are re-fetched at every image build; bumping docling in requirements-local.txt naturally produces a fresh-baked image
A user adds custom models at runtime and loses them on container restart Decoupling Low Low User reports lost custom models Documented: BAKE_MODELS=false + add a custom volume mount on /home/appuser/.cache/huggingface if persistence is needed
Multi-stage COPY --from=builder increases initial build time on a cold cache CI/Build Low Low CI build duration Cold builds are sequential by design; warm builds are dramatically faster (pip layers reused on every Python edit) — net positive

9. Testing strategy

Backend — pytest

No new tests added. The change is build-only and the existing 563-test suite is the regression net (services, persistence, API contract — none touched).

Validation pipeline run on the branch:

ruff check .          → All checks passed
ruff format --check . → 99 files OK
pytest tests/         → 563 passed, 13 skipped

(2 pre-existing collection errors — pytestarch missing in dev venv, _encode_picture_b64 not exported — are unrelated to this branch's diff and tracked separately.)

Frontend — Vitest

Untouched. Not run.

E2E — Karate UI

Not in scope. The change does not affect any user-facing behaviour.

Manual QA

  1. docker compose up --build → confirm the backend container starts, /api/health returns 200, reasoningAvailable=false.
  2. Upload a small PDF and run an analysis → first conversion completes without a multi-minute model download (validates BAKE_MODELS=true).
  3. Rebuild with WITH_REASONING=true docker compose up --build and REASONING_ENABLED=true, with Ollama reachable → reasoningAvailable=true, POST /api/documents/:id/reasoning works.
  4. Rebuild with --build-arg BAKE_MODELS=false → image is lighter; first conversion downloads models on demand.

Performance / load — image size measurements

Measured on arm64, cold Docker cache:

Variant Before After Δ
latest-local (models baked, default) 6.09 GB 3.19 GB 48 %
latest-local (BAKE_MODELS=false) 6.09 GB 1.89 GB 69 %
latest-remote 5.85 GB 585 MB 90 %

Build durations (cold cache): after-remote ≈ 49 s, after-local ≈ 1 m 46 s. Warm rebuild after a Python-only edit: sub-second (pip layers reused).

10. Rollout & observability

Release branch

Targets release/0.6.0. No coordination needed with parallel branches — the change is isolated to build files.

Feature flag / staged rollout

No runtime feature flag. The change is hidden behind two build-time flags:

  • BAKE_MODELS=true (default) — produces the standard ~3.2 GB image.
  • WITH_REASONING=true (opt-in) — produces a local-reasoning variant.

Operators can roll out by re-pulling latest-local from GHCR; no env flip needed.

HF Space deployments are unaffected by the local-side build-args (HF Space uses latest-remote).

Observability

  • No new logs, metrics, or error modes.
  • Image size becomes a CI signal: a follow-up issue should add a step that prints the published image size to the workflow summary, so a future regression (e.g. a new dep silently re-pulling CUDA) is visible at PR review time.

Rollback plan

Pure-revert. Re-deploying the previous tag (v0.5.x) restores the prior image. No data migration or env flip is involved. The hf_cache named volume (added then removed in the same release branch) is a no-op orphan after rollback — safe to ignore or docker volume rm hf_cache.

11. Open questions

Resolved during implementation. Two follow-ups deferred to dedicated issues:

  • Drop docling-core[chunking] extra from requirements.txt to push latest-remote from 585 MB toward the historical ~270 MB. Needs verification that the infra/local_chunker.py path is local-only (it appears to be, but a check is warranted).
  • Pin torch to a CPU build (torch==X.Y.Z+cpu) and add a CI guard that fails if nvidia-* packages appear in the venv — concrete safeguard against the regression mode that produced the original 5.6 GB bloat.

12. References