1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
//! Vector index implementations
//!
//! - [`HNSWIndex`]: approximate nearest neighbours. **The one you want.** Quantization is a
//! [`Storage`] mode on this index, not a separate type — see below.
//! - [`FlatIndex`]: brute force. Exact, O(n) per query. Use it as a control, and for tiny
//! corpora where an approximate index cannot pay for itself.
//!
//! There is exactly ONE approximate index type. There used to be four. `SQ8HNSWIndex`,
//! `RaBitQHNSWIndex` and `PQHNSWIndex` were all deleted — see "The standalone index types, and
//! why they are gone" below.
//!
//! # Quantization is a storage mode, not an index type
//!
//! ```
//! # use foxstash_core::index::{HNSWConfig, HNSWIndex, Storage, DistanceMetric};
//! let config = HNSWConfig {
//! storage: Storage::SQ8, // 8-bit codes inline in the node arena
//! rerank_candidates: 100, // rescore the top pool with exact f32 distances
//! metric: DistanceMetric::L2,
//! ..Default::default()
//! };
//! ```
//!
//! The graph is still *built* with exact f32 distances; only the traversal reads compressed
//! codes. That combination is what makes it fast **and** accurate: on SIFT1M it is 1.20x
//! hnswlib at 99.5% recall@10, because the hot node block shrinks 784 → 400 bytes and HNSW
//! search is memory-latency bound. `rerank_candidates: 0` drops the f32 vectors entirely
//! (0.73x hnswlib's memory) at a recall ceiling near 98.9%.
//!
//! # The standalone index types, and why they are gone
//!
//! Three of them existed. All three are deleted. They shared one shape: a "fat node"
//! (`Vec<HashSet<usize>>` adjacency plus a `String` id and `String` content **per node**), no
//! rayon so builds were sequential, and **no `metric` field at all** — hardcoded L2, while
//! [`HNSWConfig`] defaults to *cosine*. Swapping index type to save memory silently changed the
//! question being asked.
//!
//! * **`SQ8HNSWIndex`** — superseded by [`Storage::SQ8`], which beat it on recall, throughput
//! *and* build time at every `ef`.
//! * **`RaBitQHNSWIndex`** — superseded by [`Storage::RaBitQ`]. Same capability, minus the
//! pathology. (Its one unique trick, a per-query rerank pool, survives as
//! [`HNSWIndex::set_rerank_candidates`].)
//! * **`PQHNSWIndex`** — *not* superseded. Deleted because it was **dominated**. Its selling
//! point was 192x compression of the vector payload, and it could not convert that into a
//! usable index. Measured on GIST (960-d, 100k, L2 — PQ's best case, since it is L2-only):
//!
//! ```text
//! MB recall@10 QPS
//! PQHNSWIndex, no rerank 18 23.07% 1293
//! PQHNSWIndex, rerank 100 402 62.27% 790 <- ceiling
//! PQHNSWIndex, rerank 400 402 60.97% 446 <- gets WORSE
//! Storage::RaBitQ + rerank 440 97.97% 1970
//! Storage::SQ8, no rerank 139 98.40% 760
//! ```
//!
//! The ~62% is a **ceiling, not a knob**: the graph is traversed on PQ codes, so the candidate
//! pool handed to the rescoring stage does not *contain* the true neighbours — and you cannot
//! rerank your way to items you never retrieved. Widening the pool made recall fall. Worse, the
//! compression evaporates precisely when it becomes useful: reaching even 62% requires
//! retaining the f32 vectors (402 MB), at which point [`Storage::RaBitQ`] costs 440 MB and
//! delivers 98%.
//!
//! Note what the docs said before anyone re-measured: **"~55% recall@10"**. That figure was
//! produced with `rerank_candidates` at its default of **0** — the accuracy stage switched off.
//! The true no-rerank number is 23%. A bad number produced by a *disabled feature* makes the
//! feature look inherently bad, and then nobody re-measures it. This library has now been bitten
//! by that four separate times; see `benchmarks/RESULTS.md`.
//!
//! The [`ProductQuantizer`](crate::vector::product_quantize::ProductQuantizer) primitive is kept.
//! It is a perfectly good quantizer. It is just not a viable way to traverse a graph.
//!
//! A plain zero-threshold binary quantizer is not offered: on non-negative data (SIFT, and
//! most embedding models) every bit is set and the code carries no information — it measured
//! 1.2% recall@10. RaBitQ centers each vector before thresholding, which is the whole
//! difference. See `crate::vector::quantize` for the comparison.
//!
//! # Streaming Operations
//!
//! use foxstash_core::index::streaming::{BatchBuilder, BatchConfig};
//! use foxstash_core::index::HNSWIndex;
//! use foxstash_core::Document;
//!
//! let mut index = HNSWIndex::with_defaults(4);
//!
//! let config = BatchConfig::default()
//! .with_batch_size(1000)
//! .with_progress(|p| println!("Progress: {}/{}", p.completed, p.total.unwrap_or(0)));
//!
//! let documents = vec![
//! Document { id: "a".into(), content: "alpha".into(), embedding: vec![1.0, 0.0, 0.0, 0.0], metadata: None },
//! Document { id: "b".into(), content: "beta".into(), embedding: vec![0.0, 1.0, 0.0, 0.0], metadata: None },
//! Document { id: "c".into(), content: "gamma".into(), embedding: vec![0.0, 0.0, 1.0, 0.0], metadata: None },
//! ];
//!
//! let mut builder = BatchBuilder::new(&mut index, config);
//! for doc in documents {
//! builder.add(doc).unwrap();
//! }
//! let result = builder.finish();
//! assert_eq!(result.documents_indexed, 3);
//! ```
pub use FlatIndex;
pub use ;
use crate::;
/// Trait for vector similarity indexes.
///
/// Provides a common interface across all index implementations (HNSW, Flat,
/// SQ8, RaBitQ, PQ). Object-safe — works with `Box<dyn VectorIndex>`.
///
/// Construction is excluded because each index type has different configuration
/// requirements.
/// Extension trait for indexes that retain original embeddings.
///
/// Only HNSW and Flat indexes can return full documents; quantized variants
/// discard original vectors during encoding.