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
143#[derive(Clone, Debug)]
145pub struct SearchReport {
146 pub hits: Vec<SearchHit>,
147 pub strategy: Strategy,
148 pub ef: Option<usize>,
150 pub visited: usize,
151 pub distance_computations: usize,
152 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 pub deleted: usize,
164 pub nodes_per_layer: Vec<usize>,
166 pub avg_degree_layer0: f64,
167 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}