Skip to main content

velesdb_core/collection/search/
vector.rs

1//! Vector similarity search methods for Collection.
2
3use crate::collection::types::Collection;
4use crate::error::{Error, Result};
5use crate::index::VectorIndex;
6use crate::point::{Point, SearchResult};
7use crate::quantization::{distance_pq, StorageMode};
8use crate::storage::{PayloadStorage, VectorStorage};
9
10impl Collection {
11    fn search_ids_with_adc_if_pq(&self, query: &[f32], k: usize) -> Vec<(u64, f32)> {
12        let config = self.config.read();
13        let is_pq = matches!(config.storage_mode, StorageMode::ProductQuantization);
14        let higher_is_better = config.metric.higher_is_better();
15        drop(config);
16
17        if !is_pq {
18            return self.index.search(query, k);
19        }
20
21        let candidates_k = k.saturating_mul(8).max(k + 32);
22        let index_results = self.index.search(query, candidates_k);
23
24        let pq_cache = self.pq_cache.read();
25        let quantizer = self.pq_quantizer.read();
26        let Some(quantizer) = quantizer.as_ref() else {
27            return index_results.into_iter().take(k).collect();
28        };
29
30        let mut rescored: Vec<(u64, f32)> = index_results
31            .into_iter()
32            .map(|(id, fallback_score)| {
33                let score = pq_cache
34                    .get(&id)
35                    .map(|pq_vec| distance_pq(query, pq_vec, &quantizer.codebook))
36                    .unwrap_or(fallback_score);
37                (id, score)
38            })
39            .collect();
40
41        rescored.sort_by(|a, b| {
42            if higher_is_better {
43                b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
44            } else {
45                a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal)
46            }
47        });
48        rescored.truncate(k);
49        rescored
50    }
51}
52
53impl Collection {
54    /// Searches for the k nearest neighbors of the query vector.
55    ///
56    /// Uses HNSW index for fast approximate nearest neighbor search.
57    ///
58    /// # Errors
59    ///
60    /// Returns an error if the query vector dimension doesn't match the collection,
61    /// or if this is a metadata-only collection (use `query()` instead).
62    pub fn search(&self, query: &[f32], k: usize) -> Result<Vec<SearchResult>> {
63        let config = self.config.read();
64
65        // Metadata-only collections don't support vector search
66        if config.metadata_only {
67            return Err(Error::SearchNotSupported(config.name.clone()));
68        }
69
70        if query.len() != config.dimension {
71            return Err(Error::DimensionMismatch {
72                expected: config.dimension,
73                actual: query.len(),
74            });
75        }
76        drop(config);
77
78        // Use HNSW index for fast ANN search
79        let index_results = self.search_ids_with_adc_if_pq(query, k);
80
81        let vector_storage = self.vector_storage.read();
82        let payload_storage = self.payload_storage.read();
83
84        // Map index results to SearchResult with full point data
85        let results: Vec<SearchResult> = index_results
86            .into_iter()
87            .filter_map(|(id, score)| {
88                // We need to fetch vector and payload
89                let vector = vector_storage.retrieve(id).ok().flatten()?;
90                let payload = payload_storage.retrieve(id).ok().flatten();
91
92                let point = Point {
93                    id,
94                    vector,
95                    payload,
96                };
97
98                Some(SearchResult::new(point, score))
99            })
100            .collect();
101
102        Ok(results)
103    }
104
105    /// Performs vector similarity search with custom `ef_search` parameter.
106    ///
107    /// Higher `ef_search` = better recall, slower search.
108    /// Default `ef_search` is 128 (Balanced mode).
109    ///
110    /// # Errors
111    ///
112    /// Returns an error if the query vector dimension doesn't match the collection.
113    pub fn search_with_ef(
114        &self,
115        query: &[f32],
116        k: usize,
117        ef_search: usize,
118    ) -> Result<Vec<SearchResult>> {
119        let config = self.config.read();
120
121        if query.len() != config.dimension {
122            return Err(Error::DimensionMismatch {
123                expected: config.dimension,
124                actual: query.len(),
125            });
126        }
127        drop(config);
128
129        // Convert ef_search to SearchQuality
130        let quality = match ef_search {
131            0..=64 => crate::SearchQuality::Fast,
132            65..=128 => crate::SearchQuality::Balanced,
133            129..=256 => crate::SearchQuality::Accurate,
134            _ => crate::SearchQuality::Perfect,
135        };
136
137        let index_results = self.index.search_with_quality(query, k, quality);
138
139        let vector_storage = self.vector_storage.read();
140        let payload_storage = self.payload_storage.read();
141
142        let results: Vec<SearchResult> = index_results
143            .into_iter()
144            .filter_map(|(id, score)| {
145                let vector = vector_storage.retrieve(id).ok().flatten()?;
146                let payload = payload_storage.retrieve(id).ok().flatten();
147
148                let point = Point {
149                    id,
150                    vector,
151                    payload,
152                };
153
154                Some(SearchResult::new(point, score))
155            })
156            .collect();
157
158        Ok(results)
159    }
160
161    /// Performs fast vector similarity search returning only IDs and scores.
162    ///
163    /// Perf: This is ~3-5x faster than `search()` because it skips vector/payload retrieval.
164    /// Use this when you only need IDs and scores, not full point data.
165    ///
166    /// # Arguments
167    ///
168    /// * `query` - Query vector
169    /// * `k` - Maximum number of results to return
170    ///
171    /// # Returns
172    ///
173    /// Vector of (id, score) tuples sorted by similarity.
174    ///
175    /// # Errors
176    ///
177    /// Returns an error if the query vector dimension doesn't match the collection.
178    pub fn search_ids(&self, query: &[f32], k: usize) -> Result<Vec<(u64, f32)>> {
179        let config = self.config.read();
180
181        if query.len() != config.dimension {
182            return Err(Error::DimensionMismatch {
183                expected: config.dimension,
184                actual: query.len(),
185            });
186        }
187        drop(config);
188
189        // Perf: Direct HNSW search without vector/payload retrieval
190        let results = self.search_ids_with_adc_if_pq(query, k);
191        Ok(results)
192    }
193
194    /// Searches for the k nearest neighbors with metadata filtering.
195    ///
196    /// Performs post-filtering: retrieves more candidates from HNSW,
197    /// then filters by metadata conditions.
198    ///
199    /// # Arguments
200    ///
201    /// * `query` - Query vector
202    /// * `k` - Maximum number of results to return
203    /// * `filter` - Metadata filter to apply
204    ///
205    /// # Errors
206    ///
207    /// Returns an error if the query vector dimension doesn't match the collection.
208    pub fn search_with_filter(
209        &self,
210        query: &[f32],
211        k: usize,
212        filter: &crate::filter::Filter,
213    ) -> Result<Vec<SearchResult>> {
214        let config = self.config.read();
215
216        if query.len() != config.dimension {
217            return Err(Error::DimensionMismatch {
218                expected: config.dimension,
219                actual: query.len(),
220            });
221        }
222        drop(config);
223
224        // Post-filtering strategy: retrieve more candidates than k, then filter
225        // Heuristic: retrieve 4x candidates to account for filtered-out results
226        let candidates_k = k.saturating_mul(4).max(k + 10);
227        let index_results = self.search_ids_with_adc_if_pq(query, candidates_k);
228
229        let vector_storage = self.vector_storage.read();
230        let payload_storage = self.payload_storage.read();
231
232        // Map index results to SearchResult with full point data, applying filter
233        // FIX: Consistent null payload handling - match null if no payload (same as execute_query)
234        let mut results: Vec<SearchResult> = index_results
235            .into_iter()
236            .filter_map(|(id, score)| {
237                let vector = vector_storage.retrieve(id).ok().flatten()?;
238                let payload = payload_storage.retrieve(id).ok().flatten();
239
240                // Apply filter - check if filter matches payload or null
241                // This matches the behavior in execute_query for consistency
242                let matches = match payload.as_ref() {
243                    Some(p) => filter.matches(p),
244                    None => filter.matches(&serde_json::Value::Null),
245                };
246                if !matches {
247                    return None;
248                }
249
250                let point = Point {
251                    id,
252                    vector,
253                    payload,
254                };
255
256                Some(SearchResult::new(point, score))
257            })
258            .take(k)
259            .collect();
260
261        // Sort results by similarity (most similar first)
262        // For similarity metrics (Cosine, DotProduct, Jaccard): higher score = more similar → DESC
263        // For distance metrics (Euclidean, Hamming): lower score = more similar → ASC
264        let config = self.config.read();
265        let higher_is_better = config.metric.higher_is_better();
266        drop(config);
267
268        results.sort_by(|a, b| {
269            if higher_is_better {
270                // Similarity: descending (highest first)
271                b.score
272                    .partial_cmp(&a.score)
273                    .unwrap_or(std::cmp::Ordering::Equal)
274            } else {
275                // Distance: ascending (lowest first)
276                a.score
277                    .partial_cmp(&b.score)
278                    .unwrap_or(std::cmp::Ordering::Equal)
279            }
280        });
281
282        Ok(results)
283    }
284}