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)]
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 pub ef: Option<usize>,
92 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#[derive(Clone, Copy, Debug, PartialEq, Eq)]
116pub enum Strategy {
117 Hnsw,
119 Exact,
121 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#[derive(Clone, Debug)]
137pub struct SearchReport {
138 pub hits: Vec<SearchHit>,
139 pub strategy: Strategy,
140 pub ef: Option<usize>,
142 pub visited: usize,
143 pub distance_computations: usize,
144 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 pub deleted: usize,
156 pub nodes_per_layer: Vec<usize>,
158 pub avg_degree_layer0: f64,
159 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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}