Cross-Modal Search: Text↔Image Retrieval
Summary
modusbrain has a working multimodal embedding pipeline (Voyage multimodal-3,embedding_image column, 11K image chunks indexed) but search is siloed: text queries only search text embeddings, image queries don’t exist. This proposal adds cross-modal query routing so text queries can surface images and image queries can surface text, using Voyage multimodal-3’s shared embedding space.
Problem
What the user sees: You can’t search “photos from the hackathon” and get actual images. You can’t upload a photo and ask “what do we know about this person?” Text search returns text. Image embeddings sit unused except via explicitembeddingColumn: 'embedding_image' override, which no user-facing path triggers.
What the system does:
- Text queries embed through the configured text model (OpenAI/ZE) and search the text column
- The
embedding_imagecolumn exists (Voyage multimodal-3, 1024d) with 11,204 embedded chunks and a valid 83 MB HNSW index postgres-engine.ts:searchVector()supportsembeddingColumn: 'embedding_image'but the query vector must come from a compatible model (Voyage multimodal, 1024d)- Currently,
embedQuery()always uses the text embedding model, producing a 1536d or 2560d vector that can’t query the 1024d image column
- Detect cross-modal intent in a search query (“show me photos of…”, “find images from…”, or explicit image search flag)
- Embed the text query through Voyage multimodal-3 (same model used for image embeddings)
- Search the
embedding_imagecolumn with the multimodal query vector - Return image results alongside or instead of text results
- Support image-as-query: accept an image input, embed it through Voyage multimodal-3, search text embeddings (if a shared multimodal column exists) or the image column
Evidence
Image embeddings exist and are indexed
Modality metadata is broken
embedding_image IS NOT NULL but modality is not set to 'image'. This is a backfill gap from the v0.27.1 migration.
Voyage multimodal-3 is cross-modal by design
From Voyage docs: voyage-multimodal-3 encodes text, images, and interleaved text+image into the same 1024-dimensional vector space. A text query embedded through this model can find relevant images, and vice versa. modusbrain already uses it for the image column but never for query embedding.Search routing is text-only
hybrid.ts line ~414:
embedQuery() always uses the global text model. No path exists to embed a text query through the multimodal model for cross-modal search.
Proposed Fix
Phase 1: Text → Image Search
1. Cross-modal intent detection (new file:src/core/search/cross-modal.ts)
Add a lightweight intent classifier that detects when a query is looking for images:
embedding.ts)
Add embedQueryMultimodal(text: string): Promise<Float32Array> that routes through the configured multimodal model (Voyage multimodal-3) instead of the text model.
hybrid.ts)
When cross-modal intent is detected:
- Embed query through multimodal model (Voyage multimodal-3, 1024d)
- Search
embedding_imagecolumn - Return results with a
modality: 'image'tag - If intent is
'both': run text search AND image search, merge with RRF
types.ts)
Phase 2: Image → Text Search (future)
Accept an image buffer/URL as search input. Embed through Voyage multimodal-3. Search text embeddings. This requires a new search entry point (searchByImage) and MCP tool exposure. Defer to a follow-up PR.
Phase 3: Unified Multimodal Column (future)
Embed ALL content (text + images) through Voyage multimodal-3 into a single column. This creates a truly unified search space but doubles embedding costs and requires re-embedding all text. Evaluate after Phase 1 results.Backfill: Fix modality metadata
Before cross-modal search is useful, fix the modality column:Test Guidance
Red tests (should fail before fix, pass after)
- Intent detection:
detectCrossModalIntent("show me photos from the hackathon")returns'image'. - Intent detection negative:
detectCrossModalIntent("what is founder mode?")returns'text'. - Multimodal embed routing:
embedQueryMultimodal("hackathon")returns a 1024d vector (Voyage multimodal dims), not 1536d or 2560d. - Cross-modal search:
hybridSearch("show me hackathon photos", { crossModal: 'image' })returns results from theembedding_imagecolumn. - Default behavior unchanged:
hybridSearch("what is founder mode?")returns text results as before (no cross-modal unless detected). - Explicit override:
hybridSearch("anything", { crossModal: 'image' })forces image search regardless of intent detection.
Edge cases
- Query matches image intent but no image embeddings exist for the topic: return empty image results, fall back to text.
- Multimodal model not configured: skip cross-modal, log warning, return text results.
- Mixed results (‘both’ mode): text and image results merged, each tagged with modality for display.
Related Context
- PR #1106 adds dynamic text embedding column selection (prerequisite: the
embedding_columnsregistry and provider routing from that PR make this easier to implement) - v0.27.1 introduced the dual-column schema (
embedding+embedding_image) importImageFileinpostgres-engine.tshandles image ingestion and multimodal embedding- Voyage multimodal-3 is already configured as
embedding_multimodal_modelin modusbrain config - The image OCR pipeline (
embedding_image_ocr: true) extracts text from images before embedding, so image chunks have both visual and text representation