Skip to main content

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 &params.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}