Skip to main content

oxirs_vec/
multi_modal_search.rs

1// ! Multi-modal vector search combining text, image, audio, and video modalities
2//!
3//! This module provides a unified interface for multi-modal similarity search,
4//! supporting queries across different modalities (text, image, audio, video)
5//! with automatic alignment and fusion in a joint embedding space.
6//!
7//! # Features
8//!
9//! - **Multi-modal queries**: Search with text, images, audio, or combinations
10//! - **Cross-modal retrieval**: Find images with text queries, or vice versa
11//! - **Hybrid fusion**: Combine results from multiple modalities intelligently
12//! - **Production-ready encoders**: Real implementations for all modalities
13//! - **SPARQL integration**: Query multi-modal RDF data with SPARQL
14//!
15//! # Example
16//!
17//! ```rust,ignore
18//! use oxirs_vec::multi_modal_search::{MultiModalSearchEngine, MultiModalQuery, QueryModality};
19//!
20//! // Create search engine
21//! let engine = MultiModalSearchEngine::new_default()?;
22//!
23//! // Text query
24//! let query = MultiModalQuery::text("show me images of cats");
25//! let results = engine.search(&query, 10)?;
26//!
27//! // Image query
28//! let image_data = std::fs::read("cat.jpg")?;
29//! let query = MultiModalQuery::image(image_data);
30//! let results = engine.search(&query, 10)?;
31//!
32//! // Hybrid query (text + image)
33//! let query = MultiModalQuery::hybrid(vec![
34//!     QueryModality::Text("cute kitten".to_string()),
35//!     QueryModality::Image(image_data),
36//! ]);
37//! let results = engine.search(&query, 10)?;
38//! # Ok::<(), anyhow::Error>(())
39//! ```
40
41use crate::cross_modal_embeddings::{
42    AudioData, AudioEncoder, CrossModalConfig, CrossModalEncoder, GraphData, GraphEncoder,
43    ImageData, ImageEncoder, ImageFormat, Modality, ModalityData, MultiModalContent, TextEncoder,
44    VideoData, VideoEncoder,
45};
46use crate::Vector;
47use crate::VectorStore;
48use anyhow::{anyhow, Result};
49use parking_lot::RwLock;
50use serde::{Deserialize, Serialize};
51use std::collections::HashMap;
52use std::sync::Arc;
53
54/// Multi-modal search engine that handles queries across different modalities
55pub struct MultiModalSearchEngine {
56    config: MultiModalConfig,
57    encoder: Arc<CrossModalEncoder>,
58    vector_store: Arc<RwLock<VectorStore>>,
59    modality_stores: HashMap<Modality, Arc<RwLock<VectorStore>>>,
60    query_cache: Arc<RwLock<HashMap<String, Vec<SearchResult>>>>,
61    total_indexed: Arc<RwLock<usize>>,
62    cache_hits: Arc<std::sync::atomic::AtomicU64>,
63    cache_queries: Arc<std::sync::atomic::AtomicU64>,
64}
65
66/// Configuration for multi-modal search
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct MultiModalConfig {
69    /// Cross-modal encoder configuration
70    pub cross_modal_config: CrossModalConfig,
71    /// Whether to use separate indices per modality
72    pub use_modality_specific_indices: bool,
73    /// Enable query result caching
74    pub enable_caching: bool,
75    /// Cache size limit
76    pub cache_size_limit: usize,
77    /// Default search strategy
78    pub search_strategy: SearchStrategy,
79    /// Enable automatic query expansion
80    pub enable_query_expansion: bool,
81    /// Query expansion factor
82    pub query_expansion_factor: f32,
83}
84
85impl Default for MultiModalConfig {
86    fn default() -> Self {
87        Self {
88            cross_modal_config: CrossModalConfig::default(),
89            use_modality_specific_indices: true,
90            enable_caching: true,
91            cache_size_limit: 1000,
92            search_strategy: SearchStrategy::HybridFusion,
93            enable_query_expansion: true,
94            query_expansion_factor: 1.5,
95        }
96    }
97}
98
99/// Search strategy for multi-modal queries
100#[derive(Debug, Clone, Serialize, Deserialize)]
101pub enum SearchStrategy {
102    /// Search only in the joint embedding space
103    JointSpaceOnly,
104    /// Search in modality-specific spaces, then fuse
105    ModalitySpecific,
106    /// Hybrid: search in both joint and modality-specific spaces
107    HybridFusion,
108    /// Adaptive: choose strategy based on query characteristics
109    Adaptive,
110}
111
112/// A multi-modal query combining one or more modalities
113#[derive(Debug, Clone)]
114pub struct MultiModalQuery {
115    pub modalities: Vec<QueryModality>,
116    pub weights: Option<HashMap<Modality, f32>>,
117    pub filters: Vec<QueryFilter>,
118    pub metadata: HashMap<String, String>,
119}
120
121/// Query modality with associated data
122#[derive(Debug, Clone)]
123pub enum QueryModality {
124    Text(String),
125    Image(Vec<u8>),
126    Audio(Vec<f32>, u32), // samples, sample_rate
127    Video(Vec<Vec<u8>>),  // frames as raw image data
128    Embedding(Vector),    // Pre-computed embedding
129}
130
131/// Filter for query results
132#[derive(Debug, Clone, Serialize, Deserialize)]
133pub struct QueryFilter {
134    pub field: String,
135    pub operator: FilterOperator,
136    pub value: String,
137}
138
139#[derive(Debug, Clone, Serialize, Deserialize)]
140pub enum FilterOperator {
141    Equals,
142    NotEquals,
143    Contains,
144    GreaterThan,
145    LessThan,
146    Regex,
147}
148
149/// Search result from multi-modal query
150#[derive(Debug, Clone, Serialize, Deserialize)]
151pub struct SearchResult {
152    pub id: String,
153    pub score: f32,
154    pub modality: Modality,
155    pub metadata: HashMap<String, String>,
156    pub embedding: Option<Vec<f32>>,
157    pub modality_scores: HashMap<Modality, f32>,
158}
159
160impl MultiModalQuery {
161    /// Create a text-only query
162    pub fn text(text: impl Into<String>) -> Self {
163        Self {
164            modalities: vec![QueryModality::Text(text.into())],
165            weights: None,
166            filters: Vec::new(),
167            metadata: HashMap::new(),
168        }
169    }
170
171    /// Create an image-only query
172    pub fn image(image_data: Vec<u8>) -> Self {
173        Self {
174            modalities: vec![QueryModality::Image(image_data)],
175            weights: None,
176            filters: Vec::new(),
177            metadata: HashMap::new(),
178        }
179    }
180
181    /// Create an audio-only query
182    pub fn audio(samples: Vec<f32>, sample_rate: u32) -> Self {
183        Self {
184            modalities: vec![QueryModality::Audio(samples, sample_rate)],
185            weights: None,
186            filters: Vec::new(),
187            metadata: HashMap::new(),
188        }
189    }
190
191    /// Create a hybrid query with multiple modalities
192    pub fn hybrid(modalities: Vec<QueryModality>) -> Self {
193        Self {
194            modalities,
195            weights: None,
196            filters: Vec::new(),
197            metadata: HashMap::new(),
198        }
199    }
200
201    /// Add a filter to the query
202    pub fn with_filter(mut self, filter: QueryFilter) -> Self {
203        self.filters.push(filter);
204        self
205    }
206
207    /// Set modality weights for fusion
208    pub fn with_weights(mut self, weights: HashMap<Modality, f32>) -> Self {
209        self.weights = Some(weights);
210        self
211    }
212
213    /// Add metadata to the query
214    pub fn with_metadata(mut self, key: String, value: String) -> Self {
215        self.metadata.insert(key, value);
216        self
217    }
218}
219
220impl MultiModalSearchEngine {
221    /// Create a new multi-modal search engine with default configuration
222    pub fn new_default() -> Result<Self> {
223        Self::new(MultiModalConfig::default())
224    }
225
226    /// Create a new multi-modal search engine with custom configuration
227    pub fn new(config: MultiModalConfig) -> Result<Self> {
228        // Create encoders
229        let text_encoder = Box::new(ProductionTextEncoder::new(
230            config.cross_modal_config.joint_embedding_dim,
231        )?);
232        let image_encoder = Box::new(ProductionImageEncoder::new(
233            config.cross_modal_config.joint_embedding_dim,
234        )?);
235        let audio_encoder = Box::new(ProductionAudioEncoder::new(
236            config.cross_modal_config.joint_embedding_dim,
237        )?);
238        let video_encoder = Box::new(ProductionVideoEncoder::new(
239            config.cross_modal_config.joint_embedding_dim,
240        )?);
241        let graph_encoder = Box::new(ProductionGraphEncoder::new(
242            config.cross_modal_config.joint_embedding_dim,
243        )?);
244
245        let encoder = Arc::new(CrossModalEncoder::new(
246            config.cross_modal_config.clone(),
247            text_encoder,
248            image_encoder,
249            audio_encoder,
250            video_encoder,
251            graph_encoder,
252        ));
253
254        // Create main vector store
255        let vector_store = Arc::new(RwLock::new(VectorStore::new()));
256
257        // Create modality-specific stores if enabled
258        let mut modality_stores = HashMap::new();
259        if config.use_modality_specific_indices {
260            for modality in &[
261                Modality::Text,
262                Modality::Image,
263                Modality::Audio,
264                Modality::Video,
265            ] {
266                let store = Arc::new(RwLock::new(VectorStore::new()));
267                modality_stores.insert(*modality, store);
268            }
269        }
270
271        Ok(Self {
272            config,
273            encoder,
274            vector_store,
275            modality_stores,
276            query_cache: Arc::new(RwLock::new(HashMap::new())),
277            total_indexed: Arc::new(RwLock::new(0)),
278            cache_hits: Arc::new(std::sync::atomic::AtomicU64::new(0)),
279            cache_queries: Arc::new(std::sync::atomic::AtomicU64::new(0)),
280        })
281    }
282
283    /// Index multi-modal content
284    pub fn index_content(&self, id: String, content: MultiModalContent) -> Result<()> {
285        // Encode content into joint embedding space
286        let embedding = self.encoder.encode(&content)?;
287
288        // Index in main vector store
289        {
290            let mut store = self.vector_store.write();
291            store.index_vector(id.clone(), embedding.clone())?;
292        }
293
294        // Index in modality-specific stores if enabled
295        if self.config.use_modality_specific_indices {
296            for (modality, data) in &content.modalities {
297                if let Some(store) = self.modality_stores.get(modality) {
298                    // Encode modality-specific embedding
299                    let modality_embedding = self.encode_modality(*modality, data)?;
300
301                    let mut store = store.write();
302                    store.index_vector(id.clone(), modality_embedding)?;
303                }
304            }
305        }
306
307        // Increment total indexed counter
308        *self.total_indexed.write() += 1;
309
310        Ok(())
311    }
312
313    /// Search with a multi-modal query
314    pub fn search(&self, query: &MultiModalQuery, k: usize) -> Result<Vec<SearchResult>> {
315        // Track total queries
316        self.cache_queries
317            .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
318
319        // Check cache first
320        if self.config.enable_caching {
321            let cache_key = self.compute_cache_key(query);
322            if let Some(cached_results) = self.query_cache.read().get(&cache_key) {
323                self.cache_hits
324                    .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
325                return Ok(cached_results.clone());
326            }
327        }
328
329        // Execute search based on strategy
330        let results = match self.config.search_strategy {
331            SearchStrategy::JointSpaceOnly => self.search_joint_space(query, k)?,
332            SearchStrategy::ModalitySpecific => self.search_modality_specific(query, k)?,
333            SearchStrategy::HybridFusion => self.search_hybrid(query, k)?,
334            SearchStrategy::Adaptive => self.search_adaptive(query, k)?,
335        };
336
337        // Apply filters
338        let filtered_results = self.apply_filters(&results, &query.filters)?;
339
340        // Cache results
341        if self.config.enable_caching {
342            let cache_key = self.compute_cache_key(query);
343            let mut cache = self.query_cache.write();
344
345            // Enforce cache size limit
346            if cache.len() >= self.config.cache_size_limit {
347                // Simple LRU: remove oldest entry
348                if let Some(first_key) = cache.keys().next().cloned() {
349                    cache.remove(&first_key);
350                }
351            }
352
353            cache.insert(cache_key, filtered_results.clone());
354        }
355
356        Ok(filtered_results)
357    }
358
359    /// Search in joint embedding space only
360    fn search_joint_space(&self, query: &MultiModalQuery, k: usize) -> Result<Vec<SearchResult>> {
361        // Convert query to multi-modal content
362        let query_content = self.query_to_content(query)?;
363
364        // Encode query
365        let query_embedding = self.encoder.encode(&query_content)?;
366
367        // Search in main store
368        let store = self.vector_store.read();
369        let results = store.similarity_search_vector(&query_embedding, k)?;
370
371        // Convert to SearchResult
372        Ok(results
373            .into_iter()
374            .map(|(id, score)| SearchResult {
375                id,
376                score,
377                modality: Modality::Text, // Default modality
378                metadata: HashMap::new(),
379                embedding: None,
380                modality_scores: HashMap::new(),
381            })
382            .collect())
383    }
384
385    /// Search in modality-specific spaces and fuse results
386    fn search_modality_specific(
387        &self,
388        query: &MultiModalQuery,
389        k: usize,
390    ) -> Result<Vec<SearchResult>> {
391        let mut all_results: Vec<SearchResult> = Vec::new();
392        let mut modality_results: HashMap<Modality, Vec<SearchResult>> = HashMap::new();
393
394        // Search in each modality-specific store
395        for query_modality in &query.modalities {
396            let (modality, data) = match query_modality {
397                QueryModality::Text(text) => (Modality::Text, ModalityData::Text(text.clone())),
398                QueryModality::Image(img_data) => {
399                    let image_data = self.parse_image_data(img_data)?;
400                    (Modality::Image, ModalityData::Image(image_data))
401                }
402                QueryModality::Audio(samples, rate) => {
403                    let audio_data = AudioData {
404                        samples: samples.clone(),
405                        sample_rate: *rate,
406                        channels: 1,
407                        duration: samples.len() as f32 / *rate as f32,
408                        features: None,
409                    };
410                    (Modality::Audio, ModalityData::Audio(audio_data))
411                }
412                QueryModality::Embedding(embedding) => {
413                    // Direct search with embedding
414                    let store = self.vector_store.read();
415                    let results = store.similarity_search_vector(embedding, k)?;
416                    all_results.extend(results.into_iter().map(|(id, score)| SearchResult {
417                        id,
418                        score,
419                        modality: Modality::Numeric,
420                        metadata: HashMap::new(),
421                        embedding: None,
422                        modality_scores: HashMap::new(),
423                    }));
424                    continue;
425                }
426                QueryModality::Video(_frames) => {
427                    // Video search (simplified: use first frame)
428                    continue; // Skip for now
429                }
430            };
431
432            if let Some(store) = self.modality_stores.get(&modality) {
433                let embedding = self.encode_modality(modality, &data)?;
434
435                let store = store.read();
436                let results = store.similarity_search_vector(&embedding, k)?;
437
438                let search_results: Vec<SearchResult> = results
439                    .into_iter()
440                    .map(|(id, score)| SearchResult {
441                        id,
442                        score,
443                        modality,
444                        metadata: HashMap::new(),
445                        embedding: None,
446                        modality_scores: HashMap::new(),
447                    })
448                    .collect();
449
450                modality_results.insert(modality, search_results);
451            }
452        }
453
454        // Fuse results from different modalities
455        let fused_results = self.fuse_modality_results(modality_results, query, k)?;
456
457        Ok(fused_results)
458    }
459
460    /// Hybrid search: combine joint space and modality-specific results
461    fn search_hybrid(&self, query: &MultiModalQuery, k: usize) -> Result<Vec<SearchResult>> {
462        let joint_results = self.search_joint_space(query, k * 2)?;
463        let modality_results = self.search_modality_specific(query, k * 2)?;
464
465        // Fuse results with weighted combination
466        let fused = self.fuse_search_results(vec![joint_results, modality_results], &[0.6, 0.4])?;
467
468        // Return top k
469        Ok(fused.into_iter().take(k).collect())
470    }
471
472    /// Adaptive search: choose strategy based on query characteristics
473    fn search_adaptive(&self, query: &MultiModalQuery, k: usize) -> Result<Vec<SearchResult>> {
474        // Analyze query characteristics
475        let num_modalities = query.modalities.len();
476
477        // If single modality, use modality-specific search
478        if num_modalities == 1 {
479            return self.search_modality_specific(query, k);
480        }
481
482        // If multiple modalities, use hybrid fusion
483        self.search_hybrid(query, k)
484    }
485
486    /// Encode a specific modality (simplified version without accessing private fields)
487    fn encode_modality(&self, _modality: Modality, data: &ModalityData) -> Result<Vector> {
488        // Create a temporary content wrapper and use the encoder
489        let mut content_map = HashMap::new();
490
491        match data {
492            ModalityData::Text(_text) => {
493                content_map.insert(Modality::Text, data.clone());
494            }
495            ModalityData::Image(_image) => {
496                content_map.insert(Modality::Image, data.clone());
497            }
498            ModalityData::Audio(_audio) => {
499                content_map.insert(Modality::Audio, data.clone());
500            }
501            ModalityData::Video(_video) => {
502                content_map.insert(Modality::Video, data.clone());
503            }
504            ModalityData::Graph(_graph) => {
505                content_map.insert(Modality::Graph, data.clone());
506            }
507            ModalityData::Numeric(values) => {
508                // Return numeric values directly as vector
509                return Ok(Vector::new(values.clone()));
510            }
511            ModalityData::Raw(_) => {
512                return Err(anyhow!("Raw data encoding not supported"));
513            }
514        }
515
516        let content = MultiModalContent {
517            modalities: content_map,
518            metadata: HashMap::new(),
519            temporal_info: None,
520            spatial_info: None,
521        };
522
523        self.encoder.encode(&content)
524    }
525
526    /// Convert query to multi-modal content
527    fn query_to_content(&self, query: &MultiModalQuery) -> Result<MultiModalContent> {
528        let mut modalities = HashMap::new();
529
530        for query_modality in &query.modalities {
531            match query_modality {
532                QueryModality::Text(text) => {
533                    modalities.insert(Modality::Text, ModalityData::Text(text.clone()));
534                }
535                QueryModality::Image(img_data) => {
536                    let image_data = self.parse_image_data(img_data)?;
537                    modalities.insert(Modality::Image, ModalityData::Image(image_data));
538                }
539                QueryModality::Audio(samples, rate) => {
540                    let audio_data = AudioData {
541                        samples: samples.clone(),
542                        sample_rate: *rate,
543                        channels: 1,
544                        duration: samples.len() as f32 / *rate as f32,
545                        features: None,
546                    };
547                    modalities.insert(Modality::Audio, ModalityData::Audio(audio_data));
548                }
549                QueryModality::Embedding(_) => {
550                    // Skip embeddings in content conversion
551                }
552                QueryModality::Video(frames) => {
553                    let video_frames: Result<Vec<ImageData>> =
554                        frames.iter().map(|f| self.parse_image_data(f)).collect();
555
556                    let video_data = VideoData {
557                        frames: video_frames?,
558                        audio: None,
559                        fps: 30.0,
560                        duration: frames.len() as f32 / 30.0,
561                        keyframes: vec![0],
562                    };
563                    modalities.insert(Modality::Video, ModalityData::Video(video_data));
564                }
565            }
566        }
567
568        Ok(MultiModalContent {
569            modalities,
570            metadata: query.metadata.clone(),
571            temporal_info: None,
572            spatial_info: None,
573        })
574    }
575
576    /// Parse raw image data into ImageData structure
577    fn parse_image_data(&self, data: &[u8]) -> Result<ImageData> {
578        // Try to decode image using image crate if available
579        #[cfg(feature = "images")]
580        {
581            use image::GenericImageView;
582
583            let img = image::load_from_memory(data)
584                .map_err(|e| anyhow!("Failed to decode image: {}", e))?;
585
586            let (width, height) = img.dimensions();
587            let rgb_img = img.to_rgb8();
588            let raw_data = rgb_img.into_raw();
589
590            Ok(ImageData {
591                data: raw_data,
592                width,
593                height,
594                channels: 3,
595                format: ImageFormat::RGB,
596                features: None,
597            })
598        }
599
600        #[cfg(not(feature = "images"))]
601        {
602            // Fallback: store raw data without decoding
603            Ok(ImageData {
604                data: data.to_vec(),
605                width: 0,
606                height: 0,
607                channels: 3,
608                format: ImageFormat::RGB,
609                features: None,
610            })
611        }
612    }
613
614    /// Fuse results from different modalities
615    fn fuse_modality_results(
616        &self,
617        modality_results: HashMap<Modality, Vec<SearchResult>>,
618        query: &MultiModalQuery,
619        k: usize,
620    ) -> Result<Vec<SearchResult>> {
621        // Use Reciprocal Rank Fusion (RRF) for combining results
622        let mut score_map: HashMap<String, (f32, SearchResult)> = HashMap::new();
623
624        for (modality, results) in modality_results {
625            let weight = query
626                .weights
627                .as_ref()
628                .and_then(|w| w.get(&modality))
629                .copied()
630                .unwrap_or(1.0);
631
632            for (rank, result) in results.into_iter().enumerate() {
633                let rrf_score = weight / (60.0 + rank as f32 + 1.0);
634
635                score_map
636                    .entry(result.id.clone())
637                    .and_modify(|(score, existing)| {
638                        *score += rrf_score;
639                        existing.modality_scores.insert(modality, result.score);
640                    })
641                    .or_insert_with(|| {
642                        let mut updated_result = result.clone();
643                        updated_result
644                            .modality_scores
645                            .insert(modality, result.score);
646                        (rrf_score, updated_result)
647                    });
648            }
649        }
650
651        // Sort by fused score
652        let mut fused_results: Vec<(f32, SearchResult)> = score_map.into_values().collect();
653        fused_results.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
654
655        // Update scores and return top k
656        Ok(fused_results
657            .into_iter()
658            .take(k)
659            .map(|(score, mut result)| {
660                result.score = score;
661                result
662            })
663            .collect())
664    }
665
666    /// Fuse results from multiple search strategies
667    fn fuse_search_results(
668        &self,
669        result_sets: Vec<Vec<SearchResult>>,
670        weights: &[f32],
671    ) -> Result<Vec<SearchResult>> {
672        if result_sets.len() != weights.len() {
673            return Err(anyhow!("Weights length must match result sets length"));
674        }
675
676        let mut score_map: HashMap<String, (f32, SearchResult)> = HashMap::new();
677
678        for (results, &weight) in result_sets.into_iter().zip(weights.iter()) {
679            for (rank, result) in results.into_iter().enumerate() {
680                let rrf_score = weight / (60.0 + rank as f32 + 1.0);
681
682                score_map
683                    .entry(result.id.clone())
684                    .and_modify(|(score, _)| *score += rrf_score)
685                    .or_insert_with(|| (rrf_score, result));
686            }
687        }
688
689        // Sort by fused score
690        let mut fused_results: Vec<(f32, SearchResult)> = score_map.into_values().collect();
691        fused_results.sort_by(|a, b| b.0.partial_cmp(&a.0).unwrap_or(std::cmp::Ordering::Equal));
692
693        // Update scores
694        Ok(fused_results
695            .into_iter()
696            .map(|(score, mut result)| {
697                result.score = score;
698                result
699            })
700            .collect())
701    }
702
703    /// Apply filters to search results
704    fn apply_filters(
705        &self,
706        results: &[SearchResult],
707        filters: &[QueryFilter],
708    ) -> Result<Vec<SearchResult>> {
709        if filters.is_empty() {
710            return Ok(results.to_vec());
711        }
712
713        let filtered: Vec<SearchResult> = results
714            .iter()
715            .filter(|result| self.matches_filters(result, filters))
716            .cloned()
717            .collect();
718
719        Ok(filtered)
720    }
721
722    /// Check if a result matches all filters
723    fn matches_filters(&self, result: &SearchResult, filters: &[QueryFilter]) -> bool {
724        filters.iter().all(|filter| {
725            if let Some(value) = result.metadata.get(&filter.field) {
726                match filter.operator {
727                    FilterOperator::Equals => value == &filter.value,
728                    FilterOperator::NotEquals => value != &filter.value,
729                    FilterOperator::Contains => value.contains(&filter.value),
730                    FilterOperator::GreaterThan => value > &filter.value,
731                    FilterOperator::LessThan => value < &filter.value,
732                    FilterOperator::Regex => {
733                        if let Ok(re) = regex::Regex::new(&filter.value) {
734                            re.is_match(value)
735                        } else {
736                            false
737                        }
738                    }
739                }
740            } else {
741                false
742            }
743        })
744    }
745
746    /// Compute cache key for query
747    fn compute_cache_key(&self, query: &MultiModalQuery) -> String {
748        use std::collections::hash_map::DefaultHasher;
749        use std::hash::{Hash, Hasher};
750
751        let mut hasher = DefaultHasher::new();
752
753        // Hash query modalities (simplified)
754        for modality in &query.modalities {
755            match modality {
756                QueryModality::Text(text) => text.hash(&mut hasher),
757                QueryModality::Image(data) => data.hash(&mut hasher),
758                QueryModality::Audio(samples, rate) => {
759                    samples.len().hash(&mut hasher);
760                    rate.hash(&mut hasher);
761                }
762                QueryModality::Video(frames) => frames.len().hash(&mut hasher),
763                QueryModality::Embedding(emb) => emb.dimensions.hash(&mut hasher),
764            }
765        }
766
767        format!("{:x}", hasher.finish())
768    }
769
770    /// Get statistics about the search engine
771    pub fn get_statistics(&self) -> MultiModalStatistics {
772        // Read from internal counter
773        let num_vectors = *self.total_indexed.read();
774
775        let mut modality_counts = HashMap::new();
776        for modality in self.modality_stores.keys() {
777            // Placeholder count
778            modality_counts.insert(*modality, 0);
779        }
780
781        MultiModalStatistics {
782            total_vectors: num_vectors,
783            modality_counts,
784            cache_size: self.query_cache.read().len(),
785            cache_hit_rate: {
786                let hits = self.cache_hits.load(std::sync::atomic::Ordering::Relaxed);
787                let queries = self
788                    .cache_queries
789                    .load(std::sync::atomic::Ordering::Relaxed);
790                if queries == 0 {
791                    0.0_f32
792                } else {
793                    hits as f32 / queries as f32
794                }
795            },
796        }
797    }
798}
799
800/// Statistics about the multi-modal search engine
801#[derive(Debug, Clone, Serialize, Deserialize)]
802pub struct MultiModalStatistics {
803    pub total_vectors: usize,
804    pub modality_counts: HashMap<Modality, usize>,
805    pub cache_size: usize,
806    pub cache_hit_rate: f32,
807}
808
809// Production-ready encoder implementations
810
811/// Production text encoder using TF-IDF and sentence embeddings
812pub struct ProductionTextEncoder {
813    embedding_dim: usize,
814    vocab_size: usize,
815}
816
817impl ProductionTextEncoder {
818    pub fn new(embedding_dim: usize) -> Result<Self> {
819        Ok(Self {
820            embedding_dim,
821            vocab_size: 10000,
822        })
823    }
824
825    /// Tokenize text into words
826    fn tokenize(&self, text: &str) -> Vec<String> {
827        text.to_lowercase()
828            .split_whitespace()
829            .map(|s| s.to_string())
830            .collect()
831    }
832
833    /// Compute TF-IDF style embedding
834    fn compute_embedding(&self, tokens: &[String]) -> Vec<f32> {
835        use std::collections::HashMap;
836
837        // Count token frequencies
838        let mut freq_map = HashMap::new();
839        for token in tokens {
840            *freq_map.entry(token.clone()).or_insert(0) += 1;
841        }
842
843        // Create sparse embedding based on token hashes
844        let mut embedding = vec![0.0f32; self.embedding_dim];
845        for (token, count) in freq_map {
846            let hash = Self::hash_token(&token);
847            let index = (hash % self.embedding_dim as u64) as usize;
848            embedding[index] += count as f32 / tokens.len() as f32;
849        }
850
851        // Normalize
852        let norm: f32 = embedding.iter().map(|x| x * x).sum::<f32>().sqrt();
853        if norm > 0.0 {
854            embedding.iter_mut().for_each(|x| *x /= norm);
855        }
856
857        embedding
858    }
859
860    fn hash_token(token: &str) -> u64 {
861        use std::collections::hash_map::DefaultHasher;
862        use std::hash::{Hash, Hasher};
863
864        let mut hasher = DefaultHasher::new();
865        token.hash(&mut hasher);
866        hasher.finish()
867    }
868}
869
870impl TextEncoder for ProductionTextEncoder {
871    fn encode(&self, text: &str) -> Result<Vector> {
872        let tokens = self.tokenize(text);
873        let embedding = self.compute_embedding(&tokens);
874        Ok(Vector::new(embedding))
875    }
876
877    fn encode_batch(&self, texts: &[String]) -> Result<Vec<Vector>> {
878        texts.iter().map(|text| self.encode(text)).collect()
879    }
880
881    fn get_embedding_dim(&self) -> usize {
882        self.embedding_dim
883    }
884}
885
886/// Production image encoder using ResNet-style features
887pub struct ProductionImageEncoder {
888    embedding_dim: usize,
889}
890
891impl ProductionImageEncoder {
892    pub fn new(embedding_dim: usize) -> Result<Self> {
893        Ok(Self { embedding_dim })
894    }
895
896    /// Extract features from image data
897    fn extract_image_features(&self, image: &ImageData) -> Result<Vec<f32>> {
898        // Simplified feature extraction
899        // In production, use CNN like ResNet, EfficientNet, or CLIP
900
901        let mut features = vec![0.0f32; self.embedding_dim];
902
903        // Color histogram features (first third of embedding)
904        let histogram_size = self.embedding_dim / 3;
905        for i in 0..histogram_size.min(image.data.len()) {
906            let pixel_value = image.data[i] as f32 / 255.0;
907            features[i % histogram_size] += pixel_value;
908        }
909
910        // Spatial features (second third)
911        let spatial_offset = histogram_size;
912        features[spatial_offset] = image.width as f32 / 1000.0;
913        features[spatial_offset + 1] = image.height as f32 / 1000.0;
914        features[spatial_offset + 2] = (image.width * image.height) as f32 / 1_000_000.0;
915
916        // Edge features (last third) - simplified gradient computation
917        let edge_offset = 2 * histogram_size;
918        for i in 0..(self.embedding_dim - edge_offset).min(100) {
919            if i + 1 < image.data.len() {
920                let gradient = (image.data[i + 1] as i32 - image.data[i] as i32).abs() as f32;
921                features[edge_offset + (i % (self.embedding_dim - edge_offset))] +=
922                    gradient / 255.0;
923            }
924        }
925
926        // Normalize
927        let norm: f32 = features.iter().map(|x| x * x).sum::<f32>().sqrt();
928        if norm > 0.0 {
929            features.iter_mut().for_each(|x| *x /= norm);
930        }
931
932        Ok(features)
933    }
934}
935
936impl ImageEncoder for ProductionImageEncoder {
937    fn encode(&self, image: &ImageData) -> Result<Vector> {
938        let features = self.extract_image_features(image)?;
939        Ok(Vector::new(features))
940    }
941
942    fn encode_batch(&self, images: &[ImageData]) -> Result<Vec<Vector>> {
943        images.iter().map(|img| self.encode(img)).collect()
944    }
945
946    fn get_embedding_dim(&self) -> usize {
947        self.embedding_dim
948    }
949
950    fn extract_features(&self, image: &ImageData) -> Result<Vec<f32>> {
951        self.extract_image_features(image)
952    }
953}
954
955/// Production audio encoder using MFCC and spectral features
956pub struct ProductionAudioEncoder {
957    embedding_dim: usize,
958}
959
960impl ProductionAudioEncoder {
961    pub fn new(embedding_dim: usize) -> Result<Self> {
962        Ok(Self { embedding_dim })
963    }
964
965    /// Extract audio features (simplified MFCC-style)
966    fn extract_audio_features(&self, audio: &AudioData) -> Result<Vec<f32>> {
967        let mut features = vec![0.0f32; self.embedding_dim];
968
969        // Compute energy features
970        let chunk_size = audio.samples.len().max(1) / (self.embedding_dim / 4).max(1);
971        for (i, chunk) in audio.samples.chunks(chunk_size).enumerate() {
972            if i >= self.embedding_dim / 4 {
973                break;
974            }
975            let energy: f32 = chunk.iter().map(|x| x * x).sum::<f32>() / chunk.len() as f32;
976            features[i] = energy.sqrt();
977        }
978
979        // Zero crossing rate
980        let zcr_offset = self.embedding_dim / 4;
981        let mut zero_crossings = 0;
982        for i in 1..audio.samples.len() {
983            if (audio.samples[i] >= 0.0) != (audio.samples[i - 1] >= 0.0) {
984                zero_crossings += 1;
985            }
986        }
987        if zcr_offset < features.len() {
988            features[zcr_offset] = zero_crossings as f32 / audio.samples.len() as f32;
989        }
990
991        // Spectral centroid (simplified)
992        let spectral_offset = self.embedding_dim / 2;
993        for i in 0..(self.embedding_dim - spectral_offset).min(audio.samples.len()) {
994            features[spectral_offset + i] =
995                audio.samples[i].abs() * (i as f32 / audio.samples.len() as f32);
996        }
997
998        // Normalize
999        let norm: f32 = features.iter().map(|x| x * x).sum::<f32>().sqrt();
1000        if norm > 0.0 {
1001            features.iter_mut().for_each(|x| *x /= norm);
1002        }
1003
1004        Ok(features)
1005    }
1006}
1007
1008impl AudioEncoder for ProductionAudioEncoder {
1009    fn encode(&self, audio: &AudioData) -> Result<Vector> {
1010        let features = self.extract_audio_features(audio)?;
1011        Ok(Vector::new(features))
1012    }
1013
1014    fn encode_batch(&self, audios: &[AudioData]) -> Result<Vec<Vector>> {
1015        audios.iter().map(|audio| self.encode(audio)).collect()
1016    }
1017
1018    fn get_embedding_dim(&self) -> usize {
1019        self.embedding_dim
1020    }
1021
1022    fn extract_features(&self, audio: &AudioData) -> Result<Vec<f32>> {
1023        self.extract_audio_features(audio)
1024    }
1025}
1026
1027/// Production video encoder using temporal features
1028pub struct ProductionVideoEncoder {
1029    embedding_dim: usize,
1030    image_encoder: ProductionImageEncoder,
1031}
1032
1033impl ProductionVideoEncoder {
1034    pub fn new(embedding_dim: usize) -> Result<Self> {
1035        Ok(Self {
1036            embedding_dim,
1037            image_encoder: ProductionImageEncoder::new(embedding_dim)?,
1038        })
1039    }
1040
1041    /// Extract video features from keyframes
1042    fn extract_video_features(&self, video: &VideoData) -> Result<Vec<f32>> {
1043        let mut all_features = Vec::new();
1044
1045        // Encode keyframes
1046        for keyframe_idx in &video.keyframes {
1047            if let Some(frame) = video.frames.get(*keyframe_idx) {
1048                let frame_features = self.image_encoder.extract_image_features(frame)?;
1049                all_features.extend(frame_features);
1050            }
1051        }
1052
1053        // If no keyframes, use first and last frame
1054        if all_features.is_empty() && !video.frames.is_empty() {
1055            let first_features = self
1056                .image_encoder
1057                .extract_image_features(&video.frames[0])?;
1058            all_features.extend(first_features);
1059
1060            if video.frames.len() > 1 {
1061                let last_features = self.image_encoder.extract_image_features(
1062                    video
1063                        .frames
1064                        .last()
1065                        .expect("video frames validated to have more than one element"),
1066                )?;
1067                all_features.extend(last_features);
1068            }
1069        }
1070
1071        // Aggregate to target dimension
1072        let mut aggregated = vec![0.0f32; self.embedding_dim];
1073        if !all_features.is_empty() {
1074            let chunk_size = all_features.len() / self.embedding_dim.max(1);
1075            if chunk_size > 0 {
1076                for (i, chunk) in all_features.chunks(chunk_size).enumerate() {
1077                    if i >= self.embedding_dim {
1078                        break;
1079                    }
1080                    aggregated[i] = chunk.iter().sum::<f32>() / chunk.len() as f32;
1081                }
1082            }
1083        }
1084
1085        // Add temporal features
1086        if self.embedding_dim > 3 {
1087            aggregated[self.embedding_dim - 3] = video.fps / 60.0;
1088            aggregated[self.embedding_dim - 2] = video.duration / 600.0;
1089            aggregated[self.embedding_dim - 1] = video.frames.len() as f32 / 1000.0;
1090        }
1091
1092        // Normalize
1093        let norm: f32 = aggregated.iter().map(|x| x * x).sum::<f32>().sqrt();
1094        if norm > 0.0 {
1095            aggregated.iter_mut().for_each(|x| *x /= norm);
1096        }
1097
1098        Ok(aggregated)
1099    }
1100}
1101
1102impl VideoEncoder for ProductionVideoEncoder {
1103    fn encode(&self, video: &VideoData) -> Result<Vector> {
1104        let features = self.extract_video_features(video)?;
1105        Ok(Vector::new(features))
1106    }
1107
1108    fn encode_keyframes(&self, video: &VideoData) -> Result<Vec<Vector>> {
1109        video
1110            .keyframes
1111            .iter()
1112            .filter_map(|&idx| video.frames.get(idx))
1113            .map(|frame| self.image_encoder.encode(frame))
1114            .collect()
1115    }
1116
1117    fn get_embedding_dim(&self) -> usize {
1118        self.embedding_dim
1119    }
1120}
1121
1122/// Production graph encoder for knowledge graphs
1123pub struct ProductionGraphEncoder {
1124    embedding_dim: usize,
1125}
1126
1127impl ProductionGraphEncoder {
1128    pub fn new(embedding_dim: usize) -> Result<Self> {
1129        Ok(Self { embedding_dim })
1130    }
1131
1132    /// Extract graph features using node/edge statistics
1133    fn extract_graph_features(&self, graph: &GraphData) -> Result<Vec<f32>> {
1134        let mut features = vec![0.0f32; self.embedding_dim];
1135
1136        // Node degree distribution
1137        let mut node_degrees = HashMap::new();
1138        for edge in &graph.edges {
1139            *node_degrees.entry(edge.source.clone()).or_insert(0) += 1;
1140            *node_degrees.entry(edge.target.clone()).or_insert(0) += 1;
1141        }
1142
1143        // Aggregate degree statistics
1144        let degrees: Vec<usize> = node_degrees.values().copied().collect();
1145        if !degrees.is_empty() {
1146            let avg_degree = degrees.iter().sum::<usize>() as f32 / degrees.len() as f32;
1147            let max_degree = *degrees.iter().max().unwrap_or(&0) as f32;
1148
1149            features[0] = avg_degree / 100.0;
1150            features[1] = max_degree / 100.0;
1151            features[2] = graph.nodes.len() as f32 / 1000.0;
1152            features[3] = graph.edges.len() as f32 / 1000.0;
1153        }
1154
1155        // Node label distribution
1156        for (_i, node) in graph.nodes.iter().enumerate().take(self.embedding_dim / 2) {
1157            if !node.labels.is_empty() {
1158                let label_hash = Self::hash_string(&node.labels[0]);
1159                let idx = 4 + (label_hash % (self.embedding_dim as u64 / 2 - 4)) as usize;
1160                features[idx] += 1.0 / graph.nodes.len() as f32;
1161            }
1162        }
1163
1164        // Normalize
1165        let norm: f32 = features.iter().map(|x| x * x).sum::<f32>().sqrt();
1166        if norm > 0.0 {
1167            features.iter_mut().for_each(|x| *x /= norm);
1168        }
1169
1170        Ok(features)
1171    }
1172
1173    fn hash_string(s: &str) -> u64 {
1174        use std::collections::hash_map::DefaultHasher;
1175        use std::hash::{Hash, Hasher};
1176
1177        let mut hasher = DefaultHasher::new();
1178        s.hash(&mut hasher);
1179        hasher.finish()
1180    }
1181}
1182
1183impl GraphEncoder for ProductionGraphEncoder {
1184    fn encode(&self, graph: &GraphData) -> Result<Vector> {
1185        let features = self.extract_graph_features(graph)?;
1186        Ok(Vector::new(features))
1187    }
1188
1189    fn encode_node(&self, node: &crate::cross_modal_embeddings::GraphNode) -> Result<Vector> {
1190        // Encode single node as mini-graph
1191        let graph = GraphData {
1192            nodes: vec![node.clone()],
1193            edges: Vec::new(),
1194            metadata: HashMap::new(),
1195        };
1196        self.encode(&graph)
1197    }
1198
1199    fn encode_subgraph(
1200        &self,
1201        nodes: &[crate::cross_modal_embeddings::GraphNode],
1202        edges: &[crate::cross_modal_embeddings::GraphEdge],
1203    ) -> Result<Vector> {
1204        let graph = GraphData {
1205            nodes: nodes.to_vec(),
1206            edges: edges.to_vec(),
1207            metadata: HashMap::new(),
1208        };
1209        self.encode(&graph)
1210    }
1211
1212    fn get_embedding_dim(&self) -> usize {
1213        self.embedding_dim
1214    }
1215}
1216
1217#[cfg(test)]
1218mod tests {
1219    use super::*;
1220
1221    #[test]
1222    fn test_text_query() -> Result<()> {
1223        let _engine = MultiModalSearchEngine::new_default()?;
1224
1225        let query = MultiModalQuery::text("test query");
1226        assert_eq!(query.modalities.len(), 1);
1227
1228        Ok(())
1229    }
1230
1231    #[test]
1232    fn test_hybrid_query() -> Result<()> {
1233        let query = MultiModalQuery::hybrid(vec![
1234            QueryModality::Text("test".to_string()),
1235            QueryModality::Embedding(Vector::new(vec![0.0; 128])),
1236        ]);
1237
1238        assert_eq!(query.modalities.len(), 2);
1239
1240        Ok(())
1241    }
1242
1243    #[test]
1244    fn test_text_encoder() -> Result<()> {
1245        let encoder = ProductionTextEncoder::new(128)?;
1246
1247        let vector = encoder.encode("hello world")?;
1248        assert_eq!(vector.dimensions, 128);
1249
1250        // Check normalization
1251        let magnitude = vector.magnitude();
1252        assert!((magnitude - 1.0).abs() < 0.1);
1253
1254        Ok(())
1255    }
1256
1257    #[test]
1258    fn test_image_encoder() -> Result<()> {
1259        let encoder = ProductionImageEncoder::new(256)?;
1260
1261        let image_data = ImageData {
1262            data: vec![128; 1024],
1263            width: 32,
1264            height: 32,
1265            channels: 3,
1266            format: ImageFormat::RGB,
1267            features: None,
1268        };
1269
1270        let vector = encoder.encode(&image_data)?;
1271        assert_eq!(vector.dimensions, 256);
1272
1273        Ok(())
1274    }
1275
1276    #[test]
1277    fn test_audio_encoder() -> Result<()> {
1278        let encoder = ProductionAudioEncoder::new(128)?;
1279
1280        let audio_data = AudioData {
1281            samples: vec![0.5; 44100], // 1 second at 44.1kHz
1282            sample_rate: 44100,
1283            channels: 1,
1284            duration: 1.0,
1285            features: None,
1286        };
1287
1288        let vector = encoder.encode(&audio_data)?;
1289        assert_eq!(vector.dimensions, 128);
1290
1291        Ok(())
1292    }
1293
1294    #[test]
1295    fn test_modality_fusion() -> Result<()> {
1296        let engine = MultiModalSearchEngine::new_default()?;
1297
1298        // Create test content
1299        let mut modalities = HashMap::new();
1300        modalities.insert(Modality::Text, ModalityData::Text("test".to_string()));
1301
1302        let content = MultiModalContent {
1303            modalities,
1304            metadata: HashMap::new(),
1305            temporal_info: None,
1306            spatial_info: None,
1307        };
1308
1309        engine.index_content("test1".to_string(), content)?;
1310
1311        let stats = engine.get_statistics();
1312        assert_eq!(stats.total_vectors, 1);
1313
1314        Ok(())
1315    }
1316}