ic_rig/vector_store/lsh.rs
1//! Locality Sensitive Hashing (LSH) for approximate nearest-neighbour search.
2//!
3//! # The problem it solves
4//!
5//! A linear scan over N stored embeddings is O(N) — fine for hundreds of
6//! documents, painful for tens of thousands. LSH reduces the search space to a
7//! small *candidate set* in O(1) bucket lookups, then exact-scores only those.
8//!
9//! # How it works
10//!
11//! A random hyperplane through the origin splits the vector space in two.
12//! Every vector gets a 1-bit label: which side it lands on (sign of dot product).
13//! Stack `num_hyperplanes` such planes → a short binary hash per vector.
14//!
15//! Vectors with a *small angle* between them have a high probability of sharing
16//! the same hash. That probability is: `1 - angle/π`.
17//!
18//! A single table has false negatives (similar vectors that happen to straddle
19//! a hyperplane). Using `num_tables` independent sets of hyperplanes, each with
20//! its own bucket map, a pair is returned as a candidate if it matches in *any*
21//! table — driving false negatives toward zero.
22//!
23//! # Tuning
24//!
25//! | Parameter | Effect |
26//! |------------------|-------------------------------------------------|
27//! | `num_hyperplanes`| More bits → fewer candidates, faster scoring |
28//! | `num_tables` | More tables → fewer false negatives, more RAM |
29//!
30//! Start with `num_hyperplanes = 12` and `num_tables = 6` for most workloads.
31//! Increase `num_tables` if you're missing relevant results. Increase
32//! `num_hyperplanes` if the candidate set is still too large.
33//!
34//! # Example
35//!
36//! ```rust,no_run
37//! use irig::vector_store::lsh::LshIndex;
38//!
39//! // 1536-dim vectors (text-embedding-3-small), 12 bits/hash, 6 tables
40//! let mut index = LshIndex::new(1536, 12, 6, 42);
41//!
42//! let title_vec: Vec<f64> = vec![0.0; 1536];
43//! let description_vec: Vec<f64> = vec![0.0; 1536];
44//! let query_vec: Vec<f64> = vec![0.0; 1536];
45//!
46//! index.insert("page-1".into(), &title_vec);
47//! index.insert("page-2".into(), &description_vec);
48//!
49//! let candidates: Vec<String> = index.query(&query_vec);
50//! // exact-score only `candidates`, not all pages
51//! ```
52
53use std::collections::{HashMap, HashSet};
54
55use crate::embeddings::{DistanceMetric, Embedding};
56
57// ── PRNG ──────────────────────────────────────────────────────────────────────
58
59/// Xorshift64 seeded from a caller-supplied value.
60/// ICP canisters don't have `SystemTime`, so the seed comes from outside —
61/// use `ic_cdk::api::time()` or a canister-global counter.
62fn xorshift64(mut state: u64) -> impl FnMut() -> f32 {
63 move || {
64 state ^= state << 13;
65 state ^= state >> 7;
66 state ^= state << 17;
67 // Map uniformly to [-1.0, 1.0]
68 (state as i64 as f32) / (i64::MAX as f32)
69 }
70}
71
72// ── LSH projection planes ─────────────────────────────────────────────────────
73
74/// The random hyperplane matrix shared across all tables.
75struct Hyperplanes {
76 /// Flat storage: `num_tables * num_hyperplanes` unit vectors, each of
77 /// length `dim`. Indexed as `[table * num_hyperplanes + plane][dim]`.
78 planes: Vec<Vec<f32>>,
79 num_hyperplanes: usize,
80}
81
82impl Hyperplanes {
83 fn new(dim: usize, num_tables: usize, num_hyperplanes: usize, seed: u64) -> Self {
84 let mut rand = xorshift64(seed | 1); // seed must be non-zero
85 let total = num_tables * num_hyperplanes;
86 let mut planes = Vec::with_capacity(total);
87
88 for _ in 0..total {
89 let mut plane: Vec<f32> = (0..dim).map(|_| rand()).collect();
90 // Normalize so the dot product only measures direction, not magnitude.
91 let norm: f32 = plane.iter().map(|x| x * x).sum::<f32>().sqrt();
92 if norm > 0.0 {
93 plane.iter_mut().for_each(|v| *v /= norm);
94 }
95 planes.push(plane);
96 }
97
98 Self { planes, num_hyperplanes }
99 }
100
101 /// Hash a vector against the hyperplanes of one table.
102 /// Each hyperplane contributes 1 bit: 1 if dot ≥ 0, 0 otherwise.
103 fn hash(&self, vector: &[f64], table_idx: usize) -> u64 {
104 let start = table_idx * self.num_hyperplanes;
105 let mut hash = 0u64;
106
107 for (bit, plane) in self.planes[start..start + self.num_hyperplanes]
108 .iter()
109 .enumerate()
110 {
111 let dot: f32 = vector
112 .iter()
113 .zip(plane.iter())
114 .map(|(&v, &h)| v as f32 * h)
115 .sum();
116
117 if dot >= 0.0 {
118 hash |= 1 << bit;
119 }
120 }
121
122 hash
123 }
124}
125
126// ── LshIndex ──────────────────────────────────────────────────────────────────
127
128/// Approximate nearest-neighbour index backed by LSH.
129///
130/// Insert embeddings during indexing, query during search.
131/// The returned candidate IDs should then be exact-scored with cosine similarity.
132pub struct LshIndex {
133 planes: Hyperplanes,
134 /// One `HashMap<hash → [id]>` per table.
135 tables: Vec<HashMap<u64, Vec<String>>>,
136 num_tables: usize,
137}
138
139impl LshIndex {
140 /// Create a new index.
141 ///
142 /// - `dim` — dimensionality of your embedding vectors
143 /// - `num_hyperplanes` — bits per hash (12–16 is a good starting range)
144 /// - `num_tables` — number of independent hash tables (4–8 typical)
145 /// - `seed` — PRNG seed; use `ic_cdk::api::time()` on ICP
146 pub fn new(dim: usize, num_hyperplanes: usize, num_tables: usize, seed: u64) -> Self {
147 Self {
148 planes: Hyperplanes::new(dim, num_tables, num_hyperplanes, seed),
149 tables: vec![HashMap::new(); num_tables],
150 num_tables,
151 }
152 }
153
154 /// Index an embedding under `id`.
155 ///
156 /// Call once per embedding at insert time. If a document produces multiple
157 /// embeddings (title + description + keywords), insert each separately with
158 /// the same `id` — the candidate set deduplicates by id anyway.
159 pub fn insert(&mut self, id: String, embedding: &[f64]) {
160 for table_idx in 0..self.num_tables {
161 let hash = self.planes.hash(embedding, table_idx);
162 self.tables[table_idx]
163 .entry(hash)
164 .or_default()
165 .push(id.clone());
166 }
167 }
168
169 /// Return candidate IDs whose hash matches the query in at least one table.
170 ///
171 /// This is the fast path. The caller is responsible for exact-scoring the
172 /// candidates with cosine similarity and taking the top-N.
173 pub fn query(&self, embedding: &[f64]) -> Vec<String> {
174 let mut candidates = HashSet::new();
175
176 for table_idx in 0..self.num_tables {
177 let hash = self.planes.hash(embedding, table_idx);
178 if let Some(ids) = self.tables[table_idx].get(&hash) {
179 candidates.extend(ids.iter().cloned());
180 }
181 }
182
183 candidates.into_iter().collect()
184 }
185
186 /// Number of distinct IDs in the index.
187 pub fn len(&self) -> usize {
188 // Count unique IDs across all tables (table 0 is representative).
189 self.tables.first().map_or(0, |t| t.values().map(|v| v.len()).sum())
190 }
191
192 pub fn is_empty(&self) -> bool {
193 self.len() == 0
194 }
195
196 pub fn clear(&mut self) {
197 self.tables.iter_mut().for_each(|t| t.clear());
198 }
199
200 /// Score and rank candidates for `query` against `store`, returning `(id, score)` pairs.
201 ///
202 /// The LSH bucket lookup narrows the field; `metric` exact-scores the survivors.
203 /// Pass `None` to use the default `DistanceMetric::Cosine { normalized: false }`.
204 ///
205 /// Results are sorted best-first:
206 /// - similarity metrics (`Cosine`, `DotProduct`) → descending
207 /// - distance metrics (`Euclidean`, `Manhattan`, `Chebyshev`, `Angular`) → ascending
208 ///
209 /// IDs present in the candidate set but absent from `store` are silently skipped.
210 pub fn search(
211 &self,
212 query: &Embedding,
213 store: &HashMap<String, Embedding>,
214 metric: Option<DistanceMetric>,
215 ) -> Vec<(String, f64)> {
216 let metric = metric.unwrap_or(DistanceMetric::Cosine { normalized: false });
217
218 let mut ranked: Vec<(String, f64)> = self
219 .query(&query.vec)
220 .into_iter()
221 .filter_map(|id| store.get(&id).map(|emb| (id, metric.score(query, emb))))
222 .collect();
223
224 if metric.higher_is_better() {
225 ranked.sort_by(|a, b| b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal));
226 } else {
227 ranked.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
228 }
229
230 ranked
231 }
232}