Skip to main content

lean_ctx/core/
ann_cache.rs

1//! Process-wide cache for the HNSW [`AnnIndex`] used by dense semantic search.
2//!
3//! Building an HNSW graph is O(n log n) with a wide construction beam, so doing
4//! it per query would be slower than brute force. This cache keeps one built
5//! index keyed by a content fingerprint of the embedding set: repeated queries
6//! over the same corpus reuse the graph and get sub-linear search, while a
7//! changed corpus (different fingerprint) transparently triggers a rebuild.
8//!
9//! It is threshold-gated — corpora below [`ANN_MIN_VECTORS`] skip the cache and
10//! use exact SIMD brute-force top-k, which is both faster (no graph overhead)
11//! and *exact*. The threshold is deliberately high: at lean-ctx's typical scale
12//! (a few thousand chunks) exact brute force over int8/SIMD dot products is only
13//! ~1-2 ms, so HNSW's approximate recall is not worth trading. HNSW activates
14//! only for genuinely large corpora where exact scan would dominate latency.
15//! On any lock failure it falls back to brute force, so correctness never
16//! depends on the cache being available.
17
18use std::sync::{Arc, Mutex, OnceLock};
19
20use super::hnsw::{brute_force_topk, AnnIndex};
21
22/// Minimum corpus size before an HNSW graph is worth building and caching.
23/// Below this, exact SIMD brute force is faster *and* exact (no recall loss).
24pub const ANN_MIN_VECTORS: usize = 50_000;
25
26struct Cached {
27    fingerprint: u64,
28    index: AnnIndex,
29}
30
31fn cache() -> &'static Mutex<Option<Cached>> {
32    static CACHE: OnceLock<Mutex<Option<Cached>>> = OnceLock::new();
33    CACHE.get_or_init(|| Mutex::new(None))
34}
35
36/// Returns the top-k `(index, similarity)` pairs for `query` over `embeddings`,
37/// sorted by descending similarity.
38///
39/// Small corpora use exact brute force. Large corpora build (once) and reuse a
40/// cached HNSW index. Falls back to brute force on lock failure.
41///
42/// `embeddings` is taken as `Arc<[Vec<f32>]>` (the same allocation the caller
43/// already holds for per-query scoring) so building the cached HNSW index is an
44/// `Arc::clone` — a refcount bump, not a second full-precision corpus copy.
45#[must_use]
46pub fn topk(embeddings: &Arc<[Vec<f32>]>, query: &[f32], top_k: usize) -> Vec<(usize, f32)> {
47    topk_gated(embeddings, query, top_k, ANN_MIN_VECTORS)
48}
49
50/// Core implementation with an injectable gate so tests can exercise the HNSW
51/// path without materializing a 50k-vector corpus.
52fn topk_gated(
53    embeddings: &Arc<[Vec<f32>]>,
54    query: &[f32],
55    top_k: usize,
56    min_vectors: usize,
57) -> Vec<(usize, f32)> {
58    if embeddings.len() < min_vectors {
59        return brute_force_topk(embeddings, query, top_k);
60    }
61
62    let fp = fingerprint(embeddings);
63    let Ok(mut guard) = cache().lock() else {
64        return brute_force_topk(embeddings, query, top_k);
65    };
66
67    let needs_build = match guard.as_ref() {
68        Some(c) => c.fingerprint != fp,
69        None => true,
70    };
71    if needs_build {
72        *guard = Some(Cached {
73            fingerprint: fp,
74            // Arc::clone: shares the caller's corpus allocation, zero bytes copied.
75            index: AnnIndex::build(Arc::clone(embeddings)),
76        });
77    }
78
79    match guard.as_ref() {
80        Some(c) => c.index.search(query, top_k),
81        None => brute_force_topk(embeddings, query, top_k),
82    }
83}
84
85/// Cheap, content-sensitive fingerprint (FNV-1a over lengths + sampled values).
86/// Strong enough that a changed corpus reliably triggers a rebuild; a collision
87/// would only mildly degrade already-approximate recall, never break results.
88fn fingerprint(embeddings: &[Vec<f32>]) -> u64 {
89    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
90    macro_rules! mix {
91        ($x:expr) => {{
92            h ^= $x;
93            h = h.wrapping_mul(0x0000_0100_0000_01b3);
94        }};
95    }
96    mix!(embeddings.len() as u64);
97    for (i, v) in embeddings.iter().enumerate() {
98        mix!(v.len() as u64);
99        mix!(i as u64);
100        if let Some(&f) = v.first() {
101            mix!(u64::from(f.to_bits()));
102        }
103        if let Some(&f) = v.get(v.len() / 2) {
104            mix!(u64::from(f.to_bits()));
105        }
106        if let Some(&f) = v.last() {
107            mix!(u64::from(f.to_bits()));
108        }
109    }
110    h
111}
112
113#[cfg(test)]
114mod tests {
115    use super::*;
116    use std::collections::HashSet;
117
118    // Test gate that forces the HNSW path on modest corpora (AnnIndex itself
119    // switches to HNSW at 1000 vectors, so 1000 here exercises the real graph).
120    const TEST_GATE: usize = 1000;
121
122    // The cache is a single process-wide slot, so tests that drive the HNSW path
123    // must not interleave or they would clobber each other's cached index. This
124    // lock serializes them; poison is recovered since a panic in one test must
125    // not cascade into the others.
126    static TEST_LOCK: Mutex<()> = Mutex::new(());
127
128    fn serial() -> std::sync::MutexGuard<'static, ()> {
129        TEST_LOCK
130            .lock()
131            .unwrap_or_else(std::sync::PoisonError::into_inner)
132    }
133
134    /// Reads the fingerprint of the currently cached index (test-only
135    /// introspection; `tests` is a child module so it may touch private state).
136    fn cached_fingerprint() -> Option<u64> {
137        cache()
138            .lock()
139            .ok()
140            .and_then(|g| g.as_ref().map(|c| c.fingerprint))
141    }
142
143    fn random_vec(dim: usize, seed: u64) -> Vec<f32> {
144        let mut v = Vec::with_capacity(dim);
145        let mut s = seed;
146        for _ in 0..dim {
147            s = s.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
148            v.push((s as f32 / u64::MAX as f32) * 2.0 - 1.0);
149        }
150        v
151    }
152
153    /// A vector near `base` with small per-dimension noise — produces dense,
154    /// well-connected clusters where HNSW recall is high and stable (unlike a
155    /// single needle in random noise, which approximate search can miss).
156    fn jitter(base: &[f32], seed: u64, scale: f32) -> Vec<f32> {
157        base.iter()
158            .enumerate()
159            .map(|(i, &b)| {
160                let s = seed
161                    .wrapping_add(i as u64)
162                    .wrapping_mul(6_364_136_223_846_793_005)
163                    .wrapping_add(1);
164                b + ((s as f32 / u64::MAX as f32) * 2.0 - 1.0) * scale
165            })
166            .collect()
167    }
168
169    fn clustered(
170        n_clusters: usize,
171        per_cluster: usize,
172        dim: usize,
173    ) -> (Vec<Vec<f32>>, Vec<Vec<f32>>) {
174        let centers: Vec<Vec<f32>> = (0..n_clusters)
175            .map(|c| random_vec(dim, (c as u64 + 1) * 1_000))
176            .collect();
177        let mut vectors = Vec::with_capacity(n_clusters * per_cluster);
178        for (c, center) in centers.iter().enumerate() {
179            for j in 0..per_cluster {
180                vectors.push(jitter(center, (c * per_cluster + j) as u64 + 7, 0.02));
181            }
182        }
183        (vectors, centers)
184    }
185
186    #[test]
187    fn small_corpus_matches_brute_force_exactly() {
188        let vectors: Arc<[Vec<f32>]> = (0..200).map(|i| random_vec(32, i)).collect();
189        let query = random_vec(32, 9_999);
190
191        // Production gate (50k) → small corpus is exact brute force.
192        let via_cache = topk(&vectors, &query, 8);
193        let exact = brute_force_topk(&vectors, &query, 8);
194
195        assert_eq!(via_cache.len(), exact.len());
196        for (a, b) in via_cache.iter().zip(exact.iter()) {
197            assert_eq!(a.0, b.0, "below threshold must be exact brute force");
198        }
199    }
200
201    #[test]
202    fn hnsw_path_recall_matches_brute_force_on_clusters() {
203        let _serial = serial();
204        let (vectors, centers) = clustered(24, 60, 32); // 1440 vectors
205        let vectors: Arc<[Vec<f32>]> = Arc::from(vectors);
206        let query = centers[5].clone();
207        let k = 20;
208
209        let ann = topk_gated(&vectors, &query, k, TEST_GATE); // forces HNSW
210        let exact = brute_force_topk(&vectors, &query, k);
211        assert_eq!(ann.len(), k);
212
213        let exact_set: HashSet<usize> = exact.iter().map(|(i, _)| *i).collect();
214        let overlap = ann.iter().filter(|(i, _)| exact_set.contains(i)).count();
215        assert!(
216            overlap * 100 >= k * 50,
217            "HNSW recall@{k} too low: {overlap}/{k}"
218        );
219    }
220
221    #[test]
222    fn hnsw_path_results_are_descending() {
223        let _serial = serial();
224        let (vectors, centers) = clustered(20, 60, 24); // 1200 vectors
225        let vectors: Arc<[Vec<f32>]> = Arc::from(vectors);
226        let results = topk_gated(&vectors, &centers[3], 10, TEST_GATE);
227        for w in results.windows(2) {
228            assert!(
229                w[0].1 >= w[1].1,
230                "results must be sorted by descending similarity"
231            );
232        }
233    }
234
235    #[test]
236    fn rebuilds_when_corpus_changes() {
237        let _serial = serial();
238        // Two distinct corpora share the global cache slot; the fingerprint must
239        // force a rebuild so each query reflects its own corpus (no staleness).
240        // Asserting on the cached fingerprint tests the rebuild mechanism
241        // directly — deterministic, unlike HNSW's approximate top-1 recall.
242        let (a, ca) = clustered(20, 55, 32); // 1100 vectors
243        let (b, cb) = clustered(18, 60, 32); // 1080 vectors
244        let a: Arc<[Vec<f32>]> = Arc::from(a);
245        let b: Arc<[Vec<f32>]> = Arc::from(b);
246
247        let _ = topk_gated(&a, &ca[7], 5, TEST_GATE);
248        assert_eq!(
249            cached_fingerprint(),
250            Some(fingerprint(&a)),
251            "first query caches corpus A's index"
252        );
253
254        let _ = topk_gated(&b, &cb[4], 5, TEST_GATE);
255        assert_eq!(
256            cached_fingerprint(),
257            Some(fingerprint(&b)),
258            "a different corpus must force a rebuild to B"
259        );
260
261        let _ = topk_gated(&a, &ca[7], 5, TEST_GATE);
262        assert_eq!(
263            cached_fingerprint(),
264            Some(fingerprint(&a)),
265            "re-querying A must rebuild A — never serve stale B"
266        );
267    }
268
269    #[test]
270    fn fingerprint_differs_on_content_change() {
271        let a: Vec<Vec<f32>> = (0..10).map(|i| random_vec(8, i)).collect();
272        let mut b = a.clone();
273        b[3][0] += 0.5;
274        assert_ne!(fingerprint(&a), fingerprint(&b));
275    }
276}