Skip to main content

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}