Skip to main content

recern_vector/
collection.rs

1use std::collections::{BinaryHeap, HashMap};
2use std::fmt;
3use std::time::{Duration, Instant};
4
5use serde_json::Value;
6
7use crate::error::{Error, Result};
8use crate::filter::Filter;
9use crate::hnsw::{Candidate, Counters, Graph, HnswParams, Vectors};
10use crate::metric::{self, Metric};
11use crate::rng::SplitMix64;
12
13pub(crate) const DEFAULT_SEED: u64 = 0x5245_4345_524E_5645;
14const MAX_DIM: usize = 65_536;
15
16const SELECTIVITY_SAMPLE: usize = 512;
17
18/// Storage precision. Int8 uses a symmetric scale per vector; original floats
19/// are not retained. Exact searches are exact over the stored approximation.
20#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
21pub enum Quantization {
22    #[default]
23    F32,
24    Int8,
25}
26impl Quantization {
27    pub fn as_str(self) -> &'static str {
28        match self {
29            Self::F32 => "f32",
30            Self::Int8 => "int8",
31        }
32    }
33}
34
35#[derive(Clone, Copy, Debug, PartialEq, Eq)]
36pub struct CollectionConfig {
37    pub dim: usize,
38    pub metric: Metric,
39    pub hnsw: HnswParams,
40    pub quantization: Quantization,
41}
42
43impl CollectionConfig {
44    pub fn new(dim: usize, metric: Metric) -> Self {
45        Self {
46            dim,
47            metric,
48            hnsw: HnswParams::default(),
49            quantization: Quantization::F32,
50        }
51    }
52
53    pub fn with_hnsw(mut self, hnsw: HnswParams) -> Self {
54        self.hnsw = hnsw;
55        self
56    }
57
58    pub fn with_quantization(mut self, quantization: Quantization) -> Self {
59        self.quantization = quantization;
60        self
61    }
62
63    fn validate(&self) -> Result<()> {
64        if self.dim == 0 || self.dim > MAX_DIM {
65            return Err(Error::InvalidArgument(format!(
66                "dim must be between 1 and {MAX_DIM}"
67            )));
68        }
69        self.hnsw.validate()
70    }
71}
72
73/// A stored record. For cosine collections `vector` is the normalized vector.
74#[derive(Clone, Debug, PartialEq)]
75pub struct Record {
76    pub id: String,
77    pub vector: Vec<f32>,
78    pub metadata: Option<Value>,
79}
80
81#[derive(Clone, Debug, PartialEq)]
82pub struct SearchHit {
83    pub id: String,
84    pub distance: f32,
85    pub metadata: Option<Value>,
86}
87
88#[derive(Clone, Debug, Default)]
89pub struct SearchOptions {
90    /// Candidate list size; defaults to the collection's `ef_search`.
91    pub ef: Option<usize>,
92    /// Scan every record instead of using the index.
93    pub exact: bool,
94    pub filter: Option<Filter>,
95}
96
97impl SearchOptions {
98    pub fn ef(mut self, ef: usize) -> Self {
99        self.ef = Some(ef);
100        self
101    }
102
103    pub fn exact(mut self) -> Self {
104        self.exact = true;
105        self
106    }
107
108    pub fn filter(mut self, filter: Filter) -> Self {
109        self.filter = Some(filter);
110        self
111    }
112}
113
114/// How a query was answered.
115#[derive(Clone, Copy, Debug, PartialEq, Eq)]
116pub enum Strategy {
117    /// HNSW graph search (filtered results are collected while traversing).
118    Hnsw,
119    /// Full scan, requested explicitly.
120    Exact,
121    /// Full scan chosen automatically because the filter is very selective.
122    FilteredExact,
123}
124
125impl fmt::Display for Strategy {
126    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
127        f.write_str(match self {
128            Strategy::Hnsw => "hnsw",
129            Strategy::Exact => "exact",
130            Strategy::FilteredExact => "exact (selective filter)",
131        })
132    }
133}
134
135/// Search results together with the work it took to produce them.
136#[derive(Clone, Debug)]
137pub struct SearchReport {
138    pub hits: Vec<SearchHit>,
139    pub strategy: Strategy,
140    /// Candidate list size used by an HNSW search.
141    pub ef: Option<usize>,
142    pub visited: usize,
143    pub distance_computations: usize,
144    /// Estimated share of live records matching the filter.
145    pub filter_selectivity: Option<f64>,
146    pub elapsed: Duration,
147}
148
149#[derive(Clone, Debug, PartialEq)]
150pub struct CollectionStats {
151    pub name: String,
152    pub config: CollectionConfig,
153    pub live: usize,
154    /// Deleted or replaced records still occupying space until `compact`.
155    pub deleted: usize,
156    /// Nodes on each HNSW layer, bottom first.
157    pub nodes_per_layer: Vec<usize>,
158    pub avg_degree_layer0: f64,
159    /// Live records that graph search can never reach from the entry point.
160    pub unreachable: usize,
161    pub vector_bytes: usize,
162    pub graph_bytes: usize,
163    pub metadata_bytes: usize,
164}
165
166#[derive(Clone, Debug)]
167pub struct RecallOptions {
168    /// Number of stored vectors used as queries.
169    pub sample: usize,
170    pub k: usize,
171    pub ef_values: Vec<usize>,
172    pub seed: u64,
173}
174
175impl Default for RecallOptions {
176    fn default() -> Self {
177        Self {
178            sample: 100,
179            k: 10,
180            ef_values: vec![16, 32, 64, 128, 256],
181            seed: 42,
182        }
183    }
184}
185
186#[derive(Clone, Debug)]
187pub struct RecallPoint {
188    pub ef: usize,
189    /// Share of the exact top-k found by the index, averaged over queries.
190    pub recall: f64,
191    pub p50: Duration,
192    pub p95: Duration,
193}
194
195#[derive(Clone, Debug)]
196pub struct RecallReport {
197    pub k: usize,
198    pub sample: usize,
199    pub exact_p50: Duration,
200    pub points: Vec<RecallPoint>,
201}
202
203pub struct Collection {
204    pub(crate) name: String,
205    pub(crate) config: CollectionConfig,
206    pub(crate) vectors: Vectors,
207    pub(crate) ids: Vec<String>,
208    pub(crate) metadata: Vec<Option<Value>>,
209    pub(crate) deleted: Vec<bool>,
210    pub(crate) graph: Graph,
211    index: HashMap<String, u32>,
212}
213
214impl Collection {
215    pub(crate) fn new(name: &str, config: CollectionConfig) -> Result<Self> {
216        config.validate()?;
217        Ok(Self {
218            name: name.to_owned(),
219            config,
220            vectors: Vectors::with_encoding(config.dim, config.quantization, config.metric),
221            ids: Vec::new(),
222            metadata: Vec::new(),
223            deleted: Vec::new(),
224            graph: Graph::new(config.hnsw, DEFAULT_SEED),
225            index: HashMap::new(),
226        })
227    }
228
229    /// Reassembles a collection read from disk, rebuilding the id index.
230    pub(crate) fn from_parts(
231        name: String,
232        config: CollectionConfig,
233        vectors: Vectors,
234        ids: Vec<String>,
235        metadata: Vec<Option<Value>>,
236        deleted: Vec<bool>,
237        graph: Graph,
238    ) -> Result<Self> {
239        config
240            .validate()
241            .map_err(|e| Error::Corrupt(format!("collection '{name}': {e}")))?;
242        let mut index = HashMap::with_capacity(ids.len());
243        for (node, id) in ids.iter().enumerate() {
244            if !deleted[node] && index.insert(id.clone(), node as u32).is_some() {
245                return Err(Error::Corrupt(format!(
246                    "collection '{name}': duplicate id '{id}'"
247                )));
248            }
249        }
250        Ok(Self {
251            name,
252            config,
253            vectors,
254            ids,
255            metadata,
256            deleted,
257            graph,
258            index,
259        })
260    }
261
262    pub fn name(&self) -> &str {
263        &self.name
264    }
265
266    pub fn config(&self) -> &CollectionConfig {
267        &self.config
268    }
269
270    /// Number of live records.
271    pub fn len(&self) -> usize {
272        self.index.len()
273    }
274
275    pub fn is_empty(&self) -> bool {
276        self.index.is_empty()
277    }
278
279    pub fn contains(&self, id: &str) -> bool {
280        self.index.contains_key(id)
281    }
282
283    /// Inserts a record, replacing any record with the same id.
284    pub fn upsert(&mut self, id: &str, vector: &[f32], metadata: Option<Value>) -> Result<()> {
285        let vector = self.prepare(vector)?;
286        if self.ids.len() >= u32::MAX as usize {
287            return Err(Error::InvalidArgument("collection is full".into()));
288        }
289        if let Some(&old) = self.index.get(id) {
290            self.deleted[old as usize] = true;
291        }
292        let node = self.append(id.to_owned(), &vector, metadata);
293        self.graph.insert(node, &self.vectors, self.config.metric);
294        Ok(())
295    }
296
297    /// Inserts many records, replacing records with the same ids, and links
298    /// them into the index using all available cores. The batch is atomic: if
299    /// any vector is invalid, the collection is left unchanged. Returns the
300    /// number of records written.
301    pub fn upsert_many<I, S, V>(&mut self, records: I) -> Result<usize>
302    where
303        I: IntoIterator<Item = (S, V, Option<Value>)>,
304        S: Into<String>,
305        V: AsRef<[f32]>,
306    {
307        self.upsert_many_with_threads(records, default_threads())
308    }
309
310    /// [`Collection::upsert_many`] with an explicit thread count. With one
311    /// thread the graph is identical to upserting the records one by one;
312    /// with more, link order (and so the exact graph) depends on scheduling.
313    pub fn upsert_many_with_threads<I, S, V>(&mut self, records: I, threads: usize) -> Result<usize>
314    where
315        I: IntoIterator<Item = (S, V, Option<Value>)>,
316        S: Into<String>,
317        V: AsRef<[f32]>,
318    {
319        let start = self.ids.len();
320        let mut replaced = Vec::new();
321        for (id, vector, metadata) in records {
322            let id = id.into();
323            let vector = match self.prepare(vector.as_ref()) {
324                Ok(vector) if self.ids.len() < u32::MAX as usize => vector,
325                Ok(_) => {
326                    self.rollback(start, replaced);
327                    return Err(Error::InvalidArgument("collection is full".into()));
328                }
329                Err(err) => {
330                    self.rollback(start, replaced);
331                    return Err(err);
332                }
333            };
334            if let Some(&old) = self.index.get(&id) {
335                self.deleted[old as usize] = true;
336                replaced.push((id.clone(), old));
337            }
338            self.append(id, &vector, metadata);
339        }
340        let end = self.ids.len();
341        self.graph.insert_batch(
342            start as u32..end as u32,
343            &self.vectors,
344            self.config.metric,
345            threads.max(1),
346        );
347        Ok(end - start)
348    }
349
350    /// Removes a record. Returns whether it existed.
351    ///
352    /// The node stays in the graph as a waypoint until [`Collection::compact`].
353    pub fn delete(&mut self, id: &str) -> bool {
354        match self.index.remove(id) {
355            Some(node) => {
356                self.deleted[node as usize] = true;
357                true
358            }
359            None => false,
360        }
361    }
362
363    pub fn get(&self, id: &str) -> Option<Record> {
364        let node = *self.index.get(id)?;
365        Some(Record {
366            id: id.to_owned(),
367            vector: self.vectors.get(node).to_vec(),
368            metadata: self.metadata[node as usize].clone(),
369        })
370    }
371
372    pub fn search(
373        &self,
374        query: &[f32],
375        k: usize,
376        options: &SearchOptions,
377    ) -> Result<Vec<SearchHit>> {
378        Ok(self.explain(query, k, options)?.hits)
379    }
380
381    /// Runs a search and reports how it was executed.
382    pub fn explain(
383        &self,
384        query: &[f32],
385        k: usize,
386        options: &SearchOptions,
387    ) -> Result<SearchReport> {
388        let start = Instant::now();
389        if k == 0 {
390            return Err(Error::InvalidArgument("k must be greater than zero".into()));
391        }
392        let query = self.prepare(query)?;
393        let filter = options.filter.as_ref();
394        let selectivity = filter.map(|f| self.estimate_selectivity(f));
395        let base_ef = options.ef.unwrap_or(self.config.hnsw.ef_search).max(k);
396        let adaptive_ef = selectivity.map_or(base_ef, |s| {
397            ((base_ef as f64 / s.max(1.0 / self.len().max(1) as f64)).ceil() as usize)
398                .min(self.len())
399                .max(k)
400        });
401        let exact_cost = selectivity.map(|s| {
402            self.graph.len() as f64 / self.config.dim.max(1) as f64 + self.len() as f64 * s
403        });
404        let graph_cost = adaptive_ef as f64 * self.config.hnsw.m as f64 * 0.5;
405        let strategy = if options.exact {
406            Strategy::Exact
407        } else if exact_cost.is_some_and(|cost| cost <= graph_cost) {
408            Strategy::FilteredExact
409        } else {
410            Strategy::Hnsw
411        };
412
413        let accept = |node: u32| {
414            !self.deleted[node as usize]
415                && filter.is_none_or(|f| f.matches(self.metadata[node as usize].as_ref()))
416        };
417        let mut counters = Counters::default();
418        let (found, ef) = match strategy {
419            Strategy::Hnsw => {
420                let ef = adaptive_ef;
421                let found = self.graph.search(
422                    &query,
423                    k,
424                    ef,
425                    &self.vectors,
426                    self.config.metric,
427                    &accept,
428                    &mut counters,
429                );
430                (found, Some(ef))
431            }
432            Strategy::Exact | Strategy::FilteredExact => {
433                (self.scan(&query, k, &accept, &mut counters), None)
434            }
435        };
436
437        Ok(SearchReport {
438            hits: found.into_iter().map(|c| self.hit(c)).collect(),
439            strategy,
440            ef,
441            visited: counters.visited,
442            distance_computations: counters.distance_computations,
443            filter_selectivity: selectivity,
444            elapsed: start.elapsed(),
445        })
446    }
447
448    pub fn stats(&self) -> CollectionStats {
449        let nodes = self.graph.len();
450        let reachable = self.graph.reachable();
451        let unreachable = (0..nodes)
452            .filter(|&n| !self.deleted[n] && !reachable[n])
453            .count();
454        let layer0_links: usize = self.graph.links.iter().map(|layers| layers[0].len()).sum();
455        let graph_bytes: usize = self
456            .graph
457            .links
458            .iter()
459            .map(|layers| {
460                size_of::<Vec<Vec<u32>>>()
461                    + layers
462                        .iter()
463                        .map(|l| size_of::<Vec<u32>>() + l.len() * 4)
464                        .sum::<usize>()
465            })
466            .sum();
467        let metadata_bytes = self
468            .metadata
469            .iter()
470            .flatten()
471            .map(|m| serde_json::to_vec(m).map_or(0, |b| b.len()))
472            .sum();
473
474        CollectionStats {
475            name: self.name.clone(),
476            config: self.config,
477            live: self.len(),
478            deleted: nodes - self.len(),
479            nodes_per_layer: self.graph.nodes_per_layer(),
480            avg_degree_layer0: if nodes == 0 {
481                0.0
482            } else {
483                layer0_links as f64 / nodes as f64
484            },
485            unreachable,
486            vector_bytes: self.vectors.bytes(),
487            graph_bytes,
488            metadata_bytes,
489        }
490    }
491
492    /// Measures how many of the exact nearest neighbors the index finds.
493    ///
494    /// Stored vectors are sampled as queries; each query's own record is
495    /// excluded from both the exact and the approximate results.
496    pub fn estimate_recall(&self, options: &RecallOptions) -> Result<RecallReport> {
497        if options.k == 0 || options.sample == 0 || options.ef_values.is_empty() {
498            return Err(Error::InvalidArgument(
499                "k, sample and ef values must be non-empty and greater than zero".into(),
500            ));
501        }
502        let mut live: Vec<u32> = (0..self.graph.len() as u32)
503            .filter(|&n| !self.deleted[n as usize])
504            .collect();
505        if live.len() < 2 {
506            return Err(Error::InvalidArgument(
507                "recall needs at least two records".into(),
508            ));
509        }
510
511        let mut rng = SplitMix64::new(options.seed);
512        let sample = options.sample.min(live.len());
513        for i in 0..sample {
514            let j = i + rng.below(live.len() - i);
515            live.swap(i, j);
516        }
517        let queries = &live[..sample];
518        let k = options.k.min(live.len() - 1);
519        let metric = self.config.metric;
520
521        let mut exact_times = Vec::with_capacity(sample);
522        let truths: Vec<Vec<u32>> = queries
523            .iter()
524            .map(|&q| {
525                let accept = |n: u32| n != q && !self.deleted[n as usize];
526                let start = Instant::now();
527                let found = self.scan(&self.vectors.get(q), k, &accept, &mut Counters::default());
528                exact_times.push(start.elapsed());
529                found.into_iter().map(|c| c.id).collect()
530            })
531            .collect();
532
533        let mut points = Vec::with_capacity(options.ef_values.len());
534        for &ef in &options.ef_values {
535            let mut times = Vec::with_capacity(sample);
536            let mut found_total = 0;
537            for (&q, truth) in queries.iter().zip(&truths) {
538                let accept = |n: u32| n != q && !self.deleted[n as usize];
539                let start = Instant::now();
540                let found = self.graph.search(
541                    &self.vectors.get(q),
542                    k,
543                    ef.max(k),
544                    &self.vectors,
545                    metric,
546                    &accept,
547                    &mut Counters::default(),
548                );
549                times.push(start.elapsed());
550                found_total += found.iter().filter(|c| truth.contains(&c.id)).count();
551            }
552            points.push(RecallPoint {
553                ef,
554                recall: found_total as f64 / (k * sample) as f64,
555                p50: percentile(&mut times, 0.50),
556                p95: percentile(&mut times, 0.95),
557            });
558        }
559
560        Ok(RecallReport {
561            k,
562            sample,
563            exact_p50: percentile(&mut exact_times, 0.50),
564            points,
565        })
566    }
567
568    /// Rebuilds the collection without deleted records. Returns how many
569    /// were removed.
570    pub fn compact(&mut self) -> usize {
571        let removed = self.graph.len() - self.len();
572        if removed == 0 {
573            return 0;
574        }
575        let mut fresh = Collection::new(&self.name, self.config)
576            .expect("configuration was validated when the collection was created");
577        for node in 0..self.graph.len() {
578            if !self.deleted[node] {
579                let vector = self.vectors.get(node as u32);
580                fresh.append(self.ids[node].clone(), &vector, self.metadata[node].clone());
581            }
582        }
583        let live = fresh.ids.len() as u32;
584        fresh.graph.insert_batch(
585            0..live,
586            &fresh.vectors,
587            fresh.config.metric,
588            default_threads(),
589        );
590        *self = fresh;
591        removed
592    }
593
594    fn prepare(&self, vector: &[f32]) -> Result<Vec<f32>> {
595        if vector.len() != self.config.dim {
596            return Err(Error::DimensionMismatch {
597                expected: self.config.dim,
598                actual: vector.len(),
599            });
600        }
601        if vector.iter().any(|x| !x.is_finite()) {
602            return Err(Error::InvalidVector(
603                "contains NaN or infinite values".into(),
604            ));
605        }
606        let mut vector = vector.to_vec();
607        if self.config.metric == Metric::Cosine && !metric::normalize(&mut vector) {
608            return Err(Error::InvalidVector(
609                "zero vector has no direction for cosine".into(),
610            ));
611        }
612        Ok(vector)
613    }
614
615    /// Appends an already prepared record without linking it into the graph.
616    fn append(&mut self, id: String, vector: &[f32], metadata: Option<Value>) -> u32 {
617        let node = self.ids.len() as u32;
618        self.vectors.push(vector);
619        self.ids.push(id.clone());
620        self.metadata.push(metadata);
621        self.deleted.push(false);
622        self.index.insert(id, node);
623        node
624    }
625
626    /// Undoes the appends of a failed batch that started at node `start`.
627    fn rollback(&mut self, start: usize, replaced: Vec<(String, u32)>) {
628        for node in start..self.ids.len() {
629            let id = &self.ids[node];
630            if self.index.get(id) == Some(&(node as u32)) {
631                self.index.remove(id);
632            }
633        }
634        self.vectors.truncate(start);
635        self.ids.truncate(start);
636        self.metadata.truncate(start);
637        self.deleted.truncate(start);
638        for (id, old) in replaced.into_iter().rev() {
639            if (old as usize) < start {
640                self.deleted[old as usize] = false;
641                self.index.insert(id, old);
642            }
643        }
644    }
645
646    fn scan(
647        &self,
648        query: &[f32],
649        k: usize,
650        accept: &dyn Fn(u32) -> bool,
651        counters: &mut Counters,
652    ) -> Vec<Candidate> {
653        let mut heap = BinaryHeap::with_capacity(k.min(self.len()) + 1);
654        for node in 0..self.graph.len() as u32 {
655            if !accept(node) {
656                continue;
657            }
658            counters.visited += 1;
659            counters.distance_computations += 1;
660            let dist = self.vectors.distance(query, node, self.config.metric);
661            if heap.len() < k {
662                heap.push(Candidate { dist, id: node });
663            } else if heap.peek().is_some_and(|w: &Candidate| dist < w.dist) {
664                heap.pop();
665                heap.push(Candidate { dist, id: node });
666            }
667        }
668        let mut out = heap.into_vec();
669        out.sort();
670        out
671    }
672
673    /// Estimates the share of live records matching `filter`. Small
674    /// collections are checked fully; larger ones from a random sample, since
675    /// an evenly spaced one aliases with periodic insertion patterns.
676    fn estimate_selectivity(&self, filter: &Filter) -> f64 {
677        let nodes = self.graph.len();
678        let mut rng = SplitMix64::new(DEFAULT_SEED);
679        let sample: Box<dyn Iterator<Item = usize>> = if nodes <= SELECTIVITY_SAMPLE {
680            Box::new(0..nodes)
681        } else {
682            Box::new((0..SELECTIVITY_SAMPLE).map(move |_| rng.below(nodes)))
683        };
684        let (mut checked, mut matched) = (0usize, 0usize);
685        for node in sample.filter(|&n| !self.deleted[n]) {
686            checked += 1;
687            if filter.matches(self.metadata[node].as_ref()) {
688                matched += 1;
689            }
690        }
691        if checked == 0 {
692            1.0
693        } else {
694            matched as f64 / checked as f64
695        }
696    }
697
698    fn hit(&self, candidate: Candidate) -> SearchHit {
699        let node = candidate.id as usize;
700        // Rounding can push the cosine distance of identical vectors just
701        // below zero; report it as 0.
702        let distance = match self.config.metric {
703            Metric::Cosine => candidate.dist.max(0.0),
704            _ => candidate.dist,
705        };
706        SearchHit {
707            id: self.ids[node].clone(),
708            distance,
709            metadata: self.metadata[node].clone(),
710        }
711    }
712}
713
714fn default_threads() -> usize {
715    std::thread::available_parallelism().map_or(1, |n| n.get())
716}
717
718fn percentile(times: &mut [Duration], p: f64) -> Duration {
719    if times.is_empty() {
720        return Duration::ZERO;
721    }
722    times.sort_unstable();
723    times[((times.len() - 1) as f64 * p).round() as usize]
724}