Skip to main content

hawdb_optimizer_vector/
vector.rs

1// Copyright 2026 Nowledge
2//
3// Licensed under the Apache License, Version 2.0 (the "License");
4// you may not use this file except in compliance with the License.
5// You may obtain a copy of the License at
6//
7//     http://www.apache.org/licenses/LICENSE-2.0
8//
9// Unless required by applicable law or agreed to in writing, software
10// distributed under the License is distributed on an "AS IS" BASIS,
11// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12// See the License for the specific language governing permissions and
13// limitations under the License.
14
15use 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}