1use 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
34pub const MM_KIND_KEY: &str = "mm_kind";
36pub const MM_URL_KEY: &str = "mm_url";
38pub const MM_CAPTION_KEY: &str = "mm_caption";
40pub const MM_MIME_KEY: &str = "mm_mime";
42
43pub const MM_KIND_IMAGE: &str = "image";
45pub const MM_KIND_TEXT: &str = "text";
47
48#[derive(Debug, Clone)]
50#[non_exhaustive]
51pub enum MediaBlock {
52 Text(String),
54 Image(ImageAsset),
56}
57
58#[derive(Debug, Clone, Default)]
60pub struct ImageAsset {
61 pub url: String,
64 pub caption: Option<String>,
67 pub mime_type: Option<String>,
70}
71
72impl ImageAsset {
73 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 pub fn with_caption(mut self, caption: impl Into<String>) -> Self {
84 self.caption = Some(caption.into());
85 self
86 }
87
88 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#[derive(Debug, Clone, Default)]
97pub struct MultimodalChunkConfig {
98 pub text_chunk_chars: Option<usize>,
101 pub common_metadata: HashMap<String, Value>,
104}
105
106impl MultimodalChunkConfig {
107 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 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#[derive(Debug, Clone, Default)]
122pub struct MultimodalChunker {
123 config: MultimodalChunkConfig,
124}
125
126impl MultimodalChunker {
127 pub fn new() -> Self {
129 Self::default()
130 }
131
132 pub fn with_config(config: MultimodalChunkConfig) -> Self {
134 Self { config }
135 }
136
137 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 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 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#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
195pub enum ModalityFilter {
196 #[default]
198 Any,
199 Images,
201 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
223pub 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 pub fn new(store: Arc<dyn VectorStore>, vision: Arc<dyn VisionEmbeddings>) -> Self {
242 Self { store, vision }
243 }
244
245 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 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 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 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 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 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 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
425pub(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
435fn 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 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 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 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 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 assert!(docs.iter().all(|d| d.content.chars().count() <= 10));
551 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 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 #[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 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 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 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 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}