1use crate::errors::{Result, VecDbError};
2use crate::hybrid::FusionStrategy;
3use crate::types::{CollectionConfig, IndexType, SearchRequest};
4
5#[derive(Debug, Clone, PartialEq)]
10pub enum SortKey {
11 Score,
13 Field(String),
15}
16
17#[derive(Debug, Clone)]
22pub enum LogicalNode {
23 VectorScan {
25 collection: String,
26 k: usize,
27 oversample: usize,
29 alpha: f32,
31 strategy: FusionStrategy,
33 },
34 SparseScan { collection: String, k: usize },
36 Filter {
38 predicate: String,
40 input: Box<LogicalNode>,
41 },
42 Join {
44 left: Box<LogicalNode>,
45 right_collection: String,
47 left_key: String,
49 right_key: String,
51 },
52 Project {
54 columns: Vec<String>,
55 input: Box<LogicalNode>,
56 },
57 Sort {
59 by: SortKey,
60 descending: bool,
61 input: Box<LogicalNode>,
62 },
63 Limit { n: usize, input: Box<LogicalNode> },
65 HybridScan {
67 collection: String,
68 k: usize,
69 alpha: f32,
70 strategy: FusionStrategy,
71 oversample: usize,
73 },
74}
75
76#[derive(Debug, Clone)]
81pub struct PhysicalPlan {
82 pub root: LogicalNode,
84 pub index_type: IndexType,
86 pub use_hybrid: bool,
88 pub pre_filter: bool,
90 pub filter_predicate: Option<String>,
92 pub estimated_cost: f32,
94 pub candidate_k: usize,
96 pub output_k: usize,
98 pub output_columns: Vec<String>,
100}
101
102pub struct QueryPlanner {
107 pub config: CollectionConfig,
108}
109
110impl QueryPlanner {
111 pub fn new(config: CollectionConfig) -> Self {
112 Self { config }
113 }
114
115 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 pub fn should_use_hnsw(&self, vector_count: usize) -> bool {
127 vector_count >= 100
128 }
129
130 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 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 pub fn plan_search(
167 &self,
168 request: &SearchRequest,
169 vector_count: usize,
170 ) -> Result<PhysicalPlan> {
171 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 let use_hybrid = request.vector.is_some() && request.query_text.is_some();
182
183 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 };
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 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 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 LogicalNode::SparseScan {
230 collection: collection.clone(),
231 k: candidate_k,
232 }
233 };
234
235 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 let sorted = LogicalNode::Sort {
247 by: SortKey::Score,
248 descending: true,
249 input: Box::new(after_filter),
250 };
251
252 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 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 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 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#[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 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 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]
427 fn test_plan_dense_only() {
428 let planner = make_planner();
429 let request = base_request(); 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]
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]
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]
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]
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]
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]
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]
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 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]
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]
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]
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}