1use std::collections::HashMap;
22
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum VectorAlgorithm {
28 Hnsw,
31 Flat,
34}
35
36impl std::fmt::Display for VectorAlgorithm {
37 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
38 match self {
39 VectorAlgorithm::Hnsw => write!(f, "HNSW"),
40 VectorAlgorithm::Flat => write!(f, "FLAT"),
41 }
42 }
43}
44
45#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum DistanceMetric {
48 L2,
50 InnerProduct,
52 Cosine,
54}
55
56impl std::fmt::Display for DistanceMetric {
57 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
58 match self {
59 DistanceMetric::L2 => write!(f, "L2"),
60 DistanceMetric::InnerProduct => write!(f, "IP"),
61 DistanceMetric::Cosine => write!(f, "COSINE"),
62 }
63 }
64}
65
66#[derive(Debug, Clone, PartialEq)]
68pub struct VectorParams {
69 pub algorithm: VectorAlgorithm,
71 pub dim: usize,
73 pub distance_metric: DistanceMetric,
75 pub hnsw_m: Option<usize>,
77 pub hnsw_ef_construction: Option<usize>,
79}
80
81impl VectorParams {
82 pub fn hnsw(dim: usize, distance_metric: DistanceMetric) -> Self {
85 Self {
86 algorithm: VectorAlgorithm::Hnsw,
87 dim,
88 distance_metric,
89 hnsw_m: None,
90 hnsw_ef_construction: None,
91 }
92 }
93
94 pub fn flat(dim: usize, distance_metric: DistanceMetric) -> Self {
96 Self {
97 algorithm: VectorAlgorithm::Flat,
98 dim,
99 distance_metric,
100 hnsw_m: None,
101 hnsw_ef_construction: None,
102 }
103 }
104
105 pub fn with_m(mut self, m: usize) -> Self {
108 self.hnsw_m = Some(m);
109 self
110 }
111
112 pub fn with_ef_construction(mut self, ef: usize) -> Self {
115 self.hnsw_ef_construction = Some(ef);
116 self
117 }
118
119 fn to_schema_args(&self) -> Vec<String> {
122 let mut args = vec![
123 "TYPE".to_string(),
124 "FLOAT32".to_string(),
125 "DIM".to_string(),
126 self.dim.to_string(),
127 "DISTANCE_METRIC".to_string(),
128 self.distance_metric.to_string(),
129 ];
130
131 if self.algorithm == VectorAlgorithm::Hnsw {
133 if let Some(m) = self.hnsw_m {
134 args.push("M".to_string());
135 args.push(m.to_string());
136 }
137 if let Some(ef) = self.hnsw_ef_construction {
138 args.push("EF_CONSTRUCTION".to_string());
139 args.push(ef.to_string());
140 }
141 }
142
143 let mut result = vec![args.len().to_string()];
145 result.extend(args);
146 result
147 }
148}
149
150#[derive(Debug, Clone)]
152pub struct SearchIndex {
153 pub name: String,
155 pub prefix: String,
157 pub fields: Vec<SearchField>,
159}
160
161impl SearchIndex {
162 pub fn new(name: impl Into<String>, prefix: impl Into<String>) -> Self {
164 Self {
165 name: name.into(),
166 prefix: prefix.into(),
167 fields: Vec::new(),
168 }
169 }
170
171 pub fn text(mut self, name: impl Into<String>) -> Self {
173 self.fields.push(SearchField {
174 name: name.into(),
175 json_path: None,
176 field_type: SearchFieldType::Text,
177 sortable: false,
178 no_index: false,
179 });
180 self
181 }
182
183 pub fn text_at(mut self, name: impl Into<String>, json_path: impl Into<String>) -> Self {
185 self.fields.push(SearchField {
186 name: name.into(),
187 json_path: Some(json_path.into()),
188 field_type: SearchFieldType::Text,
189 sortable: false,
190 no_index: false,
191 });
192 self
193 }
194
195 pub fn text_sortable(mut self, name: impl Into<String>) -> Self {
197 self.fields.push(SearchField {
198 name: name.into(),
199 json_path: None,
200 field_type: SearchFieldType::Text,
201 sortable: true,
202 no_index: false,
203 });
204 self
205 }
206
207 pub fn numeric(mut self, name: impl Into<String>) -> Self {
209 self.fields.push(SearchField {
210 name: name.into(),
211 json_path: None,
212 field_type: SearchFieldType::Numeric,
213 sortable: false,
214 no_index: false,
215 });
216 self
217 }
218
219 pub fn numeric_at(mut self, name: impl Into<String>, json_path: impl Into<String>) -> Self {
221 self.fields.push(SearchField {
222 name: name.into(),
223 json_path: Some(json_path.into()),
224 field_type: SearchFieldType::Numeric,
225 sortable: false,
226 no_index: false,
227 });
228 self
229 }
230
231 pub fn numeric_sortable(mut self, name: impl Into<String>) -> Self {
233 self.fields.push(SearchField {
234 name: name.into(),
235 json_path: None,
236 field_type: SearchFieldType::Numeric,
237 sortable: true,
238 no_index: false,
239 });
240 self
241 }
242
243 pub fn numeric_sortable_at(mut self, name: impl Into<String>, json_path: impl Into<String>) -> Self {
245 self.fields.push(SearchField {
246 name: name.into(),
247 json_path: Some(json_path.into()),
248 field_type: SearchFieldType::Numeric,
249 sortable: true,
250 no_index: false,
251 });
252 self
253 }
254
255 pub fn tag(mut self, name: impl Into<String>) -> Self {
257 self.fields.push(SearchField {
258 name: name.into(),
259 json_path: None,
260 field_type: SearchFieldType::Tag,
261 sortable: false,
262 no_index: false,
263 });
264 self
265 }
266
267 pub fn tag_at(mut self, name: impl Into<String>, json_path: impl Into<String>) -> Self {
269 self.fields.push(SearchField {
270 name: name.into(),
271 json_path: Some(json_path.into()),
272 field_type: SearchFieldType::Tag,
273 sortable: false,
274 no_index: false,
275 });
276 self
277 }
278
279 pub fn geo(mut self, name: impl Into<String>) -> Self {
281 self.fields.push(SearchField {
282 name: name.into(),
283 json_path: None,
284 field_type: SearchFieldType::Geo,
285 sortable: false,
286 no_index: false,
287 });
288 self
289 }
290
291 pub fn vector_hnsw(
307 mut self,
308 name: impl Into<String>,
309 dim: usize,
310 metric: DistanceMetric,
311 ) -> Self {
312 self.fields.push(SearchField {
313 name: name.into(),
314 json_path: None,
315 field_type: SearchFieldType::Vector(VectorParams::hnsw(dim, metric)),
316 sortable: false,
317 no_index: false,
318 });
319 self
320 }
321
322 pub fn vector_hnsw_at(
324 mut self,
325 name: impl Into<String>,
326 json_path: impl Into<String>,
327 dim: usize,
328 metric: DistanceMetric,
329 ) -> Self {
330 self.fields.push(SearchField {
331 name: name.into(),
332 json_path: Some(json_path.into()),
333 field_type: SearchFieldType::Vector(VectorParams::hnsw(dim, metric)),
334 sortable: false,
335 no_index: false,
336 });
337 self
338 }
339
340 pub fn vector_with_params(mut self, name: impl Into<String>, params: VectorParams) -> Self {
356 self.fields.push(SearchField {
357 name: name.into(),
358 json_path: None,
359 field_type: SearchFieldType::Vector(params),
360 sortable: false,
361 no_index: false,
362 });
363 self
364 }
365
366 pub fn vector_flat(
376 mut self,
377 name: impl Into<String>,
378 dim: usize,
379 metric: DistanceMetric,
380 ) -> Self {
381 self.fields.push(SearchField {
382 name: name.into(),
383 json_path: None,
384 field_type: SearchFieldType::Vector(VectorParams::flat(dim, metric)),
385 sortable: false,
386 no_index: false,
387 });
388 self
389 }
390
391 pub fn vector_flat_at(
393 mut self,
394 name: impl Into<String>,
395 json_path: impl Into<String>,
396 dim: usize,
397 metric: DistanceMetric,
398 ) -> Self {
399 self.fields.push(SearchField {
400 name: name.into(),
401 json_path: Some(json_path.into()),
402 field_type: SearchFieldType::Vector(VectorParams::flat(dim, metric)),
403 sortable: false,
404 no_index: false,
405 });
406 self
407 }
408
409 pub fn to_ft_create_args(&self) -> Vec<String> {
411 self.to_ft_create_args_with_prefix(None)
412 }
413
414 pub fn to_ft_create_args_with_prefix(&self, redis_prefix: Option<&str>) -> Vec<String> {
419 let prefix = redis_prefix.unwrap_or("");
420
421 let mut args = vec![
422 format!("{}idx:{}", prefix, self.name),
423 "ON".to_string(),
424 "JSON".to_string(),
425 "PREFIX".to_string(),
426 "1".to_string(),
427 format!("{}{}", prefix, self.prefix),
428 "SCHEMA".to_string(),
429 ];
430
431 for field in &self.fields {
432 args.extend(field.to_schema_args());
433 }
434
435 args
436 }
437}
438
439#[derive(Debug, Clone)]
441pub struct SearchField {
442 pub name: String,
444 pub json_path: Option<String>,
446 pub field_type: SearchFieldType,
448 pub sortable: bool,
450 pub no_index: bool,
452}
453
454impl SearchField {
455 fn to_schema_args(&self) -> Vec<String> {
456 let json_path = self
458 .json_path
459 .clone()
460 .unwrap_or_else(|| format!("$.payload.{}", self.name));
461
462 let mut args = vec![
463 json_path,
464 "AS".to_string(),
465 self.name.clone(),
466 ];
467
468 match &self.field_type {
470 SearchFieldType::Vector(params) => {
471 args.push("VECTOR".to_string());
472 args.push(params.algorithm.to_string());
473 args.extend(params.to_schema_args());
474 }
475 _ => {
476 args.push(self.field_type.to_string());
477 }
478 }
479
480 if self.sortable {
481 args.push("SORTABLE".to_string());
482 }
483
484 if self.no_index {
485 args.push("NOINDEX".to_string());
486 }
487
488 args
489 }
490}
491
492#[derive(Debug, Clone, PartialEq)]
494pub enum SearchFieldType {
495 Text,
497 Numeric,
499 Tag,
501 Geo,
503 Vector(VectorParams),
505}
506
507impl std::fmt::Display for SearchFieldType {
508 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
509 match self {
510 SearchFieldType::Text => write!(f, "TEXT"),
511 SearchFieldType::Numeric => write!(f, "NUMERIC"),
512 SearchFieldType::Tag => write!(f, "TAG"),
513 SearchFieldType::Geo => write!(f, "GEO"),
514 SearchFieldType::Vector(params) => write!(f, "VECTOR {}", params.algorithm),
515 }
516 }
517}
518
519pub struct IndexManager {
521 indexes: HashMap<String, SearchIndex>,
523}
524
525impl IndexManager {
526 pub fn new() -> Self {
528 Self {
529 indexes: HashMap::new(),
530 }
531 }
532
533 pub fn register(&mut self, index: SearchIndex) {
535 self.indexes.insert(index.name.clone(), index);
536 }
537
538 pub fn get(&self, name: &str) -> Option<&SearchIndex> {
540 self.indexes.get(name)
541 }
542
543 pub fn all(&self) -> impl Iterator<Item = &SearchIndex> {
545 self.indexes.values()
546 }
547
548 pub fn ft_create_args(&self, name: &str) -> Option<Vec<String>> {
550 self.indexes.get(name).map(|idx| idx.to_ft_create_args())
551 }
552
553 pub fn find_by_prefix(&self, key: &str) -> Option<&SearchIndex> {
555 self.indexes.values().find(|idx| key.starts_with(&idx.prefix))
556 }
557}
558
559impl Default for IndexManager {
560 fn default() -> Self {
561 Self::new()
562 }
563}
564
565#[cfg(test)]
566mod tests {
567 use super::*;
568
569 #[test]
570 fn test_simple_index() {
571 let index = SearchIndex::new("users", "crdt:users:")
572 .text("name")
573 .text("email")
574 .numeric("age");
575
576 let args = index.to_ft_create_args();
577 assert_eq!(args[0], "idx:users");
578 assert_eq!(args[1], "ON");
579 assert_eq!(args[2], "JSON");
580 assert_eq!(args[3], "PREFIX");
581 assert_eq!(args[4], "1");
582 assert_eq!(args[5], "crdt:users:");
583 assert_eq!(args[6], "SCHEMA");
584 assert!(args.contains(&"$.payload.name".to_string()));
586 assert!(args.contains(&"name".to_string()));
587 assert!(args.contains(&"TEXT".to_string()));
588 }
589
590 #[test]
591 fn test_sortable_fields() {
592 let index = SearchIndex::new("users", "crdt:users:")
593 .text_sortable("name")
594 .numeric_sortable("age");
595
596 let args = index.to_ft_create_args();
597 let sortable_count = args.iter().filter(|a| *a == "SORTABLE").count();
599 assert_eq!(sortable_count, 2);
600 }
601
602 #[test]
603 fn test_tag_field() {
604 let index = SearchIndex::new("items", "crdt:items:").tag("tags");
605
606 let args = index.to_ft_create_args();
607 assert!(args.contains(&"TAG".to_string()));
608 }
609
610 #[test]
611 fn test_custom_json_path() {
612 let index =
613 SearchIndex::new("users", "crdt:users:").text_at("username", "$.profile.name");
614
615 let args = index.to_ft_create_args();
616 assert!(args.contains(&"$.profile.name".to_string()));
617 assert!(args.contains(&"username".to_string()));
618 }
619
620 #[test]
621 fn test_index_manager_register() {
622 let mut manager = IndexManager::new();
623
624 let index = SearchIndex::new("users", "crdt:users:")
625 .text("name")
626 .numeric("age");
627
628 manager.register(index);
629
630 assert!(manager.get("users").is_some());
631 assert!(manager.get("unknown").is_none());
632 }
633
634 #[test]
635 fn test_index_manager_find_by_prefix() {
636 let mut manager = IndexManager::new();
637
638 manager.register(SearchIndex::new("users", "crdt:users:"));
639 manager.register(SearchIndex::new("posts", "crdt:posts:"));
640
641 let found = manager.find_by_prefix("crdt:users:abc123");
642 assert!(found.is_some());
643 assert_eq!(found.unwrap().name, "users");
644
645 let found = manager.find_by_prefix("crdt:posts:xyz");
646 assert!(found.is_some());
647 assert_eq!(found.unwrap().name, "posts");
648
649 let not_found = manager.find_by_prefix("crdt:comments:1");
650 assert!(not_found.is_none());
651 }
652
653 #[test]
654 fn test_ft_create_full_command() {
655 let index = SearchIndex::new("users", "crdt:users:")
656 .text_sortable("name")
657 .text("email")
658 .numeric_sortable("age")
659 .tag("roles");
660
661 let args = index.to_ft_create_args();
662
663 assert_eq!(args[0], "idx:users");
672 assert_eq!(args[6], "SCHEMA");
673
674 let cmd = format!("FT.CREATE {}", args.join(" "));
676 assert!(cmd.contains("idx:users"));
677 assert!(cmd.contains("ON JSON"));
678 assert!(cmd.contains("PREFIX 1 crdt:users:"));
679 assert!(cmd.contains("$.payload.name AS name TEXT SORTABLE"));
680 assert!(cmd.contains("$.payload.email AS email TEXT"));
681 assert!(cmd.contains("$.payload.age AS age NUMERIC SORTABLE"));
682 assert!(cmd.contains("$.payload.roles AS roles TAG"));
683 }
684
685 #[test]
686 fn test_vector_hnsw_basic() {
687 let index = SearchIndex::new("docs", "crdt:docs:")
688 .text("title")
689 .vector_hnsw("embedding", 1536, DistanceMetric::Cosine);
690
691 let args = index.to_ft_create_args();
692 let cmd = format!("FT.CREATE {}", args.join(" "));
693
694 assert!(cmd.contains("$.payload.embedding AS embedding VECTOR HNSW"));
696 assert!(cmd.contains("TYPE FLOAT32"));
697 assert!(cmd.contains("DIM 1536"));
698 assert!(cmd.contains("DISTANCE_METRIC COSINE"));
699 }
700
701 #[test]
702 fn test_vector_hnsw_with_params() {
703 let params = VectorParams::hnsw(768, DistanceMetric::L2)
704 .with_m(32)
705 .with_ef_construction(400);
706
707 let index =
708 SearchIndex::new("embeddings", "crdt:embeddings:").vector_with_params("vec", params);
709
710 let args = index.to_ft_create_args();
711 let cmd = format!("FT.CREATE {}", args.join(" "));
712
713 assert!(cmd.contains("VECTOR HNSW"));
714 assert!(cmd.contains("DIM 768"));
715 assert!(cmd.contains("DISTANCE_METRIC L2"));
716 assert!(cmd.contains("M 32"));
717 assert!(cmd.contains("EF_CONSTRUCTION 400"));
718 }
719
720 #[test]
721 fn test_vector_flat() {
722 let index = SearchIndex::new("small", "crdt:small:")
723 .vector_flat("embedding", 384, DistanceMetric::InnerProduct);
724
725 let args = index.to_ft_create_args();
726 let cmd = format!("FT.CREATE {}", args.join(" "));
727
728 assert!(cmd.contains("VECTOR FLAT"));
729 assert!(cmd.contains("DIM 384"));
730 assert!(cmd.contains("DISTANCE_METRIC IP"));
731 }
732
733 #[test]
734 fn test_vector_custom_path() {
735 let index = SearchIndex::new("docs", "crdt:docs:").vector_hnsw_at(
736 "embedding",
737 "$.metadata.vector",
738 512,
739 DistanceMetric::Cosine,
740 );
741
742 let args = index.to_ft_create_args();
743 let cmd = format!("FT.CREATE {}", args.join(" "));
744
745 assert!(cmd.contains("$.metadata.vector AS embedding VECTOR HNSW"));
746 }
747
748 #[test]
749 fn test_vector_params_nargs() {
750 let params = VectorParams::hnsw(1536, DistanceMetric::Cosine);
752 let args = params.to_schema_args();
753
754 assert_eq!(args[0], "6");
756 assert_eq!(args[1], "TYPE");
757 assert_eq!(args[2], "FLOAT32");
758 assert_eq!(args[3], "DIM");
759 assert_eq!(args[4], "1536");
760 assert_eq!(args[5], "DISTANCE_METRIC");
761 assert_eq!(args[6], "COSINE");
762 }
763
764 #[test]
765 fn test_vector_params_nargs_with_hnsw_options() {
766 let params = VectorParams::hnsw(1536, DistanceMetric::Cosine)
767 .with_m(24)
768 .with_ef_construction(300);
769 let args = params.to_schema_args();
770
771 assert_eq!(args[0], "10");
773 assert!(args.contains(&"M".to_string()));
774 assert!(args.contains(&"24".to_string()));
775 assert!(args.contains(&"EF_CONSTRUCTION".to_string()));
776 assert!(args.contains(&"300".to_string()));
777 }
778
779 #[test]
780 fn test_mixed_index_with_vectors() {
781 let index = SearchIndex::new("documents", "crdt:documents:")
782 .text_sortable("title")
783 .text("content")
784 .tag("category")
785 .numeric("created_at")
786 .vector_hnsw("embedding", 1536, DistanceMetric::Cosine);
787
788 let args = index.to_ft_create_args();
789 let cmd = format!("FT.CREATE {}", args.join(" "));
790
791 assert!(cmd.contains("AS title TEXT SORTABLE"));
793 assert!(cmd.contains("AS content TEXT"));
794 assert!(cmd.contains("AS category TAG"));
795 assert!(cmd.contains("AS created_at NUMERIC"));
796 assert!(cmd.contains("AS embedding VECTOR HNSW"));
797 }
798
799 #[test]
800 fn test_distance_metrics_display() {
801 assert_eq!(format!("{}", DistanceMetric::L2), "L2");
802 assert_eq!(format!("{}", DistanceMetric::InnerProduct), "IP");
803 assert_eq!(format!("{}", DistanceMetric::Cosine), "COSINE");
804 }
805
806 #[test]
807 fn test_vector_algorithm_display() {
808 assert_eq!(format!("{}", VectorAlgorithm::Hnsw), "HNSW");
809 assert_eq!(format!("{}", VectorAlgorithm::Flat), "FLAT");
810 }
811}