Skip to main content

vecdb_core/planner/
executor.rs

1use std::collections::HashSet;
2
3use crate::errors::{Result, VecDbError};
4use crate::hybrid::FusionStrategy;
5use crate::storage::Storage;
6use crate::types::{SearchResult, Vector};
7
8use super::plan::{LogicalNode, PhysicalPlan};
9
10// ─────────────────────────────────────────────────────────────────
11// PlanExecutor
12// ─────────────────────────────────────────────────────────────────
13
14pub struct PlanExecutor<'a> {
15    storage: &'a Storage,
16}
17
18impl<'a> PlanExecutor<'a> {
19    pub fn new(storage: &'a Storage) -> Self {
20        Self { storage }
21    }
22
23    /// Execute a physical plan against the storage layer.
24    ///
25    /// Steps:
26    /// 0. Pre-filter: if `plan.pre_filter`, fetch matching ids from metadata.
27    /// 1. Run the appropriate scan (hybrid / dense / sparse).
28    /// 2. Apply allowed-id filter (pre-filter narrowing) and full predicate filter.
29    /// 3. Re-sort by score descending.
30    /// 4. Limit to `plan.output_k`.
31    /// 5. Apply column projection.
32    pub fn execute(
33        &self,
34        plan: &PhysicalPlan,
35        query_vector: Option<&Vector>,
36        query_text: Option<&str>,
37    ) -> Result<Vec<SearchResult>> {
38        // ── Stage 0: Pre-filter (allowed id set) ─────────────────
39        let allowed_ids: Option<HashSet<String>> = if plan.pre_filter {
40            plan.filter_predicate.as_deref().and_then(|pred| {
41                match serde_json::from_str::<serde_json::Value>(pred) {
42                    Ok(fv) => match self
43                        .storage
44                        .metadata
45                        .filter_ids(&fv, &self.storage.config.indexed_payload_fields)
46                    {
47                        Ok(ids) => Some(ids.into_iter().collect()),
48                        Err(e) => {
49                            tracing::warn!("pre-filter failed ({e}), falling back to post-filter");
50                            None
51                        }
52                    },
53                    Err(e) => {
54                        tracing::warn!("pre-filter: could not parse predicate ({e}), skipping");
55                        None
56                    }
57                }
58            })
59        } else {
60            None
61        };
62
63        // ── Stage 1: Scan ─────────────────────────────────────────
64        let mut results: Vec<SearchResult> = if plan.use_hybrid
65            || matches!(
66                find_scan_node(&plan.root),
67                Some(LogicalNode::HybridScan { .. })
68            ) {
69            self.storage.search_hybrid(
70                query_vector,
71                query_text,
72                plan.candidate_k,
73                extract_alpha(plan),
74                extract_strategy(plan),
75            )?
76        } else {
77            let scan = find_scan_node(&plan.root);
78            let is_sparse = matches!(scan, Some(LogicalNode::SparseScan { .. }));
79
80            if is_sparse {
81                let qt = query_text.ok_or_else(|| {
82                    VecDbError::InvalidQuery("sparse search requires query_text".into())
83                })?;
84                self.storage.search_sparse(qt, plan.candidate_k)?
85            } else {
86                let qv = query_vector.ok_or_else(|| {
87                    VecDbError::InvalidQuery("dense search requires query_vector".into())
88                })?;
89                // Predicate pushdown: for a filtered dense scan, generate
90                // candidates from the filter (Storage picks brute-force over the
91                // matching set vs ANN+post-filter by selectivity) instead of
92                // ANN-then-discard. Falls back to a plain dense scan otherwise.
93                match plan.filter_predicate.as_deref() {
94                    Some(pred) if plan.pre_filter => {
95                        match serde_json::from_str::<serde_json::Value>(pred) {
96                            Ok(fv) => {
97                                self.storage
98                                    .search_dense_filtered(qv, plan.candidate_k, &fv)?
99                            }
100                            Err(_) => self.storage.search_dense(qv, plan.candidate_k)?,
101                        }
102                    }
103                    _ => self.storage.search_dense(qv, plan.candidate_k)?,
104                }
105            }
106        };
107
108        // ── Stage 2a: Narrow to pre-filtered allowed ids ──────────
109        if let Some(ref ids) = allowed_ids {
110            results.retain(|r| ids.contains(&r.id));
111        }
112
113        // ── Stage 2b: Post-scan predicate filter ──────────────────
114        if let Some(ref pred) = plan.filter_predicate {
115            match serde_json::from_str::<serde_json::Value>(pred) {
116                Ok(fv) => results.retain(|r| apply_json_filter(r, &fv)),
117                Err(e) => {
118                    tracing::warn!(
119                        "could not parse filter predicate as JSON ({e}), skipping filter"
120                    );
121                }
122            }
123        }
124
125        // ── Stage 3: Sort ─────────────────────────────────────────
126        results.sort_unstable_by(|a, b| {
127            b.score
128                .partial_cmp(&a.score)
129                .unwrap_or(std::cmp::Ordering::Equal)
130        });
131
132        // ── Stage 4: Limit ────────────────────────────────────────
133        results.truncate(plan.output_k);
134
135        // ── Stage 5: Projection ───────────────────────────────────
136        results = apply_projection(results, &plan.output_columns);
137
138        Ok(results)
139    }
140}
141
142// ─────────────────────────────────────────────────────────────────
143// Private helpers — scan-tree navigation
144// ─────────────────────────────────────────────────────────────────
145
146fn find_scan_node(node: &LogicalNode) -> Option<&LogicalNode> {
147    match node {
148        LogicalNode::VectorScan { .. }
149        | LogicalNode::SparseScan { .. }
150        | LogicalNode::HybridScan { .. } => Some(node),
151        LogicalNode::Filter { input, .. }
152        | LogicalNode::Sort { input, .. }
153        | LogicalNode::Limit { input, .. }
154        | LogicalNode::Project { input, .. } => find_scan_node(input),
155        LogicalNode::Join { left, .. } => find_scan_node(left),
156    }
157}
158
159fn extract_alpha(plan: &PhysicalPlan) -> f32 {
160    fn from_node(node: &LogicalNode) -> Option<f32> {
161        match node {
162            LogicalNode::HybridScan { alpha, .. } | LogicalNode::VectorScan { alpha, .. } => {
163                Some(*alpha)
164            }
165            LogicalNode::Filter { input, .. }
166            | LogicalNode::Sort { input, .. }
167            | LogicalNode::Limit { input, .. }
168            | LogicalNode::Project { input, .. } => from_node(input),
169            LogicalNode::Join { left, .. } => from_node(left),
170            LogicalNode::SparseScan { .. } => None,
171        }
172    }
173    from_node(&plan.root).unwrap_or(0.7)
174}
175
176fn extract_strategy(plan: &PhysicalPlan) -> FusionStrategy {
177    fn from_node(node: &LogicalNode) -> Option<FusionStrategy> {
178        match node {
179            LogicalNode::HybridScan { strategy, .. } | LogicalNode::VectorScan { strategy, .. } => {
180                Some(strategy.clone())
181            }
182            LogicalNode::Filter { input, .. }
183            | LogicalNode::Sort { input, .. }
184            | LogicalNode::Limit { input, .. }
185            | LogicalNode::Project { input, .. } => from_node(input),
186            LogicalNode::Join { left, .. } => from_node(left),
187            LogicalNode::SparseScan { .. } => None,
188        }
189    }
190    from_node(&plan.root).unwrap_or_default()
191}
192
193// ─────────────────────────────────────────────────────────────────
194// Filter evaluation
195// ─────────────────────────────────────────────────────────────────
196
197/// Evaluate `filter` against a single `SearchResult`.
198///
199/// Supported filter shapes:
200/// - **Structured condition**: `{"field":"f","op":"=","value":v}` — single predicate.
201/// - **Array of conditions**: `[cond, ...]` — AND of all conditions.
202/// - **Legacy flat map**: `{"key": value}` — equality for every key (HTTP API compat).
203pub(crate) fn apply_json_filter(record: &SearchResult, filter: &serde_json::Value) -> bool {
204    apply_filter_with(filter, &|field| resolve_field(record, field))
205}
206
207/// Evaluate a JSON filter against an arbitrary field resolver. This is the one
208/// place the filter grammar lives; both the post-scan record path and the
209/// metadata-store pushdown path share it, so their semantics can never drift.
210///
211/// `resolve(field)` returns the field's value (`None` if absent), where `field`
212/// is `"id"` or a dotted payload path.
213pub(crate) fn apply_filter_with<R>(filter: &serde_json::Value, resolve: &R) -> bool
214where
215    R: Fn(&str) -> Option<serde_json::Value>,
216{
217    match filter {
218        serde_json::Value::Array(conditions) => {
219            conditions.iter().all(|c| apply_filter_with(c, resolve))
220        }
221        serde_json::Value::Object(obj) => {
222            if obj.contains_key("field") && obj.contains_key("op") && obj.contains_key("value") {
223                let (Some(field), Some(op), Some(filter_val)) = (
224                    obj.get("field").and_then(|v| v.as_str()),
225                    obj.get("op").and_then(|v| v.as_str()),
226                    obj.get("value"),
227                ) else {
228                    return false;
229                };
230                resolve(field)
231                    .map(|rv| eval_op(&rv, op, filter_val))
232                    .unwrap_or(false)
233            } else {
234                // Legacy flat-map equality (HTTP API SearchRequest.filter).
235                obj.iter()
236                    .all(|(k, v)| resolve(k).map(|rv| eq_values(&rv, v)).unwrap_or(false))
237            }
238        }
239        _ => false,
240    }
241}
242
243/// Collect every field path a filter references (for pushing extraction into
244/// the metadata store). Returns `None` if the filter shape is unsupported.
245pub(crate) fn collect_filter_fields(
246    filter: &serde_json::Value,
247    out: &mut std::collections::BTreeSet<String>,
248) -> bool {
249    match filter {
250        serde_json::Value::Array(cs) => cs.iter().all(|c| collect_filter_fields(c, out)),
251        serde_json::Value::Object(obj) => {
252            if obj.contains_key("field") && obj.contains_key("op") && obj.contains_key("value") {
253                match obj.get("field").and_then(|v| v.as_str()) {
254                    Some(f) => {
255                        out.insert(f.to_string());
256                        true
257                    }
258                    None => false,
259                }
260            } else {
261                for k in obj.keys() {
262                    out.insert(k.to_string());
263                }
264                true
265            }
266        }
267        _ => false,
268    }
269}
270
271/// Resolve a field path from a `SearchResult`.
272///
273/// - `"id"` → `record.id`
274/// - `"a.b.c"` → navigate `record.payload` via nested object keys.
275fn resolve_field(record: &SearchResult, field: &str) -> Option<serde_json::Value> {
276    if field == "id" {
277        return Some(serde_json::Value::String(record.id.clone()));
278    }
279    let mut current = &record.payload;
280    for part in field.split('.') {
281        current = current.get(part)?;
282    }
283    Some(current.clone())
284}
285
286fn eval_op(rv: &serde_json::Value, op: &str, fv: &serde_json::Value) -> bool {
287    match op {
288        "=" => eq_values(rv, fv),
289        "!=" => !eq_values(rv, fv),
290        "<" | "<=" | ">" | ">=" => {
291            let Some(r) = to_f64(rv) else {
292                return false;
293            };
294            let Some(f) = to_f64(fv) else {
295                return false;
296            };
297            match op {
298                "<" => r < f,
299                "<=" => r <= f,
300                ">" => r > f,
301                _ => r >= f,
302            }
303        }
304        "LIKE" => {
305            let (Some(s), Some(p)) = (rv.as_str(), fv.as_str()) else {
306                return false;
307            };
308            like_match(s.as_bytes(), p.as_bytes())
309        }
310        _ => false,
311    }
312}
313
314fn eq_values(a: &serde_json::Value, b: &serde_json::Value) -> bool {
315    match (a, b) {
316        (serde_json::Value::String(s1), serde_json::Value::String(s2)) => s1 == s2,
317        (serde_json::Value::Bool(b1), serde_json::Value::Bool(b2)) => b1 == b2,
318        _ => match (to_f64(a), to_f64(b)) {
319            (Some(fa), Some(fb)) => fa == fb,
320            _ => a == b,
321        },
322    }
323}
324
325fn to_f64(v: &serde_json::Value) -> Option<f64> {
326    match v {
327        serde_json::Value::Number(n) => n.as_f64(),
328        serde_json::Value::String(s) => s.parse::<f64>().ok(),
329        _ => None,
330    }
331}
332
333/// `%` wildcard matcher (case-sensitive, no regex).
334fn like_match(text: &[u8], pattern: &[u8]) -> bool {
335    match pattern.first() {
336        None => text.is_empty(),
337        Some(b'%') => (0..=text.len()).any(|i| like_match(&text[i..], &pattern[1..])),
338        Some(&pc) => text
339            .first()
340            .map(|&tc| tc == pc && like_match(&text[1..], &pattern[1..]))
341            .unwrap_or(false),
342    }
343}
344
345// ─────────────────────────────────────────────────────────────────
346// Projection
347// ─────────────────────────────────────────────────────────────────
348
349/// Keep only the columns listed in `columns` from each result's payload.
350///
351/// - Empty `columns` or `["*"]` → return results unchanged (SELECT *).
352/// - `"id"`, `"score"`, `"similarity"` → top-level fields always kept.
353/// - `"text"` → kept only when listed; otherwise set to `None`.
354/// - Any other name → kept from `payload` if present.
355fn apply_projection(results: Vec<SearchResult>, columns: &[String]) -> Vec<SearchResult> {
356    if columns.is_empty() || columns.iter().any(|c| c == "*") {
357        return results;
358    }
359    let keep_text = columns.iter().any(|c| c == "text");
360    results
361        .into_iter()
362        .map(|mut r| {
363            if let serde_json::Value::Object(ref orig) = r.payload.clone() {
364                let mut new_payload = serde_json::Map::new();
365                for col in columns {
366                    match col.as_str() {
367                        "id" | "score" | "similarity" | "text" => {}
368                        key => {
369                            if let Some(v) = orig.get(key) {
370                                new_payload.insert(key.to_string(), v.clone());
371                            }
372                        }
373                    }
374                }
375                r.payload = serde_json::Value::Object(new_payload);
376            }
377            if !keep_text {
378                r.text = None;
379            }
380            r
381        })
382        .collect()
383}