sync_engine/search/
index_manager.rs

1// Copyright (c) 2025-2026 Adrian Robinson. Licensed under the AGPL-3.0.
2// See LICENSE file in the project root for full license text.
3
4//! Index Manager
5//!
6//! Manages RediSearch index lifecycle - creation, deletion, and schema management.
7//!
8//! # RediSearch Index Creation
9//!
10//! ```text
11//! FT.CREATE idx:users
12//!   ON JSON
13//!   PREFIX 1 crdt:users:
14//!   SCHEMA
15//!     $.name AS name TEXT
16//!     $.email AS email TEXT SORTABLE
17//!     $.age AS age NUMERIC SORTABLE
18//!     $.tags AS tags TAG
19//! ```
20
21use std::collections::HashMap;
22
23/// Vector search algorithm for RediSearch.
24/// HNSW provides faster queries with higher memory usage.
25/// FLAT provides exact results with O(n) query time.
26#[derive(Debug, Clone, Copy, PartialEq, Eq)]
27pub enum VectorAlgorithm {
28    /// Hierarchical Navigable Small World - approximate nearest neighbors.
29    /// Faster queries but requires more memory. Good for large datasets.
30    Hnsw,
31    /// Brute-force flat index - exact nearest neighbors.
32    /// O(n) query time but lower memory. Good for smaller datasets.
33    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/// Distance metric for vector similarity search.
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
47pub enum DistanceMetric {
48    /// L2 Euclidean distance - good for dense embeddings
49    L2,
50    /// Inner product - good for normalized vectors (e.g., cosine pre-normalized)
51    InnerProduct,
52    /// Cosine similarity - good for text embeddings
53    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/// Parameters for RediSearch vector fields.
67#[derive(Debug, Clone, PartialEq)]
68pub struct VectorParams {
69    /// The algorithm to use (HNSW or FLAT)
70    pub algorithm: VectorAlgorithm,
71    /// Vector dimensionality (must match your embeddings)
72    pub dim: usize,
73    /// Distance metric for similarity calculation
74    pub distance_metric: DistanceMetric,
75    /// HNSW M parameter: number of outgoing edges per node (default 16)
76    pub hnsw_m: Option<usize>,
77    /// HNSW EF_CONSTRUCTION: search depth during index building (default 200)
78    pub hnsw_ef_construction: Option<usize>,
79}
80
81impl VectorParams {
82    /// Create HNSW vector params with sensible defaults.
83    /// M=16, EF_CONSTRUCTION=200 are RediSearch defaults.
84    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    /// Create FLAT vector params.
95    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    /// Set HNSW M parameter (number of edges per node).
106    /// Higher = better recall, more memory. Typical: 12-48.
107    pub fn with_m(mut self, m: usize) -> Self {
108        self.hnsw_m = Some(m);
109        self
110    }
111
112    /// Set HNSW EF_CONSTRUCTION parameter (build-time search depth).
113    /// Higher = better index quality, slower build. Typical: 100-500.
114    pub fn with_ef_construction(mut self, ef: usize) -> Self {
115        self.hnsw_ef_construction = Some(ef);
116        self
117    }
118
119    /// Generate RediSearch schema arguments for this vector field.
120    /// Returns: [nargs, TYPE, FLOAT32, DIM, {dim}, DISTANCE_METRIC, {metric}, ...]
121    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        // Add HNSW-specific params if set
132        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        // Prepend the count (RediSearch requires nargs before params)
144        let mut result = vec![args.len().to_string()];
145        result.extend(args);
146        result
147    }
148}
149
150/// Search index definition
151#[derive(Debug, Clone)]
152pub struct SearchIndex {
153    /// Index name (will be prefixed with "idx:")
154    pub name: String,
155    /// Key prefix this index covers (e.g., "crdt:users:")
156    pub prefix: String,
157    /// Field definitions for the index
158    pub fields: Vec<SearchField>,
159}
160
161impl SearchIndex {
162    /// Create a new search index definition
163    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    /// Add a text field
172    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    /// Add a text field with custom JSON path
184    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    /// Add a sortable text field
196    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    /// Add a numeric field
208    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    /// Add a numeric field with custom JSON path
220    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    /// Add a sortable numeric field
232    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    /// Add a sortable numeric field with custom JSON path
244    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    /// Add a tag field
256    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    /// Add a tag field with custom JSON path
268    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    /// Add a geo field
280    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    /// Add an HNSW vector field for similarity search.
292    ///
293    /// HNSW (Hierarchical Navigable Small World) is an approximate nearest neighbors
294    /// algorithm that provides fast queries with good recall.
295    ///
296    /// # Arguments
297    /// * `name` - Field name
298    /// * `dim` - Vector dimensionality (must match your embeddings)
299    /// * `metric` - Distance metric (L2, InnerProduct, or Cosine)
300    ///
301    /// # Example
302    /// ```ignore
303    /// SearchIndex::new("docs", "crdt:docs:")
304    ///     .vector_hnsw("embedding", 1536, DistanceMetric::Cosine)
305    /// ```
306    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    /// Add an HNSW vector field with custom JSON path.
323    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    /// Add an HNSW vector field with tuned parameters.
341    ///
342    /// # Arguments
343    /// * `name` - Field name
344    /// * `params` - Pre-configured VectorParams with custom M and EF_CONSTRUCTION
345    ///
346    /// # Example
347    /// ```ignore
348    /// let params = VectorParams::hnsw(1536, DistanceMetric::Cosine)
349    ///     .with_m(32)
350    ///     .with_ef_construction(400);
351    ///
352    /// SearchIndex::new("docs", "crdt:docs:")
353    ///     .vector_with_params("embedding", params)
354    /// ```
355    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    /// Add a FLAT vector field for exact similarity search.
367    ///
368    /// FLAT is a brute-force algorithm with O(n) query time but provides
369    /// exact results. Good for smaller datasets (<10k vectors).
370    ///
371    /// # Arguments
372    /// * `name` - Field name
373    /// * `dim` - Vector dimensionality (must match your embeddings)
374    /// * `metric` - Distance metric (L2, InnerProduct, or Cosine)
375    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    /// Add a FLAT vector field with custom JSON path.
392    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    /// Generate the FT.CREATE command arguments
410    pub fn to_ft_create_args(&self) -> Vec<String> {
411        self.to_ft_create_args_with_prefix(None)
412    }
413
414    /// Generate FT.CREATE args with optional global redis prefix
415    ///
416    /// The redis_prefix is prepended to both the index name and the key prefix
417    /// to match the actual key structure in Redis.
418    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/// Search field definition
440#[derive(Debug, Clone)]
441pub struct SearchField {
442    /// Field name (used in queries)
443    pub name: String,
444    /// JSON path (defaults to $.{name})
445    pub json_path: Option<String>,
446    /// Field type
447    pub field_type: SearchFieldType,
448    /// Whether the field is sortable
449    pub sortable: bool,
450    /// Whether to exclude from indexing (useful for SORTABLE-only fields)
451    pub no_index: bool,
452}
453
454impl SearchField {
455    fn to_schema_args(&self) -> Vec<String> {
456        // Default path: user data is stored under $.payload in our JSON wrapper
457        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        // Vector fields have special syntax: VECTOR {ALGO} {nargs} {params...}
469        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/// Search field types supported by RediSearch
493#[derive(Debug, Clone, PartialEq)]
494pub enum SearchFieldType {
495    /// Full-text searchable field
496    Text,
497    /// Numeric field (supports range queries)
498    Numeric,
499    /// Tag field (exact match, supports OR)
500    Tag,
501    /// Geographic field (latitude, longitude)
502    Geo,
503    /// Vector field for similarity search with algorithm-specific params
504    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
519/// Index manager for RediSearch
520pub struct IndexManager {
521    /// Registered indexes by name
522    indexes: HashMap<String, SearchIndex>,
523}
524
525impl IndexManager {
526    /// Create a new index manager
527    pub fn new() -> Self {
528        Self {
529            indexes: HashMap::new(),
530        }
531    }
532
533    /// Register an index definition
534    pub fn register(&mut self, index: SearchIndex) {
535        self.indexes.insert(index.name.clone(), index);
536    }
537
538    /// Get a registered index by name
539    pub fn get(&self, name: &str) -> Option<&SearchIndex> {
540        self.indexes.get(name)
541    }
542
543    /// Get all registered indexes
544    pub fn all(&self) -> impl Iterator<Item = &SearchIndex> {
545        self.indexes.values()
546    }
547
548    /// Generate FT.CREATE arguments for an index
549    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    /// Find index by prefix match
554    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        // Default paths are $.payload.{name} since user data is stored under payload
585        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        // Check SORTABLE appears
598        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        // Verify the structure
664        // FT.CREATE idx:users ON JSON PREFIX 1 crdt:users: SCHEMA
665        //   $.payload.name AS name TEXT SORTABLE
666        //   $.payload.email AS email TEXT
667        //   $.payload.age AS age NUMERIC SORTABLE
668        //   $.payload.roles AS roles TAG
669        // Note: default paths use $.payload.{field} since user data is wrapped in payload
670
671        assert_eq!(args[0], "idx:users");
672        assert_eq!(args[6], "SCHEMA");
673
674        // Build the full command string for verification
675        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        // Verify HNSW vector field structure
695        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        // Verify nargs count is correct
751        let params = VectorParams::hnsw(1536, DistanceMetric::Cosine);
752        let args = params.to_schema_args();
753
754        // nargs should be first: 6 base params (TYPE FLOAT32 DIM 1536 DISTANCE_METRIC COSINE)
755        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        // nargs should be 10: 6 base + M 24 + EF_CONSTRUCTION 300
772        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        // Verify all field types are present
792        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}