1use hawdb_plan_cypher::{VectorBackendSelectionReason, VectorCandidateSource, VectorPhysicalPlan};
16use std::cmp::Ordering;
17use std::collections::BTreeSet;
18use std::fmt::{Display, Formatter};
19
20#[derive(Debug, Clone, Copy, PartialEq, Eq)]
21pub enum VectorScoreSource {
22 Unavailable,
23 RawVector,
24 AnnApproximate,
25 QuantizedApproximate,
26}
27
28impl VectorScoreSource {
29 pub const fn as_str(self) -> &'static str {
30 match self {
31 Self::Unavailable => "unavailable",
32 Self::RawVector => "raw_vector",
33 Self::AnnApproximate => "ann_approximate",
34 Self::QuantizedApproximate => "quantized_approximate",
35 }
36 }
37}
38
39#[derive(Debug, Clone, Copy, PartialEq, Eq)]
40pub enum VectorExecutionBackend {
41 ScalarFlat,
42 AnnProjection,
43 QuantizedProjection,
44 Unavailable,
45}
46
47impl VectorExecutionBackend {
48 pub const fn as_str(self) -> &'static str {
49 match self {
50 Self::ScalarFlat => "scalar_flat",
51 Self::AnnProjection => "ann_projection",
52 Self::QuantizedProjection => "quantized_projection",
53 Self::Unavailable => "unavailable",
54 }
55 }
56
57 const fn from_candidate_source(source: VectorCandidateSource) -> Self {
58 match source {
59 VectorCandidateSource::Scalar => Self::ScalarFlat,
60 VectorCandidateSource::Ann => Self::AnnProjection,
61 VectorCandidateSource::Quantized => Self::QuantizedProjection,
62 }
63 }
64}
65
66#[derive(Debug, Clone, Copy, PartialEq, Eq)]
67pub enum VectorCompressionMode {
68 Unspecified,
69 Disabled,
70 Preferred,
71 Required,
72}
73
74impl VectorCompressionMode {
75 pub const fn as_str(self) -> &'static str {
76 match self {
77 Self::Unspecified => "unspecified",
78 Self::Disabled => "disabled",
79 Self::Preferred => "preferred",
80 Self::Required => "required",
81 }
82 }
83}
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq)]
86pub enum VectorFallbackReasonCode {
87 VectorDimensionMismatch,
88 VectorIndexEmpty,
89 CompressedVectorProjectionUnavailable,
90 CompressedVectorBudgetExceeded,
91 DiskAnnCacheBudgetExceeded,
92 QueryEmbeddingMissing,
93}
94
95impl VectorFallbackReasonCode {
96 pub const fn as_str(self) -> &'static str {
97 match self {
98 Self::VectorDimensionMismatch => "vector_dimension_mismatch",
99 Self::VectorIndexEmpty => "vector_index_empty",
100 Self::CompressedVectorProjectionUnavailable => {
101 "compressed_vector_projection_unavailable"
102 }
103 Self::CompressedVectorBudgetExceeded => "compressed_vector_budget_exceeded",
104 Self::DiskAnnCacheBudgetExceeded => "diskann_cache_budget_exceeded",
105 Self::QueryEmbeddingMissing => "query_embedding_missing",
106 }
107 }
108}
109
110#[derive(Debug, Clone, PartialEq)]
111pub struct VectorCandidate {
112 pub id: String,
113 pub score: f64,
114}
115
116#[derive(Debug, Clone, PartialEq)]
117pub struct VectorCandidateBatch {
118 pub score_source: VectorScoreSource,
119 pub candidates: Vec<VectorCandidate>,
120}
121
122#[derive(Debug, Clone, PartialEq)]
123pub struct VectorRawScore {
124 pub id: String,
125 pub score: f64,
126}
127
128#[derive(Debug, Clone, Copy)]
129pub struct VectorCandidateScanRequest<'a> {
130 pub source: VectorCandidateSource,
131 pub embedding_dimension: usize,
132 pub candidate_limit: usize,
133 pub filter_fields: &'a [String],
134}
135
136#[derive(Debug, Clone, Copy)]
137pub struct VectorRawRerankRequest<'a> {
138 pub embedding_dimension: usize,
139 pub candidates: &'a [VectorCandidate],
140}
141
142#[derive(Debug)]
143pub struct VectorResidualFilterRequest<'a> {
144 pub fields: &'a [String],
145 pub candidates: Vec<VectorCandidate>,
146}
147
148pub trait VectorExecutionSource {
149 type Error;
150
151 fn scan_candidates(
152 &mut self,
153 request: VectorCandidateScanRequest<'_>,
154 ) -> Result<VectorCandidateBatch, Self::Error>;
155
156 fn filter_residual(
157 &mut self,
158 request: VectorResidualFilterRequest<'_>,
159 ) -> Result<Vec<VectorCandidate>, Self::Error>;
160
161 fn rerank_raw(
162 &mut self,
163 request: VectorRawRerankRequest<'_>,
164 ) -> Result<Vec<VectorRawScore>, Self::Error>;
165
166 fn raw_vector_bytes_read(&self) -> u64 {
167 0
168 }
169
170 fn candidate_scan_metrics(&self) -> Option<VectorCandidateScanMetrics> {
171 None
172 }
173
174 fn capture_candidate_ids(&self) -> bool {
175 false
176 }
177}
178
179#[derive(Debug, Clone, PartialEq, Eq)]
180pub struct VectorCandidateScanMetrics {
181 pub kernel: String,
182 pub worker_count: usize,
183 pub segment_count: usize,
184 pub scanned_segment_count: usize,
185 pub scored_document_count: usize,
186 pub filtered_document_count: usize,
187 pub scanned_block_count: usize,
188 pub skipped_block_count: usize,
189 pub adaptive_exact_scan_count: usize,
190 pub payload_bytes_read: u64,
191 pub admitted_working_bytes: usize,
192}
193
194#[derive(Debug, Clone, PartialEq, Eq)]
195pub struct VectorExecutionReport {
196 pub backend: VectorExecutionBackend,
197 pub compression_mode: VectorCompressionMode,
198 pub candidate_source: VectorCandidateSource,
199 pub backend_selection_reason: Option<VectorBackendSelectionReason>,
200 pub estimated_raw_vector_bytes: Option<u64>,
201 pub filter_selectivity_per_million: Option<u32>,
202 pub candidate_score_source: VectorScoreSource,
203 pub final_score_source: VectorScoreSource,
204 pub generated_candidate_count: usize,
205 pub descriptor_pruned_count: usize,
206 pub scalar_filtered_count: usize,
207 pub residual_filtered_count: usize,
208 pub candidate_scan_rounds: usize,
209 pub reranked_candidate_count: usize,
210 pub returned_count: usize,
211 pub raw_vector_bytes_read: u64,
212 pub candidate_scan_metrics: Option<VectorCandidateScanMetrics>,
213 pub index_covered_document_count: Option<usize>,
214 pub index_candidate_document_count: Option<usize>,
215 pub index_coverage_complete: Option<bool>,
216 pub fallback_reason_codes: Vec<VectorFallbackReasonCode>,
217}
218
219#[derive(Debug, Clone, PartialEq)]
220pub struct VectorExecutionOutput {
221 pub scores: Vec<VectorRawScore>,
222 pub candidate_ids: Vec<String>,
223 pub report: VectorExecutionReport,
224}
225
226#[derive(Debug, Clone, PartialEq, Eq)]
227pub enum VectorExecutionError<E> {
228 InvalidPlan(&'static str),
229 Source(E),
230 DuplicateCandidate,
231 DuplicateRawScore,
232 NonFiniteCandidateScore,
233 NonFiniteRawScore,
234 ResidualFilterAddedCandidate,
235 RawScoreOutsideCandidateSet,
236}
237
238impl<E: Display> Display for VectorExecutionError<E> {
239 fn fmt(&self, formatter: &mut Formatter<'_>) -> std::fmt::Result {
240 match self {
241 Self::InvalidPlan(reason) => write!(formatter, "invalid vector plan: {reason}"),
242 Self::Source(error) => write!(formatter, "vector execution source failed: {error}"),
243 Self::DuplicateCandidate => formatter.write_str("vector candidate ids must be unique"),
244 Self::DuplicateRawScore => formatter.write_str("raw vector score ids must be unique"),
245 Self::NonFiniteCandidateScore => {
246 formatter.write_str("vector candidate score must be finite")
247 }
248 Self::NonFiniteRawScore => formatter.write_str("raw vector score must be finite"),
249 Self::ResidualFilterAddedCandidate => {
250 formatter.write_str("residual vector filter may only retain generated candidates")
251 }
252 Self::RawScoreOutsideCandidateSet => {
253 formatter.write_str("raw vector score must reference a generated candidate")
254 }
255 }
256 }
257}
258
259impl<E: std::error::Error + 'static> std::error::Error for VectorExecutionError<E> {}
260
261pub fn execute_vector_plan<S: VectorExecutionSource>(
262 plan: &VectorPhysicalPlan,
263 source: &mut S,
264) -> Result<VectorExecutionOutput, VectorExecutionError<S::Error>> {
265 let VectorPhysicalPlan::TopK {
266 limit: top_k,
267 input,
268 } = plan
269 else {
270 return Err(VectorExecutionError::InvalidPlan("missing TopK root"));
271 };
272 let VectorPhysicalPlan::RawVectorRerank {
273 embedding_dimension: rerank_dimension,
274 input,
275 } = input.as_ref()
276 else {
277 return Err(VectorExecutionError::InvalidPlan(
278 "TopK must consume RawVectorRerank",
279 ));
280 };
281 let (residual_filter, input) = match input.as_ref() {
282 VectorPhysicalPlan::ResidualFilter {
283 fields,
284 initial_candidate_limit,
285 input,
286 } => (
287 Some((fields.as_slice(), *initial_candidate_limit)),
288 input.as_ref(),
289 ),
290 input => (None, input),
291 };
292 let VectorPhysicalPlan::VectorCandidateScan {
293 source: candidate_source,
294 embedding_dimension: candidate_dimension,
295 candidate_limit,
296 input,
297 } = input
298 else {
299 return Err(VectorExecutionError::InvalidPlan(
300 "RawVectorRerank must consume VectorCandidateScan",
301 ));
302 };
303 let VectorPhysicalPlan::Filter { fields } = input.as_ref() else {
304 return Err(VectorExecutionError::InvalidPlan(
305 "VectorCandidateScan must consume Filter",
306 ));
307 };
308 if candidate_dimension != rerank_dimension {
309 return Err(VectorExecutionError::InvalidPlan(
310 "candidate and rerank dimensions differ",
311 ));
312 }
313 if *top_k == 0 || *candidate_limit < *top_k {
314 return Err(VectorExecutionError::InvalidPlan(
315 "candidate and top-k limits are inconsistent",
316 ));
317 }
318 if let Some((_, initial_limit)) = residual_filter
319 && (initial_limit < *top_k || initial_limit > *candidate_limit)
320 {
321 return Err(VectorExecutionError::InvalidPlan(
322 "residual candidate window is outside the top-k candidate budget",
323 ));
324 }
325
326 let mut scan_limit = residual_filter
327 .map(|(_, initial_limit)| initial_limit)
328 .unwrap_or(*candidate_limit);
329 let mut candidate_scan_rounds = 0;
330 let (batch, generated_candidate_count) = loop {
331 candidate_scan_rounds += 1;
332 let mut batch = source
333 .scan_candidates(VectorCandidateScanRequest {
334 source: *candidate_source,
335 embedding_dimension: *candidate_dimension,
336 candidate_limit: scan_limit,
337 filter_fields: fields,
338 })
339 .map_err(VectorExecutionError::Source)?;
340 validate_candidates(&batch.candidates)?;
341 sort_candidates(&mut batch.candidates);
342 batch.candidates.truncate(scan_limit);
343 let generated_candidate_count = batch.candidates.len();
344 if let Some((residual_fields, _)) = residual_filter {
345 let generated_ids = batch
346 .candidates
347 .iter()
348 .map(|candidate| candidate.id.clone())
349 .collect::<BTreeSet<_>>();
350 batch.candidates = source
351 .filter_residual(VectorResidualFilterRequest {
352 fields: residual_fields,
353 candidates: batch.candidates,
354 })
355 .map_err(VectorExecutionError::Source)?;
356 validate_candidates(&batch.candidates)?;
357 if batch
358 .candidates
359 .iter()
360 .any(|candidate| !generated_ids.contains(&candidate.id))
361 {
362 return Err(VectorExecutionError::ResidualFilterAddedCandidate);
363 }
364 }
365 if residual_filter.is_none()
366 || batch.candidates.len() >= *top_k
367 || scan_limit >= *candidate_limit
368 || generated_candidate_count < scan_limit
369 {
370 break (batch, generated_candidate_count);
371 }
372 scan_limit = scan_limit.saturating_mul(2).min(*candidate_limit);
373 };
374 let residual_filtered_count = generated_candidate_count.saturating_sub(batch.candidates.len());
375 let candidate_ids = if source.capture_candidate_ids() {
376 batch
377 .candidates
378 .iter()
379 .map(|candidate| candidate.id.clone())
380 .collect()
381 } else {
382 Vec::new()
383 };
384
385 let mut raw_scores = source
386 .rerank_raw(VectorRawRerankRequest {
387 embedding_dimension: *rerank_dimension,
388 candidates: &batch.candidates,
389 })
390 .map_err(VectorExecutionError::Source)?;
391 validate_raw_scores(&batch.candidates, &raw_scores)?;
392 sort_raw_scores(&mut raw_scores);
393 let reranked_candidate_count = raw_scores.len();
394 raw_scores.truncate(*top_k);
395
396 let final_score_source = if batch.score_source == VectorScoreSource::Unavailable {
397 VectorScoreSource::Unavailable
398 } else {
399 VectorScoreSource::RawVector
400 };
401 Ok(VectorExecutionOutput {
402 candidate_ids,
403 report: VectorExecutionReport {
404 backend: VectorExecutionBackend::from_candidate_source(*candidate_source),
405 compression_mode: VectorCompressionMode::Unspecified,
406 candidate_source: *candidate_source,
407 backend_selection_reason: None,
408 estimated_raw_vector_bytes: None,
409 filter_selectivity_per_million: None,
410 candidate_score_source: batch.score_source,
411 final_score_source,
412 generated_candidate_count,
413 descriptor_pruned_count: 0,
414 scalar_filtered_count: residual_filtered_count,
415 residual_filtered_count,
416 candidate_scan_rounds,
417 reranked_candidate_count,
418 returned_count: raw_scores.len(),
419 raw_vector_bytes_read: source.raw_vector_bytes_read(),
420 candidate_scan_metrics: source.candidate_scan_metrics(),
421 index_covered_document_count: None,
422 index_candidate_document_count: None,
423 index_coverage_complete: None,
424 fallback_reason_codes: Vec::new(),
425 },
426 scores: raw_scores,
427 })
428}
429
430fn validate_candidates<E>(candidates: &[VectorCandidate]) -> Result<(), VectorExecutionError<E>> {
431 let mut ids = BTreeSet::new();
432 for candidate in candidates {
433 if !candidate.score.is_finite() {
434 return Err(VectorExecutionError::NonFiniteCandidateScore);
435 }
436 if !ids.insert(candidate.id.as_str()) {
437 return Err(VectorExecutionError::DuplicateCandidate);
438 }
439 }
440 Ok(())
441}
442
443fn validate_raw_scores<E>(
444 candidates: &[VectorCandidate],
445 raw_scores: &[VectorRawScore],
446) -> Result<(), VectorExecutionError<E>> {
447 let candidate_ids = candidates
448 .iter()
449 .map(|candidate| candidate.id.as_str())
450 .collect::<BTreeSet<_>>();
451 let mut score_ids = BTreeSet::new();
452 for score in raw_scores {
453 if !score.score.is_finite() {
454 return Err(VectorExecutionError::NonFiniteRawScore);
455 }
456 if !candidate_ids.contains(score.id.as_str()) {
457 return Err(VectorExecutionError::RawScoreOutsideCandidateSet);
458 }
459 if !score_ids.insert(score.id.as_str()) {
460 return Err(VectorExecutionError::DuplicateRawScore);
461 }
462 }
463 Ok(())
464}
465
466fn sort_candidates(candidates: &mut [VectorCandidate]) {
467 candidates.sort_by(|left, right| {
468 descending_score_order(left.score, &left.id, right.score, &right.id)
469 });
470}
471
472fn sort_raw_scores(scores: &mut [VectorRawScore]) {
473 scores.sort_by(|left, right| {
474 descending_score_order(left.score, &left.id, right.score, &right.id)
475 });
476}
477
478fn descending_score_order(
479 left_score: f64,
480 left_id: &str,
481 right_score: f64,
482 right_id: &str,
483) -> Ordering {
484 right_score
485 .partial_cmp(&left_score)
486 .unwrap_or(Ordering::Equal)
487 .then_with(|| left_id.cmp(right_id))
488}
489
490#[cfg(test)]
491mod tests;