Skip to main content

lc_rag/
multimodal.rs

1// lc-rag/src/multimodal.rs
2//! Multimodal (image + text) chunking and retrieval (B7, v0.22.4).
3//!
4//! A cross-modal embedding model ([`VisionEmbeddings`]) maps images **and**
5//! text into one shared vector space. This module closes the RAG loop:
6//!
7//! 1. [`MultimodalChunker`] turns an ordered mix of text blocks and image
8//!    assets into modality-tagged [`Document`]s;
9//! 2. [`MultimodalRetriever`] embeds image documents with
10//!    [`VisionEmbeddings::embed_image`] and text documents with
11//!    [`VisionEmbeddings::embed_text`], stores them through any
12//!    [`VectorStore`], and answers plain-text queries with a mixed
13//!    image/text result set;
14//! 3. modality filtering (`mm_kind` metadata) lets callers retrieve images
15//!    only or text only; image queries are supported via
16//!    [`MultimodalRetriever::retrieve_by_image`].
17//!
18//! # Metadata convention
19//!
20//! Every emitted document carries an `mm_kind` tag (`"image"` / `"text"`);
21//! image documents additionally carry `mm_url`, `mm_caption`, and `mm_mime`.
22//! Keys are exported as constants so downstream filters stay in sync.
23
24use std::collections::HashMap;
25use std::sync::Arc;
26
27use async_trait::async_trait;
28use lc_embeddings::{ImageInput, VisionEmbeddings};
29use lc_vector_stores::{Document, FilterOp, MetadataFilter, SearchResult, VectorStore};
30use serde_json::Value;
31
32use crate::retriever::{RetrieverError, RetrieverTrait};
33
34/// Metadata key marking the document modality (`"image"` / `"text"`).
35pub const MM_KIND_KEY: &str = "mm_kind";
36/// Metadata key holding the image reference (http(s) URL or data URI).
37pub const MM_URL_KEY: &str = "mm_url";
38/// Metadata key holding the image caption / alt text.
39pub const MM_CAPTION_KEY: &str = "mm_caption";
40/// Metadata key holding the image MIME type.
41pub const MM_MIME_KEY: &str = "mm_mime";
42
43/// `mm_kind` value for image documents.
44pub const MM_KIND_IMAGE: &str = "image";
45/// `mm_kind` value for text documents.
46pub const MM_KIND_TEXT: &str = "text";
47
48/// One ordered block of a multimodal source document.
49#[derive(Debug, Clone)]
50#[non_exhaustive]
51pub enum MediaBlock {
52    /// A text block (may be split into several chunks).
53    Text(String),
54    /// An image asset with an optional caption.
55    Image(ImageAsset),
56}
57
58/// An image reference plus optional descriptive metadata.
59#[derive(Debug, Clone, Default)]
60pub struct ImageAsset {
61    /// Image reference: an http(s) URL the embedding provider can fetch, or
62    /// a complete `data:` URI carrying inline base64 bytes.
63    pub url: String,
64    /// Caption / alt text. Stored verbatim as the document content so that
65    /// lexical tooling (BM25, keyword filters) stays functional.
66    pub caption: Option<String>,
67    /// Image MIME type (e.g. `image/png`). Informational for URL references,
68    /// required when the reference is inline bytes on inline-only backends.
69    pub mime_type: Option<String>,
70}
71
72impl ImageAsset {
73    /// Creates an image asset from a reference (URL or data URI).
74    pub fn new(url: impl Into<String>) -> Self {
75        Self {
76            url: url.into(),
77            caption: None,
78            mime_type: None,
79        }
80    }
81
82    /// Attaches a caption.
83    pub fn with_caption(mut self, caption: impl Into<String>) -> Self {
84        self.caption = Some(caption.into());
85        self
86    }
87
88    /// Attaches a MIME type.
89    pub fn with_mime(mut self, mime_type: impl Into<String>) -> Self {
90        self.mime_type = Some(mime_type.into());
91        self
92    }
93}
94
95/// Configuration for [`MultimodalChunker`].
96#[derive(Debug, Clone, Default)]
97pub struct MultimodalChunkConfig {
98    /// Maximum characters per text chunk. `None` keeps each text block as a
99    /// single document. Splitting is UTF-8 boundary-safe (no mid-`char` cuts).
100    pub text_chunk_chars: Option<usize>,
101    /// Metadata inherited by every emitted document (source, page, …). The
102    /// `mm_*` keys are reserved and overwrite inherited values.
103    pub common_metadata: HashMap<String, Value>,
104}
105
106impl MultimodalChunkConfig {
107    /// Creates config with a fixed maximum text-chunk length.
108    pub fn with_chunk_chars(mut self, chunk_chars: usize) -> Self {
109        self.text_chunk_chars = Some(chunk_chars.max(1));
110        self
111    }
112
113    /// Adds one inherited metadata entry.
114    pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
115        self.common_metadata.insert(key.into(), value.into());
116        self
117    }
118}
119
120/// Turns ordered [`MediaBlock`]s into modality-tagged [`Document`]s.
121#[derive(Debug, Clone, Default)]
122pub struct MultimodalChunker {
123    config: MultimodalChunkConfig,
124}
125
126impl MultimodalChunker {
127    /// Creates a chunker with default config (one document per text block).
128    pub fn new() -> Self {
129        Self::default()
130    }
131
132    /// Creates a chunker with explicit config.
133    pub fn with_config(config: MultimodalChunkConfig) -> Self {
134        Self { config }
135    }
136
137    /// Chunks the blocks into [`Document`]s, preserving input order.
138    ///
139    /// - Empty/whitespace text blocks are skipped;
140    /// - text blocks are optionally split into UTF-8-safe windows;
141    /// - images always become one document each; an image without a caption
142    ///   still embeds (its content is empty), but [`RetrieverError`] is never
143    ///   raised at chunk time — validation happens at embedding time.
144    pub fn chunk(&self, blocks: &[MediaBlock]) -> Vec<Document> {
145        let mut documents = Vec::with_capacity(blocks.len());
146        for block in blocks {
147            match block {
148                MediaBlock::Text(text) if text.trim().is_empty() => continue,
149                MediaBlock::Text(text) => {
150                    for piece in split_text(text, self.config.text_chunk_chars) {
151                        documents.push(self.text_document(piece));
152                    }
153                }
154                MediaBlock::Image(asset) => documents.push(self.image_document(asset)),
155            }
156        }
157        documents
158    }
159
160    /// Builds a tagged text document directly.
161    pub fn text_document(&self, content: impl Into<String>) -> Document {
162        let mut doc = Document::new(content);
163        for (key, value) in &self.config.common_metadata {
164            doc.metadata.insert(key.clone(), value.clone());
165        }
166        doc.metadata
167            .insert(MM_KIND_KEY.to_string(), Value::from(MM_KIND_TEXT));
168        doc
169    }
170
171    /// Builds a tagged image document directly.
172    pub fn image_document(&self, asset: &ImageAsset) -> Document {
173        let mut doc = Document::new(asset.caption.clone().unwrap_or_default());
174        for (key, value) in &self.config.common_metadata {
175            doc.metadata.insert(key.clone(), value.clone());
176        }
177        doc.metadata
178            .insert(MM_KIND_KEY.to_string(), Value::from(MM_KIND_IMAGE));
179        doc.metadata
180            .insert(MM_URL_KEY.to_string(), Value::from(asset.url.clone()));
181        if let Some(caption) = &asset.caption {
182            doc.metadata
183                .insert(MM_CAPTION_KEY.to_string(), Value::from(caption.clone()));
184        }
185        if let Some(mime) = &asset.mime_type {
186            doc.metadata
187                .insert(MM_MIME_KEY.to_string(), Value::from(mime.clone()));
188        }
189        doc
190    }
191}
192
193/// Which modality a multimodal retrieval call may return.
194#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
195pub enum ModalityFilter {
196    /// Both images and text (default).
197    #[default]
198    Any,
199    /// Image documents only.
200    Images,
201    /// Text documents only.
202    Texts,
203}
204
205impl ModalityFilter {
206    fn metadata_filter(self) -> Option<MetadataFilter> {
207        match self {
208            ModalityFilter::Any => None,
209            ModalityFilter::Images => Some(MetadataFilter::field(
210                MM_KIND_KEY,
211                FilterOp::Eq,
212                MM_KIND_IMAGE,
213            )),
214            ModalityFilter::Texts => Some(MetadataFilter::field(
215                MM_KIND_KEY,
216                FilterOp::Eq,
217                MM_KIND_TEXT,
218            )),
219        }
220    }
221}
222
223/// Retriever over a shared image/text vector space.
224pub struct MultimodalRetriever {
225    store: Arc<dyn VectorStore>,
226    vision: Arc<dyn VisionEmbeddings>,
227}
228
229impl std::fmt::Debug for MultimodalRetriever {
230    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
231        f.debug_struct("MultimodalRetriever")
232            .field("model", &self.vision.model_name())
233            .field("dimension", &self.vision.dimension())
234            .finish()
235    }
236}
237
238impl MultimodalRetriever {
239    /// Creates a multimodal retriever over `store`, embedding both modalities
240    /// with the shared-space `vision` model.
241    pub fn new(store: Arc<dyn VectorStore>, vision: Arc<dyn VisionEmbeddings>) -> Self {
242        Self { store, vision }
243    }
244
245    /// Retrieves with a text query restricted to `modality`.
246    pub async fn retrieve_modality(
247        &self,
248        query: &str,
249        k: usize,
250        modality: ModalityFilter,
251    ) -> Result<Vec<Document>, RetrieverError> {
252        let results = self.search_text(query, k, modality).await?;
253        Ok(results.into_iter().map(|r| r.document).collect())
254    }
255
256    /// Retrieves with a text query, returning scores and modality metadata.
257    pub async fn retrieve_with_scores_modality(
258        &self,
259        query: &str,
260        k: usize,
261        modality: ModalityFilter,
262    ) -> Result<Vec<SearchResult>, RetrieverError> {
263        self.search_text(query, k, modality).await
264    }
265
266    /// Retrieves with an image query (image→image / image→text search).
267    pub async fn retrieve_by_image(
268        &self,
269        image: &ImageInput,
270        k: usize,
271        modality: ModalityFilter,
272    ) -> Result<Vec<Document>, RetrieverError> {
273        let query_embedding = self
274            .vision
275            .embed_image(image)
276            .await
277            .map_err(|e| RetrieverError::EmbeddingError(e.to_string()))?;
278        let results = self.search_vector(&query_embedding, k, modality).await?;
279        Ok(results.into_iter().map(|r| r.document).collect())
280    }
281
282    async fn search_text(
283        &self,
284        query: &str,
285        k: usize,
286        modality: ModalityFilter,
287    ) -> Result<Vec<SearchResult>, RetrieverError> {
288        let query_embedding = self
289            .vision
290            .embed_text(query)
291            .await
292            .map_err(|e| RetrieverError::EmbeddingError(e.to_string()))?;
293        self.search_vector(&query_embedding, k, modality).await
294    }
295
296    async fn search_vector(
297        &self,
298        query_embedding: &[f32],
299        k: usize,
300        modality: ModalityFilter,
301    ) -> Result<Vec<SearchResult>, RetrieverError> {
302        let results = match modality.metadata_filter() {
303            None => self.store.similarity_search(query_embedding, k).await?,
304            Some(filter) => {
305                self.store
306                    .similarity_search_with_filter(query_embedding, k, Some(&filter))
307                    .await?
308            }
309        };
310
311        // Belt-and-braces: the trait's default filtered method errors rather
312        // than ignoring the filter, but a custom backend that silently ignores
313        // it would otherwise leak the wrong modality into a narrowed query.
314        Ok(results
315            .into_iter()
316            .filter(|r| match modality {
317                ModalityFilter::Any => true,
318                ModalityFilter::Images => {
319                    r.document.metadata.get(MM_KIND_KEY).and_then(Value::as_str)
320                        == Some(MM_KIND_IMAGE)
321                }
322                ModalityFilter::Texts => {
323                    r.document.metadata.get(MM_KIND_KEY).and_then(Value::as_str)
324                        == Some(MM_KIND_TEXT)
325                }
326            })
327            .collect())
328    }
329
330    /// Embeds a mixed document batch: one batched image call, text one-by-one
331    /// (the trait has no text batch primitive), then restores request order.
332    async fn embed_documents_mixed(
333        &self,
334        documents: &[Document],
335    ) -> Result<Vec<Vec<f32>>, RetrieverError> {
336        let mut image_slots = Vec::new();
337        let mut image_inputs = Vec::new();
338        let mut text_slots = Vec::new();
339
340        for (index, doc) in documents.iter().enumerate() {
341            match doc.metadata.get(MM_KIND_KEY).and_then(Value::as_str) {
342                Some(MM_KIND_IMAGE) => {
343                    let reference = doc
344                        .metadata
345                        .get(MM_URL_KEY)
346                        .and_then(Value::as_str)
347                        .filter(|url| !url.trim().is_empty())
348                        .ok_or_else(|| {
349                            RetrieverError::InvalidDocument(format!(
350                                "image document {index} is missing {MM_URL_KEY}"
351                            ))
352                        })?;
353                    image_slots.push(index);
354                    image_inputs.push(parse_image_reference(reference));
355                }
356                // Untagged documents are treated as text — the historical default.
357                Some(MM_KIND_TEXT) | None => {
358                    if doc.content.trim().is_empty() {
359                        return Err(RetrieverError::EmbeddingError(format!(
360                            "text document {index} has empty content"
361                        )));
362                    }
363                    text_slots.push(index);
364                }
365                Some(other) => {
366                    return Err(RetrieverError::InvalidDocument(format!(
367                        "unknown {MM_KIND_KEY} value {other:?} on document {index}"
368                    )));
369                }
370            }
371        }
372
373        let mut vectors: Vec<Option<Vec<f32>>> = vec![None; documents.len()];
374
375        if !image_inputs.is_empty() {
376            let image_vectors = self
377                .vision
378                .embed_images(&image_inputs)
379                .await
380                .map_err(|e| RetrieverError::EmbeddingError(e.to_string()))?;
381            for (slot, vector) in image_slots.into_iter().zip(image_vectors) {
382                vectors[slot] = Some(vector);
383            }
384        }
385
386        for slot in text_slots {
387            vectors[slot] = Some(
388                self.vision
389                    .embed_text(&documents[slot].content)
390                    .await
391                    .map_err(|e| RetrieverError::EmbeddingError(e.to_string()))?,
392            );
393        }
394
395        // Every slot was filled or the function returned early above.
396        Ok(vectors.into_iter().map(Option::unwrap).collect())
397    }
398}
399
400#[async_trait]
401impl RetrieverTrait for MultimodalRetriever {
402    async fn retrieve(&self, query: &str, k: usize) -> Result<Vec<Document>, RetrieverError> {
403        self.retrieve_modality(query, k, ModalityFilter::Any).await
404    }
405
406    async fn retrieve_with_scores(
407        &self,
408        query: &str,
409        k: usize,
410    ) -> Result<Vec<SearchResult>, RetrieverError> {
411        self.retrieve_with_scores_modality(query, k, ModalityFilter::Any)
412            .await
413    }
414
415    async fn add_documents(&self, documents: Vec<Document>) -> Result<(), RetrieverError> {
416        if documents.is_empty() {
417            return Ok(());
418        }
419        let embeddings = self.embed_documents_mixed(&documents).await?;
420        self.store.add_documents(documents, embeddings).await?;
421        Ok(())
422    }
423}
424
425/// Converts a stored reference (`mm_url`) into an [`ImageInput`]: data URIs are
426/// marked inline, everything else passes through as a provider-fetched URL.
427pub(crate) fn parse_image_reference(reference: &str) -> ImageInput {
428    if reference.starts_with("data:") {
429        ImageInput::from_data_uri(reference)
430    } else {
431        ImageInput::from_url(reference)
432    }
433}
434
435/// UTF-8-safe fixed-width window splitter.
436///
437/// `None` or zero-sized windows return the text as one (trimmed, non-empty)
438/// piece. Windows are cut on whitespace boundaries when one falls within the
439/// last 20% of the window, otherwise at a safe char boundary.
440fn split_text(text: &str, chunk_chars: Option<usize>) -> Vec<String> {
441    let Some(chunk_chars) = chunk_chars.filter(|n| *n > 0) else {
442        return vec![text.to_string()];
443    };
444    let chars: Vec<char> = text.chars().collect();
445    if chars.len() <= chunk_chars {
446        return vec![text.to_string()];
447    }
448
449    let mut pieces = Vec::new();
450    let mut start = 0usize;
451    while start < chars.len() {
452        let mut end = (start + chunk_chars).min(chars.len());
453        if end < chars.len() {
454            // Prefer a whitespace boundary in the trailing part of the window.
455            let look_back = start + (chunk_chars * 4 / 5);
456            if let Some(space) = (look_back..end).rfind(|i| chars[*i].is_whitespace()) {
457                end = space;
458            } else if !chars[end].is_whitespace() {
459                // Otherwise avoid leaving a dangling whitespace at the boundary.
460                end = chars[..end]
461                    .iter()
462                    .rposition(|c| c.is_whitespace())
463                    .filter(|p| *p > start)
464                    .unwrap_or(end);
465            }
466        }
467        let piece: String = chars[start..end].iter().collect();
468        let trimmed = piece.trim();
469        if !trimmed.is_empty() {
470            pieces.push(trimmed.to_string());
471        }
472        if end <= start {
473            // Defensive: guarantee forward progress on pathological input.
474            end = start + 1;
475        }
476        start = end;
477        while start < chars.len() && chars[start].is_whitespace() {
478            start += 1;
479        }
480    }
481    pieces
482}
483
484#[cfg(test)]
485mod tests {
486    use super::*;
487    use lc_embeddings::MockVisionEmbeddings;
488    use lc_vector_stores::InMemoryVectorStore;
489
490    fn sample_blocks() -> Vec<MediaBlock> {
491        vec![
492            MediaBlock::Text("a cat sat on the mat".into()),
493            MediaBlock::Image(
494                ImageAsset::new("https://cdn.example.com/cat.png")
495                    .with_caption("a photo of a cat")
496                    .with_mime("image/png"),
497            ),
498            MediaBlock::Text("the dog ran in the park".into()),
499            MediaBlock::Image(
500                ImageAsset::new("data:image/jpeg;base64,amVlZw").with_caption("a photo of a dog"),
501            ),
502        ]
503    }
504
505    #[test]
506    fn chunker_tags_modality_and_preserves_order() {
507        let config = MultimodalChunkConfig::default().with_metadata("source", "catalog");
508        let chunker = MultimodalChunker::with_config(config);
509        let docs = chunker.chunk(&sample_blocks());
510        assert_eq!(docs.len(), 4);
511        assert_eq!(kind(&docs[0]), Some(MM_KIND_TEXT));
512        assert_eq!(docs[0].content, "a cat sat on the mat");
513        assert_eq!(
514            docs[0].metadata.get("source").and_then(Value::as_str),
515            Some("catalog")
516        );
517
518        assert_eq!(kind(&docs[1]), Some(MM_KIND_IMAGE));
519        assert_eq!(
520            docs[1].metadata.get(MM_URL_KEY).and_then(Value::as_str),
521            Some("https://cdn.example.com/cat.png")
522        );
523        assert_eq!(
524            docs[1].metadata.get(MM_CAPTION_KEY).and_then(Value::as_str),
525            Some("a photo of a cat")
526        );
527        // Caption is the document content, keeping lexical tooling usable.
528        assert_eq!(docs[1].content, "a photo of a cat");
529
530        assert_eq!(kind(&docs[2]), Some(MM_KIND_TEXT));
531        assert_eq!(kind(&docs[3]), Some(MM_KIND_IMAGE));
532        assert_eq!(
533            docs[3].metadata.get(MM_URL_KEY).and_then(Value::as_str),
534            Some("data:image/jpeg;base64,amVlZw")
535        );
536    }
537
538    #[test]
539    fn chunker_skips_blank_text_and_splits_long_blocks_safely() {
540        let config = MultimodalChunkConfig::default().with_chunk_chars(10);
541        let chunker = MultimodalChunker::with_config(config);
542        let blocks = vec![
543            MediaBlock::Text("   ".into()),
544            MediaBlock::Text("abcdefghij klmnopqrst".into()),
545        ];
546        let docs = chunker.chunk(&blocks);
547        assert!(docs.len() >= 2);
548        assert!(docs.iter().all(|d| !d.content.trim().is_empty()));
549        // No chunk exceeds the configured window (whitespace-trimmed).
550        assert!(docs.iter().all(|d| d.content.chars().count() <= 10));
551        // All pieces stay valid UTF-8 (they do by construction) and reorder back.
552        let joined: String = docs
553            .iter()
554            .map(|d| d.content.as_str())
555            .collect::<Vec<_>>()
556            .join(" ");
557        assert!(joined.starts_with("abcdefghij"));
558    }
559
560    #[test]
561    fn unicode_split_does_not_panic_or_split_scalar() {
562        let chunker =
563            MultimodalChunker::with_config(MultimodalChunkConfig::default().with_chunk_chars(3));
564        let docs = chunker.chunk(&[MediaBlock::Text("猫🐶狗🦊兔".into())]);
565        assert!(!docs.is_empty());
566        let rejoined: String = docs.iter().map(|d| d.content.clone()).collect();
567        assert_eq!(rejoined, "猫🐶狗🦊兔");
568    }
569
570    fn kind(doc: &Document) -> Option<&str> {
571        doc.metadata.get(MM_KIND_KEY).and_then(Value::as_str)
572    }
573
574    /// Aligns mock vectors so that text "…cat…" and the cat image share one
575    /// direction, and likewise for dog.
576    fn aligned_vision() -> Arc<dyn VisionEmbeddings> {
577        let vision = MockVisionEmbeddings::new(4);
578        vision.with_text_vector("a cat sat on the mat", vec![1.0, 0.0, 0.0, 0.0]);
579        vision.with_text_vector("the dog ran in the park", vec![0.0, 1.0, 0.0, 0.0]);
580        vision.with_image_vector(
581            &ImageInput::from_url("https://cdn.example.com/cat.png"),
582            vec![1.0, 0.0, 0.0, 0.0],
583        );
584        vision.with_image_vector(
585            &ImageInput::from_data_uri("data:image/jpeg;base64,amVlZw"),
586            vec![0.0, 1.0, 0.0, 0.0],
587        );
588        Arc::new(vision)
589    }
590
591    /// B7 图文混合检索连通: text query over a mixed image/text corpus returns
592    /// cross-modal matches; modality filters and image queries work as well.
593    #[tokio::test]
594    async fn mixed_image_text_retrieval_is_connected() {
595        let store: Arc<dyn VectorStore> = Arc::new(InMemoryVectorStore::new());
596        let vision = aligned_vision();
597        let retriever = MultimodalRetriever::new(store.clone(), vision);
598
599        let documents = MultimodalChunker::new().chunk(&sample_blocks());
600        retriever.add_documents(documents).await.unwrap();
601        assert_eq!(store.count().await, 4);
602
603        // Text query "cat" shares its space with the cat TEXT and cat IMAGE.
604        let results = retriever
605            .retrieve_with_scores_modality("a cat sat on the mat", 2, ModalityFilter::Any)
606            .await
607            .unwrap();
608        assert_eq!(results.len(), 2);
609        let kinds: Vec<&str> = results.iter().map(|r| kind(&r.document).unwrap()).collect();
610        assert!(
611            kinds.contains(&MM_KIND_IMAGE) && kinds.contains(&MM_KIND_TEXT),
612            "expected one image + one text hit, got {kinds:?}"
613        );
614        assert!((results[0].score - 1.0).abs() < 1e-5);
615
616        // Images-only filter: text docs never leak through.
617        let images = retriever
618            .retrieve_modality("a cat sat on the mat", 5, ModalityFilter::Images)
619            .await
620            .unwrap();
621        assert_eq!(images.len(), 2);
622        assert!(images.iter().all(|d| kind(d) == Some(MM_KIND_IMAGE)));
623        assert_eq!(
624            images[0].metadata.get(MM_URL_KEY).and_then(Value::as_str),
625            Some("https://cdn.example.com/cat.png")
626        );
627
628        // Texts-only filter: both text docs are eligible, the matching one ranks first.
629        let texts = retriever
630            .retrieve_modality("the dog ran in the park", 5, ModalityFilter::Texts)
631            .await
632            .unwrap();
633        assert_eq!(texts.len(), 2);
634        assert!(texts.iter().all(|d| kind(d) == Some(MM_KIND_TEXT)));
635        assert!(texts[0].content.contains("dog"));
636
637        // Image→everything query with the dog picture hits the dog side.
638        let dog_image = ImageInput::from_data_uri("data:image/jpeg;base64,amVlZw");
639        let by_image = retriever
640            .retrieve_by_image(&dog_image, 1, ModalityFilter::Any)
641            .await
642            .unwrap();
643        assert_eq!(by_image.len(), 1);
644        let hit = &by_image[0];
645        assert!(
646            hit.content.contains("dog")
647                || hit
648                    .metadata
649                    .get(MM_URL_KEY)
650                    .and_then(Value::as_str)
651                    .is_some_and(|u| u.contains("amVlZw"))
652        );
653    }
654
655    #[tokio::test]
656    async fn missing_image_reference_is_an_explicit_error() {
657        let store: Arc<dyn VectorStore> = Arc::new(InMemoryVectorStore::new());
658        let retriever = MultimodalRetriever::new(store, aligned_vision());
659
660        let bad = Document::new("broken image")
661            .with_metadata(MM_KIND_KEY, MM_KIND_IMAGE)
662            .with_metadata(MM_URL_KEY, "  ");
663        let err = retriever.add_documents(vec![bad]).await.unwrap_err();
664        assert!(matches!(err, RetrieverError::InvalidDocument(_)));
665    }
666
667    #[tokio::test]
668    async fn works_as_retriever_trait_object() {
669        let store: Arc<dyn VectorStore> = Arc::new(InMemoryVectorStore::new());
670        let retriever: Arc<dyn RetrieverTrait> =
671            Arc::new(MultimodalRetriever::new(store, aligned_vision()));
672
673        let documents = MultimodalChunker::new().chunk(&sample_blocks());
674        retriever.add_documents(documents).await.unwrap();
675        let hits = retriever.retrieve("a cat sat on the mat", 1).await.unwrap();
676        assert_eq!(hits.len(), 1);
677    }
678}