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