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*.
12//! On any lock failure it falls back to brute force, so correctness never
13//! depends on the cache being available.
14
15use std::sync::{Mutex, OnceLock};
16
17use super::hnsw::{AnnIndex, FlatEmbeddings, brute_force_topk};
18
19/// Minimum corpus size before an HNSW graph is worth building and caching.
20/// Below this, exact SIMD brute force is faster *and* exact (no recall loss).
21/// At 2500, a medium codebase (~7k chunks for lean-ctx itself) enters the
22/// HNSW path and gets sub-linear dense search; brute force remains the default
23/// for smaller projects where it is both simpler and just as fast.
24pub const ANN_MIN_VECTORS: usize = 2_500;
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/// Drop the cached HNSW index (#685 eviction hook). The next large-corpus
37/// query transparently rebuilds it; queries in between fall back to exact
38/// brute force, so correctness is unaffected. Called by the eviction
39/// orchestrator under memory pressure — before this hook the built graph +
40/// its `FlatEmbeddings` corpus stayed resident forever.
41pub fn clear() {
42    if let Ok(mut guard) = cache().lock() {
43        *guard = None;
44    }
45}
46
47/// Approximate resident bytes held by the cached HNSW index (flat embedding
48/// matrix + graph adjacency), 0 when empty. Used by the eviction orchestrator
49/// to weigh the ANN cache against the RSS budget.
50#[must_use]
51pub fn memory_usage_bytes() -> usize {
52    let Ok(guard) = cache().lock() else {
53        return 0;
54    };
55    guard.as_ref().map_or(0, |c| c.index.memory_usage_bytes())
56}
57
58/// Returns the top-k `(index, similarity)` pairs for `query` over `embeddings`,
59/// sorted by descending similarity.
60///
61/// Small corpora use exact brute force. Large corpora build (once) and reuse a
62/// cached HNSW index. Falls back to brute force on lock failure.
63///
64/// The [`FlatEmbeddings`] data is shared via `Arc::clone` (a refcount bump, zero
65/// bytes copied) when building the cached HNSW index.
66#[must_use]
67pub fn topk(embeddings: &FlatEmbeddings, query: &[f32], top_k: usize) -> Vec<(usize, f32)> {
68    topk_gated(embeddings, query, top_k, ANN_MIN_VECTORS)
69}
70
71/// Core implementation with an injectable gate so tests can exercise the HNSW
72/// path without materializing a 50k-vector corpus.
73fn topk_gated(
74    embeddings: &FlatEmbeddings,
75    query: &[f32],
76    top_k: usize,
77    min_vectors: usize,
78) -> Vec<(usize, f32)> {
79    if embeddings.n_vectors() < min_vectors {
80        return brute_force_topk(embeddings, query, top_k);
81    }
82
83    let fp = fingerprint(embeddings);
84    let Ok(mut guard) = cache().lock() else {
85        return brute_force_topk(embeddings, query, top_k);
86    };
87
88    let needs_build = match guard.as_ref() {
89        Some(c) => c.fingerprint != fp,
90        None => true,
91    };
92    if needs_build {
93        *guard = Some(Cached {
94            fingerprint: fp,
95            index: AnnIndex::build(embeddings.clone()),
96        });
97    }
98
99    match guard.as_ref() {
100        Some(c) => c.index.search(query, top_k),
101        None => brute_force_topk(embeddings, query, top_k),
102    }
103}
104
105/// Cheap, content-sensitive fingerprint (FNV-1a over lengths + sampled values).
106/// Operates directly on the flat [`FlatEmbeddings`] buffer.
107fn fingerprint(embeddings: &FlatEmbeddings) -> u64 {
108    let mut h: u64 = 0xcbf2_9ce4_8422_2325;
109    macro_rules! mix {
110        ($x:expr_2021) => {{
111            h ^= $x;
112            h = h.wrapping_mul(0x0000_0100_0000_01b3);
113        }};
114    }
115    let n = embeddings.n_vectors();
116    mix!(n as u64);
117    mix!(embeddings.dim as u64);
118    for i in 0..n {
119        let v = embeddings.get(i);
120        mix!(i as u64);
121        if let Some(&f) = v.first() {
122            mix!(u64::from(f.to_bits()));
123        }
124        if let Some(&f) = v.get(v.len() / 2) {
125            mix!(u64::from(f.to_bits()));
126        }
127        if let Some(&f) = v.last() {
128            mix!(u64::from(f.to_bits()));
129        }
130    }
131    h
132}
133
134/// Cross-module test lock for the process-wide cache slot above. `clear()`
135/// is called from more than one module's tests (this module's own HNSW-path
136/// tests, and `eviction_orchestrator`'s eviction tests) — every test that
137/// builds/reads/clears the shared cache must hold this for its duration, or
138/// a parallel `clear()` from an unrelated test can wipe it mid-assertion.
139/// Poison is recovered since a panic in one test must not cascade into others.
140#[cfg(test)]
141pub(crate) fn test_lock() -> std::sync::MutexGuard<'static, ()> {
142    static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
143    LOCK.get_or_init(|| Mutex::new(()))
144        .lock()
145        .unwrap_or_else(std::sync::PoisonError::into_inner)
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use std::collections::HashSet;
152
153    // Test gate that forces the HNSW path on modest corpora (AnnIndex itself
154    // switches to HNSW at 1000 vectors, so 1000 here exercises the real graph).
155    const TEST_GATE: usize = 1000;
156
157    fn serial() -> std::sync::MutexGuard<'static, ()> {
158        super::test_lock()
159    }
160
161    /// Reads the fingerprint of the currently cached index (test-only
162    /// introspection; `tests` is a child module so it may touch private state).
163    fn cached_fingerprint() -> Option<u64> {
164        cache()
165            .lock()
166            .ok()
167            .and_then(|g| g.as_ref().map(|c| c.fingerprint))
168    }
169
170    fn random_vec(dim: usize, seed: u64) -> Vec<f32> {
171        let mut v = Vec::with_capacity(dim);
172        let mut s = seed;
173        for _ in 0..dim {
174            s = s.wrapping_mul(6_364_136_223_846_793_005).wrapping_add(1);
175            v.push((s as f32 / u64::MAX as f32) * 2.0 - 1.0);
176        }
177        v
178    }
179
180    fn flat_from(vecs: Vec<Vec<f32>>) -> FlatEmbeddings {
181        FlatEmbeddings::from_vecs(vecs)
182    }
183
184    /// A vector near `base` with small per-dimension noise.
185    fn jitter(base: &[f32], seed: u64, scale: f32) -> Vec<f32> {
186        base.iter()
187            .enumerate()
188            .map(|(i, &b)| {
189                let s = seed
190                    .wrapping_add(i as u64)
191                    .wrapping_mul(6_364_136_223_846_793_005)
192                    .wrapping_add(1);
193                b + ((s as f32 / u64::MAX as f32) * 2.0 - 1.0) * scale
194            })
195            .collect()
196    }
197
198    fn clustered(
199        n_clusters: usize,
200        per_cluster: usize,
201        dim: usize,
202    ) -> (FlatEmbeddings, Vec<Vec<f32>>) {
203        let centers: Vec<Vec<f32>> = (0..n_clusters)
204            .map(|c| random_vec(dim, (c as u64 + 1) * 1_000))
205            .collect();
206        let mut vectors = Vec::with_capacity(n_clusters * per_cluster);
207        for (c, center) in centers.iter().enumerate() {
208            for j in 0..per_cluster {
209                vectors.push(jitter(center, (c * per_cluster + j) as u64 + 7, 0.02));
210            }
211        }
212        (flat_from(vectors), centers)
213    }
214
215    #[test]
216    fn small_corpus_matches_brute_force_exactly() {
217        let flat = flat_from((0..200).map(|i| random_vec(32, i)).collect());
218        let query = random_vec(32, 9_999);
219
220        // Production gate (2500) → 200 vectors is below threshold → exact brute force.
221        let via_cache = topk(&flat, &query, 8);
222        let exact = brute_force_topk(&flat, &query, 8);
223
224        assert_eq!(via_cache.len(), exact.len());
225        for (a, b) in via_cache.iter().zip(exact.iter()) {
226            assert_eq!(a.0, b.0, "below threshold must be exact brute force");
227        }
228    }
229
230    #[test]
231    fn hnsw_path_recall_matches_brute_force_on_clusters() {
232        let _serial = serial();
233        let (flat, centers) = clustered(24, 60, 32); // 1440 vectors
234        let query = &centers[5];
235        let k = 20;
236
237        let ann = topk_gated(&flat, query, k, TEST_GATE); // forces HNSW
238        let exact = brute_force_topk(&flat, query, k);
239        assert_eq!(ann.len(), k);
240
241        let exact_set: HashSet<usize> = exact.iter().map(|(i, _)| *i).collect();
242        let overlap = ann.iter().filter(|(i, _)| exact_set.contains(i)).count();
243        assert!(
244            overlap * 100 >= k * 50,
245            "HNSW recall@{k} too low: {overlap}/{k}"
246        );
247    }
248
249    #[test]
250    fn hnsw_path_results_are_descending() {
251        let _serial = serial();
252        let (flat, centers) = clustered(20, 60, 24); // 1200 vectors
253        let results = topk_gated(&flat, &centers[3], 10, TEST_GATE);
254        for w in results.windows(2) {
255            assert!(
256                w[0].1 >= w[1].1,
257                "results must be sorted by descending similarity"
258            );
259        }
260    }
261
262    #[test]
263    fn rebuilds_when_corpus_changes() {
264        let _serial = serial();
265        let (a, ca) = clustered(20, 55, 32); // 1100 vectors
266        let (b, cb) = clustered(18, 60, 32); // 1080 vectors
267
268        let _ = topk_gated(&a, &ca[7], 5, TEST_GATE);
269        assert_eq!(
270            cached_fingerprint(),
271            Some(fingerprint(&a)),
272            "first query caches corpus A's index"
273        );
274
275        let _ = topk_gated(&b, &cb[4], 5, TEST_GATE);
276        assert_eq!(
277            cached_fingerprint(),
278            Some(fingerprint(&b)),
279            "a different corpus must force a rebuild to B"
280        );
281
282        let _ = topk_gated(&a, &ca[7], 5, TEST_GATE);
283        assert_eq!(
284            cached_fingerprint(),
285            Some(fingerprint(&a)),
286            "re-querying A must rebuild A — never serve stale B"
287        );
288    }
289
290    #[test]
291    fn fingerprint_differs_on_content_change() {
292        let a = flat_from((0..10).map(|i| random_vec(8, i)).collect());
293        let mut b_vecs: Vec<Vec<f32>> = (0..10).map(|i| random_vec(8, i)).collect();
294        b_vecs[3][0] += 0.5;
295        let b = flat_from(b_vecs);
296        assert_ne!(fingerprint(&a), fingerprint(&b));
297    }
298}