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#[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#[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)]
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 pub ef: Option<usize>,
100 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
124pub enum Strategy {
125 Hnsw,
127 Exact,
129 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
143struct SearchCandidates {
144 candidates: Vec<Candidate>,
145 strategy: Strategy,
146 ef: Option<usize>,
147 counters: Counters,
148 selectivity: Option<f64>,
149}
150
151#[derive(Clone, Debug)]
153pub struct SearchReport {
154 pub hits: Vec<SearchHit>,
155 pub strategy: Strategy,
156 pub ef: Option<usize>,
158 pub visited: usize,
159 pub distance_computations: usize,
160 pub filter_selectivity: Option<f64>,
162 pub elapsed: Duration,
163}
164
165#[derive(Clone, Debug, PartialEq)]
166pub struct CollectionStats {
167 pub name: String,
168 pub config: CollectionConfig,
169 pub live: usize,
170 pub deleted: usize,
172 pub nodes_per_layer: Vec<usize>,
174 pub avg_degree_layer0: f64,
175 pub unreachable: usize,
177 pub vector_bytes: usize,
178 pub graph_bytes: usize,
179 pub metadata_bytes: usize,
180}
181
182#[derive(Clone, Debug)]
183pub struct RecallOptions {
184 pub sample: usize,
186 pub k: usize,
187 pub ef_values: Vec<usize>,
188 pub seed: u64,
189}
190
191impl Default for RecallOptions {
192 fn default() -> Self {
193 Self {
194 sample: 100,
195 k: 10,
196 ef_values: vec![16, 32, 64, 128, 256],
197 seed: 42,
198 }
199 }
200}
201
202#[derive(Clone, Debug)]
203pub struct RecallPoint {
204 pub ef: usize,
205 pub recall: f64,
207 pub p50: Duration,
208 pub p95: Duration,
209}
210
211#[derive(Clone, Debug)]
212pub struct RecallReport {
213 pub k: usize,
214 pub sample: usize,
215 pub exact_p50: Duration,
216 pub points: Vec<RecallPoint>,
217}
218
219pub struct Collection {
220 pub(crate) name: String,
221 pub(crate) config: CollectionConfig,
222 pub(crate) vectors: Vectors,
223 pub(crate) ids: Vec<String>,
224 pub(crate) metadata: Vec<Option<Value>>,
225 pub(crate) deleted: Vec<bool>,
226 pub(crate) graph: Graph,
227 index: HashMap<String, u32>,
228}
229
230impl Collection {
231 pub(crate) fn new(name: &str, config: CollectionConfig) -> Result<Self> {
232 config.validate()?;
233 Ok(Self {
234 name: name.to_owned(),
235 config,
236 vectors: Vectors::with_encoding(config.dim, config.quantization, config.metric),
237 ids: Vec::new(),
238 metadata: Vec::new(),
239 deleted: Vec::new(),
240 graph: Graph::new(config.hnsw, DEFAULT_SEED),
241 index: HashMap::new(),
242 })
243 }
244
245 pub(crate) fn from_parts(
247 name: String,
248 config: CollectionConfig,
249 vectors: Vectors,
250 ids: Vec<String>,
251 metadata: Vec<Option<Value>>,
252 deleted: Vec<bool>,
253 graph: Graph,
254 ) -> Result<Self> {
255 config
256 .validate()
257 .map_err(|e| Error::Corrupt(format!("collection '{name}': {e}")))?;
258 let mut index = HashMap::with_capacity(ids.len());
259 for (node, id) in ids.iter().enumerate() {
260 if !deleted[node] && index.insert(id.clone(), node as u32).is_some() {
261 return Err(Error::Corrupt(format!(
262 "collection '{name}': duplicate id '{id}'"
263 )));
264 }
265 }
266 Ok(Self {
267 name,
268 config,
269 vectors,
270 ids,
271 metadata,
272 deleted,
273 graph,
274 index,
275 })
276 }
277
278 pub fn name(&self) -> &str {
279 &self.name
280 }
281
282 pub fn config(&self) -> &CollectionConfig {
283 &self.config
284 }
285
286 pub fn len(&self) -> usize {
288 self.index.len()
289 }
290
291 pub fn is_empty(&self) -> bool {
292 self.index.is_empty()
293 }
294
295 pub fn contains(&self, id: &str) -> bool {
296 self.index.contains_key(id)
297 }
298
299 pub fn upsert(&mut self, id: &str, vector: &[f32], metadata: Option<Value>) -> Result<()> {
301 let vector = self.prepare(vector)?;
302 if self.ids.len() >= u32::MAX as usize {
303 return Err(Error::InvalidArgument("collection is full".into()));
304 }
305 if let Some(&old) = self.index.get(id) {
306 self.deleted[old as usize] = true;
307 }
308 let node = self.append(id.to_owned(), &vector, metadata);
309 self.graph.insert(node, &self.vectors, self.config.metric);
310 Ok(())
311 }
312
313 pub fn upsert_many<I, S, V>(&mut self, records: I) -> Result<usize>
318 where
319 I: IntoIterator<Item = (S, V, Option<Value>)>,
320 S: Into<String>,
321 V: AsRef<[f32]>,
322 {
323 self.upsert_many_with_threads(records, default_threads())
324 }
325
326 pub fn upsert_many_with_threads<I, S, V>(&mut self, records: I, threads: usize) -> Result<usize>
330 where
331 I: IntoIterator<Item = (S, V, Option<Value>)>,
332 S: Into<String>,
333 V: AsRef<[f32]>,
334 {
335 let start = self.ids.len();
336 let mut replaced = Vec::new();
337 for (id, vector, metadata) in records {
338 let id = id.into();
339 let vector = match self.prepare(vector.as_ref()) {
340 Ok(vector) if self.ids.len() < u32::MAX as usize => vector,
341 Ok(_) => {
342 self.rollback(start, replaced);
343 return Err(Error::InvalidArgument("collection is full".into()));
344 }
345 Err(err) => {
346 self.rollback(start, replaced);
347 return Err(err);
348 }
349 };
350 if let Some(&old) = self.index.get(&id) {
351 self.deleted[old as usize] = true;
352 replaced.push((id.clone(), old));
353 }
354 self.append(id, &vector, metadata);
355 }
356 let end = self.ids.len();
357 self.graph.insert_batch(
358 start as u32..end as u32,
359 &self.vectors,
360 self.config.metric,
361 threads.max(1),
362 );
363 Ok(end - start)
364 }
365
366 pub fn delete(&mut self, id: &str) -> bool {
370 match self.index.remove(id) {
371 Some(node) => {
372 self.deleted[node as usize] = true;
373 true
374 }
375 None => false,
376 }
377 }
378
379 pub fn get(&self, id: &str) -> Option<Record> {
380 let node = *self.index.get(id)?;
381 Some(Record {
382 id: id.to_owned(),
383 vector: self.vectors.get(node).to_vec(),
384 metadata: self.metadata[node as usize].clone(),
385 })
386 }
387
388 pub fn records_page(&self, offset: usize, limit: usize) -> Result<RecordPage> {
392 if limit == 0 || limit > 10_000 || offset > self.ids.len() {
393 return Err(Error::InvalidArgument(
394 "record page requires a valid offset and a limit from 1 to 10000".into(),
395 ));
396 }
397 let mut records = Vec::with_capacity(limit.min(self.len()));
398 let mut next = offset;
399 while next < self.ids.len() && records.len() < limit {
400 let node = next;
401 next += 1;
402 if !self.deleted[node] {
403 records.push(Record {
404 id: self.ids[node].clone(),
405 vector: self.vectors.get(node as u32).to_vec(),
406 metadata: self.metadata[node].clone(),
407 });
408 }
409 }
410 while next < self.ids.len() && self.deleted[next] {
411 next += 1;
412 }
413 Ok(RecordPage {
414 records,
415 next_offset: (next < self.ids.len()).then_some(next),
416 })
417 }
418
419 pub fn search(
420 &self,
421 query: &[f32],
422 k: usize,
423 options: &SearchOptions,
424 ) -> Result<Vec<SearchHit>> {
425 Ok(self.explain(query, k, options)?.hits)
426 }
427
428 pub fn explain(
430 &self,
431 query: &[f32],
432 k: usize,
433 options: &SearchOptions,
434 ) -> Result<SearchReport> {
435 let start = Instant::now();
436 let found = self.search_candidates(query, k, options)?;
437 Ok(SearchReport {
438 hits: found.candidates.into_iter().map(|c| self.hit(c)).collect(),
439 strategy: found.strategy,
440 ef: found.ef,
441 visited: found.counters.visited,
442 distance_computations: found.counters.distance_computations,
443 filter_selectivity: found.selectivity,
444 elapsed: start.elapsed(),
445 })
446 }
447
448 pub fn visit_search<'a>(
452 &'a self,
453 query: &[f32],
454 k: usize,
455 options: &SearchOptions,
456 mut visitor: impl FnMut(&'a str, f32, Option<&'a Value>) -> Result<()>,
457 ) -> Result<()> {
458 for candidate in self.search_candidates(query, k, options)?.candidates {
459 let node = candidate.id as usize;
460 let distance = if self.config.metric == Metric::Cosine {
461 candidate.dist.max(0.0)
462 } else {
463 candidate.dist
464 };
465 visitor(&self.ids[node], distance, self.metadata[node].as_ref())?;
466 }
467 Ok(())
468 }
469
470 fn search_candidates(
471 &self,
472 query: &[f32],
473 k: usize,
474 options: &SearchOptions,
475 ) -> Result<SearchCandidates> {
476 if k == 0 {
477 return Err(Error::InvalidArgument("k must be greater than zero".into()));
478 }
479 let query = self.prepare(query)?;
480 let filter = options.filter.as_ref();
481 let selectivity = filter.map(|f| self.estimate_selectivity(f));
482 let base_ef = options.ef.unwrap_or(self.config.hnsw.ef_search).max(k);
483 let adaptive_ef = selectivity.map_or(base_ef, |s| {
484 ((base_ef as f64 / s.max(1.0 / self.len().max(1) as f64)).ceil() as usize)
485 .min(self.len())
486 .max(k)
487 });
488 let exact_cost = selectivity.map(|s| {
489 self.graph.len() as f64 / self.config.dim.max(1) as f64 + self.len() as f64 * s
490 });
491 let graph_cost = adaptive_ef as f64 * self.config.hnsw.m as f64 * 0.5;
492 let strategy = if options.exact {
493 Strategy::Exact
494 } else if exact_cost.is_some_and(|cost| cost <= graph_cost) {
495 Strategy::FilteredExact
496 } else {
497 Strategy::Hnsw
498 };
499
500 let accept = |node: u32| {
501 !self.deleted[node as usize]
502 && filter.is_none_or(|f| f.matches(self.metadata[node as usize].as_ref()))
503 };
504 let mut counters = Counters::default();
505 let (found, ef) = match strategy {
506 Strategy::Hnsw => {
507 let ef = adaptive_ef;
508 let found = self.graph.search(
509 &query,
510 k,
511 ef,
512 &self.vectors,
513 self.config.metric,
514 &accept,
515 &mut counters,
516 );
517 (found, Some(ef))
518 }
519 Strategy::Exact | Strategy::FilteredExact => {
520 (self.scan(&query, k, &accept, &mut counters), None)
521 }
522 };
523
524 Ok(SearchCandidates {
525 candidates: found,
526 strategy,
527 ef,
528 counters,
529 selectivity,
530 })
531 }
532
533 pub fn stats(&self) -> CollectionStats {
534 let nodes = self.graph.len();
535 let reachable = self.graph.reachable();
536 let unreachable = (0..nodes)
537 .filter(|&n| !self.deleted[n] && !reachable[n])
538 .count();
539 let layer0_links: usize = self.graph.links.iter().map(|layers| layers[0].len()).sum();
540 let graph_bytes: usize = self
541 .graph
542 .links
543 .iter()
544 .map(|layers| {
545 size_of::<Vec<Vec<u32>>>()
546 + layers
547 .iter()
548 .map(|l| size_of::<Vec<u32>>() + l.len() * 4)
549 .sum::<usize>()
550 })
551 .sum();
552 let metadata_bytes = self
553 .metadata
554 .iter()
555 .flatten()
556 .map(|m| serde_json::to_vec(m).map_or(0, |b| b.len()))
557 .sum();
558
559 CollectionStats {
560 name: self.name.clone(),
561 config: self.config,
562 live: self.len(),
563 deleted: nodes - self.len(),
564 nodes_per_layer: self.graph.nodes_per_layer(),
565 avg_degree_layer0: if nodes == 0 {
566 0.0
567 } else {
568 layer0_links as f64 / nodes as f64
569 },
570 unreachable,
571 vector_bytes: self.vectors.bytes(),
572 graph_bytes,
573 metadata_bytes,
574 }
575 }
576
577 pub fn estimate_recall(&self, options: &RecallOptions) -> Result<RecallReport> {
582 if options.k == 0 || options.sample == 0 || options.ef_values.is_empty() {
583 return Err(Error::InvalidArgument(
584 "k, sample and ef values must be non-empty and greater than zero".into(),
585 ));
586 }
587 let mut live: Vec<u32> = (0..self.graph.len() as u32)
588 .filter(|&n| !self.deleted[n as usize])
589 .collect();
590 if live.len() < 2 {
591 return Err(Error::InvalidArgument(
592 "recall needs at least two records".into(),
593 ));
594 }
595
596 let mut rng = SplitMix64::new(options.seed);
597 let sample = options.sample.min(live.len());
598 for i in 0..sample {
599 let j = i + rng.below(live.len() - i);
600 live.swap(i, j);
601 }
602 let queries = &live[..sample];
603 let k = options.k.min(live.len() - 1);
604 let metric = self.config.metric;
605
606 let mut exact_times = Vec::with_capacity(sample);
607 let truths: Vec<Vec<u32>> = queries
608 .iter()
609 .map(|&q| {
610 let accept = |n: u32| n != q && !self.deleted[n as usize];
611 let start = Instant::now();
612 let found = self.scan(&self.vectors.get(q), k, &accept, &mut Counters::default());
613 exact_times.push(start.elapsed());
614 found.into_iter().map(|c| c.id).collect()
615 })
616 .collect();
617
618 let mut points = Vec::with_capacity(options.ef_values.len());
619 for &ef in &options.ef_values {
620 let mut times = Vec::with_capacity(sample);
621 let mut found_total = 0;
622 for (&q, truth) in queries.iter().zip(&truths) {
623 let accept = |n: u32| n != q && !self.deleted[n as usize];
624 let start = Instant::now();
625 let found = self.graph.search(
626 &self.vectors.get(q),
627 k,
628 ef.max(k),
629 &self.vectors,
630 metric,
631 &accept,
632 &mut Counters::default(),
633 );
634 times.push(start.elapsed());
635 found_total += found.iter().filter(|c| truth.contains(&c.id)).count();
636 }
637 points.push(RecallPoint {
638 ef,
639 recall: found_total as f64 / (k * sample) as f64,
640 p50: percentile(&mut times, 0.50),
641 p95: percentile(&mut times, 0.95),
642 });
643 }
644
645 Ok(RecallReport {
646 k,
647 sample,
648 exact_p50: percentile(&mut exact_times, 0.50),
649 points,
650 })
651 }
652
653 pub fn compact(&mut self) -> usize {
656 let removed = self.graph.len() - self.len();
657 if removed == 0 {
658 return 0;
659 }
660 let mut fresh = Collection::new(&self.name, self.config)
661 .expect("configuration was validated when the collection was created");
662 for node in 0..self.graph.len() {
663 if !self.deleted[node] {
664 let vector = self.vectors.get(node as u32);
665 fresh.append(self.ids[node].clone(), &vector, self.metadata[node].clone());
666 }
667 }
668 let live = fresh.ids.len() as u32;
669 fresh.graph.insert_batch(
670 0..live,
671 &fresh.vectors,
672 fresh.config.metric,
673 default_threads(),
674 );
675 *self = fresh;
676 removed
677 }
678
679 fn prepare(&self, vector: &[f32]) -> Result<Vec<f32>> {
680 if vector.len() != self.config.dim {
681 return Err(Error::DimensionMismatch {
682 expected: self.config.dim,
683 actual: vector.len(),
684 });
685 }
686 if vector.iter().any(|x| !x.is_finite()) {
687 return Err(Error::InvalidVector(
688 "contains NaN or infinite values".into(),
689 ));
690 }
691 let mut vector = vector.to_vec();
692 if self.config.metric == Metric::Cosine && !metric::normalize(&mut vector) {
693 return Err(Error::InvalidVector(
694 "zero vector has no direction for cosine".into(),
695 ));
696 }
697 Ok(vector)
698 }
699
700 fn append(&mut self, id: String, vector: &[f32], metadata: Option<Value>) -> u32 {
702 let node = self.ids.len() as u32;
703 self.vectors.push(vector);
704 self.ids.push(id.clone());
705 self.metadata.push(metadata);
706 self.deleted.push(false);
707 self.index.insert(id, node);
708 node
709 }
710
711 fn rollback(&mut self, start: usize, replaced: Vec<(String, u32)>) {
713 for node in start..self.ids.len() {
714 let id = &self.ids[node];
715 if self.index.get(id) == Some(&(node as u32)) {
716 self.index.remove(id);
717 }
718 }
719 self.vectors.truncate(start);
720 self.ids.truncate(start);
721 self.metadata.truncate(start);
722 self.deleted.truncate(start);
723 for (id, old) in replaced.into_iter().rev() {
724 if (old as usize) < start {
725 self.deleted[old as usize] = false;
726 self.index.insert(id, old);
727 }
728 }
729 }
730
731 fn scan(
732 &self,
733 query: &[f32],
734 k: usize,
735 accept: &dyn Fn(u32) -> bool,
736 counters: &mut Counters,
737 ) -> Vec<Candidate> {
738 let mut heap = BinaryHeap::with_capacity(k.min(self.len()) + 1);
739 for node in 0..self.graph.len() as u32 {
740 if !accept(node) {
741 continue;
742 }
743 counters.visited += 1;
744 counters.distance_computations += 1;
745 let dist = self.vectors.distance(query, node, self.config.metric);
746 if heap.len() < k {
747 heap.push(Candidate { dist, id: node });
748 } else if heap.peek().is_some_and(|w: &Candidate| dist < w.dist) {
749 heap.pop();
750 heap.push(Candidate { dist, id: node });
751 }
752 }
753 let mut out = heap.into_vec();
754 out.sort();
755 out
756 }
757
758 fn estimate_selectivity(&self, filter: &Filter) -> f64 {
762 let nodes = self.graph.len();
763 let mut rng = SplitMix64::new(DEFAULT_SEED);
764 let sample: Box<dyn Iterator<Item = usize>> = if nodes <= SELECTIVITY_SAMPLE {
765 Box::new(0..nodes)
766 } else {
767 Box::new((0..SELECTIVITY_SAMPLE).map(move |_| rng.below(nodes)))
768 };
769 let (mut checked, mut matched) = (0usize, 0usize);
770 for node in sample.filter(|&n| !self.deleted[n]) {
771 checked += 1;
772 if filter.matches(self.metadata[node].as_ref()) {
773 matched += 1;
774 }
775 }
776 if checked == 0 {
777 1.0
778 } else {
779 matched as f64 / checked as f64
780 }
781 }
782
783 fn hit(&self, candidate: Candidate) -> SearchHit {
784 let node = candidate.id as usize;
785 let distance = match self.config.metric {
788 Metric::Cosine => candidate.dist.max(0.0),
789 _ => candidate.dist,
790 };
791 SearchHit {
792 id: self.ids[node].clone(),
793 distance,
794 metadata: self.metadata[node].clone(),
795 }
796 }
797}
798
799fn default_threads() -> usize {
800 std::thread::available_parallelism().map_or(1, |n| n.get())
801}
802
803fn percentile(times: &mut [Duration], p: f64) -> Duration {
804 if times.is_empty() {
805 return Duration::ZERO;
806 }
807 times.sort_unstable();
808 times[((times.len() - 1) as f64 * p).round() as usize]
809}