Skip to content

feat: optional multimodal ingest + visual retrieval (CLIP cross-modal + text-page coupling)#283

Open
gloeckle-direct-ki wants to merge 4 commits into
danny-avila:mainfrom
gloeckle-direct-ki:feature/multimodal-ingest
Open

feat: optional multimodal ingest + visual retrieval (CLIP cross-modal + text-page coupling)#283
gloeckle-direct-ki wants to merge 4 commits into
danny-avila:mainfrom
gloeckle-direct-ki:feature/multimodal-ingest

Conversation

@gloeckle-direct-ki

Copy link
Copy Markdown

Problem

Text-only RAG is blind to visual content: layout, color, typography, image placement, charts. For our use-case (feedback on direct-mail and print collateral) that's where the expertise lives — a vision-capable model asked "how is the layout on page 2 composed?" has nothing to work with.

Solution

Optional, env-gated parallel visual pipeline alongside the existing text path:

Upload                                Query
  ↓                                    ↓
text path (unchanged)               text-sim over langchain_pg_embedding
visual path (new)                   visual_matches over visual_chunks
  PyMuPDF/fitz → page PNGs            ├─ primary: text-coupled (visuals of pages
  → POST /embed/image                 │           that text-RAG retrieved)
  → UPSERT visual_chunks              └─ secondary: CLIP cross-modal scoring

Three new env vars, all optional, all off by default:

Var Effect
VISUAL_EMBED_URL URL of the embedding sidecar (returns 768-dim vectors). Unset → no behavior change.
VISUAL_TEXT_COUPLED Default true once VISUAL_EMBED_URL is set: attach the visuals of pages that text-RAG retrieved, regardless of CLIP score. Bypasses CLIP's well-known weakness on conceptual queries.
VISUAL_TEXT_COUPLED_MAX_PAGES Cap so a large text-hit swarm can't drop 50 images on the LLM (default 4).

/query returns the new visual_matches shape only when the client sets include_visual=true; legacy clients see the existing flat list.

Why text-coupling matters (UAT receipt)

We tested with a 100-page mixed business PDF: a German query "first photo in the Vietnam section" matched the page with a big "Vietnam" headline (CLIP weighted text-in-image), not the page with the actual group photo (smaller mention). The actual group-photo page was not in CLIP's top-7 for ANY phrasing we tried. Text-RAG, by contrast, immediately retrieved the right pages from the section text.

So we merge the signals: text-RAG decides which pages are relevant, CLIP supplies additional candidates. Primary always wins on dedup.

What this PR does NOT do

  • Does not bundle the embedding model. We run a separate ~200-LOC Python sidecar with nomic-ai/nomic-embed-vision-v1.5 + nomic-embed-text-v1.5 (shared 768-dim space → cross-modal). Anyone can plug in OpenCLIP, original CLIP, or any service exposing /embed/image and /embed/text.
  • Does not require the text path to change. Visual ingest fires after text ingest succeeds, never blocks it.

Soft-fail policy

Every step soft-fails to text-only: missing renderer dependency, sidecar down, DB unreachable, malformed response, page-rendering failure for one of N pages. A broken visual pipeline must NEVER break the existing text RAG.

Tests

Includes 14+ pytest cases covering: text-coupling primary/secondary merge, dedup by (file_id, page_number), max-pages cap, soft-fail paths, /query response shape (legacy flat-list and new {chunks, visual_matches}), threshold filtering.

Why upstream

CLIP-style visual retrieval is increasingly relevant for design-heavy / scanned-document workloads, but every install will use a different embedding model — hence the HTTP-sidecar split. The text-coupling refinement is independent of model choice and addresses a real CLIP failure mode we hit in production.

Stacks on top of #282 (the pre-extraction webhook PR) — that one merges first, this one would rebase to ~3 commits.


Co-authored-by: Claude (Anthropic)

pucawo and others added 4 commits April 20, 2026 09:51
Adds a tiny hook at the end of the document-loading pipeline that forwards
the original file to an HTTP webhook whenever text extraction produced
effectively-empty pages (e.g. scanned PDFs). The webhook is expected to
return `{"text": "...", "provider": "..."}`; its output replaces the
extraction and is tagged with `ocr_used=True` so downstream pipelines can
tell text-backed chunks from OCR-backed chunks.

Disabled by default — enables only when PRE_EXTRACTION_WEBHOOK_URL is set.
Configurable threshold (PRE_EXTRACTION_WEBHOOK_MIN_CHARS, default 100 chars
per page) and timeout (PRE_EXTRACTION_WEBHOOK_TIMEOUT, default 60 s).
Webhook failures never break ingest — on any error we fall back to the
original extraction with a warning log.

This keeps the core simple while letting external services participate in
ingest (OCR, translation, custom chunking, …) without subclassing loaders.

Tests: 6 new pytest cases covering the disabled path, the threshold
branches, HTTP failures, empty-response fallback, and the empty-documents
edge case.
Adds a feature-flagged visual pipeline alongside the existing text path:
when VISUAL_EMBED_URL points at a CLIP-style image-embed service, PDFs
are additionally rendered per-page via pdftoppm, each page PNG is sent
to the sidecar, and the resulting 768-dim vectors are persisted in a
new pgvector table (visual_chunks). The same sidecar's text endpoint
is used at query time so a text query can match against image vectors
in the shared cross-modal space.

