use std::collections::HashMap;
use std::sync::Arc;
use async_trait::async_trait;
use lc_embeddings::{ImageInput, VisionEmbeddings};
use lc_vector_stores::{Document, FilterOp, MetadataFilter, SearchResult, VectorStore};
use serde_json::Value;
use crate::retriever::{RetrieverError, RetrieverTrait};
pub const MM_KIND_KEY: &str = "mm_kind";
pub const MM_URL_KEY: &str = "mm_url";
pub const MM_CAPTION_KEY: &str = "mm_caption";
pub const MM_MIME_KEY: &str = "mm_mime";
pub const MM_KIND_IMAGE: &str = "image";
pub const MM_KIND_TEXT: &str = "text";
#[derive(Debug, Clone)]
#[non_exhaustive]
pub enum MediaBlock {
Text(String),
Image(ImageAsset),
}
#[derive(Debug, Clone, Default)]
pub struct ImageAsset {
pub url: String,
pub caption: Option<String>,
pub mime_type: Option<String>,
}
impl ImageAsset {
pub fn new(url: impl Into<String>) -> Self {
Self {
url: url.into(),
caption: None,
mime_type: None,
}
}
pub fn with_caption(mut self, caption: impl Into<String>) -> Self {
self.caption = Some(caption.into());
self
}
pub fn with_mime(mut self, mime_type: impl Into<String>) -> Self {
self.mime_type = Some(mime_type.into());
self
}
}
#[derive(Debug, Clone, Default)]
pub struct MultimodalChunkConfig {
pub text_chunk_chars: Option<usize>,
pub common_metadata: HashMap<String, Value>,
}
impl MultimodalChunkConfig {
pub fn with_chunk_chars(mut self, chunk_chars: usize) -> Self {
self.text_chunk_chars = Some(chunk_chars.max(1));
self
}
pub fn with_metadata(mut self, key: impl Into<String>, value: impl Into<Value>) -> Self {
self.common_metadata.insert(key.into(), value.into());
self
}
}
#[derive(Debug, Clone, Default)]
pub struct MultimodalChunker {
config: MultimodalChunkConfig,
}
impl MultimodalChunker {
pub fn new() -> Self {
Self::default()
}
pub fn with_config(config: MultimodalChunkConfig) -> Self {
Self { config }
}
pub fn chunk(&self, blocks: &[MediaBlock]) -> Vec<Document> {
let mut documents = Vec::with_capacity(blocks.len());
for block in blocks {
match block {
MediaBlock::Text(text) if text.trim().is_empty() => continue,
MediaBlock::Text(text) => {
for piece in split_text(text, self.config.text_chunk_chars) {
documents.push(self.text_document(piece));
}
}
MediaBlock::Image(asset) => documents.push(self.image_document(asset)),
}
}
documents
}
pub fn text_document(&self, content: impl Into<String>) -> Document {
let mut doc = Document::new(content);
for (key, value) in &self.config.common_metadata {
doc.metadata.insert(key.clone(), value.clone());
}
doc.metadata
.insert(MM_KIND_KEY.to_string(), Value::from(MM_KIND_TEXT));
doc
}
pub fn image_document(&self, asset: &ImageAsset) -> Document {
let mut doc = Document::new(asset.caption.clone().unwrap_or_default());
for (key, value) in &self.config.common_metadata {
doc.metadata.insert(key.clone(), value.clone());
}
doc.metadata
.insert(MM_KIND_KEY.to_string(), Value::from(MM_KIND_IMAGE));
doc.metadata
.insert(MM_URL_KEY.to_string(), Value::from(asset.url.clone()));
if let Some(caption) = &asset.caption {
doc.metadata
.insert(MM_CAPTION_KEY.to_string(), Value::from(caption.clone()));
}
if let Some(mime) = &asset.mime_type {
doc.metadata
.insert(MM_MIME_KEY.to_string(), Value::from(mime.clone()));
}
doc
}
}
#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
pub enum ModalityFilter {
#[default]
Any,
Images,
Texts,
}
impl ModalityFilter {
fn metadata_filter(self) -> Option<MetadataFilter> {
match self {
ModalityFilter::Any => None,
ModalityFilter::Images => Some(MetadataFilter::field(
MM_KIND_KEY,
FilterOp::Eq,
MM_KIND_IMAGE,
)),
ModalityFilter::Texts => Some(MetadataFilter::field(
MM_KIND_KEY,
FilterOp::Eq,
MM_KIND_TEXT,
)),
}
}
}
pub struct MultimodalRetriever {
store: Arc<dyn VectorStore>,
vision: Arc<dyn VisionEmbeddings>,
}
impl std::fmt::Debug for MultimodalRetriever {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MultimodalRetriever")
.field("model", &self.vision.model_name())
.field("dimension", &self.vision.dimension())
.finish()
}
}
impl MultimodalRetriever {
pub fn new(store: Arc<dyn VectorStore>, vision: Arc<dyn VisionEmbeddings>) -> Self {
Self { store, vision }
}
pub async fn retrieve_modality(
&self,
query: &str,
k: usize,
modality: ModalityFilter,
) -> Result<Vec<Document>, RetrieverError> {
let results = self.search_text(query, k, modality).await?;
Ok(results.into_iter().map(|r| r.document).collect())
}
pub async fn retrieve_with_scores_modality(
&self,
query: &str,
k: usize,
modality: ModalityFilter,
) -> Result<Vec<SearchResult>, RetrieverError> {
self.search_text(query, k, modality).await
}
pub async fn retrieve_by_image(
&self,
image: &ImageInput,
k: usize,
modality: ModalityFilter,
) -> Result<Vec<Document>, RetrieverError> {
let query_embedding = self
.vision
.embed_image(image)
.await
.map_err(|e| RetrieverError::EmbeddingError(e.to_string()))?;
let results = self.search_vector(&query_embedding, k, modality).await?;
Ok(results.into_iter().map(|r| r.document).collect())
}
async fn search_text(
&self,
query: &str,
k: usize,
modality: ModalityFilter,
) -> Result<Vec<SearchResult>, RetrieverError> {
let query_embedding = self
.vision
.embed_text(query)
.await
.map_err(|e| RetrieverError::EmbeddingError(e.to_string()))?;
self.search_vector(&query_embedding, k, modality).await
}
async fn search_vector(
&self,
query_embedding: &[f32],
k: usize,
modality: ModalityFilter,
) -> Result<Vec<SearchResult>, RetrieverError> {
let results = match modality.metadata_filter() {
None => self.store.similarity_search(query_embedding, k).await?,
Some(filter) => {
self.store
.similarity_search_with_filter(query_embedding, k, Some(&filter))
.await?
}
};
Ok(results
.into_iter()
.filter(|r| match modality {
ModalityFilter::Any => true,
ModalityFilter::Images => {
r.document.metadata.get(MM_KIND_KEY).and_then(Value::as_str)
== Some(MM_KIND_IMAGE)
}
ModalityFilter::Texts => {
r.document.metadata.get(MM_KIND_KEY).and_then(Value::as_str)
== Some(MM_KIND_TEXT)
}
})
.collect())
}
async fn embed_documents_mixed(
&self,
documents: &[Document],
) -> Result<Vec<Vec<f32>>, RetrieverError> {
let mut image_slots = Vec::new();
let mut image_inputs = Vec::new();
let mut text_slots = Vec::new();
for (index, doc) in documents.iter().enumerate() {
match doc.metadata.get(MM_KIND_KEY).and_then(Value::as_str) {
Some(MM_KIND_IMAGE) => {
let reference = doc
.metadata
.get(MM_URL_KEY)
.and_then(Value::as_str)
.filter(|url| !url.trim().is_empty())
.ok_or_else(|| {
RetrieverError::InvalidDocument(format!(
"image document {index} is missing {MM_URL_KEY}"
))
})?;
image_slots.push(index);
image_inputs.push(parse_image_reference(reference));
}
Some(MM_KIND_TEXT) | None => {
if doc.content.trim().is_empty() {
return Err(RetrieverError::EmbeddingError(format!(
"text document {index} has empty content"
)));
}
text_slots.push(index);
}
Some(other) => {
return Err(RetrieverError::InvalidDocument(format!(
"unknown {MM_KIND_KEY} value {other:?} on document {index}"
)));
}
}
}
let mut vectors: Vec<Option<Vec<f32>>> = vec![None; documents.len()];
if !image_inputs.is_empty() {
let image_vectors = self
.vision
.embed_images(&image_inputs)
.await
.map_err(|e| RetrieverError::EmbeddingError(e.to_string()))?;
for (slot, vector) in image_slots.into_iter().zip(image_vectors) {
vectors[slot] = Some(vector);
}
}
for slot in text_slots {
vectors[slot] = Some(
self.vision
.embed_text(&documents[slot].content)
.await
.map_err(|e| RetrieverError::EmbeddingError(e.to_string()))?,
);
}
Ok(vectors.into_iter().map(Option::unwrap).collect())
}
}
#[async_trait]
impl RetrieverTrait for MultimodalRetriever {
async fn retrieve(&self, query: &str, k: usize) -> Result<Vec<Document>, RetrieverError> {
self.retrieve_modality(query, k, ModalityFilter::Any).await
}
async fn retrieve_with_scores(
&self,
query: &str,
k: usize,
) -> Result<Vec<SearchResult>, RetrieverError> {
self.retrieve_with_scores_modality(query, k, ModalityFilter::Any)
.await
}
async fn add_documents(&self, documents: Vec<Document>) -> Result<(), RetrieverError> {
if documents.is_empty() {
return Ok(());
}
let embeddings = self.embed_documents_mixed(&documents).await?;
self.store.add_documents(documents, embeddings).await?;
Ok(())
}
}
pub(crate) fn parse_image_reference(reference: &str) -> ImageInput {
if reference.starts_with("data:") {
ImageInput::from_data_uri(reference)
} else {
ImageInput::from_url(reference)
}
}
fn split_text(text: &str, chunk_chars: Option<usize>) -> Vec<String> {
let Some(chunk_chars) = chunk_chars.filter(|n| *n > 0) else {
return vec![text.to_string()];
};
let chars: Vec<char> = text.chars().collect();
if chars.len() <= chunk_chars {
return vec![text.to_string()];
}
let mut pieces = Vec::new();
let mut start = 0usize;
while start < chars.len() {
let mut end = (start + chunk_chars).min(chars.len());
if end < chars.len() {
let look_back = start + (chunk_chars * 4 / 5);
if let Some(space) = (look_back..end).rfind(|i| chars[*i].is_whitespace()) {
end = space;
} else if !chars[end].is_whitespace() {
end = chars[..end]
.iter()
.rposition(|c| c.is_whitespace())
.filter(|p| *p > start)
.unwrap_or(end);
}
}
let piece: String = chars[start..end].iter().collect();
let trimmed = piece.trim();
if !trimmed.is_empty() {
pieces.push(trimmed.to_string());
}
if end <= start {
end = start + 1;
}
start = end;
while start < chars.len() && chars[start].is_whitespace() {
start += 1;
}
}
pieces
}
#[cfg(test)]
mod tests {
use super::*;
use lc_embeddings::MockVisionEmbeddings;
use lc_vector_stores::InMemoryVectorStore;
fn sample_blocks() -> Vec<MediaBlock> {
vec![
MediaBlock::Text("a cat sat on the mat".into()),
MediaBlock::Image(
ImageAsset::new("https://cdn.example.com/cat.png")
.with_caption("a photo of a cat")
.with_mime("image/png"),
),
MediaBlock::Text("the dog ran in the park".into()),
MediaBlock::Image(
ImageAsset::new("data:image/jpeg;base64,amVlZw").with_caption("a photo of a dog"),
),
]
}
#[test]
fn chunker_tags_modality_and_preserves_order() {
let config = MultimodalChunkConfig::default().with_metadata("source", "catalog");
let chunker = MultimodalChunker::with_config(config);
let docs = chunker.chunk(&sample_blocks());
assert_eq!(docs.len(), 4);
assert_eq!(kind(&docs[0]), Some(MM_KIND_TEXT));
assert_eq!(docs[0].content, "a cat sat on the mat");
assert_eq!(
docs[0].metadata.get("source").and_then(Value::as_str),
Some("catalog")
);
assert_eq!(kind(&docs[1]), Some(MM_KIND_IMAGE));
assert_eq!(
docs[1].metadata.get(MM_URL_KEY).and_then(Value::as_str),
Some("https://cdn.example.com/cat.png")
);
assert_eq!(
docs[1].metadata.get(MM_CAPTION_KEY).and_then(Value::as_str),
Some("a photo of a cat")
);
assert_eq!(docs[1].content, "a photo of a cat");
assert_eq!(kind(&docs[2]), Some(MM_KIND_TEXT));
assert_eq!(kind(&docs[3]), Some(MM_KIND_IMAGE));
assert_eq!(
docs[3].metadata.get(MM_URL_KEY).and_then(Value::as_str),
Some("data:image/jpeg;base64,amVlZw")
);
}
#[test]
fn chunker_skips_blank_text_and_splits_long_blocks_safely() {
let config = MultimodalChunkConfig::default().with_chunk_chars(10);
let chunker = MultimodalChunker::with_config(config);
let blocks = vec![
MediaBlock::Text(" ".into()),
MediaBlock::Text("abcdefghij klmnopqrst".into()),
];
let docs = chunker.chunk(&blocks);
assert!(docs.len() >= 2);
assert!(docs.iter().all(|d| !d.content.trim().is_empty()));
assert!(docs.iter().all(|d| d.content.chars().count() <= 10));
let joined: String = docs
.iter()
.map(|d| d.content.as_str())
.collect::<Vec<_>>()
.join(" ");
assert!(joined.starts_with("abcdefghij"));
}
#[test]
fn unicode_split_does_not_panic_or_split_scalar() {
let chunker =
MultimodalChunker::with_config(MultimodalChunkConfig::default().with_chunk_chars(3));
let docs = chunker.chunk(&[MediaBlock::Text("猫🐶狗🦊兔".into())]);
assert!(!docs.is_empty());
let rejoined: String = docs.iter().map(|d| d.content.clone()).collect();
assert_eq!(rejoined, "猫🐶狗🦊兔");
}
fn kind(doc: &Document) -> Option<&str> {
doc.metadata.get(MM_KIND_KEY).and_then(Value::as_str)
}
fn aligned_vision() -> Arc<dyn VisionEmbeddings> {
let vision = MockVisionEmbeddings::new(4);
vision.with_text_vector("a cat sat on the mat", vec![1.0, 0.0, 0.0, 0.0]);
vision.with_text_vector("the dog ran in the park", vec![0.0, 1.0, 0.0, 0.0]);
vision.with_image_vector(
&ImageInput::from_url("https://cdn.example.com/cat.png"),
vec![1.0, 0.0, 0.0, 0.0],
);
vision.with_image_vector(
&ImageInput::from_data_uri("data:image/jpeg;base64,amVlZw"),
vec![0.0, 1.0, 0.0, 0.0],
);
Arc::new(vision)
}
#[tokio::test]
async fn mixed_image_text_retrieval_is_connected() {
let store: Arc<dyn VectorStore> = Arc::new(InMemoryVectorStore::new());
let vision = aligned_vision();
let retriever = MultimodalRetriever::new(store.clone(), vision);
let documents = MultimodalChunker::new().chunk(&sample_blocks());
retriever.add_documents(documents).await.unwrap();
assert_eq!(store.count().await, 4);
let results = retriever
.retrieve_with_scores_modality("a cat sat on the mat", 2, ModalityFilter::Any)
.await
.unwrap();
assert_eq!(results.len(), 2);
let kinds: Vec<&str> = results.iter().map(|r| kind(&r.document).unwrap()).collect();
assert!(
kinds.contains(&MM_KIND_IMAGE) && kinds.contains(&MM_KIND_TEXT),
"expected one image + one text hit, got {kinds:?}"
);
assert!((results[0].score - 1.0).abs() < 1e-5);
let images = retriever
.retrieve_modality("a cat sat on the mat", 5, ModalityFilter::Images)
.await
.unwrap();
assert_eq!(images.len(), 2);
assert!(images.iter().all(|d| kind(d) == Some(MM_KIND_IMAGE)));
assert_eq!(
images[0].metadata.get(MM_URL_KEY).and_then(Value::as_str),
Some("https://cdn.example.com/cat.png")
);
let texts = retriever
.retrieve_modality("the dog ran in the park", 5, ModalityFilter::Texts)
.await
.unwrap();
assert_eq!(texts.len(), 2);
assert!(texts.iter().all(|d| kind(d) == Some(MM_KIND_TEXT)));
assert!(texts[0].content.contains("dog"));
let dog_image = ImageInput::from_data_uri("data:image/jpeg;base64,amVlZw");
let by_image = retriever
.retrieve_by_image(&dog_image, 1, ModalityFilter::Any)
.await
.unwrap();
assert_eq!(by_image.len(), 1);
let hit = &by_image[0];
assert!(
hit.content.contains("dog")
|| hit
.metadata
.get(MM_URL_KEY)
.and_then(Value::as_str)
.is_some_and(|u| u.contains("amVlZw"))
);
}
#[tokio::test]
async fn missing_image_reference_is_an_explicit_error() {
let store: Arc<dyn VectorStore> = Arc::new(InMemoryVectorStore::new());
let retriever = MultimodalRetriever::new(store, aligned_vision());
let bad = Document::new("broken image")
.with_metadata(MM_KIND_KEY, MM_KIND_IMAGE)
.with_metadata(MM_URL_KEY, " ");
let err = retriever.add_documents(vec![bad]).await.unwrap_err();
assert!(matches!(err, RetrieverError::InvalidDocument(_)));
}
#[tokio::test]
async fn works_as_retriever_trait_object() {
let store: Arc<dyn VectorStore> = Arc::new(InMemoryVectorStore::new());
let retriever: Arc<dyn RetrieverTrait> =
Arc::new(MultimodalRetriever::new(store, aligned_vision()));
let documents = MultimodalChunker::new().chunk(&sample_blocks());
retriever.add_documents(documents).await.unwrap();
let hits = retriever.retrieve("a cat sat on the mat", 1).await.unwrap();
assert_eq!(hits.len(), 1);
}
}