1use crate::hnsw::{DistanceMetric, SearchResult, VectorIndex};
7use crate::metadata::{Metadata, MetadataFilter, MetadataStore, TemporalOptions};
8use crate::stats::{IndexHealth, IndexStats, MemoryUsage, PerfTimer, StatsSnapshot};
9use ipfrs_core::{Cid, Error, Result};
10use lru::LruCache;
11use serde::{Deserialize, Serialize};
12use std::collections::HashSet;
13use std::num::NonZeroUsize;
14use std::sync::{Arc, RwLock};
15
16#[derive(Debug, Clone)]
18pub struct HybridConfig {
19 pub dimension: usize,
21 pub metric: DistanceMetric,
23 pub max_connections: usize,
25 pub ef_construction: usize,
27 pub ef_search: usize,
29 pub cache_size: usize,
31 pub collect_stats: bool,
33 pub filter_strategy: FilterStrategy,
35}
36
37impl Default for HybridConfig {
38 fn default() -> Self {
39 Self {
40 dimension: 768,
41 metric: DistanceMetric::Cosine,
42 max_connections: 16,
43 ef_construction: 200,
44 ef_search: 50,
45 cache_size: 1000,
46 collect_stats: true,
47 filter_strategy: FilterStrategy::Auto,
48 }
49 }
50}
51
52#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
54pub enum FilterStrategy {
55 Auto,
57 PreFilter,
59 PostFilter,
61}
62
63#[derive(Debug, Clone)]
65pub struct HybridQuery {
66 pub vector: Vec<f32>,
68 pub k: usize,
70 pub filter: Option<MetadataFilter>,
72 pub temporal: Option<TemporalOptions>,
74 pub min_score: Option<f32>,
76 pub ef_search: Option<usize>,
78 pub include_metadata: bool,
80}
81
82impl HybridQuery {
83 pub fn knn(vector: Vec<f32>, k: usize) -> Self {
85 Self {
86 vector,
87 k,
88 filter: None,
89 temporal: None,
90 min_score: None,
91 ef_search: None,
92 include_metadata: false,
93 }
94 }
95
96 pub fn with_filter(mut self, filter: MetadataFilter) -> Self {
98 self.filter = Some(filter);
99 self
100 }
101
102 pub fn with_temporal(mut self, temporal: TemporalOptions) -> Self {
104 self.temporal = Some(temporal);
105 self
106 }
107
108 pub fn with_min_score(mut self, min_score: f32) -> Self {
110 self.min_score = Some(min_score);
111 self
112 }
113
114 pub fn with_metadata(mut self) -> Self {
116 self.include_metadata = true;
117 self
118 }
119
120 pub fn with_ef_search(mut self, ef_search: usize) -> Self {
122 self.ef_search = Some(ef_search);
123 self
124 }
125}
126
127#[derive(Debug, Clone)]
129pub struct HybridResult {
130 pub cid: Cid,
132 pub score: f32,
134 pub metadata: Option<Metadata>,
136}
137
138impl From<SearchResult> for HybridResult {
139 fn from(result: SearchResult) -> Self {
140 Self {
141 cid: result.cid,
142 score: result.score,
143 metadata: None,
144 }
145 }
146}
147
148#[derive(Debug, Clone)]
150pub struct HybridResponse {
151 pub results: Vec<HybridResult>,
153 pub total_evaluated: usize,
155 pub latency_us: u64,
157 pub strategy_used: FilterStrategy,
159}
160
161pub struct HybridIndex {
163 vector_index: Arc<RwLock<VectorIndex>>,
165 metadata_store: Arc<MetadataStore>,
167 config: HybridConfig,
169 stats: Arc<IndexStats>,
171 cache: Arc<RwLock<LruCache<u64, Vec<HybridResult>>>>,
173}
174
175impl HybridIndex {
176 pub fn new(config: HybridConfig) -> Result<Self> {
178 let vector_index = VectorIndex::new(
179 config.dimension,
180 config.metric,
181 config.max_connections,
182 config.ef_construction,
183 )?;
184
185 let cache_size = NonZeroUsize::new(config.cache_size)
186 .unwrap_or(NonZeroUsize::new(1000).expect("1000 > 0"));
187
188 Ok(Self {
189 vector_index: Arc::new(RwLock::new(vector_index)),
190 metadata_store: Arc::new(MetadataStore::new()),
191 config,
192 stats: Arc::new(IndexStats::new()),
193 cache: Arc::new(RwLock::new(LruCache::new(cache_size))),
194 })
195 }
196
197 pub fn with_defaults() -> Result<Self> {
199 Self::new(HybridConfig::default())
200 }
201
202 pub fn insert(&self, cid: &Cid, vector: &[f32], metadata: Option<Metadata>) -> Result<()> {
204 let timer = PerfTimer::start();
205
206 self.vector_index
208 .write()
209 .unwrap_or_else(|e| e.into_inner())
210 .insert(cid, vector)?;
211
212 if let Some(meta) = metadata {
214 self.metadata_store.insert(*cid, meta)?;
215 } else {
216 self.metadata_store.insert(*cid, Metadata::new())?;
218 }
219
220 if self.config.collect_stats {
221 self.stats.record_insert(timer.stop());
222 }
223
224 self.cache
226 .write()
227 .unwrap_or_else(|e| e.into_inner())
228 .clear();
229
230 Ok(())
231 }
232
233 pub fn insert_batch(&self, items: &[(Cid, Vec<f32>, Option<Metadata>)]) -> Result<()> {
235 for (cid, vector, metadata) in items {
236 self.insert(cid, vector, metadata.clone())?;
237 }
238 Ok(())
239 }
240
241 pub fn delete(&self, cid: &Cid) -> Result<()> {
243 self.vector_index
244 .write()
245 .unwrap_or_else(|e| e.into_inner())
246 .delete(cid)?;
247 self.metadata_store.remove(cid)?;
248
249 if self.config.collect_stats {
250 self.stats.record_delete();
251 }
252
253 self.cache
255 .write()
256 .unwrap_or_else(|e| e.into_inner())
257 .clear();
258
259 Ok(())
260 }
261
262 pub async fn search(&self, query: HybridQuery) -> Result<HybridResponse> {
264 let timer = PerfTimer::start();
265
266 let strategy = self.determine_strategy(&query);
268 let mut total_evaluated = 0;
269
270 let results = match strategy {
271 FilterStrategy::PreFilter => {
272 self.search_pre_filter(&query, &mut total_evaluated).await?
273 }
274 FilterStrategy::PostFilter | FilterStrategy::Auto => {
275 self.search_post_filter(&query, &mut total_evaluated)
276 .await?
277 }
278 };
279
280 let latency = timer.stop();
281
282 if self.config.collect_stats {
283 self.stats.record_search(latency, query.k, results.len());
284 }
285
286 Ok(HybridResponse {
287 results,
288 total_evaluated,
289 latency_us: latency.as_micros() as u64,
290 strategy_used: strategy,
291 })
292 }
293
294 async fn search_pre_filter(
296 &self,
297 query: &HybridQuery,
298 total_evaluated: &mut usize,
299 ) -> Result<Vec<HybridResult>> {
300 let candidates: HashSet<Cid> = if let Some(ref filter) = query.filter {
302 self.metadata_store.filter(filter).into_iter().collect()
303 } else {
304 self.metadata_store.cids().into_iter().collect()
306 };
307
308 let candidates = if let Some(ref temporal) = query.temporal {
310 let time_filtered = self
311 .metadata_store
312 .get_by_time_range(temporal.start, temporal.end);
313 candidates
314 .intersection(&time_filtered.into_iter().collect())
315 .copied()
316 .collect()
317 } else {
318 candidates
319 };
320
321 *total_evaluated = candidates.len();
322
323 if candidates.is_empty() {
324 return Ok(Vec::new());
325 }
326
327 let ef_search = query.ef_search.unwrap_or(self.config.ef_search);
329 let fetch_k = (query.k * 3).max(100); let search_results = self
332 .vector_index
333 .read()
334 .unwrap_or_else(|e| e.into_inner())
335 .search(&query.vector, fetch_k, ef_search)?;
336
337 let mut results: Vec<HybridResult> = search_results
339 .into_iter()
340 .filter(|r| candidates.contains(&r.cid))
341 .map(|r| {
342 let mut hr = HybridResult::from(r);
343 if let Some(ref temporal) = query.temporal {
345 if let Some(meta) = self.metadata_store.get(&hr.cid) {
346 let boost = temporal.recency_multiplier(meta.created_at);
347 hr.score *= boost;
348 }
349 }
350 hr
351 })
352 .collect();
353
354 if let Some(min_score) = query.min_score {
356 results.retain(|r| r.score >= min_score);
357 }
358
359 results.sort_by(|a, b| {
361 b.score
362 .partial_cmp(&a.score)
363 .unwrap_or(std::cmp::Ordering::Equal)
364 });
365 results.truncate(query.k);
366
367 if query.include_metadata {
369 for result in &mut results {
370 result.metadata = self.metadata_store.get(&result.cid);
371 }
372 }
373
374 Ok(results)
375 }
376
377 async fn search_post_filter(
379 &self,
380 query: &HybridQuery,
381 total_evaluated: &mut usize,
382 ) -> Result<Vec<HybridResult>> {
383 let ef_search = query.ef_search.unwrap_or(self.config.ef_search);
384
385 let fetch_k = if query.filter.is_some() || query.temporal.is_some() {
387 (query.k * 5).max(100)
388 } else {
389 query.k
390 };
391
392 let search_results = self
393 .vector_index
394 .read()
395 .unwrap_or_else(|e| e.into_inner())
396 .search(&query.vector, fetch_k, ef_search)?;
397
398 *total_evaluated = search_results.len();
399
400 let mut results: Vec<HybridResult> = search_results
401 .into_iter()
402 .filter_map(|r| {
403 if let Some(ref filter) = query.filter {
405 if let Some(meta) = self.metadata_store.get(&r.cid) {
406 if !filter.matches(&meta) {
407 return None;
408 }
409 } else {
410 return None; }
412 }
413
414 if let Some(ref temporal) = query.temporal {
416 if let Some(meta) = self.metadata_store.get(&r.cid) {
417 if let (Some(start), Some(end)) = (temporal.start, temporal.end) {
418 if meta.created_at < start || meta.created_at > end {
419 return None;
420 }
421 }
422 }
423 }
424
425 let mut hr = HybridResult::from(r);
426
427 if let Some(ref temporal) = query.temporal {
429 if let Some(meta) = self.metadata_store.get(&hr.cid) {
430 let boost = temporal.recency_multiplier(meta.created_at);
431 hr.score *= boost;
432 }
433 }
434
435 Some(hr)
436 })
437 .collect();
438
439 if let Some(min_score) = query.min_score {
441 results.retain(|r| r.score >= min_score);
442 }
443
444 if query.temporal.is_some() {
446 results.sort_by(|a, b| {
447 b.score
448 .partial_cmp(&a.score)
449 .unwrap_or(std::cmp::Ordering::Equal)
450 });
451 }
452
453 results.truncate(query.k);
454
455 if query.include_metadata {
457 for result in &mut results {
458 result.metadata = self.metadata_store.get(&result.cid);
459 }
460 }
461
462 Ok(results)
463 }
464
465 fn determine_strategy(&self, query: &HybridQuery) -> FilterStrategy {
467 if self.config.filter_strategy != FilterStrategy::Auto {
468 return self.config.filter_strategy;
469 }
470
471 let total_count = self.metadata_store.len();
473 if total_count == 0 {
474 return FilterStrategy::PostFilter;
475 }
476
477 if query.filter.is_none() && query.temporal.is_none() {
479 return FilterStrategy::PostFilter;
480 }
481
482 let filtered_count = if let Some(ref filter) = query.filter {
484 self.metadata_store.filter(filter).len()
485 } else {
486 total_count
487 };
488
489 let selectivity = filtered_count as f64 / total_count as f64;
490
491 if selectivity < 0.1 {
494 FilterStrategy::PreFilter
495 } else {
496 FilterStrategy::PostFilter
497 }
498 }
499
500 pub fn len(&self) -> usize {
502 self.vector_index
503 .read()
504 .unwrap_or_else(|e| e.into_inner())
505 .len()
506 }
507
508 pub fn is_empty(&self) -> bool {
510 self.len() == 0
511 }
512
513 pub fn contains(&self, cid: &Cid) -> bool {
515 self.vector_index
516 .read()
517 .unwrap_or_else(|e| e.into_inner())
518 .contains(cid)
519 }
520
521 pub fn get_metadata(&self, cid: &Cid) -> Option<Metadata> {
523 self.metadata_store.get(cid)
524 }
525
526 pub fn update_metadata(&self, cid: &Cid, metadata: Metadata) -> Result<()> {
528 if !self.contains(cid) {
529 return Err(Error::NotFound(format!("CID not in index: {}", cid)));
530 }
531 self.metadata_store.insert(*cid, metadata)?;
532 Ok(())
533 }
534
535 pub fn stats(&self) -> StatsSnapshot {
537 self.stats.snapshot()
538 }
539
540 pub fn health(&self) -> IndexHealth {
542 let stats = self.stats.snapshot();
543 IndexHealth::analyze(self.len(), self.config.dimension, Some(&stats))
544 }
545
546 pub fn memory_usage(&self) -> MemoryUsage {
548 MemoryUsage::estimate(
549 self.len(),
550 self.config.dimension,
551 self.metadata_store.len(),
552 self.config.cache_size,
553 )
554 }
555
556 pub fn facet_counts(&self, field: &str) -> std::collections::HashMap<String, usize> {
558 self.metadata_store.get_facet_counts(field)
559 }
560
561 pub fn clear_cache(&self) {
563 self.cache
564 .write()
565 .unwrap_or_else(|e| e.into_inner())
566 .clear();
567 }
568
569 pub fn reset_stats(&self) {
571 self.stats.reset();
572 }
573
574 pub async fn save(&self, path: impl AsRef<std::path::Path>) -> Result<()> {
576 self.vector_index
577 .read()
578 .unwrap_or_else(|e| e.into_inner())
579 .save(path)
580 }
581
582 pub fn clear(&self) -> Result<()> {
584 let new_index = VectorIndex::new(
586 self.config.dimension,
587 self.config.metric,
588 self.config.max_connections,
589 self.config.ef_construction,
590 )?;
591
592 *self.vector_index.write().unwrap_or_else(|e| e.into_inner()) = new_index;
593 self.metadata_store.clear();
594 self.cache
595 .write()
596 .unwrap_or_else(|e| e.into_inner())
597 .clear();
598 self.stats.reset();
599
600 Ok(())
601 }
602
603 pub fn prune_by_ttl(&self, ttl_seconds: u64) -> Result<usize> {
614 let now = std::time::SystemTime::now()
615 .duration_since(std::time::UNIX_EPOCH)
616 .unwrap_or_default()
617 .as_secs();
618
619 let cutoff = now.saturating_sub(ttl_seconds);
620
621 self.prune_older_than(cutoff)
622 }
623
624 pub fn prune_older_than(&self, timestamp: u64) -> Result<usize> {
632 let cids_to_remove: Vec<Cid> = self
634 .metadata_store
635 .cids()
636 .into_iter()
637 .filter(|cid| {
638 self.metadata_store
639 .get(cid)
640 .map(|m| m.created_at < timestamp)
641 .unwrap_or(false)
642 })
643 .collect();
644
645 let count = cids_to_remove.len();
646
647 for cid in &cids_to_remove {
649 let _ = self
650 .vector_index
651 .write()
652 .unwrap_or_else(|e| e.into_inner())
653 .delete(cid);
654 let _ = self.metadata_store.remove(cid);
655 }
656
657 self.cache
659 .write()
660 .unwrap_or_else(|e| e.into_inner())
661 .clear();
662
663 Ok(count)
664 }
665
666 pub fn prune_to_max_entries(&self, max_entries: usize) -> Result<usize> {
674 let current_count = self.len();
675 if current_count <= max_entries {
676 return Ok(0);
677 }
678
679 let mut entries: Vec<(Cid, u64)> = self
681 .metadata_store
682 .cids()
683 .into_iter()
684 .filter_map(|cid| self.metadata_store.get(&cid).map(|m| (cid, m.created_at)))
685 .collect();
686
687 entries.sort_by_key(|(_, ts)| *ts);
689
690 let to_remove = current_count - max_entries;
692
693 for (cid, _) in entries.iter().take(to_remove) {
695 let _ = self
696 .vector_index
697 .write()
698 .unwrap_or_else(|e| e.into_inner())
699 .delete(cid);
700 let _ = self.metadata_store.remove(cid);
701 }
702
703 self.cache
705 .write()
706 .unwrap_or_else(|e| e.into_inner())
707 .clear();
708
709 Ok(to_remove)
710 }
711
712 pub fn prune_lru(&self, max_entries: usize) -> Result<usize> {
723 let current_count = self.len();
724 if current_count <= max_entries {
725 return Ok(0);
726 }
727
728 let mut entries: Vec<(Cid, u64)> = self
730 .metadata_store
731 .cids()
732 .into_iter()
733 .filter_map(|cid| self.metadata_store.get(&cid).map(|m| (cid, m.updated_at)))
734 .collect();
735
736 entries.sort_by_key(|(_, ts)| *ts);
738
739 let to_remove = current_count - max_entries;
741
742 for (cid, _) in entries.iter().take(to_remove) {
744 let _ = self
745 .vector_index
746 .write()
747 .unwrap_or_else(|e| e.into_inner())
748 .delete(cid);
749 let _ = self.metadata_store.remove(cid);
750 }
751
752 self.cache
754 .write()
755 .unwrap_or_else(|e| e.into_inner())
756 .clear();
757
758 Ok(to_remove)
759 }
760
761 pub fn pruning_stats(&self) -> PruningStats {
763 let now = std::time::SystemTime::now()
764 .duration_since(std::time::UNIX_EPOCH)
765 .unwrap_or_default()
766 .as_secs();
767
768 let entries: Vec<(u64, u64)> = self
769 .metadata_store
770 .cids()
771 .into_iter()
772 .filter_map(|cid| {
773 self.metadata_store
774 .get(&cid)
775 .map(|m| (m.created_at, m.updated_at))
776 })
777 .collect();
778
779 if entries.is_empty() {
780 return PruningStats::default();
781 }
782
783 let oldest_created = entries.iter().map(|(c, _)| *c).min().unwrap_or(now);
784 let newest_created = entries.iter().map(|(c, _)| *c).max().unwrap_or(now);
785 let oldest_updated = entries.iter().map(|(_, u)| *u).min().unwrap_or(now);
786
787 let age_1day = entries.iter().filter(|(c, _)| now - *c < 86400).count();
788 let age_7days = entries.iter().filter(|(c, _)| now - *c < 86400 * 7).count();
789 let age_30days = entries
790 .iter()
791 .filter(|(c, _)| now - *c < 86400 * 30)
792 .count();
793
794 PruningStats {
795 total_entries: entries.len(),
796 oldest_entry_age: now.saturating_sub(oldest_created),
797 newest_entry_age: now.saturating_sub(newest_created),
798 oldest_update_age: now.saturating_sub(oldest_updated),
799 entries_last_day: age_1day,
800 entries_last_week: age_7days,
801 entries_last_month: age_30days,
802 }
803 }
804}
805
806#[derive(Debug, Clone, Default, Serialize, Deserialize)]
808pub struct PruningStats {
809 pub total_entries: usize,
811 pub oldest_entry_age: u64,
813 pub newest_entry_age: u64,
815 pub oldest_update_age: u64,
817 pub entries_last_day: usize,
819 pub entries_last_week: usize,
821 pub entries_last_month: usize,
823}
824
825impl PruningStats {
826 pub fn summary(&self) -> String {
828 format!(
829 "Total: {}, Last day: {}, Last week: {}, Last month: {}, Oldest: {}s ago",
830 self.total_entries,
831 self.entries_last_day,
832 self.entries_last_week,
833 self.entries_last_month,
834 self.oldest_entry_age
835 )
836 }
837
838 pub fn would_prune_for_ttl(&self, ttl_seconds: u64) -> usize {
840 if ttl_seconds < 86400 {
842 self.total_entries - self.entries_last_day
843 } else if ttl_seconds < 86400 * 7 {
844 self.total_entries - self.entries_last_week
845 } else if ttl_seconds < 86400 * 30 {
846 self.total_entries - self.entries_last_month
847 } else {
848 0
849 }
850 }
851}
852
853#[cfg(test)]
854mod tests {
855 use super::*;
856 use crate::metadata::MetadataValue;
857
858 fn test_cid(n: u8) -> Cid {
859 let cids = [
861 "bafybeigdyrzt5sfp7udm7hu76uh7y26nf3efuylqabf3oclgtqy55fbzdi",
862 "bafybeiczsscdsbs7ffqz55asqdf3smv6klcw3gofszvwlyarci47bgf354",
863 "bafybeibvfkifsqbapirjrj7zbfwddz5qz5awvbftjgktpcqcxjkzstszlm",
864 ];
865 cids[n as usize % cids.len()]
866 .parse()
867 .expect("test: parse test cid")
868 }
869
870 #[tokio::test]
871 async fn test_hybrid_index_basic() {
872 let config = HybridConfig {
873 dimension: 4,
874 ..Default::default()
875 };
876
877 let index = HybridIndex::new(config).expect("test: create hybrid index basic");
878
879 let cid1 = test_cid(0);
880 let vec1 = vec![1.0, 0.0, 0.0, 0.0];
881 let meta1 = Metadata::new().with_string("type", "image");
882
883 index
884 .insert(&cid1, &vec1, Some(meta1))
885 .expect("test: insert cid1 basic");
886
887 assert_eq!(index.len(), 1);
888 assert!(index.contains(&cid1));
889 }
890
891 #[tokio::test]
892 async fn test_hybrid_search() {
893 let config = HybridConfig {
894 dimension: 4,
895 ..Default::default()
896 };
897
898 let index = HybridIndex::new(config).expect("test: create hybrid index for search");
899
900 let cid1 = test_cid(0);
902 let vec1 = vec![1.0, 0.0, 0.0, 0.0];
903 let meta1 = Metadata::new()
904 .with_string("type", "image")
905 .with_integer("size", 1024);
906
907 let cid2 = test_cid(1);
908 let vec2 = vec![0.9, 0.1, 0.0, 0.0];
909 let meta2 = Metadata::new()
910 .with_string("type", "document")
911 .with_integer("size", 2048);
912
913 let cid3 = test_cid(2);
914 let vec3 = vec![0.0, 1.0, 0.0, 0.0];
915 let meta3 = Metadata::new()
916 .with_string("type", "audio")
917 .with_integer("size", 512);
918
919 index
920 .insert(&cid1, &vec1, Some(meta1))
921 .expect("test: insert cid1 for search");
922 index
923 .insert(&cid2, &vec2, Some(meta2))
924 .expect("test: insert cid2 for search");
925 index
926 .insert(&cid3, &vec3, Some(meta3))
927 .expect("test: insert cid3 for search");
928
929 let mut query = HybridQuery::knn(vec![1.0, 0.0, 0.0, 0.0], 2);
931 query.ef_search = Some(50); let response = index.search(query).await.expect("test: hybrid search");
933
934 assert!(
935 !response.results.is_empty(),
936 "Expected at least 1 result, got {}",
937 response.results.len()
938 );
939 assert!(
941 !response.results.is_empty() && response.results.len() <= 2,
942 "Expected 1-2 results, got {}",
943 response.results.len()
944 );
945 assert_eq!(response.results[0].cid, cid1);
947 }
948
949 #[tokio::test]
950 async fn test_filtered_search() {
951 let config = HybridConfig {
952 dimension: 4,
953 ..Default::default()
954 };
955
956 let index =
957 HybridIndex::new(config).expect("test: create hybrid index for filtered search");
958
959 let cid1 = test_cid(0);
960 let vec1 = vec![1.0, 0.0, 0.0, 0.0];
961 let meta1 = Metadata::new().with_string("category", "tech");
962
963 let cid2 = test_cid(1);
964 let vec2 = vec![0.9, 0.1, 0.0, 0.0];
965 let meta2 = Metadata::new().with_string("category", "science");
966
967 index
968 .insert(&cid1, &vec1, Some(meta1))
969 .expect("test: insert cid1 filtered");
970 index
971 .insert(&cid2, &vec2, Some(meta2))
972 .expect("test: insert cid2 filtered");
973
974 let filter = MetadataFilter::eq("category", MetadataValue::String("tech".to_string()));
976 let query = HybridQuery::knn(vec![0.9, 0.1, 0.0, 0.0], 10).with_filter(filter);
977 let response = index.search(query).await.expect("test: filtered search");
978
979 assert_eq!(response.results.len(), 1);
981 assert_eq!(response.results[0].cid, cid1);
982 }
983
984 #[tokio::test]
985 async fn test_search_with_metadata() {
986 let config = HybridConfig {
987 dimension: 4,
988 ..Default::default()
989 };
990
991 let index = HybridIndex::new(config).expect("test: create hybrid index with metadata");
992
993 let cid1 = test_cid(0);
994 let vec1 = vec![1.0, 0.0, 0.0, 0.0];
995 let meta1 = Metadata::new().with_string("title", "Test Document");
996
997 index
998 .insert(&cid1, &vec1, Some(meta1))
999 .expect("test: insert cid1 with metadata");
1000
1001 let query = HybridQuery::knn(vec![1.0, 0.0, 0.0, 0.0], 1).with_metadata();
1002 let response = index
1003 .search(query)
1004 .await
1005 .expect("test: search with metadata");
1006
1007 assert_eq!(response.results.len(), 1);
1008 assert!(response.results[0].metadata.is_some());
1009
1010 let meta = response.results[0]
1011 .metadata
1012 .as_ref()
1013 .expect("test: result should have metadata");
1014 assert_eq!(
1015 meta.get("title"),
1016 Some(&MetadataValue::String("Test Document".to_string()))
1017 );
1018 }
1019
1020 #[test]
1021 fn test_health_and_stats() {
1022 let config = HybridConfig {
1023 dimension: 4,
1024 ..Default::default()
1025 };
1026
1027 let index = HybridIndex::new(config).expect("test: create hybrid index for health stats");
1028
1029 let health = index.health();
1030 assert_eq!(health.size, 0);
1031
1032 let stats = index.stats();
1033 assert_eq!(stats.search_count, 0);
1034 }
1035
1036 #[test]
1037 fn test_pruning_to_max_entries() {
1038 let config = HybridConfig {
1039 dimension: 4,
1040 ..Default::default()
1041 };
1042
1043 let index = HybridIndex::new(config).expect("test: create hybrid index for pruning");
1044
1045 for i in 0..3 {
1047 let cid = test_cid(i);
1048 let vec = vec![i as f32, 0.0, 0.0, 0.0];
1049 let meta = Metadata::new().with_integer("order", i as i64);
1050 index
1051 .insert(&cid, &vec, Some(meta))
1052 .expect("test: insert vector for pruning");
1053 }
1054
1055 assert_eq!(index.len(), 3);
1056
1057 let pruned = index
1059 .prune_to_max_entries(2)
1060 .expect("test: prune to max entries");
1061 assert_eq!(pruned, 1);
1062 assert_eq!(index.len(), 2);
1063 }
1064
1065 #[test]
1066 fn test_pruning_stats() {
1067 let config = HybridConfig {
1068 dimension: 4,
1069 ..Default::default()
1070 };
1071
1072 let index = HybridIndex::new(config).expect("test: create hybrid index for pruning stats");
1073
1074 for i in 0..3 {
1076 let cid = test_cid(i);
1077 let vec = vec![i as f32, 0.0, 0.0, 0.0];
1078 index
1079 .insert(&cid, &vec, None)
1080 .expect("test: insert vector for pruning stats");
1081 }
1082
1083 let stats = index.pruning_stats();
1084 assert_eq!(stats.total_entries, 3);
1085 assert_eq!(stats.entries_last_day, 3);
1087 assert_eq!(stats.entries_last_week, 3);
1088 }
1089}