1use crate::{OptimizerContext, VectorPrecision};
16use hawdb_plan_core::{
17 VectorBackendSelectionReason, VectorCandidateSource, VectorPhysicalPlan,
18 VectorSearchLogicalPlan,
19};
20use std::fmt::{Display, Formatter};
21
22const SELECTIVITY_SCALE: u64 = 1_000_000;
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq)]
25pub enum VectorCompressionPreference {
26 Disabled,
27 Preferred,
28 Required,
29}
30
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum AdaptiveVectorBackend {
33 ScalarFlat,
34 QuantizedProjection,
35 RequiredProjectionUnavailable,
36}
37
38impl AdaptiveVectorBackend {
39 pub const fn as_str(self) -> &'static str {
40 match self {
41 Self::ScalarFlat => "scalar_flat",
42 Self::QuantizedProjection => "quantized_projection",
43 Self::RequiredProjectionUnavailable => "required_projection_unavailable",
44 }
45 }
46
47 pub const fn candidate_source(self) -> VectorCandidateSource {
48 match self {
49 Self::ScalarFlat | Self::RequiredProjectionUnavailable => VectorCandidateSource::Scalar,
50 Self::QuantizedProjection => VectorCandidateSource::Quantized,
51 }
52 }
53}
54
55#[derive(Debug, Clone, Copy, PartialEq, Eq)]
56pub struct AdaptiveVectorBackendPolicy {
57 pub flat_scan_max_documents: usize,
58 pub high_filter_selectivity_per_million: u32,
59 pub flat_scan_memory_budget_bytes: u64,
60}
61
62impl Default for AdaptiveVectorBackendPolicy {
63 fn default() -> Self {
64 Self {
65 flat_scan_max_documents: 4_096,
66 high_filter_selectivity_per_million: 750_000,
67 flat_scan_memory_budget_bytes: 16 * 1024 * 1024,
68 }
69 }
70}
71
72#[derive(Debug, Clone, Copy, PartialEq, Eq)]
73pub struct AdaptiveVectorBackendInput {
74 pub compression_preference: VectorCompressionPreference,
75 pub document_count: usize,
76 pub filtered_document_count: usize,
77 pub embedding_dimension: usize,
78 pub recall_validation_probe: bool,
79 pub quantized_projection_available: bool,
80 pub quantized_projection_covered_document_count: usize,
81}
82
83#[derive(Debug, Clone, Copy, PartialEq, Eq)]
84pub struct AdaptiveVectorBackendDecision {
85 pub backend: AdaptiveVectorBackend,
86 pub reason: VectorBackendSelectionReason,
87 pub estimated_raw_vector_bytes: u64,
88 pub filter_selectivity_per_million: u32,
89 pub quantized_projection_coverage_complete: bool,
90}
91
92pub fn select_adaptive_vector_backend(
93 input: AdaptiveVectorBackendInput,
94 policy: AdaptiveVectorBackendPolicy,
95) -> AdaptiveVectorBackendDecision {
96 let estimated_raw_vector_bytes = u64::try_from(input.filtered_document_count)
97 .unwrap_or(u64::MAX)
98 .saturating_mul(u64::try_from(input.embedding_dimension).unwrap_or(u64::MAX))
99 .saturating_mul(std::mem::size_of::<f32>() as u64);
100 let filtered_out_count = input
101 .document_count
102 .saturating_sub(input.filtered_document_count);
103 let filter_selectivity_per_million = if input.document_count == 0 {
104 0
105 } else {
106 u32::try_from(
107 u64::try_from(filtered_out_count)
108 .unwrap_or(u64::MAX)
109 .saturating_mul(SELECTIVITY_SCALE)
110 / u64::try_from(input.document_count).unwrap_or(u64::MAX),
111 )
112 .unwrap_or(u32::MAX)
113 };
114 let quantized_projection_coverage_complete = input.quantized_projection_available
115 && input.quantized_projection_covered_document_count >= input.filtered_document_count;
116 let decision = |backend, reason| AdaptiveVectorBackendDecision {
117 backend,
118 reason,
119 estimated_raw_vector_bytes,
120 filter_selectivity_per_million,
121 quantized_projection_coverage_complete,
122 };
123
124 if input.recall_validation_probe {
125 return decision(
126 AdaptiveVectorBackend::ScalarFlat,
127 VectorBackendSelectionReason::RecallValidationProbe,
128 );
129 }
130 if input.compression_preference == VectorCompressionPreference::Disabled {
131 return decision(
132 AdaptiveVectorBackend::ScalarFlat,
133 VectorBackendSelectionReason::CompressionDisabled,
134 );
135 }
136 if input.compression_preference == VectorCompressionPreference::Required {
137 if !input.quantized_projection_available {
138 return decision(
139 AdaptiveVectorBackend::RequiredProjectionUnavailable,
140 VectorBackendSelectionReason::QuantizedProjectionUnavailable,
141 );
142 }
143 if !quantized_projection_coverage_complete {
144 return decision(
145 AdaptiveVectorBackend::RequiredProjectionUnavailable,
146 VectorBackendSelectionReason::QuantizedProjectionCoverageIncomplete,
147 );
148 }
149 return decision(
150 AdaptiveVectorBackend::QuantizedProjection,
151 VectorBackendSelectionReason::QuantizedRequired,
152 );
153 }
154 if input.filtered_document_count <= policy.flat_scan_max_documents {
155 return decision(
156 AdaptiveVectorBackend::ScalarFlat,
157 VectorBackendSelectionReason::SmallFilteredCandidateSet,
158 );
159 }
160 if filter_selectivity_per_million >= policy.high_filter_selectivity_per_million {
161 return decision(
162 AdaptiveVectorBackend::ScalarFlat,
163 VectorBackendSelectionReason::HighFilterSelectivity,
164 );
165 }
166 if estimated_raw_vector_bytes <= policy.flat_scan_memory_budget_bytes {
167 return decision(
168 AdaptiveVectorBackend::ScalarFlat,
169 VectorBackendSelectionReason::RawVectorsWithinMemoryBudget,
170 );
171 }
172 if !input.quantized_projection_available {
173 return decision(
174 AdaptiveVectorBackend::ScalarFlat,
175 VectorBackendSelectionReason::QuantizedProjectionUnavailable,
176 );
177 }
178 if !quantized_projection_coverage_complete {
179 return decision(
180 AdaptiveVectorBackend::ScalarFlat,
181 VectorBackendSelectionReason::QuantizedProjectionCoverageIncomplete,
182 );
183 }
184 decision(
185 AdaptiveVectorBackend::QuantizedProjection,
186 VectorBackendSelectionReason::QuantizedPreferred,
187 )
188}
189
190#[derive(Debug, Clone, Copy, PartialEq, Eq)]
191pub enum VectorPlanError {
192 EmptyEmbedding,
193 EmptyTopK,
194 CandidateLimitBelowTopK,
195 InitialCandidateLimitBelowTopK,
196 InitialCandidateLimitAboveBudget,
197 MissingFilter,
198 MissingCandidateScan,
199 MissingRawRerank,
200 MissingTopK,
201}
202
203impl Display for VectorPlanError {
204 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
205 formatter.write_str(match self {
206 Self::EmptyEmbedding => "vector embedding dimension must be non-zero",
207 Self::EmptyTopK => "vector top-k must be non-zero",
208 Self::CandidateLimitBelowTopK => "vector candidate limit must cover top-k",
209 Self::InitialCandidateLimitBelowTopK => {
210 "initial vector candidate limit must cover top-k"
211 }
212 Self::InitialCandidateLimitAboveBudget => {
213 "initial vector candidate limit must not exceed the candidate budget"
214 }
215 Self::MissingFilter => "vector pipeline must start with filter",
216 Self::MissingCandidateScan => {
217 "vector pipeline must include candidate scan after filter"
218 }
219 Self::MissingRawRerank => "vector pipeline must raw-rerank candidates",
220 Self::MissingTopK => "vector pipeline must apply top-k after raw rerank",
221 })
222 }
223}
224
225impl std::error::Error for VectorPlanError {}
226
227#[derive(Debug, Clone, PartialEq, Eq)]
228pub struct VectorPlanProperties {
229 pub precision: VectorPrecision,
230 pub priority: u8,
231 pub max_parallelism: usize,
232 pub max_memory_bytes: Option<u64>,
233}
234
235impl VectorPlanProperties {
236 pub fn execution_resource_profile(&self) -> hawdb_plan_core::VectorExecutionResourceProfile {
237 hawdb_plan_core::VectorExecutionResourceProfile {
238 priority: self.priority,
239 max_parallelism: self.max_parallelism.max(1),
240 max_working_memory_bytes: self.max_memory_bytes,
241 }
242 }
243}
244
245#[derive(Debug, Clone, PartialEq, Eq)]
246pub struct PlannedVectorSearch {
247 pub plan: VectorPhysicalPlan,
248 pub properties: VectorPlanProperties,
249}
250
251pub fn plan_vector_search(
252 logical: &VectorSearchLogicalPlan,
253 context: &OptimizerContext,
254) -> Result<PlannedVectorSearch, VectorPlanError> {
255 if logical.embedding_dimension == 0 {
256 return Err(VectorPlanError::EmptyEmbedding);
257 }
258 if logical.top_k == 0 {
259 return Err(VectorPlanError::EmptyTopK);
260 }
261 if logical.candidate_limit < logical.top_k {
262 return Err(VectorPlanError::CandidateLimitBelowTopK);
263 }
264 if logical.initial_candidate_limit < logical.top_k {
265 return Err(VectorPlanError::InitialCandidateLimitBelowTopK);
266 }
267 if logical.initial_candidate_limit > logical.candidate_limit {
268 return Err(VectorPlanError::InitialCandidateLimitAboveBudget);
269 }
270
271 let filter = VectorPhysicalPlan::Filter {
272 fields: logical.filter_fields.clone(),
273 };
274 let candidates = VectorPhysicalPlan::VectorCandidateScan {
275 source: logical.candidate_source,
276 embedding_dimension: logical.embedding_dimension,
277 candidate_limit: logical.candidate_limit,
278 input: Box::new(filter),
279 };
280 let candidates = if logical.residual_filter_fields.is_empty() {
281 candidates
282 } else {
283 VectorPhysicalPlan::ResidualFilter {
284 fields: logical.residual_filter_fields.clone(),
285 initial_candidate_limit: logical.initial_candidate_limit,
286 input: Box::new(candidates),
287 }
288 };
289 let rerank = VectorPhysicalPlan::RawVectorRerank {
290 embedding_dimension: logical.embedding_dimension,
291 input: Box::new(candidates),
292 };
293 let plan = VectorPhysicalPlan::TopK {
294 limit: logical.top_k,
295 input: Box::new(rerank),
296 };
297 validate_vector_pipeline(&plan)?;
298
299 Ok(PlannedVectorSearch {
300 plan,
301 properties: VectorPlanProperties {
302 precision: VectorPrecision::RawReranked,
303 priority: context.resource_hints().priority,
304 max_parallelism: context.resource_hints().max_parallelism.max(1),
305 max_memory_bytes: context.resource_hints().max_memory_bytes,
306 },
307 })
308}
309
310pub fn validate_vector_pipeline(plan: &VectorPhysicalPlan) -> Result<(), VectorPlanError> {
311 let VectorPhysicalPlan::TopK { input, .. } = plan else {
312 return Err(VectorPlanError::MissingTopK);
313 };
314 let VectorPhysicalPlan::RawVectorRerank { input, .. } = input.as_ref() else {
315 return Err(VectorPlanError::MissingRawRerank);
316 };
317 let input = match input.as_ref() {
318 VectorPhysicalPlan::ResidualFilter { input, .. } => input.as_ref(),
319 input => input,
320 };
321 let VectorPhysicalPlan::VectorCandidateScan { input, .. } = input else {
322 return Err(VectorPlanError::MissingCandidateScan);
323 };
324 if !matches!(input.as_ref(), VectorPhysicalPlan::Filter { .. }) {
325 return Err(VectorPlanError::MissingFilter);
326 }
327 Ok(())
328}
329
330#[cfg(test)]
331mod tests {
332 use super::*;
333 use crate::{QueryFamily, ResourceHints};
334 use hawdb_plan_core::VectorCandidateSource;
335
336 #[test]
337 fn vector_pipeline_enforces_filter_candidate_raw_rerank_top_k() {
338 let logical = VectorSearchLogicalPlan {
339 embedding_dimension: 384,
340 filter_fields: vec!["space_id".to_string(), "unit_type".to_string()],
341 residual_filter_fields: Vec::new(),
342 initial_candidate_limit: 64,
343 candidate_source: VectorCandidateSource::Quantized,
344 candidate_limit: 64,
345 top_k: 10,
346 };
347 let context = OptimizerContext::default()
348 .with_query_family(QueryFamily::VectorSearch)
349 .with_resource_hints(ResourceHints {
350 priority: 128,
351 max_memory_bytes: Some(8 * 1024 * 1024),
352 max_parallelism: 2,
353 });
354
355 let planned = plan_vector_search(&logical, &context).unwrap();
356
357 assert_eq!(
358 planned.plan.operator_pipeline(),
359 vec!["Filter", "VectorCandidateScan", "RawVectorRerank", "TopK"]
360 );
361 assert_eq!(planned.properties.precision, VectorPrecision::RawReranked);
362 assert_eq!(planned.properties.priority, 128);
363 assert_eq!(planned.properties.max_parallelism, 2);
364 assert_eq!(planned.properties.max_memory_bytes, Some(8 * 1024 * 1024));
365 }
366
367 #[test]
368 fn vector_pipeline_rejects_final_candidate_scores_without_raw_rerank() {
369 let invalid = VectorPhysicalPlan::TopK {
370 limit: 10,
371 input: Box::new(VectorPhysicalPlan::VectorCandidateScan {
372 source: VectorCandidateSource::Ann,
373 embedding_dimension: 384,
374 candidate_limit: 40,
375 input: Box::new(VectorPhysicalPlan::Filter { fields: Vec::new() }),
376 }),
377 };
378
379 assert_eq!(
380 validate_vector_pipeline(&invalid),
381 Err(VectorPlanError::MissingRawRerank)
382 );
383 }
384
385 #[test]
386 fn vector_pipeline_places_residual_filter_before_raw_rerank() {
387 let logical = VectorSearchLogicalPlan {
388 embedding_dimension: 384,
389 filter_fields: vec!["space_id".to_string()],
390 residual_filter_fields: vec!["complex_visibility".to_string()],
391 initial_candidate_limit: 10,
392 candidate_source: VectorCandidateSource::Ann,
393 candidate_limit: 80,
394 top_k: 10,
395 };
396
397 let planned = plan_vector_search(&logical, &OptimizerContext::default()).unwrap();
398
399 assert_eq!(
400 planned.plan.operator_pipeline(),
401 vec![
402 "Filter",
403 "VectorCandidateScan",
404 "ResidualFilter",
405 "RawVectorRerank",
406 "TopK"
407 ]
408 );
409 assert_eq!(validate_vector_pipeline(&planned.plan), Ok(()));
410 }
411
412 fn adaptive_input(
413 compression_preference: VectorCompressionPreference,
414 ) -> AdaptiveVectorBackendInput {
415 AdaptiveVectorBackendInput {
416 compression_preference,
417 document_count: 100_000,
418 filtered_document_count: 100_000,
419 embedding_dimension: 768,
420 recall_validation_probe: false,
421 quantized_projection_available: true,
422 quantized_projection_covered_document_count: 100_000,
423 }
424 }
425
426 #[test]
427 fn adaptive_vector_backend_prefers_scalar_for_small_filtered_sets() {
428 let decision = select_adaptive_vector_backend(
429 AdaptiveVectorBackendInput {
430 filtered_document_count: 512,
431 quantized_projection_covered_document_count: 512,
432 ..adaptive_input(VectorCompressionPreference::Preferred)
433 },
434 AdaptiveVectorBackendPolicy::default(),
435 );
436
437 assert_eq!(decision.backend, AdaptiveVectorBackend::ScalarFlat);
438 assert_eq!(
439 decision.reason,
440 VectorBackendSelectionReason::SmallFilteredCandidateSet
441 );
442 }
443
444 #[test]
445 fn adaptive_vector_backend_prefers_scalar_after_selective_filtering() {
446 let decision = select_adaptive_vector_backend(
447 AdaptiveVectorBackendInput {
448 filtered_document_count: 10_000,
449 quantized_projection_covered_document_count: 10_000,
450 ..adaptive_input(VectorCompressionPreference::Preferred)
451 },
452 AdaptiveVectorBackendPolicy {
453 flat_scan_max_documents: 1_000,
454 flat_scan_memory_budget_bytes: 1,
455 ..AdaptiveVectorBackendPolicy::default()
456 },
457 );
458
459 assert_eq!(decision.backend, AdaptiveVectorBackend::ScalarFlat);
460 assert_eq!(
461 decision.reason,
462 VectorBackendSelectionReason::HighFilterSelectivity
463 );
464 assert_eq!(decision.filter_selectivity_per_million, 900_000);
465 }
466
467 #[test]
468 fn adaptive_vector_backend_uses_quantized_projection_over_memory_budget() {
469 let decision = select_adaptive_vector_backend(
470 adaptive_input(VectorCompressionPreference::Preferred),
471 AdaptiveVectorBackendPolicy::default(),
472 );
473
474 assert_eq!(decision.backend, AdaptiveVectorBackend::QuantizedProjection);
475 assert_eq!(
476 decision.reason,
477 VectorBackendSelectionReason::QuantizedPreferred
478 );
479 assert!(decision.estimated_raw_vector_bytes > 16 * 1024 * 1024);
480 }
481
482 #[test]
483 fn adaptive_vector_backend_keeps_recall_validation_exact() {
484 let decision = select_adaptive_vector_backend(
485 AdaptiveVectorBackendInput {
486 recall_validation_probe: true,
487 ..adaptive_input(VectorCompressionPreference::Required)
488 },
489 AdaptiveVectorBackendPolicy::default(),
490 );
491
492 assert_eq!(decision.backend, AdaptiveVectorBackend::ScalarFlat);
493 assert_eq!(
494 decision.reason,
495 VectorBackendSelectionReason::RecallValidationProbe
496 );
497 }
498
499 #[test]
500 fn adaptive_vector_backend_fails_closed_for_required_incomplete_projection() {
501 let decision = select_adaptive_vector_backend(
502 AdaptiveVectorBackendInput {
503 quantized_projection_covered_document_count: 99_999,
504 ..adaptive_input(VectorCompressionPreference::Required)
505 },
506 AdaptiveVectorBackendPolicy::default(),
507 );
508
509 assert_eq!(
510 decision.backend,
511 AdaptiveVectorBackend::RequiredProjectionUnavailable
512 );
513 assert_eq!(
514 decision.reason,
515 VectorBackendSelectionReason::QuantizedProjectionCoverageIncomplete
516 );
517 assert!(!decision.quantized_projection_coverage_complete);
518 }
519}