iqdb_flat/flat.rs
1//! [`FlatIndex`] — brute-force exact nearest-neighbour search.
2//!
3//! Storage is four parallel `Vec`s of length `len()` (the row payload
4//! `Vec<Arc<[f32]>>`, ids `Vec<VectorId>`, optional metadata
5//! `Vec<Option<Metadata>>`, and insertion-sequence numbers `Vec<u64>`),
6//! plus an `id_to_pos: HashMap<VectorId, usize>` that points at the
7//! current Vec position of each live id.
8//!
9//! The row payload is wrapped in [`std::sync::Arc`] so a consumer that
10//! also keeps the vector in its own record store can share a single
11//! payload allocation with the index. `FlatIndex` pushes the `Arc` it
12//! receives via [`IndexCore::insert`] verbatim; it never allocates a fresh
13//! `[f32]` buffer of its own. Search paths reborrow each `Arc` as `&[f32]`
14//! for the distance kernels, so the per-row distance loop is identical to a
15//! plain `Vec<Vec<f32>>` layout while keeping the zero-copy insert.
16//!
17//! Insert and delete are both amortized `O(1)` against the corpus size:
18//!
19//! - **Insert.** Reject if `id_to_pos` already contains `id` (`Duplicate`);
20//! else push the row, id, metadata, and a fresh monotonic
21//! `seq = next_seq++` to the four parallel `Vec`s and record
22//! `id_to_pos[id] = len - 1`.
23//! - **Delete.** Look up `pos = id_to_pos.remove(id)?`. `swap_remove` each
24//! of the four parallel `Vec`s at `pos`. If `pos < new_len`, the entry
25//! formerly at the back is now at `pos`; update its `id_to_pos` slot.
26//!
27//! The stable tiebreaker on top-`k` keys off the stored `seq`, not the
28//! row's position, so `swap_remove`'s reordering does not change query
29//! results. The "earlier-inserted-wins" semantic is preserved exactly: a
30//! re-inserted id gets a fresh `next_seq`, which is larger than every
31//! existing `seq`, and therefore sorts last in ties.
32//!
33//! A search scans every entry (subject to an optional metadata
34//! pre-filter), computes the distance for each through
35//! [`iqdb_distance::compute_batch`], normalises `DotProduct` to honour
36//! `Hit.distance`'s "smaller is nearer" contract, selects the top-`k`
37//! via [`crate::topk::select_topk_indices`] keyed by `(distance, seq)`,
38//! and returns the chosen [`Hit`]s in best-first order.
39
40use std::collections::HashMap;
41use std::mem::size_of;
42use std::sync::Arc;
43
44use iqdb_filter::FilterEvaluator;
45use iqdb_index::{Index, IndexCore, IndexStats};
46use iqdb_types::{
47 DistanceMetric, Filter, Hit, IqdbError, Metadata, Result, SearchParams, VectorId,
48};
49
50use crate::topk;
51
52/// Configuration for [`FlatIndex::new`].
53///
54/// Unit struct: the flat index has no tunable knobs. It exists so
55/// [`FlatIndex`] satisfies [`iqdb_index::Index`]'s associated
56/// [`Config`](iqdb_index::Index::Config) bound (`Default + Clone`), and so
57/// future knobs (initial capacity, parallel chunk size) can land here
58/// without changing the trait surface.
59///
60/// # Examples
61///
62/// ```
63/// use iqdb_flat::FlatConfig;
64///
65/// let config = FlatConfig;
66/// let _cloned = config.clone();
67/// ```
68#[derive(Debug, Default, Clone, PartialEq, Eq)]
69pub struct FlatConfig;
70
71/// Brute-force exact nearest-neighbour index.
72///
73/// See the crate-level docs for the design notes and the
74/// [`iqdb_index::IndexCore`] / [`iqdb_index::Index`] contracts this type
75/// satisfies.
76///
77/// # Examples
78///
79/// ```
80/// use std::sync::Arc;
81///
82/// use iqdb_flat::{FlatConfig, FlatIndex};
83/// use iqdb_index::{Index, IndexCore};
84/// use iqdb_types::{DistanceMetric, SearchParams, VectorId};
85///
86/// # fn main() -> iqdb_types::Result<()> {
87/// let mut idx = FlatIndex::new(2, DistanceMetric::Euclidean, FlatConfig)?;
88/// idx.insert(VectorId::from(1u64), Arc::<[f32]>::from(&[0.0, 0.0][..]), None)?;
89/// idx.insert(VectorId::from(2u64), Arc::<[f32]>::from(&[3.0, 4.0][..]), None)?;
90///
91/// let hits = idx.search(&[0.0, 0.0], &SearchParams::new(1, DistanceMetric::Euclidean))?;
92/// assert_eq!(hits.len(), 1);
93/// assert_eq!(hits[0].id, VectorId::U64(1));
94/// # Ok(())
95/// # }
96/// ```
97#[derive(Debug)]
98pub struct FlatIndex {
99 dim: usize,
100 metric: DistanceMetric,
101 vectors: Vec<Arc<[f32]>>,
102 ids: Vec<VectorId>,
103 metadata: Vec<Option<Metadata>>,
104 /// Monotonic insertion-sequence number per row, parallel to `vectors`.
105 /// Top-`k` selection tie-breaks on this — *not* on the row's position —
106 /// so `swap_remove` does not change query results.
107 seqs: Vec<u64>,
108 /// Next sequence number to assign on insert. Monotonically increasing;
109 /// never recycled. A re-inserted id therefore gets a fresh `seq` larger
110 /// than every existing `seq` and sorts last in ties.
111 next_seq: u64,
112 /// Live id → current Vec position. Maintained on insert and on the
113 /// `swap_remove` step of delete. Keeps duplicate checks and lookups
114 /// `O(1)` regardless of corpus size.
115 id_to_pos: HashMap<VectorId, usize>,
116}
117
118impl FlatIndex {
119 /// Builds an empty index for `dim`-component vectors compared under
120 /// `metric`.
121 ///
122 /// Returns [`IqdbError::InvalidConfig`] when `dim == 0`. This is the
123 /// same construction surface as [`Index::new`]; calling it directly
124 /// is the convenient path when the concrete type is known and there is
125 /// nothing to configure.
126 ///
127 /// # Examples
128 ///
129 /// ```
130 /// use iqdb_flat::FlatIndex;
131 /// use iqdb_types::DistanceMetric;
132 ///
133 /// let idx = FlatIndex::new_unconfigured(3, DistanceMetric::Cosine)?;
134 /// assert_eq!(idx.dim(), 3);
135 /// assert!(idx.is_empty());
136 /// # Ok::<(), iqdb_types::IqdbError>(())
137 /// ```
138 pub fn new_unconfigured(dim: usize, metric: DistanceMetric) -> Result<Self> {
139 if dim == 0 {
140 return Err(IqdbError::InvalidConfig {
141 reason: "FlatIndex dim must be greater than zero",
142 });
143 }
144 Ok(Self {
145 dim,
146 metric,
147 vectors: Vec::new(),
148 ids: Vec::new(),
149 metadata: Vec::new(),
150 seqs: Vec::new(),
151 next_seq: 0,
152 id_to_pos: HashMap::new(),
153 })
154 }
155
156 /// The dimensionality the index was built for.
157 ///
158 /// # Examples
159 ///
160 /// ```
161 /// use iqdb_flat::{FlatConfig, FlatIndex};
162 /// use iqdb_index::Index;
163 /// use iqdb_types::DistanceMetric;
164 ///
165 /// let idx = FlatIndex::new(8, DistanceMetric::Euclidean, FlatConfig)?;
166 /// assert_eq!(idx.dim(), 8);
167 /// # Ok::<(), iqdb_types::IqdbError>(())
168 /// ```
169 #[must_use]
170 pub fn dim(&self) -> usize {
171 self.dim
172 }
173
174 /// The distance metric the index was built for.
175 ///
176 /// # Examples
177 ///
178 /// ```
179 /// use iqdb_flat::{FlatConfig, FlatIndex};
180 /// use iqdb_index::Index;
181 /// use iqdb_types::DistanceMetric;
182 ///
183 /// let idx = FlatIndex::new(8, DistanceMetric::Cosine, FlatConfig)?;
184 /// assert_eq!(idx.metric(), DistanceMetric::Cosine);
185 /// # Ok::<(), iqdb_types::IqdbError>(())
186 /// ```
187 #[must_use]
188 pub fn metric(&self) -> DistanceMetric {
189 self.metric
190 }
191
192 /// The number of searchable vectors in the index.
193 ///
194 /// # Examples
195 ///
196 /// ```
197 /// use std::sync::Arc;
198 ///
199 /// use iqdb_flat::{FlatConfig, FlatIndex};
200 /// use iqdb_index::{Index, IndexCore};
201 /// use iqdb_types::{DistanceMetric, VectorId};
202 ///
203 /// let mut idx = FlatIndex::new(1, DistanceMetric::Euclidean, FlatConfig)?;
204 /// assert_eq!(idx.len(), 0);
205 /// idx.insert(VectorId::from(1u64), Arc::<[f32]>::from(&[0.0][..]), None)?;
206 /// assert_eq!(idx.len(), 1);
207 /// # Ok::<(), iqdb_types::IqdbError>(())
208 /// ```
209 #[must_use]
210 pub fn len(&self) -> usize {
211 self.ids.len()
212 }
213
214 /// Returns `true` when the index holds no vectors.
215 ///
216 /// # Examples
217 ///
218 /// ```
219 /// use iqdb_flat::{FlatConfig, FlatIndex};
220 /// use iqdb_index::Index;
221 /// use iqdb_types::DistanceMetric;
222 ///
223 /// let idx = FlatIndex::new(1, DistanceMetric::Euclidean, FlatConfig)?;
224 /// assert!(idx.is_empty());
225 /// # Ok::<(), iqdb_types::IqdbError>(())
226 /// ```
227 #[must_use]
228 pub fn is_empty(&self) -> bool {
229 self.ids.is_empty()
230 }
231
232 fn check_dim(&self, vector_len: usize) -> Result<()> {
233 if vector_len != self.dim {
234 return Err(IqdbError::DimensionMismatch {
235 expected: self.dim,
236 found: vector_len,
237 });
238 }
239 Ok(())
240 }
241
242 /// Approximate resident footprint of the index, in bytes.
243 ///
244 /// Counts the row payload once. The value feeds
245 /// [`IndexStats::memory_bytes`], which is documented as best-effort for
246 /// capacity dashboards, not exact accounting.
247 fn approximate_memory_bytes(&self) -> usize {
248 // `Arc<[f32]>` allocates exactly `len * size_of::<f32>()` of
249 // payload (no spare capacity) plus a fixed header for the strong
250 // and weak refcounts. The header is two `usize`s — the documented
251 // `ArcInner` layout for sized slices — independent of the payload
252 // length.
253 let arc_header_bytes = 2 * size_of::<usize>();
254 let vectors_bytes = self
255 .vectors
256 .iter()
257 .map(|arc| arc.len() * size_of::<f32>() + arc_header_bytes)
258 .sum::<usize>()
259 + self.vectors.capacity() * size_of::<Arc<[f32]>>();
260 let ids_bytes = self.ids.capacity() * size_of::<VectorId>();
261 let metadata_bytes = self.metadata.capacity() * size_of::<Option<Metadata>>();
262 let seqs_bytes = self.seqs.capacity() * size_of::<u64>();
263 // `HashMap` capacity overhead is implementation-defined; this is a
264 // rough lower bound (key + value per slot) and matches the
265 // "approximate" contract on `IndexStats::memory_bytes`.
266 let id_to_pos_bytes =
267 self.id_to_pos.capacity() * (size_of::<VectorId>() + size_of::<usize>());
268 vectors_bytes + ids_bytes + metadata_bytes + seqs_bytes + id_to_pos_bytes
269 }
270
271 /// Filter-None search hot path.
272 ///
273 /// The candidate slice references are the only N-sized allocation
274 /// this path makes beyond the distance buffer and the returned hits;
275 /// `seqs` is read directly from `self.seqs[..]` and the top-`k`
276 /// `chosen` indices are *storage* indices, mapped straight back into
277 /// `self.ids` and `self.metadata`. No `accepted: Vec<usize>` /
278 /// `accepted_seqs: Vec<u64>` indirection is built (that is the
279 /// filter-Some path's cost). This keeps a no-filter search's
280 /// allocation count independent of corpus size — see
281 /// `tests/no_alloc.rs`.
282 fn search_unfiltered(&self, query: &[f32], k: usize) -> Result<Vec<Hit>> {
283 // Reborrow each `Arc<[f32]>` as `&[f32]` for the distance kernel.
284 let candidates: Vec<&[f32]> = self.vectors.iter().map(|arc| &arc[..]).collect();
285 let mut distances = vec![0.0_f32; candidates.len()];
286 compute_distances(self.metric, query, &candidates, &mut distances)?;
287
288 // DotProduct: raw value is "larger is more similar"; flip the
289 // sign so `Hit.distance` follows the "smaller is nearer" contract
290 // for every metric.
291 if matches!(self.metric, DistanceMetric::DotProduct) {
292 for value in distances.iter_mut() {
293 *value = -*value;
294 }
295 }
296
297 let chosen = topk::select_topk_indices(&distances, &self.seqs, k);
298 let mut hits = Vec::with_capacity(chosen.len());
299 for storage_idx in chosen {
300 hits.push(Hit {
301 id: self.ids[storage_idx].clone(),
302 distance: distances[storage_idx],
303 metadata: self.metadata[storage_idx].clone(),
304 });
305 }
306 Ok(hits)
307 }
308
309 /// Filter-Some search path. Validates the filter once via
310 /// [`FilterEvaluator::new`] (enforces depth and `In` caps;
311 /// pathological filters surface as [`IqdbError::InvalidFilter`]),
312 /// then collects surviving storage indices, the parallel `seqs`
313 /// slice the tie-breaker needs, and the candidate slice references
314 /// the per-row distance loop consumes.
315 fn search_filtered(&self, query: &[f32], k: usize, filter: &Filter) -> Result<Vec<Hit>> {
316 let evaluator = FilterEvaluator::new(filter.clone())?;
317 let accepted: Vec<usize> = (0..self.ids.len())
318 .filter(|&i| evaluator.evaluate(self.metadata[i].as_ref()))
319 .collect();
320 if accepted.is_empty() {
321 return Ok(Vec::new());
322 }
323
324 let candidates: Vec<&[f32]> = accepted.iter().map(|&i| &self.vectors[i][..]).collect();
325 let accepted_seqs: Vec<u64> = accepted.iter().map(|&i| self.seqs[i]).collect();
326 let mut distances = vec![0.0_f32; candidates.len()];
327 compute_distances(self.metric, query, &candidates, &mut distances)?;
328
329 if matches!(self.metric, DistanceMetric::DotProduct) {
330 for value in distances.iter_mut() {
331 *value = -*value;
332 }
333 }
334
335 let chosen = topk::select_topk_indices(&distances, &accepted_seqs, k);
336 let mut hits = Vec::with_capacity(chosen.len());
337 // INVARIANT: `chosen[i]` is a *candidate-space* index — i.e. a
338 // position into `distances` and `accepted_seqs`, NOT a storage
339 // position. `search_unfiltered` works directly in storage space
340 // (no `accepted` indirection); the two paths intentionally differ
341 // here. Changing `topk::select_topk_indices`'s index space would
342 // silently produce wrong distances unless both call sites update.
343 for candidate_idx in chosen {
344 let storage_idx = accepted[candidate_idx];
345 hits.push(Hit {
346 id: self.ids[storage_idx].clone(),
347 distance: distances[candidate_idx],
348 metadata: self.metadata[storage_idx].clone(),
349 });
350 }
351 Ok(hits)
352 }
353}
354
355impl IndexCore for FlatIndex {
356 fn insert(
357 &mut self,
358 id: VectorId,
359 vector: Arc<[f32]>,
360 metadata: Option<Metadata>,
361 ) -> Result<()> {
362 self.check_dim(vector.len())?;
363 // Duplicate check is O(1) via the id→position map. The dimension
364 // check fires before this so a bad-dim insert with an already-known
365 // id still surfaces `DimensionMismatch`, not `Duplicate`.
366 if self.id_to_pos.contains_key(&id) {
367 return Err(IqdbError::Duplicate);
368 }
369 let pos = self.ids.len();
370 let seq = self.next_seq;
371 self.next_seq = self
372 .next_seq
373 .checked_add(1)
374 .ok_or(IqdbError::InvalidConfig {
375 reason: "FlatIndex insertion sequence counter overflowed u64",
376 })?;
377 // Take ownership of the caller's `Arc<[f32]>` directly — no payload
378 // copy. A caller that keeps its own strong reference shares the
379 // underlying `[f32]` allocation one-to-one with the index.
380 self.vectors.push(vector);
381 self.ids.push(id.clone());
382 self.metadata.push(metadata);
383 self.seqs.push(seq);
384 let _prev = self.id_to_pos.insert(id, pos);
385 Ok(())
386 }
387
388 /// Reserves capacity for all backing stores up front, then inserts each
389 /// item via [`insert`](IndexCore::insert).
390 ///
391 /// This is the same fail-fast contract as the trait default (the first
392 /// error returns immediately; inserts before it remain), but a single
393 /// `reserve(items.len())` on each of the four `Vec`s and the
394 /// `HashMap` replaces the `O(log n)` incremental reallocations the
395 /// default loop would trigger — a measurable win for bulk loads.
396 fn insert_batch(&mut self, items: Vec<(VectorId, Arc<[f32]>, Option<Metadata>)>) -> Result<()> {
397 let additional = items.len();
398 self.vectors.reserve(additional);
399 self.ids.reserve(additional);
400 self.metadata.reserve(additional);
401 self.seqs.reserve(additional);
402 self.id_to_pos.reserve(additional);
403 for (id, vector, metadata) in items {
404 self.insert(id, vector, metadata)?;
405 }
406 Ok(())
407 }
408
409 fn delete(&mut self, id: &VectorId) -> Result<()> {
410 let pos = self.id_to_pos.remove(id).ok_or(IqdbError::NotFound)?;
411 // swap_remove: O(1) per Vec; the row formerly at the back is moved
412 // to `pos`. If that swap actually moved a row (i.e. `pos` wasn't
413 // already the back), patch the swapped-in row's `id_to_pos` slot so
414 // it still points at its new position. The `seq` it carries is
415 // preserved — that is how the tiebreaker survives reordering.
416 let _v = self.vectors.swap_remove(pos);
417 let _i = self.ids.swap_remove(pos);
418 let _m = self.metadata.swap_remove(pos);
419 let _s = self.seqs.swap_remove(pos);
420 if pos < self.ids.len() {
421 let _prev = self.id_to_pos.insert(self.ids[pos].clone(), pos);
422 }
423 Ok(())
424 }
425
426 /// Searches for the top-`k` nearest neighbours under `params.metric`.
427 ///
428 /// Returns [`IqdbError::DimensionMismatch`] if `query.len() != self.dim`,
429 /// [`IqdbError::InvalidMetric`] if the params metric does not match the
430 /// index's, and [`IqdbError::InvalidFilter`] if `params.filter` is
431 /// supplied and rejected by [`FilterEvaluator::new`] (depth or `In`
432 /// cardinality past `iqdb_filter`'s `MAX_FILTER_DEPTH` /
433 /// `MAX_IN_VALUES`). A pathological filter surfaces as a clean error
434 /// rather than overflowing the search thread.
435 fn search(&self, query: &[f32], params: &SearchParams) -> Result<Vec<Hit>> {
436 self.check_dim(query.len())?;
437 if params.metric != self.metric {
438 return Err(IqdbError::InvalidMetric);
439 }
440 if params.k == 0 || self.ids.is_empty() {
441 return Ok(Vec::new());
442 }
443
444 // Two paths. The filter-None hot path skips the `accepted` /
445 // `accepted_seqs` materialisations the filter-Some path needs to
446 // map back through — two N-sized allocations gone per search when
447 // no filter is supplied (the common case). The filter-Some path is
448 // unchanged because every allocation there carries information the
449 // post-distance stage cannot recover.
450 match ¶ms.filter {
451 None => self.search_unfiltered(query, params.k),
452 Some(filter) => self.search_filtered(query, params.k, filter),
453 }
454 }
455
456 fn len(&self) -> usize {
457 FlatIndex::len(self)
458 }
459
460 fn is_empty(&self) -> bool {
461 FlatIndex::is_empty(self)
462 }
463
464 fn dim(&self) -> usize {
465 FlatIndex::dim(self)
466 }
467
468 fn metric(&self) -> DistanceMetric {
469 FlatIndex::metric(self)
470 }
471
472 fn flush(&mut self) -> Result<()> {
473 // Flat is purely in-memory; nothing to flush.
474 Ok(())
475 }
476
477 fn stats(&self) -> IndexStats {
478 IndexStats {
479 n_vectors: self.ids.len(),
480 memory_bytes: self.approximate_memory_bytes(),
481 disk_bytes: None,
482 index_type: "flat",
483 // FlatIndex has no per-kind counters; reporting `None` avoids
484 // allocating an empty HashMap on every `stats()` call.
485 extra: None,
486 }
487 }
488}
489
490impl Index for FlatIndex {
491 type Config = FlatConfig;
492
493 fn new(dim: usize, metric: DistanceMetric, _config: Self::Config) -> Result<Self> {
494 Self::new_unconfigured(dim, metric)
495 }
496}
497
498fn compute_distances(
499 metric: DistanceMetric,
500 query: &[f32],
501 candidates: &[&[f32]],
502 out: &mut [f32],
503) -> Result<()> {
504 #[cfg(feature = "parallel")]
505 {
506 crate::parallel::compute_distances(metric, query, candidates, out)
507 }
508 #[cfg(not(feature = "parallel"))]
509 {
510 iqdb_distance::compute_batch(metric, query, candidates, out)
511 }
512}
513
514#[cfg(test)]
515mod tests {
516 //! Pointer-identity proof for the zero-copy insert contract.
517 //!
518 //! [`IndexCore::insert`] takes the caller's `Arc<[f32]>` by value;
519 //! [`FlatIndex`] stores it verbatim without allocating a fresh payload.
520 //! A consumer uses this to share one underlying allocation between its
521 //! record store and the index row.
522
523 #![allow(clippy::unwrap_used)]
524
525 use super::*;
526
527 #[test]
528 fn insert_stores_caller_arc_without_reallocating_payload() {
529 let mut idx = FlatIndex::new_unconfigured(3, DistanceMetric::Euclidean).unwrap();
530 let payload: Arc<[f32]> = Arc::from(&[1.0_f32, 2.0, 3.0][..]);
531 let caller_ptr = Arc::as_ptr(&payload);
532
533 idx.insert(VectorId::from(1u64), Arc::clone(&payload), None)
534 .unwrap();
535
536 let stored = &idx.vectors[0];
537 assert_eq!(
538 Arc::as_ptr(stored),
539 caller_ptr,
540 "FlatIndex MUST store the caller's Arc verbatim — no fresh \
541 allocation, no copy",
542 );
543 // Caller + index = 2 strong refs.
544 assert_eq!(Arc::strong_count(&payload), 2);
545 }
546
547 #[test]
548 fn delete_drops_the_stored_strong_ref() {
549 let mut idx = FlatIndex::new_unconfigured(2, DistanceMetric::Cosine).unwrap();
550 let payload: Arc<[f32]> = Arc::from(&[0.5_f32, -0.5][..]);
551 idx.insert(VectorId::from(9u64), Arc::clone(&payload), None)
552 .unwrap();
553 assert_eq!(Arc::strong_count(&payload), 2);
554
555 idx.delete(&VectorId::from(9u64)).unwrap();
556 assert_eq!(
557 Arc::strong_count(&payload),
558 1,
559 "delete drops the index's strong ref; only the caller's remains",
560 );
561 }
562}