Skip to main content

yo_vector/
hnsw.rs

1//! HNSW as a compatibility view, not as an index (`10` section 7).
2//!
3//! Clients pass `M`, `EF_CONSTRUCTION` and `EF_RUNTIME` to `VADD` and
4//! `FT.CREATE` and expect them to do something, because against Redis and
5//! valkey they do. There is no graph here to point them at.
6//!
7//! There are three things you can do about that and two of them are bad. You
8//! can reject the parameters, which breaks every client that has ever created a
9//! vector index. You can accept them and do nothing, which is worse, because
10//! someone raises `EF_RUNTIME` to fix their recall and nothing happens and they
11//! have no way to find out why. Or you can work out what each one was for and
12//! do that thing, which is what this is.
13//!
14//! # The mapping
15//!
16//! `EF_CONSTRUCTION` is how hard the index works while building. Here that is
17//! the size a posting is split and merged around, which is what decides how
18//! many vectors a probe reads and how finely the space is cut.
19//!
20//! `EF_RUNTIME` is the search beam, the pool of candidates a query keeps. Here
21//! that is [`Tuning::probe`] and [`Tuning::rerank`], which are the two things
22//! that decide how much a query looks at.
23//!
24//! `INITIAL_CAP` is how many vectors are coming, so the postings for them can
25//! be allocated up front instead of grown into.
26//!
27//! `M` is the out degree of a graph. There is no graph, so there is nothing for
28//! it to mean, and it is recorded and echoed back by `FT.INFO` and `VINFO` and
29//! changes nothing. That is not a fudge. Quietly mapping it onto some unrelated
30//! knob so the number looks used would be worse than admitting it does not
31//! apply.
32//!
33//! # What the client is actually promised
34//!
35//! Not that a given `EF_RUNTIME` reads the same number of vectors it would
36//! under HNSW. It does not, and it could not: a graph walk at `EF_RUNTIME` 10
37//! touches tens of vectors and a probe of eight postings here touches two
38//! thousand, because measuring one against a code is a popcount and following a
39//! graph edge is a cache miss. The absolute numbers are not comparable and
40//! pretending they are would be the lie.
41//!
42//! What is promised is the thing a client relies on, which is that the knob
43//! responds: raise `EF_RUNTIME` and the search does proportionally more work
44//! and finds more, lower it and it does less and finds less. That is what
45//! somebody turning it up at three in the morning needs to be true, and
46//! `an_ef_runtime_client_gets_what_it_turned_the_knob_for` measures it rather
47//! than asserting it.
48//!
49//! # When somebody really does want a graph
50//!
51//! [`Compat::Strict`] exists for that, and it does not build one. It reports
52//! [`Plan::Graph`], and the layer above turns that into a refusal, so a client
53//! that asked for HNSW and meant HNSW is told it is not here rather than being
54//! served something else under the name. The default is [`Compat::Permissive`],
55//! where the parameters are honoured as above and the partition index serves.
56//!
57//! ```
58//! use yo_vector::hnsw::{Compat, Plan, Requested};
59//!
60//! // What FT.CREATE sends when nobody set anything.
61//! let asked = Requested::default();
62//! let Plan::Partitions { tuning, .. } = asked.plan(Compat::Permissive) else {
63//!     panic!("permissive serves it")
64//! };
65//! // Redis's defaults come out as ours, so a client that set nothing gets the
66//! // index tuned the way it would have been anyway.
67//! assert_eq!(tuning.posting, yo_vector::Tuning::default().posting);
68//!
69//! // And a client that meant it is not quietly served something else.
70//! assert!(matches!(asked.plan(Compat::Strict), Plan::Graph));
71//! ```
72
73use crate::Tuning;
74
75/// Redis's default `M`, which is echoed and otherwise ignored.
76pub const M: usize = 16;
77/// Redis's default `EF_CONSTRUCTION`, which maps to our default posting size.
78pub const EF_CONSTRUCTION: usize = 200;
79/// Redis's default `EF_RUNTIME`, which maps to our default probe and rerank.
80pub const EF_RUNTIME: usize = 10;
81
82/// What a client asked for when it said HNSW.
83///
84/// Every field is optional because every one of them is optional on the wire,
85/// and the whole thing is `Copy` and public because most of what happens to it
86/// is being handed straight back out by `FT.INFO`.
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
88pub struct Requested {
89    /// The graph out degree, which is recorded and echoed and does nothing.
90    pub m: usize,
91    /// How hard to work while building, which sets the posting size.
92    pub ef_construction: usize,
93    /// The search beam, which sets the probe and the rerank width.
94    pub ef_runtime: usize,
95    /// How many vectors are coming, so the postings can be there already.
96    pub initial_cap: Option<usize>,
97}
98
99impl Default for Requested {
100    /// What Redis uses when the client set nothing.
101    fn default() -> Requested {
102        Requested {
103            m: M,
104            ef_construction: EF_CONSTRUCTION,
105            ef_runtime: EF_RUNTIME,
106            initial_cap: None,
107        }
108    }
109}
110
111/// Whether an HNSW request is served by the partition index or refused.
112#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
113pub enum Compat {
114    /// Honour the parameters, serve with the partition index, and say in
115    /// `FT.INFO` what is really running. This is the default and it is what
116    /// almost everybody wants, because almost nobody asked for a graph, they
117    /// asked for vector search and HNSW is what the last engine called it.
118    #[default]
119    Permissive,
120    /// A request for HNSW means HNSW. Nothing here builds one, so this refuses
121    /// rather than substituting.
122    Strict,
123}
124
125/// What to do with an HNSW request.
126#[derive(Debug, Clone, Copy, PartialEq)]
127pub enum Plan {
128    /// Serve it here, tuned like this, with this many postings ready.
129    Partitions {
130        /// The partition index tuning the parameters map to.
131        tuning: Tuning,
132        /// How many postings to allocate up front, from `INITIAL_CAP`.
133        capacity: usize,
134    },
135    /// The client asked for a real graph under [`Compat::Strict`]. There is not
136    /// one, so the layer above refuses.
137    Graph,
138}
139
140impl Requested {
141    /// What to do about it.
142    #[must_use]
143    pub fn plan(&self, compat: Compat) -> Plan {
144        match compat {
145            Compat::Strict => Plan::Graph,
146            Compat::Permissive => Plan::Partitions {
147                tuning: self.tuning(),
148                capacity: self.capacity(),
149            },
150        }
151    }
152
153    /// The partition index tuning these parameters ask for.
154    ///
155    /// Everything is scaled against the defaults, so a client that set nothing
156    /// gets exactly [`Tuning::default`] and a client that doubled a parameter
157    /// gets roughly double whatever it controls. The clamps at either end are
158    /// there because these numbers arrive from the network and nothing stops
159    /// somebody sending `EF_RUNTIME 4000000000`.
160    #[must_use]
161    pub fn tuning(&self) -> Tuning {
162        let base = Tuning::default();
163        Tuning {
164            posting: scale(base.posting, self.ef_construction, EF_CONSTRUCTION).clamp(32, 4096),
165            probe: scale(base.probe, self.ef_runtime, EF_RUNTIME).clamp(1, 256),
166            rerank: scale(base.rerank, self.ef_runtime, EF_RUNTIME).clamp(1, 64),
167            // Not derived from anything HNSW has, because HNSW has no filters
168            // to widen for.
169            ..base
170        }
171    }
172
173    /// How many postings to have ready, from `INITIAL_CAP`.
174    ///
175    /// Zero when the client did not say, which means grow into it as usual.
176    #[must_use]
177    pub fn capacity(&self) -> usize {
178        match self.initial_cap {
179            Some(n) if n > 0 => n.div_ceil(self.tuning().posting),
180            _ => 0,
181        }
182    }
183}
184
185/// `base * asked / default`, rounded rather than truncated so that halving a
186/// small number does not land on zero by accident.
187fn scale(base: usize, asked: usize, default: usize) -> usize {
188    let asked = asked.min(1 << 24);
189    (base * asked + default / 2) / default
190}
191
192#[cfg(test)]
193mod tests {
194    use super::*;
195    use crate::{Bits, Partitions, Vectors};
196    use yo_common::Rng;
197
198    #[test]
199    fn the_defaults_a_client_did_not_set_are_the_defaults_it_would_have_got() {
200        let tuning = Requested::default().tuning();
201        let base = Tuning::default();
202        assert_eq!(tuning.posting, base.posting);
203        assert_eq!(tuning.probe, base.probe);
204        assert_eq!(tuning.rerank, base.rerank);
205        assert_eq!(tuning.sweep, base.sweep);
206        assert_eq!(tuning.widen, base.widen);
207        assert_eq!(Requested::default().capacity(), 0);
208    }
209
210    #[test]
211    fn m_is_echoed_and_changes_nothing() {
212        let base = Requested::default();
213        let plenty = Requested { m: 512, ..base };
214        assert_eq!(plenty.tuning().posting, base.tuning().posting);
215        assert_eq!(plenty.tuning().probe, base.tuning().probe);
216        // And it comes back out for FT.INFO, which is all it was ever for.
217        assert_eq!(plenty.m, 512);
218    }
219
220    #[test]
221    fn ef_construction_moves_the_posting_size_and_nothing_else() {
222        let base = Requested::default();
223        let harder = Requested {
224            ef_construction: EF_CONSTRUCTION * 2,
225            ..base
226        };
227        assert_eq!(harder.tuning().posting, base.tuning().posting * 2);
228        assert_eq!(harder.tuning().probe, base.tuning().probe);
229    }
230
231    #[test]
232    fn ef_runtime_moves_the_probe_and_the_rerank_and_nothing_else() {
233        let base = Requested::default();
234        let wider = Requested {
235            ef_runtime: EF_RUNTIME * 4,
236            ..base
237        };
238        assert_eq!(wider.tuning().probe, base.tuning().probe * 4);
239        assert_eq!(wider.tuning().rerank, base.tuning().rerank * 4);
240        assert_eq!(wider.tuning().posting, base.tuning().posting);
241    }
242
243    #[test]
244    fn a_number_off_the_network_cannot_ask_for_something_absurd() {
245        let silly = Requested {
246            m: usize::MAX,
247            ef_construction: usize::MAX,
248            ef_runtime: usize::MAX,
249            initial_cap: Some(usize::MAX),
250        };
251        let tuning = silly.tuning();
252        assert_eq!(tuning.posting, 4096);
253        assert_eq!(tuning.probe, 256);
254        assert_eq!(tuning.rerank, 64);
255
256        // And nothing goes to zero at the other end, because a probe of zero is
257        // an index that answers nothing.
258        let none = Requested {
259            ef_construction: 0,
260            ef_runtime: 0,
261            ..Requested::default()
262        };
263        assert_eq!(none.tuning().posting, 32);
264        assert_eq!(none.tuning().probe, 1);
265        assert_eq!(none.tuning().rerank, 1);
266    }
267
268    #[test]
269    fn initial_cap_asks_for_the_postings_the_vectors_will_need() {
270        let asked = Requested {
271            initial_cap: Some(100_000),
272            ..Requested::default()
273        };
274        // A hundred thousand vectors at 256 to a posting.
275        assert_eq!(asked.capacity(), 391);
276
277        // And it follows EF_CONSTRUCTION, because that is what sets the size.
278        let bigger = Requested {
279            ef_construction: EF_CONSTRUCTION * 2,
280            ..asked
281        };
282        assert_eq!(bigger.capacity(), 196);
283    }
284
285    #[test]
286    fn strict_refuses_rather_than_serving_something_else() {
287        let asked = Requested::default();
288        assert_eq!(asked.plan(Compat::Strict), Plan::Graph);
289        assert_eq!(
290            asked.plan(Compat::Permissive),
291            Plan::Partitions {
292                tuning: asked.tuning(),
293                capacity: 0,
294            }
295        );
296        // Permissive is the default, because almost nobody asked for a graph.
297        assert_eq!(Compat::default(), Compat::Permissive);
298    }
299
300    struct Store(Vec<Vec<f32>>);
301
302    impl Vectors for Store {
303        fn get(&self, id: u64, into: &mut [f32]) -> bool {
304            match self.0.get(id as usize) {
305                Some(v) => {
306                    into.copy_from_slice(v);
307                    true
308                }
309                None => false,
310            }
311        }
312    }
313
314    fn corpus(dim: usize, n: usize, clusters: usize, seed: u64) -> Store {
315        let mut rng = Rng::new(seed);
316        let centres: Vec<Vec<f32>> = (0..clusters).map(|_| draw(dim, &mut rng)).collect();
317        Store(
318            (0..n)
319                .map(|i| {
320                    let off = draw(dim, &mut rng);
321                    let mut v: Vec<f32> = centres[i % clusters]
322                        .iter()
323                        .zip(&off)
324                        .map(|(c, o)| c + o * 0.7)
325                        .collect();
326                    unit(&mut v);
327                    v
328                })
329                .collect(),
330        )
331    }
332
333    fn draw(dim: usize, rng: &mut Rng) -> Vec<f32> {
334        let mut v: Vec<f32> = (0..dim)
335            .map(|i| {
336                let u = (rng.next_u64() >> 40) as f32 / (1u32 << 24) as f32;
337                let heavy = if i < dim / 16 { 6.0 } else { 1.0 };
338                (u * 2.0 - 1.0) * heavy
339            })
340            .collect();
341        unit(&mut v);
342        v
343    }
344
345    fn unit(v: &mut [f32]) {
346        let len = v.iter().map(|c| c * c).sum::<f32>().sqrt();
347        for c in v {
348            *c /= len;
349        }
350    }
351
352    fn truth(q: &[f32], store: &Store, k: usize) -> Vec<u64> {
353        let mut all: Vec<(u64, f32)> = store
354            .0
355            .iter()
356            .enumerate()
357            .map(|(i, v)| {
358                (
359                    i as u64,
360                    q.iter().zip(v).map(|(a, b)| (a - b) * (a - b)).sum::<f32>(),
361                )
362            })
363            .collect();
364        all.sort_by(|a, b| a.1.total_cmp(&b.1));
365        all[..k].iter().map(|(i, _)| *i).collect()
366    }
367
368    /// Build one index at the posting size the parameters ask for, then search
369    /// it at the probe and rerank they ask for, and say what fraction of the
370    /// true nearest ten came back.
371    fn recall(dim: usize, asked: Requested, seed: u64) -> f32 {
372        let store = corpus(dim, 4000, 24, seed);
373        let mut ix = Partitions::new(dim, Bits::One, 7, asked.tuning());
374        for (id, v) in store.0.iter().enumerate() {
375            ix.insert(id as u64, v);
376            if id % 128 == 0 {
377                ix.maintain(&store, 4096);
378            }
379        }
380        ix.maintain(&store, 1 << 20);
381
382        let queries = corpus(dim, 30, 24, seed ^ 0x5eed);
383        let mut hits = 0usize;
384        for q in &queries.0 {
385            let want = truth(q, &store, 10);
386            let got: Vec<u64> = ix.search(q, 10, &store).into_iter().map(|h| h.id).collect();
387            hits += want.iter().filter(|id| got.contains(id)).count();
388        }
389        hits as f32 / (queries.0.len() * 10) as f32
390    }
391
392    /// The promise the mapping actually makes, measured rather than asserted.
393    ///
394    /// Not that a given `EF_RUNTIME` reads the same vectors it would under a
395    /// graph, which it does not and could not. That turning it up finds more,
396    /// which is the thing somebody turning it up is relying on.
397    #[test]
398    fn an_ef_runtime_client_gets_what_it_turned_the_knob_for() {
399        let dim = 64;
400        let low = recall(
401            dim,
402            Requested {
403                ef_runtime: 1,
404                ..Requested::default()
405            },
406            0x1379,
407        );
408        let high = recall(
409            dim,
410            Requested {
411                ef_runtime: EF_RUNTIME * 8,
412                ..Requested::default()
413            },
414            0x1379,
415        );
416        assert!(
417            high > low + 0.05,
418            "raising EF_RUNTIME went from {low} to {high}, which is not a knob doing anything"
419        );
420        assert!(high >= 0.95, "the top of the range only reached {high}");
421    }
422
423    /// The same for `EF_CONSTRUCTION`, which is a build time knob, so what it
424    /// buys is a finer cut of the space for the same probe.
425    #[test]
426    fn an_ef_construction_client_gets_what_it_turned_the_knob_for() {
427        let dim = 64;
428        let coarse = recall(
429            dim,
430            Requested {
431                ef_construction: EF_CONSTRUCTION * 8,
432                ef_runtime: 2,
433                ..Requested::default()
434            },
435            0x2468,
436        );
437        let fine = recall(
438            dim,
439            Requested {
440                ef_construction: EF_CONSTRUCTION / 4,
441                ef_runtime: 2,
442                ..Requested::default()
443            },
444            0x2468,
445        );
446        assert!(
447            fine > coarse + 0.05,
448            "smaller postings for the same probe went from {coarse} to {fine}"
449        );
450    }
451}