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}