/query gains an opt-in `include_visual: bool`. When true the response
is wrapped as `{"chunks": [...], "visual_matches": [{file_id,
page_number, image_path, score}]}`; default remains the legacy flat
list so existing callers are unaffected. Scores below a configurable
cosine threshold are dropped to keep cross-modal noise out.

Everything is soft-fail by design — missing pdftoppm, unreachable
sidecar or DB issues log a warning and continue, so the text ingest
path is never broken by the visual one.

Env vars (all empty / unset by default):
- VISUAL_EMBED_URL            enables the pipeline (e.g. http://127.0.0.1:8002/embed/image)
- VISUAL_TEXT_EMBED_URL       optional; defaults to the image URL with /embed/image → /embed/text
- VISUAL_PAGE_DPI=100         pdftoppm DPI (CLIP normalises anyway)
- VISUAL_STORAGE_ROOT=/var/rag-visual
- VISUAL_EMBED_TIMEOUT=30     HTTP timeout per page
- VISUAL_SCORE_THRESHOLD=0.25 cosine cutoff at retrieval time
- VISUAL_QUERY_TOP_K=3

Tests: 12 new pytest cases for the ingest helper (disabled-is-noop,
non-PDF-is-noop, pdftoppm-missing soft-fail, sidecar HTTP, DB upsert,
threshold filter, happy-path end-to-end, single-page failure keeps
going). Two new FastAPI cases for the /query response shape with
include_visual=True, including the empty-text-chunks case.
CLIP-style embeddings weight text-inside-images, so pages with matching
headlines can outrank pages with the actually-relevant photo. Fixture B
(IRZ_JB_2024.pdf) made this unworkable: page 75 (Workshop group photo
under "Länderberichte") was unreachable for any query phrasing because
page 76 shouted "Vietnam" in its headline.

Couple visual retrieval to the text pipeline's decisions: when /query
text-search hits pages {X, Y, Z}, attach those pages' visuals as
primary visual_matches regardless of CLIP score. CLIP remains as a
secondary signal for pages the text pipeline missed.

Config:
  VISUAL_TEXT_COUPLED=true          (default on)
  VISUAL_TEXT_COUPLED_MAX_PAGES=4   (cap the primary signal)

Merge semantics: dedup by (file_id, page_number), primary wins, synthetic
score=1.0 on coupled hits to stay above VISUAL_SCORE_THRESHOLD filters.
Soft-fails everywhere — if the DB lookup breaks, we fall back to the
Phase-3 CLIP-only path.

Tests:
  - unit: fetch_visual_chunks_for_pages (only-requested-pages, missing)
  - integration: coupling wins, disabled falls back, max-pages cap

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The text-coupling code in _pages_by_file_from_text_docs originally only
looked up 'page_number', but LangChain's PyPDFLoader (which rag_api uses
for PDFs) stores it as 'page'. Prod smoke test against Fixture B
(IRZ_JB_2024.pdf) showed all visual_matches came back with
source='clip' and page 75 was missing — because text-coupling silently
no-oped on every chunk.

Fix: prefer 'page_number' (custom loaders), fall back to 'page'
(PyPDFLoader). Unit test covers both keys, deduplication, and rank
preservation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@danny-avila

Copy link
Copy Markdown
Owner

@codex review

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 829afb3656

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment on lines +516 to +520
visual = await _fetch_visual_matches_for_file_ids(
request,
body.query,
[body.file_id],
text_documents=authorized_documents,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Block visual lookup when file access is unauthorized

When include_visual=true, this route calls visual retrieval even if authorized_documents is empty due to a user/file ownership mismatch. In that case _fetch_visual_matches_for_file_ids still runs CLIP search by file_id and can return image_path/page data for another user's file, creating a data-leak path that only requires knowing a valid file_id and having visual retrieval enabled.

Useful? React with 👍 / 👎.

Comment on lines +349 to +350
if page_number is None:
page_number = metadata.get("page")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Convert PyPDF page metadata to visual page numbering

The text-coupling fallback reads metadata['page'] directly, but PyPDFLoader emits zero-based page indices while visual ingest persists one-based page numbers (i + 1 in render output). Without normalizing here, text-coupled lookups query the wrong rows in visual_chunks, so primary visual matches for PDF text hits are missed or shifted by one page.

Useful? React with 👍 / 👎.

Comment thread app/utils/visual_embed.py
Comment on lines +256 to +257
except RuntimeError as exc:
logger.warning("visual ingest: pdftoppm step failed for %s: %s", file_id, exc)

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Catch non-RuntimeError render failures in visual ingest

This handler only catches RuntimeError, but render_pdf_pages can raise other OSError subclasses (for example PermissionError from out_dir.mkdir) before it wraps exceptions. Those errors will bubble out of maybe_embed_visuals and fail /embed requests, which breaks the intended soft-fail behavior whenever VISUAL_STORAGE_ROOT is misconfigured or not writable.

Useful? React with 👍 / 👎.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants