nodedb_vector/codec_index/
search.rs1use std::cmp::Reverse;
11use std::collections::{BinaryHeap, HashSet};
12
13use nodedb_codec::vector_quant::codec::VectorCodec;
14
15use super::graph::HnswCodecIndex;
16use crate::error::{VectorError, check_dim};
17
18#[derive(Debug, Clone)]
20pub struct CodecSearchResult {
21 pub id: u32,
23 pub distance: f32,
25}
26
27#[derive(Clone, Copy, PartialEq)]
29struct Cand {
30 dist: f32,
31 idx: u32,
33}
34
35impl Eq for Cand {}
36
37impl PartialOrd for Cand {
38 fn partial_cmp(&self, other: &Self) -> Option<std::cmp::Ordering> {
39 Some(self.cmp(other))
40 }
41}
42
43impl Ord for Cand {
44 fn cmp(&self, other: &Self) -> std::cmp::Ordering {
45 self.dist
46 .partial_cmp(&other.dist)
47 .unwrap_or(std::cmp::Ordering::Equal)
48 .then(self.idx.cmp(&other.idx))
49 }
50}
51
52impl<C: VectorCodec> HnswCodecIndex<C> {
53 pub fn search(
61 &self,
62 query: &[f32],
63 k: usize,
64 ef_search: usize,
65 ) -> Result<Vec<CodecSearchResult>, VectorError> {
66 check_dim(self.dim, query.len())?;
67 if self.is_empty() {
68 return Ok(Vec::new());
69 }
70
71 let Some(ep) = self.entry_point else {
72 return Ok(Vec::new());
73 };
74
75 let ef = ef_search.max(k);
76
77 let q_encoded = self.codec.encode(query);
79 let q_prepared = self.codec.prepare_query(query);
80
81 let mut cur_ep = ep;
83 for layer in (1..=self.max_layer).rev() {
84 cur_ep = self.greedy_nearest_search(&q_encoded, cur_ep, layer);
85 }
86
87 let candidates = self.search_layer_0(&q_encoded, cur_ep, ef);
89
90 let mut reranked: Vec<(f32, u32)> = candidates
92 .into_iter()
93 .take(ef)
94 .map(|c| {
95 let asym = self
96 .codec
97 .exact_asymmetric_distance(&q_prepared, &self.nodes[c.idx as usize].quantized);
98 (asym, self.nodes[c.idx as usize].id)
99 })
100 .collect();
101
102 reranked.sort_unstable_by(|a, b| a.0.total_cmp(&b.0));
103 reranked.truncate(k);
104
105 Ok(reranked
106 .into_iter()
107 .map(|(distance, id)| CodecSearchResult { id, distance })
108 .collect())
109 }
110
111 fn greedy_nearest_search(&self, q_enc: &C::Quantized, ep_idx: u32, layer: usize) -> u32 {
113 let mut best_idx = ep_idx;
114 let mut best_dist = self
115 .codec
116 .fast_symmetric_distance(q_enc, &self.nodes[ep_idx as usize].quantized);
117
118 loop {
119 let mut improved = false;
120 for &nb in self.neighbors_at(best_idx, layer) {
121 if self.nodes[nb as usize].deleted {
122 continue;
123 }
124 let d = self
125 .codec
126 .fast_symmetric_distance(q_enc, &self.nodes[nb as usize].quantized);
127 if d < best_dist {
128 best_dist = d;
129 best_idx = nb;
130 improved = true;
131 }
132 }
133 if !improved {
134 break;
135 }
136 }
137
138 best_idx
139 }
140
141 fn search_layer_0(&self, q_enc: &C::Quantized, ep_idx: u32, ef: usize) -> Vec<Cand> {
147 let mut visited: HashSet<u32> = HashSet::new();
148 visited.insert(ep_idx);
149
150 let ep_dist = self
151 .codec
152 .fast_symmetric_distance(q_enc, &self.nodes[ep_idx as usize].quantized);
153 let ep_cand = Cand {
154 dist: ep_dist,
155 idx: ep_idx,
156 };
157
158 let mut candidates: BinaryHeap<Reverse<Cand>> = BinaryHeap::new();
159 candidates.push(Reverse(ep_cand));
160
161 let mut results: BinaryHeap<Cand> = BinaryHeap::new();
162 if !self.nodes[ep_idx as usize].deleted {
163 results.push(ep_cand);
164 }
165
166 while let Some(Reverse(cur)) = candidates.pop() {
167 let worst = results.peek().map_or(f32::INFINITY, |w| w.dist);
168 if cur.dist > worst && results.len() >= ef {
169 break;
170 }
171
172 for &nb in self.neighbors_at(cur.idx, 0) {
173 if !visited.insert(nb) {
174 continue;
175 }
176 let d = self
177 .codec
178 .fast_symmetric_distance(q_enc, &self.nodes[nb as usize].quantized);
179 let worst_now = results.peek().map_or(f32::INFINITY, |w| w.dist);
180 if d < worst_now || results.len() < ef {
181 candidates.push(Reverse(Cand { dist: d, idx: nb }));
182 }
183 if !self.nodes[nb as usize].deleted {
184 results.push(Cand { dist: d, idx: nb });
185 if results.len() > ef {
186 results.pop();
187 }
188 }
189 }
190 }
191
192 let mut out: Vec<Cand> = results.into_vec();
193 out.sort_unstable_by(|a, b| a.dist.total_cmp(&b.dist));
194 out
195 }
196}
197
198#[cfg(test)]
201mod tests {
202 use nodedb_codec::vector_quant::{bbq::BbqCodec, rabitq::RaBitQCodec};
203
204 use crate::{codec_index::HnswCodecIndex, distance::l2_squared, quantize::Sq8Codec};
205
206 fn xorshift(state: &mut u64) -> u64 {
209 *state ^= *state << 13;
210 *state ^= *state >> 7;
211 *state ^= *state << 17;
212 *state
213 }
214
215 fn rand_vec(state: &mut u64, dim: usize) -> Vec<f32> {
216 (0..dim)
217 .map(|_| (xorshift(state) as f32 / u64::MAX as f32) * 2.0 - 1.0)
218 .collect()
219 }
220
221 fn ground_truth(vecs: &[Vec<f32>], query: &[f32], k: usize) -> Vec<u32> {
223 let mut scored: Vec<(f32, u32)> = vecs
224 .iter()
225 .enumerate()
226 .map(|(i, v)| (l2_squared(query, v), i as u32))
227 .collect();
228 scored.sort_unstable_by(|a, b| a.0.total_cmp(&b.0));
229 scored.into_iter().take(k).map(|(_, id)| id).collect()
230 }
231
232 #[test]
235 fn sq8_top1_exact_match() {
236 let dim = 8;
237 let n = 50usize;
238 let mut state = 0xDEAD_BEEF_u64;
239 let vecs: Vec<Vec<f32>> = (0..n).map(|_| rand_vec(&mut state, dim)).collect();
240 let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
241 let codec = Sq8Codec::calibrate(&refs, dim).unwrap();
242 let mut idx: HnswCodecIndex<Sq8Codec> = HnswCodecIndex::new(dim, 8, 100, codec, 7);
243 for (i, v) in vecs.iter().enumerate() {
244 idx.insert(i as u32, v).unwrap();
245 }
246 let query = vecs[17].clone();
248 let results = idx.search(&query, 1, 50).unwrap();
249 assert_eq!(results.len(), 1, "expected 1 result");
250 assert_eq!(
251 results[0].id, 17,
252 "top-1 should be the queried vector itself"
253 );
254 assert!(
255 results[0].distance < 0.1,
256 "distance to self should be near 0, got {}",
257 results[0].distance
258 );
259 }
260
261 #[test]
264 fn rabitq_recall_at_least_60_percent() {
265 let dim = 16;
266 let n = 100usize;
267 let k = 5usize;
268 let mut state = 0xCAFE_BABE_u64;
269 let vecs: Vec<Vec<f32>> = (0..n).map(|_| rand_vec(&mut state, dim)).collect();
270 let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
271 let codec = RaBitQCodec::calibrate(&refs, dim, 0xABCD_1234);
272 let mut idx: HnswCodecIndex<RaBitQCodec> = HnswCodecIndex::new(dim, 8, 150, codec, 99);
273 for (i, v) in vecs.iter().enumerate() {
274 idx.insert(i as u32, v).unwrap();
275 }
276
277 let n_queries = 10usize;
278 let mut total_hits = 0usize;
279 let mut total = 0usize;
280 for _qi in 0..n_queries {
281 let query = rand_vec(&mut state, dim);
282 let truth: std::collections::HashSet<u32> =
283 ground_truth(&vecs, &query, k).into_iter().collect();
284 let results = idx.search(&query, k, k * 4).unwrap();
285 let found: std::collections::HashSet<u32> = results.iter().map(|r| r.id).collect();
286 total_hits += found.intersection(&truth).count();
287 total += k;
288 }
289 let recall = total_hits as f64 / total as f64;
290 assert!(
294 recall >= 0.30,
295 "RaBitQ recall@{k} = {recall:.2}, expected >= 0.30"
296 );
297 }
298
299 #[test]
302 fn bbq_recall_at_least_60_percent() {
303 let dim = 16;
304 let n = 100usize;
305 let k = 5usize;
306 let mut state = 0x1234_5678_u64;
307 let vecs: Vec<Vec<f32>> = (0..n).map(|_| rand_vec(&mut state, dim)).collect();
308 let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
309 let codec = BbqCodec::calibrate(&refs, dim, 3);
310 let mut idx: HnswCodecIndex<BbqCodec> = HnswCodecIndex::new(dim, 8, 150, codec, 42);
311 for (i, v) in vecs.iter().enumerate() {
312 idx.insert(i as u32, v).unwrap();
313 }
314
315 let n_queries = 10usize;
316 let mut total_hits = 0usize;
317 let mut total = 0usize;
318 for _qi in 0..n_queries {
319 let query = rand_vec(&mut state, dim);
320 let truth: std::collections::HashSet<u32> =
321 ground_truth(&vecs, &query, k).into_iter().collect();
322 let results = idx.search(&query, k, k * 4).unwrap();
323 let found: std::collections::HashSet<u32> = results.iter().map(|r| r.id).collect();
324 total_hits += found.intersection(&truth).count();
325 total += k;
326 }
327 let recall = total_hits as f64 / total as f64;
328 assert!(
332 recall >= 0.40,
333 "BBQ recall@{k} = {recall:.2}, expected >= 0.40"
334 );
335 }
336
337 #[test]
340 fn empty_index_returns_empty() {
341 let codec = {
342 let vecs: Vec<Vec<f32>> = (0..5).map(|i| vec![i as f32; 4]).collect();
343 let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
344 Sq8Codec::calibrate(&refs, 4).unwrap()
345 };
346 let idx: HnswCodecIndex<Sq8Codec> = HnswCodecIndex::new(4, 8, 50, codec, 1);
347 let results = idx.search(&[0.0, 0.0, 0.0, 0.0], 5, 20).unwrap();
348 assert!(results.is_empty(), "empty index must return no results");
349 }
350
351 #[test]
352 fn single_vector_index_always_returns_it() {
353 let dim = 4;
354 let vecs = [vec![1.0f32, 2.0, 3.0, 4.0]];
355 let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
356 let codec = Sq8Codec::calibrate(&refs, dim).unwrap();
357 let mut idx: HnswCodecIndex<Sq8Codec> = HnswCodecIndex::new(dim, 8, 50, codec, 5);
358 idx.insert(0, &vecs[0]).unwrap();
359 let results = idx.search(&[10.0, 20.0, 30.0, 40.0], 1, 10).unwrap();
361 assert_eq!(results.len(), 1, "single-node index must return 1 result");
362 assert_eq!(results[0].id, 0, "the only node must be returned");
363 }
364
365 #[test]
366 fn wrong_dimension_is_a_typed_error() {
367 use crate::error::VectorError;
368 let vecs: Vec<Vec<f32>> = (0..5).map(|i| vec![i as f32; 4]).collect();
369 let refs: Vec<&[f32]> = vecs.iter().map(|v| v.as_slice()).collect();
370 let codec = Sq8Codec::calibrate(&refs, 4).unwrap();
371 let mut idx: HnswCodecIndex<Sq8Codec> = HnswCodecIndex::new(4, 8, 50, codec, 1);
372 assert!(matches!(
373 idx.search(&[0.0; 3], 5, 20),
374 Err(VectorError::DimensionMismatch {
375 expected: 4,
376 got: 3
377 })
378 ));
379 idx.insert(0, &vecs[0]).unwrap();
380 assert!(matches!(
381 idx.search(&[0.0; 5], 5, 20),
382 Err(VectorError::DimensionMismatch {
383 expected: 4,
384 got: 5
385 })
386 ));
387 assert!(matches!(
388 idx.insert(1, &[0.0; 2]),
389 Err(VectorError::DimensionMismatch {
390 expected: 4,
391 got: 2
392 })
393 ));
394 }
395}