1use crate::error::Result;
4use crate::index::flat::FlatIndex;
5
6#[cfg(feature = "hnsw")]
7use crate::index::hnsw::HnswIndex;
8
9use crate::index::VectorIndex;
10use crate::types::*;
11use parking_lot::RwLock;
12use std::sync::Arc;
13
14use crate::storage_memory::MemoryStorage;
15
16#[cfg(feature = "storage")]
17enum StorageBackend {
18 Persistent(crate::storage::VectorStorage),
19 Memory(MemoryStorage),
20}
21
22#[cfg(not(feature = "storage"))]
23type StorageBackend = MemoryStorage;
24
25#[cfg(feature = "storage")]
26impl StorageBackend {
27 fn insert(&self, entry: &VectorEntry) -> Result<VectorId> {
28 match self {
29 Self::Persistent(storage) => storage.insert(entry),
30 Self::Memory(storage) => storage.insert(entry),
31 }
32 }
33
34 fn insert_batch(&self, entries: &[VectorEntry]) -> Result<Vec<VectorId>> {
35 match self {
36 Self::Persistent(storage) => storage.insert_batch(entries),
37 Self::Memory(storage) => storage.insert_batch(entries),
38 }
39 }
40
41 fn get(&self, id: &str) -> Result<Option<VectorEntry>> {
42 match self {
43 Self::Persistent(storage) => storage.get(id),
44 Self::Memory(storage) => storage.get(id),
45 }
46 }
47
48 fn delete(&self, id: &str) -> Result<bool> {
49 match self {
50 Self::Persistent(storage) => storage.delete(id),
51 Self::Memory(storage) => storage.delete(id),
52 }
53 }
54
55 fn len(&self) -> Result<usize> {
56 match self {
57 Self::Persistent(storage) => storage.len(),
58 Self::Memory(storage) => storage.len(),
59 }
60 }
61
62 fn is_empty(&self) -> Result<bool> {
63 match self {
64 Self::Persistent(storage) => storage.is_empty(),
65 Self::Memory(storage) => storage.is_empty(),
66 }
67 }
68
69 fn all_ids(&self) -> Result<Vec<VectorId>> {
70 match self {
71 Self::Persistent(storage) => storage.all_ids(),
72 Self::Memory(storage) => storage.all_ids(),
73 }
74 }
75
76 fn save_config_value(&self, key: &str, value: &str) -> Result<()> {
77 match self {
78 Self::Persistent(storage) => storage.save_config_value(key, value),
79 Self::Memory(storage) => storage.save_config_value(key, value),
80 }
81 }
82
83 fn load_config_value(&self, key: &str) -> Result<Option<String>> {
84 match self {
85 Self::Persistent(storage) => storage.load_config_value(key),
86 Self::Memory(storage) => storage.load_config_value(key),
87 }
88 }
89}
90
91pub struct VectorDB {
93 storage: Arc<StorageBackend>,
94 index: Arc<RwLock<Box<dyn VectorIndex>>>,
95 options: DbOptions,
96}
97
98impl VectorDB {
99 #[allow(unused_mut)] pub fn new(mut options: DbOptions) -> Result<Self> {
107 #[cfg(feature = "storage")]
108 let storage = {
109 if options.storage_path.starts_with("memory://") {
110 Arc::new(StorageBackend::Memory(MemoryStorage::new(
111 options.dimensions,
112 )?))
113 } else {
114 let temp_storage =
117 crate::storage::VectorStorage::new(&options.storage_path, options.dimensions)?;
118
119 let stored_config = temp_storage.load_config()?;
120
121 if let Some(config) = stored_config {
122 tracing::info!(
124 "Loading existing database with {} dimensions",
125 config.dimensions
126 );
127 options = DbOptions {
128 storage_path: options.storage_path.clone(),
130 dimensions: config.dimensions,
132 distance_metric: config.distance_metric,
133 hnsw_config: config.hnsw_config,
134 quantization: config.quantization,
135 };
136 Arc::new(StorageBackend::Persistent(
138 crate::storage::VectorStorage::new(
139 &options.storage_path,
140 options.dimensions,
141 )?,
142 ))
143 } else {
144 tracing::info!(
146 "Creating new database with {} dimensions",
147 options.dimensions
148 );
149 temp_storage.save_config(&options)?;
150 Arc::new(StorageBackend::Persistent(temp_storage))
151 }
152 }
153 };
154
155 #[cfg(not(feature = "storage"))]
156 let storage = Arc::new(MemoryStorage::new(options.dimensions)?);
157
158 let turbo4_params = match &options.quantization {
162 Some(crate::types::QuantizationConfig::Turbo4 {
163 rotation_seed,
164 rescore_multiplier,
165 policy,
166 search_quantization,
167 }) => Some((
168 *rotation_seed,
169 *rescore_multiplier,
170 *policy,
171 *search_quantization,
172 )),
173 _ => None,
174 };
175
176 #[allow(unused_mut)] let mut index: Box<dyn VectorIndex> = if let Some(hnsw_config) = &options.hnsw_config {
178 #[cfg(feature = "hnsw")]
179 {
180 if let Some((rotation_seed, rescore_multiplier, policy, search_quantization)) =
181 turbo4_params
182 {
183 tracing::info!(
184 "Turbo4 quantization active ({policy:?} policy): {} bytes/vector instead of {}",
185 options.dimensions / 2 + 8,
186 options.dimensions * 4
187 );
188 Box::new(crate::index::turbo4::Turbo4HnswIndex::new(
189 options.dimensions,
190 options.distance_metric,
191 hnsw_config.clone(),
192 rotation_seed,
193 rescore_multiplier,
194 policy,
195 search_quantization,
196 )?) as Box<dyn VectorIndex>
197 } else {
198 Box::new(HnswIndex::new(
199 options.dimensions,
200 options.distance_metric,
201 hnsw_config.clone(),
202 )?) as Box<dyn VectorIndex>
203 }
204 }
205 #[cfg(not(feature = "hnsw"))]
206 {
207 tracing::warn!("HNSW requested but not available (WASM build), using flat index");
209 Box::new(FlatIndex::new(options.dimensions, options.distance_metric))
210 }
211 } else {
212 Box::new(FlatIndex::new(options.dimensions, options.distance_metric))
213 };
214
215 match &options.quantization {
222 None | Some(crate::types::QuantizationConfig::None) => {}
223 Some(crate::types::QuantizationConfig::Turbo4 { .. }) => {
224 if options.hnsw_config.is_none() || cfg!(not(feature = "hnsw")) {
225 tracing::warn!(
226 "QuantizationConfig::Turbo4 requires an HNSW index (hnsw_config set \
227 and the `hnsw` feature enabled); falling back to an unquantized \
228 flat index — no compression is happening."
229 );
230 }
231 }
232 other => {
233 tracing::warn!(
234 "DbOptions.quantization = {:?} is set but not yet applied — the \
235 index is stored unquantized (no compression / memory reduction). \
236 See issue #563. Use QuantizationConfig::Turbo4 for applied \
237 quantization (ADR-296).",
238 other
239 );
240 }
241 }
242
243 #[cfg(feature = "storage")]
250 if let Some((rotation_seed, _, _, _)) = turbo4_params {
251 use crate::encoding::{CodecKind, VectorProvenance, CODEC_VERSION};
252 const PROVENANCE_KEY: &str = "turbo4_provenance";
253 let current = VectorProvenance {
254 model_id: None,
255 codec: CodecKind::Turbo4,
256 codec_version: CODEC_VERSION,
257 rotation_seed: Some(rotation_seed),
258 dim: options.dimensions,
259 metric: options.distance_metric,
260 source_hash: None,
261 lineage: Vec::new(),
262 };
263 match storage.load_config_value(PROVENANCE_KEY)? {
264 Some(stored_json) => {
265 let stored: VectorProvenance =
266 serde_json::from_str(&stored_json).map_err(|e| {
267 crate::error::RuvectorError::SerializationError(format!(
268 "corrupt turbo4 provenance record: {e}"
269 ))
270 })?;
271 if stored.rotation_seed != current.rotation_seed
272 || stored.dim != current.dim
273 || stored.metric != current.metric
274 || stored.codec_version != current.codec_version
275 {
276 return Err(crate::error::RuvectorError::InvalidParameter(format!(
277 "Turbo4 provenance mismatch: stored {stored:?} vs requested \
278 {current:?}. Codes are bound to (rotation_seed, dim, metric, \
279 codec_version); migrate the collection instead of changing \
280 them in place."
281 )));
282 }
283 }
284 None => {
285 let json = serde_json::to_string(¤t).map_err(|e| {
286 crate::error::RuvectorError::SerializationError(e.to_string())
287 })?;
288 storage.save_config_value(PROVENANCE_KEY, &json)?;
289 }
290 }
291 }
292
293 #[cfg(feature = "storage")]
296 {
297 let stored_ids = storage.all_ids()?;
298 if !stored_ids.is_empty() {
299 tracing::info!(
300 "Rebuilding index from {} persisted vectors",
301 stored_ids.len()
302 );
303
304 let mut entries = Vec::with_capacity(stored_ids.len());
306 for id in stored_ids {
307 if let Some(entry) = storage.get(&id)? {
308 entries.push((id, entry.vector));
309 }
310 }
311
312 index.add_batch(entries)?;
314
315 tracing::info!("Index rebuilt successfully");
316 }
317 }
318
319 Ok(Self {
320 storage,
321 index: Arc::new(RwLock::new(index)),
322 options,
323 })
324 }
325
326 pub fn with_dimensions(dimensions: usize) -> Result<Self> {
328 let options = DbOptions {
329 dimensions,
330 ..DbOptions::default()
331 };
332 Self::new(options)
333 }
334
335 pub fn insert(&self, entry: VectorEntry) -> Result<VectorId> {
337 let id = self.storage.insert(&entry)?;
338
339 let mut index = self.index.write();
341 index.add(id.clone(), entry.vector)?;
342
343 Ok(id)
344 }
345
346 pub fn insert_batch(&self, entries: impl AsRef<[VectorEntry]>) -> Result<Vec<VectorId>> {
348 let entries = entries.as_ref();
349 let ids = self.storage.insert_batch(entries)?;
350
351 let mut index = self.index.write();
353 let index_entries: Vec<_> = ids
354 .iter()
355 .zip(entries.iter())
356 .map(|(id, entry)| (id.clone(), entry.vector.clone()))
357 .collect();
358
359 index.add_batch(index_entries)?;
360
361 Ok(ids)
362 }
363
364 pub fn search(&self, query: SearchQuery) -> Result<Vec<SearchResult>> {
366 let index = self.index.read();
367 let verify = matches!(
375 self.options.quantization,
376 Some(crate::types::QuantizationConfig::Turbo4 { .. })
377 );
378 let fetch_k = if verify {
379 query.k.saturating_mul(2)
380 } else {
381 query.k
382 };
383 let mut results = index.search(&query.vector, fetch_k)?;
384
385 for result in &mut results {
387 if let Ok(Some(entry)) = self.storage.get(&result.id) {
388 result.vector = Some(entry.vector);
389 result.metadata = entry.metadata;
390 }
391 }
392
393 if verify {
394 for r in &mut results {
395 if let Some(v) = &r.vector {
396 r.score = crate::encoding::metric_distance(
397 self.options.distance_metric,
398 &query.vector,
399 v,
400 );
401 }
402 }
403 results.sort_unstable_by(|a, b| {
404 a.score
405 .partial_cmp(&b.score)
406 .unwrap_or(std::cmp::Ordering::Equal)
407 });
408 results.truncate(query.k);
409 }
410
411 if let Some(filter) = &query.filter {
413 results.retain(|r| {
414 if let Some(metadata) = &r.metadata {
415 filter
416 .iter()
417 .all(|(key, value)| metadata.get(key).is_some_and(|v| v == value))
418 } else {
419 false
420 }
421 });
422 }
423
424 Ok(results)
425 }
426
427 pub fn delete(&self, id: &str) -> Result<bool> {
429 let deleted_storage = self.storage.delete(id)?;
430
431 if deleted_storage {
432 let mut index = self.index.write();
433 let _ = index.remove(&id.to_string())?;
434 }
435
436 Ok(deleted_storage)
437 }
438
439 pub fn get(&self, id: &str) -> Result<Option<VectorEntry>> {
441 self.storage.get(id)
442 }
443
444 pub fn len(&self) -> Result<usize> {
446 self.storage.len()
447 }
448
449 pub fn is_empty(&self) -> Result<bool> {
451 self.storage.is_empty()
452 }
453
454 pub fn options(&self) -> &DbOptions {
456 &self.options
457 }
458
459 #[cfg(feature = "storage")]
462 pub fn save_config_value(&self, key: &str, value: &str) -> Result<()> {
463 self.storage.save_config_value(key, value)
464 }
465
466 #[cfg(feature = "storage")]
468 pub fn load_config_value(&self, key: &str) -> Result<Option<String>> {
469 self.storage.load_config_value(key)
470 }
471
472 pub fn keys(&self) -> Result<Vec<String>> {
474 self.storage.all_ids()
475 }
476}
477
478#[cfg(test)]
479mod tests {
480 use super::*;
481
482 #[cfg(feature = "storage")]
483 #[test]
484 fn memory_url_selects_memory_backend_when_storage_is_compiled() {
485 let options = DbOptions {
486 dimensions: 3,
487 distance_metric: DistanceMetric::Euclidean,
488 storage_path: "memory://feature-unification-regression".to_string(),
489 hnsw_config: None,
490 quantization: None,
491 };
492 let db = VectorDB::new(options).unwrap();
493 db.insert(VectorEntry {
494 id: Some("one".to_string()),
495 vector: vec![1.0, 2.0, 3.0],
496 metadata: None,
497 })
498 .unwrap();
499 assert_eq!(db.len().unwrap(), 1);
500 assert!(!std::path::Path::new("memory:").exists());
501 }
502 use std::path::Path;
503 use tempfile::tempdir;
504
505 #[test]
506 fn test_vector_db_creation() -> Result<()> {
507 let dir = tempdir().unwrap();
508 let mut options = DbOptions::default();
509 options.storage_path = dir.path().join("test.db").to_string_lossy().to_string();
510 options.dimensions = 3;
511
512 let db = VectorDB::new(options)?;
513 assert!(db.is_empty()?);
514
515 Ok(())
516 }
517
518 #[test]
519 fn test_insert_and_search() -> Result<()> {
520 let dir = tempdir().unwrap();
521 let mut options = DbOptions::default();
522 options.storage_path = dir.path().join("test.db").to_string_lossy().to_string();
523 options.dimensions = 3;
524 options.distance_metric = DistanceMetric::Euclidean; options.hnsw_config = None; let db = VectorDB::new(options)?;
528
529 db.insert(VectorEntry {
531 id: Some("v1".to_string()),
532 vector: vec![1.0, 0.0, 0.0],
533 metadata: None,
534 })?;
535
536 db.insert(VectorEntry {
537 id: Some("v2".to_string()),
538 vector: vec![0.0, 1.0, 0.0],
539 metadata: None,
540 })?;
541
542 db.insert(VectorEntry {
543 id: Some("v3".to_string()),
544 vector: vec![0.0, 0.0, 1.0],
545 metadata: None,
546 })?;
547
548 let results = db.search(SearchQuery {
550 vector: vec![1.0, 0.0, 0.0],
551 k: 2,
552 filter: None,
553 ef_search: None,
554 })?;
555
556 assert!(results.len() >= 1);
557 assert_eq!(results[0].id, "v1", "First result should be exact match");
558 assert!(
559 results[0].score < 0.01,
560 "Exact match should have ~0 distance"
561 );
562
563 Ok(())
564 }
565
566 #[test]
568 #[cfg(all(feature = "hnsw", feature = "storage"))]
569 fn test_turbo4_quantized_db() -> Result<()> {
570 let dir = tempdir().unwrap();
571 let db_path = dir.path().join("turbo4.db").to_string_lossy().to_string();
572 let dim = 64usize;
573
574 let mk_options = || {
575 let mut options = DbOptions::default();
576 options.storage_path = db_path.clone();
577 options.dimensions = dim;
578 options.distance_metric = DistanceMetric::Euclidean;
579 options.quantization = Some(QuantizationConfig::Turbo4 {
580 rotation_seed: 42,
581 rescore_multiplier: 4,
582 policy: SearchPolicy::Balanced,
583 search_quantization: SearchQuantization::default(),
584 });
585 options
586 };
587
588 let vectors: Vec<Vec<f32>> = (0..80u64)
592 .map(|i| {
593 (0..dim as u64)
594 .map(|j| {
595 let mut z = (i << 32 | j).wrapping_add(0x9E3779B97F4A7C15);
596 z = (z ^ (z >> 30)).wrapping_mul(0xBF58476D1CE4E5B9);
597 z = (z ^ (z >> 27)).wrapping_mul(0x94D049BB133111EB);
598 ((z >> 40) as f32 / (1u64 << 24) as f32) * 2.0 - 1.0
599 })
600 .collect()
601 })
602 .collect();
603
604 {
605 let db = VectorDB::new(mk_options())?;
606 for (i, v) in vectors.iter().enumerate() {
607 db.insert(VectorEntry {
608 id: Some(format!("v{i}")),
609 vector: v.clone(),
610 metadata: None,
611 })?;
612 }
613 let results = db.search(SearchQuery {
614 vector: vectors[5].clone(),
615 k: 3,
616 filter: None,
617 ef_search: None,
618 })?;
619 assert_eq!(results[0].id, "v5", "self-query must return itself first");
620 }
621
622 {
625 let db = VectorDB::new(mk_options())?;
626 assert!(matches!(
627 db.options().quantization,
628 Some(QuantizationConfig::Turbo4 {
629 rotation_seed: 42,
630 ..
631 })
632 ));
633 let results = db.search(SearchQuery {
634 vector: vectors[12].clone(),
635 k: 3,
636 filter: None,
637 ef_search: None,
638 })?;
639 assert_eq!(results[0].id, "v12", "self-query after restart");
640 }
641 Ok(())
642 }
643
644 #[test]
647 #[cfg(all(feature = "hnsw", feature = "storage"))]
648 fn test_turbo4_provenance_guard() -> Result<()> {
649 let dir = tempdir().unwrap();
650 let db_path = dir.path().join("prov.db").to_string_lossy().to_string();
651 let mk_options = || {
652 let mut options = DbOptions::default();
653 options.storage_path = db_path.clone();
654 options.dimensions = 64;
655 options.distance_metric = DistanceMetric::Euclidean;
656 options.quantization = Some(QuantizationConfig::Turbo4 {
657 rotation_seed: 42,
658 rescore_multiplier: 4,
659 policy: SearchPolicy::Balanced,
660 search_quantization: SearchQuantization::default(),
661 });
662 options
663 };
664
665 {
666 let db = VectorDB::new(mk_options())?;
667 let json = db
668 .load_config_value("turbo4_provenance")?
669 .expect("provenance record must be written when Turbo4 is active");
670 let prov: crate::encoding::VectorProvenance = serde_json::from_str(&json).unwrap();
671 assert_eq!(prov.rotation_seed, Some(42));
672 assert_eq!(prov.dim, 64);
673 assert_eq!(prov.codec_version, crate::encoding::CODEC_VERSION);
674 }
675
676 {
678 let db = VectorDB::new(mk_options())?;
679 let mut prov: crate::encoding::VectorProvenance =
681 serde_json::from_str(&db.load_config_value("turbo4_provenance")?.unwrap()).unwrap();
682 prov.rotation_seed = Some(7);
683 db.save_config_value("turbo4_provenance", &serde_json::to_string(&prov).unwrap())?;
684 }
685
686 let err = VectorDB::new(mk_options());
689 assert!(
690 err.is_err(),
691 "reopen with mismatched provenance must fail, got Ok"
692 );
693 Ok(())
694 }
695
696 #[test]
699 #[cfg(feature = "storage")]
700 fn test_search_after_restart() -> Result<()> {
701 let dir = tempdir().unwrap();
702 let db_path = dir.path().join("persist.db").to_string_lossy().to_string();
703
704 {
706 let mut options = DbOptions::default();
707 options.storage_path = db_path.clone();
708 options.dimensions = 3;
709 options.distance_metric = DistanceMetric::Euclidean;
710 options.hnsw_config = None;
711
712 let db = VectorDB::new(options)?;
713
714 db.insert(VectorEntry {
715 id: Some("v1".to_string()),
716 vector: vec![1.0, 0.0, 0.0],
717 metadata: None,
718 })?;
719
720 db.insert(VectorEntry {
721 id: Some("v2".to_string()),
722 vector: vec![0.0, 1.0, 0.0],
723 metadata: None,
724 })?;
725
726 db.insert(VectorEntry {
727 id: Some("v3".to_string()),
728 vector: vec![0.7, 0.7, 0.0],
729 metadata: None,
730 })?;
731
732 let results = db.search(SearchQuery {
734 vector: vec![0.8, 0.6, 0.0],
735 k: 3,
736 filter: None,
737 ef_search: None,
738 })?;
739 assert_eq!(results.len(), 3, "Should find all 3 vectors before restart");
740 }
741 {
745 let mut options = DbOptions::default();
746 options.storage_path = db_path.clone();
747 options.dimensions = 3;
748 options.distance_metric = DistanceMetric::Euclidean;
749 options.hnsw_config = None;
750
751 let db = VectorDB::new(options)?;
752
753 assert_eq!(db.len()?, 3, "Should have 3 vectors after restart");
755
756 let v1 = db.get("v1")?;
758 assert!(v1.is_some(), "get() should work after restart");
759
760 let results = db.search(SearchQuery {
762 vector: vec![0.8, 0.6, 0.0],
763 k: 3,
764 filter: None,
765 ef_search: None,
766 })?;
767
768 assert_eq!(
769 results.len(),
770 3,
771 "search() should return results after restart (was returning 0 before fix)"
772 );
773
774 assert_eq!(
776 results[0].id, "v3",
777 "v3 [0.7, 0.7, 0.0] should be closest to query [0.8, 0.6, 0.0]"
778 );
779 }
780
781 Ok(())
782 }
783}