Skip to main content

ailake_query/
pruner.rs

1// SPDX-License-Identifier: MIT OR Apache-2.0
2use std::collections::HashMap;
3
4use ailake_catalog::{decode_centroid, DataFileEntry};
5use ailake_core::VectorMetric;
6use ailake_vec::{cosine_distance, dot_product, euclidean_distance};
7use tracing::debug;
8
9pub struct VectorPruner;
10
11impl VectorPruner {
12    /// Remove files whose centroid is geometrically guaranteed to contain no vectors
13    /// within `threshold` distance of `query`.
14    ///
15    /// Pruning condition: `distance(query, centroid) - radius > threshold`
16    /// Files without centroid metadata are kept (conservative fallback).
17    pub fn prune(
18        files: Vec<DataFileEntry>,
19        query: &[f32],
20        metric: VectorMetric,
21        threshold: f32,
22    ) -> Vec<DataFileEntry> {
23        files
24            .into_iter()
25            .filter(|entry| {
26                match decode_centroid(entry, metric) {
27                    Some(centroid) => {
28                        // Centroid is stored for the primary column. When searching a
29                        // secondary column with a different dimension (multimodal), dims
30                        // won't match — skip pruning and keep the file conservatively.
31                        if centroid.values.len() != query.len() {
32                            debug!(
33                                "ailake: pruner {} — centroid dim={} != query dim={}, skipping (secondary column)",
34                                entry.path,
35                                centroid.values.len(),
36                                query.len(),
37                            );
38                            return true;
39                        }
40                        let dist = compute_distance(query, &centroid.values, metric);
41                        let keep = dist - centroid.radius <= threshold;
42                        debug!(
43                            "ailake: pruner {} — dist={:.4} radius={:.4} edge={:.4} threshold={:.4} → {}",
44                            entry.path,
45                            dist,
46                            centroid.radius,
47                            dist - centroid.radius,
48                            threshold,
49                            if keep { "KEEP" } else { "PRUNE" }
50                        );
51                        keep
52                    }
53                    None => {
54                        debug!(
55                            "ailake: pruner {} — no centroid metadata, keeping (conservative fallback)",
56                            entry.path
57                        );
58                        true // no centroid → keep (safe fallback)
59                    }
60                }
61            })
62            .collect()
63    }
64}
65
66fn compute_distance(a: &[f32], b: &[f32], metric: VectorMetric) -> f32 {
67    match metric {
68        VectorMetric::Cosine | VectorMetric::NormalizedCosine => cosine_distance(a, b),
69        VectorMetric::Euclidean => euclidean_distance(a, b),
70        VectorMetric::DotProduct => -dot_product(a, b),
71    }
72}
73
74/// File-level BM25 Bloom filter pruner (Phase F).
75///
76/// Given a map of `file_path → BloomFilter` loaded from the Puffin stats file,
77/// removes files where no query term can possibly appear. Zero false negatives:
78/// if a term is in the file, the Bloom filter will return `true`. Files without
79/// a Bloom filter entry are kept (conservative fallback for V2 tables or files
80/// written before Phase F).
81pub struct BloomPruner;
82
83impl BloomPruner {
84    /// Skip files whose Bloom filter guarantees no query term is present.
85    ///
86    /// Returns the subset of `files` that *may* contain at least one query term.
87    /// Files absent from `bloom_map` are always kept.
88    pub fn prune(
89        files: Vec<DataFileEntry>,
90        query_text: &str,
91        bloom_map: &HashMap<String, crate::bloom::BloomFilter>,
92    ) -> Vec<DataFileEntry> {
93        let query_terms: Vec<String> = crate::bm25::tokenize(query_text);
94        if query_terms.is_empty() || bloom_map.is_empty() {
95            return files;
96        }
97        let before = files.len();
98        let surviving: Vec<DataFileEntry> = files
99            .into_iter()
100            .filter(|entry| match bloom_map.get(&entry.path) {
101                Some(bloom) => {
102                    let keep = query_terms.iter().any(|t| bloom.may_contain(t));
103                    debug!(
104                        "ailake: bloom pruner {} — {} query terms, keep={}",
105                        entry.path,
106                        query_terms.len(),
107                        keep
108                    );
109                    keep
110                }
111                None => true,
112            })
113            .collect();
114        debug!(
115            "ailake: bloom pruning — {}/{} files survive",
116            surviving.len(),
117            before
118        );
119        surviving
120    }
121}
122
123#[cfg(test)]
124mod tests {
125    use super::*;
126    use ailake_catalog::{make_data_file_entry, VectorIndexInfo};
127    use ailake_core::VectorMetric;
128    use ailake_vec::compute_centroid_and_radius;
129
130    fn make_entry(path: &str, vecs: &[Vec<f32>], metric: VectorMetric) -> DataFileEntry {
131        let centroid = compute_centroid_and_radius(vecs, metric);
132        make_data_file_entry(
133            path,
134            vecs.len() as u64,
135            1024,
136            &centroid,
137            VectorIndexInfo {
138                column: "embedding",
139                dim: vecs[0].len() as u32,
140                hnsw_offset: 0,
141                hnsw_len: 0,
142            },
143        )
144    }
145
146    #[test]
147    fn prunes_far_file() {
148        // File centroid near [1,0,0], query near [0,0,1] — orthogonal → prune
149        let vecs = vec![vec![1.0f32, 0.0, 0.0], vec![0.9, 0.1, 0.0]];
150        let entry = make_entry("far.parquet", &vecs, VectorMetric::Cosine);
151        let query = vec![0.0f32, 0.0, 1.0];
152        let pruned = VectorPruner::prune(vec![entry], &query, VectorMetric::Cosine, 0.1);
153        assert!(pruned.is_empty(), "far file should be pruned");
154    }
155
156    #[test]
157    fn keeps_nearby_file() {
158        let vecs = vec![vec![1.0f32, 0.0, 0.0], vec![0.99, 0.1, 0.0]];
159        let entry = make_entry("near.parquet", &vecs, VectorMetric::Cosine);
160        let query = vec![1.0f32, 0.0, 0.0];
161        let kept = VectorPruner::prune(vec![entry], &query, VectorMetric::Cosine, 0.5);
162        assert_eq!(kept.len(), 1, "nearby file should be kept");
163    }
164
165    #[test]
166    fn no_centroid_always_kept() {
167        let entry = DataFileEntry {
168            path: "unknown.parquet".into(),
169            record_count: 10,
170            file_size_bytes: 512,
171            centroid_b64: None,
172            radius: None,
173            hnsw_offset: None,
174            hnsw_len: None,
175            vector_column: None,
176            vector_dim: None,
177            extra_vector_indexes: vec![],
178            index_status: ailake_catalog::IndexStatus::Ready,
179            index_error: None,
180            batch_id: None,
181            embedding_model: None,
182            partition_value: None,
183            deletion_vector: None,
184            first_row_id: None,
185            column_stats: None,
186        };
187        let query = vec![0.0f32, 0.0, 1.0];
188        let kept = VectorPruner::prune(vec![entry], &query, VectorMetric::Cosine, 0.0);
189        assert_eq!(kept.len(), 1);
190    }
191}