Skip to main content

vecdb_core/planner/
plan.rs

1use crate::errors::{Result, VecDbError};
2use crate::hybrid::FusionStrategy;
3use crate::types::{CollectionConfig, IndexType, SearchRequest};
4
5// ─────────────────────────────────────────────────────────────────
6// SortKey
7// ─────────────────────────────────────────────────────────────────
8
9#[derive(Debug, Clone, PartialEq)]
10pub enum SortKey {
11    /// Sort by the vector similarity score (default).
12    Score,
13    /// Sort by a metadata field name.
14    Field(String),
15}
16
17// ─────────────────────────────────────────────────────────────────
18// LogicalNode
19// ─────────────────────────────────────────────────────────────────
20
21#[derive(Debug, Clone)]
22pub enum LogicalNode {
23    /// Scan the vector index for nearest neighbors.
24    VectorScan {
25        collection: String,
26        k: usize,
27        /// Oversampling multiplier (dense fetches k * oversample candidates).
28        oversample: usize,
29        /// Alpha for hybrid fusion (1.0 = pure dense, 0.0 = pure sparse).
30        alpha: f32,
31        /// Fusion strategy.
32        strategy: FusionStrategy,
33    },
34    /// Full sparse BM25 scan (no dense pre-filtering).
35    SparseScan { collection: String, k: usize },
36    /// Apply a scalar metadata filter.
37    Filter {
38        /// Predicate string (serialised JSON or SQL-like).
39        predicate: String,
40        input: Box<LogicalNode>,
41    },
42    /// Join vector candidates with a metadata table.
43    Join {
44        left: Box<LogicalNode>,
45        /// Name of the metadata table / collection to join against.
46        right_collection: String,
47        /// Join key on the left side (field in vector payload).
48        left_key: String,
49        /// Join key on the right side (field in metadata).
50        right_key: String,
51    },
52    /// Project (select) specific fields from results.
53    Project {
54        columns: Vec<String>,
55        input: Box<LogicalNode>,
56    },
57    /// Sort results by a field or score.
58    Sort {
59        by: SortKey,
60        descending: bool,
61        input: Box<LogicalNode>,
62    },
63    /// Limit number of results.
64    Limit { n: usize, input: Box<LogicalNode> },
65    /// Hybrid scan: dense HNSW + sparse BM25 combined.
66    HybridScan {
67        collection: String,
68        k: usize,
69        alpha: f32,
70        strategy: FusionStrategy,
71        /// Dense oversampling factor before sparse re-scoring.
72        oversample: usize,
73    },
74}
75
76// ─────────────────────────────────────────────────────────────────
77// PhysicalPlan
78// ─────────────────────────────────────────────────────────────────
79
80#[derive(Debug, Clone)]
81pub struct PhysicalPlan {
82    /// The root logical node.
83    pub root: LogicalNode,
84    /// Which index backend to use.
85    pub index_type: IndexType,
86    /// Whether to run hybrid retrieval.
87    pub use_hybrid: bool,
88    /// Whether to apply metadata filter BEFORE the vector scan.
89    pub pre_filter: bool,
90    /// The scalar predicate to apply (serialised JSON), if any.
91    pub filter_predicate: Option<String>,
92    /// Estimated cost in arbitrary units.
93    pub estimated_cost: f32,
94    /// How many dense candidates to fetch (`output_k * oversample`).
95    pub candidate_k: usize,
96    /// Final k — how many results to return after filtering and sorting.
97    pub output_k: usize,
98    /// Columns to project onto results. Empty means SELECT * (keep everything).
99    pub output_columns: Vec<String>,
100}
101
102// ─────────────────────────────────────────────────────────────────
103// QueryPlanner
104// ─────────────────────────────────────────────────────────────────
105
106pub struct QueryPlanner {
107    pub config: CollectionConfig,
108}
109
110impl QueryPlanner {
111    pub fn new(config: CollectionConfig) -> Self {
112        Self { config }
113    }
114
115    // ── Cost model ───────────────────────────────────────────────
116
117    /// Estimated cost for HNSW lookup.
118    ///
119    /// Models O(log N) graph traversal: `ln(N) * k * 0.1`.
120    fn estimate_index_cost(&self, vector_count: usize, k: usize) -> f32 {
121        (vector_count as f32).ln() * k as f32 * 0.1
122    }
123
124    /// Returns `true` if the collection is large enough for HNSW to be
125    /// faster than brute force (matches `HnswIndex` auto-build threshold).
126    pub fn should_use_hnsw(&self, vector_count: usize) -> bool {
127        vector_count >= 100
128    }
129
130    /// Returns `true` when pre-filtering (metadata first, then vector scan)
131    /// is beneficial.
132    ///
133    /// - Selectivity < 0.1: filter is highly selective → pre-filter saves work.
134    /// - Collection > 100 000 vectors: large collections always benefit.
135    pub fn should_pre_filter(&self, estimated_selectivity: f32, vector_count: usize) -> bool {
136        estimated_selectivity < 0.1 || vector_count > 100_000
137    }
138
139    /// Compute the number of dense candidates to fetch.
140    ///
141    /// Multipliers:
142    /// - hybrid + filter → ×10 (capped)
143    /// - hybrid only     → ×5
144    /// - filter only     → ×3
145    /// - neither         → ×1
146    ///
147    /// Always at least `output_k`, at most 10 000.
148    pub fn compute_candidate_k(
149        &self,
150        output_k: usize,
151        use_hybrid: bool,
152        has_filter: bool,
153    ) -> usize {
154        let mult: usize = match (use_hybrid, has_filter) {
155            (true, true) => 10,
156            (true, false) => 5,
157            (false, true) => 3,
158            (false, false) => 1,
159        };
160        (output_k * mult).min(10_000).max(output_k)
161    }
162
163    // ── Plan construction ─────────────────────────────────────────
164
165    /// Build a physical plan for a vector / text search request.
166    pub fn plan_search(
167        &self,
168        request: &SearchRequest,
169        vector_count: usize,
170    ) -> Result<PhysicalPlan> {
171        // Validate: at least one signal required.
172        if request.vector.is_none() && request.query_text.is_none() {
173            return Err(VecDbError::InvalidQuery(
174                "search requires at least one of vector or query_text".into(),
175            ));
176        }
177
178        let collection = self.config.name.clone();
179
180        // Decide retrieval mode.
181        let use_hybrid = request.vector.is_some() && request.query_text.is_some();
182
183        // Index type.
184        let index_type = if self.should_use_hnsw(vector_count) {
185            IndexType::HNSW
186        } else {
187            tracing::debug!(
188                "collection size {} below HNSW threshold — brute force will be used",
189                vector_count
190            );
191            IndexType::HNSW // IVF not yet implemented; always HNSW
192        };
193
194        let has_filter = request.filter.is_some();
195        let candidate_k = self.compute_candidate_k(request.k, use_hybrid, has_filter);
196        let pre_filter = has_filter && self.should_pre_filter(0.05, vector_count);
197
198        // Serialise the filter predicate for the physical plan and Filter node.
199        let filter_predicate: Option<String> = if has_filter {
200            request
201                .filter
202                .as_ref()
203                .map(|f| serde_json::to_string(f).unwrap_or_default())
204        } else {
205            None
206        };
207
208        // ── Build logical tree bottom-up ──────────────────────────
209
210        // 1. Scan node.
211        let scan: LogicalNode = if use_hybrid {
212            LogicalNode::HybridScan {
213                collection: collection.clone(),
214                k: candidate_k,
215                alpha: request.alpha,
216                strategy: FusionStrategy::WeightedSum,
217                oversample: 5,
218            }
219        } else if request.vector.is_some() {
220            LogicalNode::VectorScan {
221                collection: collection.clone(),
222                k: candidate_k,
223                oversample: 1,
224                alpha: 1.0,
225                strategy: FusionStrategy::WeightedSum,
226            }
227        } else {
228            // query_text only.
229            LogicalNode::SparseScan {
230                collection: collection.clone(),
231                k: candidate_k,
232            }
233        };
234
235        // 2. Optional filter wrapper.
236        let after_filter: LogicalNode = if let Some(ref pred) = filter_predicate {
237            LogicalNode::Filter {
238                predicate: pred.clone(),
239                input: Box::new(scan),
240            }
241        } else {
242            scan
243        };
244
245        // 3. Sort.
246        let sorted = LogicalNode::Sort {
247            by: SortKey::Score,
248            descending: true,
249            input: Box::new(after_filter),
250        };
251
252        // 4. Limit.
253        let root = LogicalNode::Limit {
254            n: request.k,
255            input: Box::new(sorted),
256        };
257
258        let estimated_cost = self.estimate_index_cost(vector_count, candidate_k);
259
260        Ok(PhysicalPlan {
261            root,
262            index_type,
263            use_hybrid,
264            pre_filter,
265            filter_predicate,
266            estimated_cost,
267            candidate_k,
268            output_k: request.k,
269            output_columns: vec![],
270        })
271    }
272
273    /// Build a physical plan from a SQL string.
274    ///
275    /// The SQL must contain a `VECTOR_SIM(col, [...]) op threshold` condition.
276    /// An optional scalar equality filter (`AND field = value`) is translated
277    /// into the plan's `filter_predicate`.
278    pub fn plan_sql(&self, sql: &str, vector_count: usize) -> Result<PhysicalPlan> {
279        use crate::planner::sql::{AstConverter, SqlParser};
280        let stmt = SqlParser::parse(sql)?;
281        let converter = AstConverter::new(QueryPlanner::new(self.config.clone()));
282        converter.convert(stmt, vector_count)
283    }
284
285    /// Return a human-readable description of the physical plan.
286    pub fn explain(&self, plan: &PhysicalPlan) -> String {
287        let filter_str = plan.filter_predicate.as_deref().unwrap_or("None");
288        let tree = Self::format_node(&plan.root, 2);
289        format!(
290            "PhysicalPlan {{\n  index_type: {:?}\n  use_hybrid: {}\n  pre_filter: {}\n  candidate_k: {}\n  output_k: {}\n  estimated_cost: {:.2}\n  filter: {}\n  logical_tree:\n{}}}\n",
291            plan.index_type,
292            plan.use_hybrid,
293            plan.pre_filter,
294            plan.candidate_k,
295            plan.output_k,
296            plan.estimated_cost,
297            filter_str,
298            tree,
299        )
300    }
301
302    /// Recursively format a logical node with indentation.
303    ///
304    /// Each nesting level adds 2 spaces. `indent=2` gives 4-space indent for
305    /// the first tree level (matching the `logical_tree:` header).
306    fn format_node(node: &LogicalNode, indent: usize) -> String {
307        let pad = " ".repeat(indent * 2);
308        let next = indent + 1;
309        match node {
310            LogicalNode::VectorScan { k, alpha, .. } => {
311                format!("{pad}VectorScan(k={k}, alpha={alpha:.2})\n")
312            }
313            LogicalNode::SparseScan { k, .. } => {
314                format!("{pad}SparseScan(k={k})\n")
315            }
316            LogicalNode::HybridScan {
317                k, alpha, strategy, ..
318            } => {
319                let strat = match strategy {
320                    FusionStrategy::WeightedSum => "WeightedSum",
321                    FusionStrategy::ReciprocalRankFusion { .. } => "RRF",
322                };
323                format!("{pad}HybridScan(k={k}, alpha={alpha:.2}, strategy={strat})\n")
324            }
325            LogicalNode::Filter { predicate, input } => {
326                format!(
327                    "{pad}Filter({predicate})\n{}",
328                    Self::format_node(input, next)
329                )
330            }
331            LogicalNode::Sort {
332                by,
333                descending,
334                input,
335            } => {
336                let dir = if *descending { "desc" } else { "asc" };
337                let by_str = match by {
338                    SortKey::Score => "score".to_string(),
339                    SortKey::Field(f) => f.clone(),
340                };
341                format!(
342                    "{pad}Sort({by_str} {dir})\n{}",
343                    Self::format_node(input, next)
344                )
345            }
346            LogicalNode::Limit { n, input } => {
347                format!("{pad}Limit({n})\n{}", Self::format_node(input, next))
348            }
349            LogicalNode::Project { columns, input } => {
350                format!(
351                    "{pad}Project([{}])\n{}",
352                    columns.join(", "),
353                    Self::format_node(input, next)
354                )
355            }
356            LogicalNode::Join {
357                left,
358                right_collection,
359                left_key,
360                right_key,
361            } => {
362                format!(
363                    "{pad}Join({right_collection}, {left_key}={right_key})\n{}",
364                    Self::format_node(left, next)
365                )
366            }
367        }
368    }
369}
370
371// ─────────────────────────────────────────────────────────────────
372// Tests
373// ─────────────────────────────────────────────────────────────────
374
375#[cfg(test)]
376mod tests {
377    use super::*;
378    use crate::storage::Storage;
379    use crate::types::{CollectionConfig, SearchRequest, VectorRecord};
380    use chrono::Utc;
381    use serde_json::json;
382    use tempfile::tempdir;
383
384    fn make_planner() -> QueryPlanner {
385        QueryPlanner::new(CollectionConfig::new("testcol", 4))
386    }
387
388    fn base_request() -> SearchRequest {
389        SearchRequest {
390            vector: Some(vec![0.1_f32, 0.2, 0.3, 0.4]),
391            query_text: None,
392            k: 10,
393            alpha: 0.7,
394            collection: "testcol".into(),
395            filter: None,
396        }
397    }
398
399    /// Walk the logical tree and return the scan node type as a static str.
400    fn scan_type(node: &LogicalNode) -> &'static str {
401        match node {
402            LogicalNode::VectorScan { .. } => "VectorScan",
403            LogicalNode::SparseScan { .. } => "SparseScan",
404            LogicalNode::HybridScan { .. } => "HybridScan",
405            LogicalNode::Filter { input, .. } => scan_type(input),
406            LogicalNode::Sort { input, .. } => scan_type(input),
407            LogicalNode::Limit { input, .. } => scan_type(input),
408            LogicalNode::Project { input, .. } => scan_type(input),
409            LogicalNode::Join { left, .. } => scan_type(left),
410        }
411    }
412
413    /// Walk the tree looking for a Filter node.
414    fn has_filter_node(node: &LogicalNode) -> bool {
415        match node {
416            LogicalNode::Filter { .. } => true,
417            LogicalNode::Sort { input, .. }
418            | LogicalNode::Limit { input, .. }
419            | LogicalNode::Project { input, .. } => has_filter_node(input),
420            LogicalNode::Join { left, .. } => has_filter_node(left),
421            _ => false,
422        }
423    }
424
425    // ── Test 1 ───────────────────────────────────────────────────
426    #[test]
427    fn test_plan_dense_only() {
428        let planner = make_planner();
429        let request = base_request(); // vector=Some, text=None
430        let plan = planner.plan_search(&request, 1000).unwrap();
431
432        assert!(!plan.use_hybrid);
433        assert_eq!(plan.output_k, 10);
434        assert_eq!(plan.index_type, IndexType::HNSW);
435        assert!(plan.candidate_k >= 10);
436        assert_eq!(scan_type(&plan.root), "VectorScan");
437    }
438
439    // ── Test 2 ───────────────────────────────────────────────────
440    #[test]
441    fn test_plan_sparse_only() {
442        let planner = make_planner();
443        let request = SearchRequest {
444            vector: None,
445            query_text: Some("machine learning".into()),
446            k: 5,
447            ..base_request()
448        };
449        let plan = planner.plan_search(&request, 1000).unwrap();
450
451        assert!(!plan.use_hybrid);
452        assert_eq!(scan_type(&plan.root), "SparseScan");
453        assert!(plan.candidate_k >= 5);
454    }
455
456    // ── Test 3 ───────────────────────────────────────────────────
457    #[test]
458    fn test_plan_hybrid() {
459        let planner = make_planner();
460        let request = SearchRequest {
461            vector: Some(vec![0.1_f32, 0.2, 0.3, 0.4]),
462            query_text: Some("machine learning".into()),
463            k: 10,
464            ..base_request()
465        };
466        let plan = planner.plan_search(&request, 1000).unwrap();
467
468        assert!(plan.use_hybrid);
469        assert_eq!(scan_type(&plan.root), "HybridScan");
470        assert!(
471            plan.candidate_k >= 50,
472            "hybrid candidate_k must be >= 50 (10 * 5), got {}",
473            plan.candidate_k
474        );
475    }
476
477    // ── Test 4 ───────────────────────────────────────────────────
478    #[test]
479    fn test_plan_with_filter() {
480        let planner = make_planner();
481        let request = SearchRequest {
482            filter: Some(json!({"region": "US"})),
483            ..base_request()
484        };
485        let plan = planner.plan_search(&request, 1000).unwrap();
486
487        assert!(plan.filter_predicate.is_some());
488        assert!(
489            has_filter_node(&plan.root),
490            "Filter node must be in the tree"
491        );
492    }
493
494    // ── Test 5 ───────────────────────────────────────────────────
495    #[test]
496    fn test_cost_model_small_collection() {
497        let planner = make_planner();
498        assert!(!planner.should_use_hnsw(50), "50 < 100 → brute force");
499        assert!(planner.should_use_hnsw(100), "100 == threshold → HNSW");
500        assert!(planner.should_use_hnsw(1_000_000), "large → HNSW");
501    }
502
503    // ── Test 6 ───────────────────────────────────────────────────
504    #[test]
505    fn test_candidate_k_hybrid_with_filter() {
506        let planner = make_planner();
507        assert_eq!(
508            planner.compute_candidate_k(10, true, true),
509            100,
510            "hybrid+filter → 10 * 10"
511        );
512        assert_eq!(
513            planner.compute_candidate_k(10, true, false),
514            50,
515            "hybrid only → 10 * 5"
516        );
517        assert_eq!(
518            planner.compute_candidate_k(10, false, false),
519            10,
520            "neither → 10 * 1"
521        );
522    }
523
524    // ── Test 7 ───────────────────────────────────────────────────
525    #[test]
526    fn test_explain_output() {
527        let planner = make_planner();
528        let plan = PhysicalPlan {
529            root: LogicalNode::Limit {
530                n: 10,
531                input: Box::new(LogicalNode::Sort {
532                    by: SortKey::Score,
533                    descending: true,
534                    input: Box::new(LogicalNode::HybridScan {
535                        collection: "testcol".into(),
536                        k: 50,
537                        alpha: 0.7,
538                        strategy: FusionStrategy::WeightedSum,
539                        oversample: 5,
540                    }),
541                }),
542            },
543            index_type: IndexType::HNSW,
544            use_hybrid: true,
545            pre_filter: false,
546            filter_predicate: None,
547            estimated_cost: 12.34,
548            candidate_k: 50,
549            output_k: 10,
550            output_columns: vec![],
551        };
552
553        let output = planner.explain(&plan);
554        assert!(
555            output.contains("HybridScan"),
556            "explain must contain 'HybridScan'"
557        );
558        assert!(
559            output.contains("Limit(10)"),
560            "explain must contain 'Limit(10)'"
561        );
562        assert!(
563            output.contains("use_hybrid: true"),
564            "explain must contain 'use_hybrid: true'"
565        );
566    }
567
568    // ── Test 8 ───────────────────────────────────────────────────
569    #[test]
570    fn test_executor_dense_search() {
571        let dir = tempdir().unwrap();
572        let config = CollectionConfig::new("exdense", 4);
573        let mut storage = Storage::create(dir.path(), &config).unwrap();
574
575        let now = Utc::now();
576        for i in 0..10usize {
577            storage
578                .upsert(VectorRecord {
579                    id: format!("v{i}"),
580                    vector: vec![i as f32, i as f32 + 1.0, 0.0, 1.0],
581                    payload: json!({ "i": i }),
582                    text: None,
583                    created_at: now,
584                    updated_at: now,
585                })
586                .unwrap();
587        }
588
589        let request = SearchRequest {
590            vector: Some(vec![4.0_f32, 5.0, 0.0, 1.0]),
591            query_text: None,
592            k: 5,
593            alpha: 0.7,
594            collection: "exdense".into(),
595            filter: None,
596        };
597
598        let results = storage.execute_search(&request).unwrap();
599        assert!(
600            !results.is_empty(),
601            "dense execute_search must return results"
602        );
603        assert!(results.len() <= 5);
604        for r in &results {
605            assert!(r.score >= 0.0, "score {} < 0.0", r.score);
606        }
607        // Sorted descending.
608        for i in 1..results.len() {
609            assert!(
610                results[i - 1].score >= results[i].score,
611                "results not sorted at index {i}"
612            );
613        }
614    }
615
616    // ── Test 9 ───────────────────────────────────────────────────
617    #[test]
618    fn test_executor_hybrid_search() {
619        let dir = tempdir().unwrap();
620        let config = CollectionConfig::new("exhybrid", 4);
621        let mut storage = Storage::create(dir.path(), &config).unwrap();
622
623        let texts = [
624            "machine learning classification",
625            "deep neural network training",
626            "random forest ensemble method",
627            "gradient boosting decision tree",
628            "support vector machine kernel",
629            "convolutional network image recognition",
630            "recurrent network sequence modeling",
631            "attention transformer language model",
632            "unsupervised clustering algorithm",
633            "reinforcement learning reward signal",
634        ];
635        let now = Utc::now();
636        for (i, text) in texts.iter().enumerate() {
637            storage
638                .upsert(VectorRecord {
639                    id: format!("h{i}"),
640                    vector: vec![i as f32, i as f32 + 1.0, 0.5, 1.0],
641                    payload: json!({ "i": i }),
642                    text: Some(text.to_string()),
643                    created_at: now,
644                    updated_at: now,
645                })
646                .unwrap();
647        }
648
649        let request = SearchRequest {
650            vector: Some(vec![3.0_f32, 4.0, 0.5, 1.0]),
651            query_text: Some("machine learning".into()),
652            k: 5,
653            alpha: 0.7,
654            collection: "exhybrid".into(),
655            filter: None,
656        };
657
658        let results = storage.execute_search(&request).unwrap();
659        assert!(!results.is_empty());
660        for i in 1..results.len() {
661            assert!(
662                results[i - 1].score >= results[i].score,
663                "results not sorted at index {i}"
664            );
665        }
666    }
667
668    // ── Test 10 ──────────────────────────────────────────────────
669    #[test]
670    fn test_executor_invalid_no_inputs() {
671        let dir = tempdir().unwrap();
672        let config = CollectionConfig::new("exinvalid", 4);
673        let storage = Storage::create(dir.path(), &config).unwrap();
674
675        let request = SearchRequest {
676            vector: None,
677            query_text: None,
678            k: 5,
679            alpha: 0.7,
680            collection: "exinvalid".into(),
681            filter: None,
682        };
683
684        let result = storage.execute_search(&request);
685        assert!(
686            matches!(result, Err(VecDbError::InvalidQuery(_))),
687            "expected InvalidQuery error"
688        );
689    }
690
691    // ── Test 11 ──────────────────────────────────────────────────
692    #[test]
693    fn test_pre_filter_threshold() {
694        let planner = make_planner();
695
696        assert!(
697            planner.should_pre_filter(0.05, 50_000),
698            "selectivity 0.05 < 0.1 → pre-filter"
699        );
700        assert!(
701            !planner.should_pre_filter(0.5, 50_000),
702            "selectivity 0.5 >= 0.1 and count <= 100k → no pre-filter"
703        );
704        assert!(
705            planner.should_pre_filter(0.5, 200_000),
706            "count > 100k → always pre-filter"
707        );
708    }
709}