Skip to main content

ruvector_core/
vector_db.rs

1//! Main VectorDB interface
2
3use 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
91/// Main vector database
92pub struct VectorDB {
93    storage: Arc<StorageBackend>,
94    index: Arc<RwLock<Box<dyn VectorIndex>>>,
95    options: DbOptions,
96}
97
98impl VectorDB {
99    /// Create a new vector database with the given options
100    ///
101    /// If a storage path is provided and contains persisted vectors,
102    /// the HNSW index will be automatically rebuilt from storage.
103    /// If opening an existing database, the stored configuration (dimensions,
104    /// distance metric, etc.) will be used instead of the provided options.
105    #[allow(unused_mut)] // `options` is mutated only when feature = "storage"
106    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                // First, try to load existing configuration from the database
115                // We create a temporary storage to check for config
116                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                    // Existing database - use stored configuration
123                    tracing::info!(
124                        "Loading existing database with {} dimensions",
125                        config.dimensions
126                    );
127                    options = DbOptions {
128                        // Keep the provided storage path (may have changed)
129                        storage_path: options.storage_path.clone(),
130                        // Use stored configuration for everything else
131                        dimensions: config.dimensions,
132                        distance_metric: config.distance_metric,
133                        hnsw_config: config.hnsw_config,
134                        quantization: config.quantization,
135                    };
136                    // Recreate storage with correct dimensions
137                    Arc::new(StorageBackend::Persistent(
138                        crate::storage::VectorStorage::new(
139                            &options.storage_path,
140                            options.dimensions,
141                        )?,
142                    ))
143                } else {
144                    // New database - save the configuration
145                    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        // Choose index based on configuration and available features.
159        // Turbo4 quantization (ADR-296) is applied here: with an HNSW config
160        // it swaps the f32 index for one that stores only packed 4-bit codes.
161        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)] // `index` is mutated only when feature = "storage"
177        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                // Fall back to flat index if HNSW is not available
208                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        // The legacy variants of `DbOptions.quantization` are persisted and
216        // restored but not applied to the index or storage representation
217        // (issue #563). Warn loudly rather than silently ignoring a requested
218        // quantization so callers don't assume a memory reduction that isn't
219        // happening. `Turbo4` (ADR-296) IS applied — but only on the HNSW
220        // path, so warn if it was requested without one.
221        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        // ADR-297 §7: persist the collection-level provenance record and
244        // validate the codec contract on reopen. Codes encoded under one
245        // rotation seed are meaningless under another, and a codec_version
246        // bump means tables/layout changed — refuse to open rather than
247        // silently serve wrong results (a migration re-encodes; ADR-297
248        // phase D wires that path).
249        #[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(&current).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        // Rebuild index from persisted vectors if storage is not empty
294        // This fixes the bug where search() returns empty results after restart
295        #[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                // Batch load all vectors for efficient index rebuilding
305                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                // Add all vectors to index in batch for better performance
313                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    /// Create with default options
327    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    /// Insert a vector entry
336    pub fn insert(&self, entry: VectorEntry) -> Result<VectorId> {
337        let id = self.storage.insert(&entry)?;
338
339        // Add to index
340        let mut index = self.index.write();
341        index.add(id.clone(), entry.vector)?;
342
343        Ok(id)
344    }
345
346    /// Insert multiple vectors in a batch
347    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        // Add to index
352        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    /// Search for similar vectors
365    pub fn search(&self, query: SearchQuery) -> Result<Vec<SearchResult>> {
366        let index = self.index.read();
367        // Verification tier (ADR-297 §2): with Turbo4 quantization, over-fetch
368        // 2k candidates and re-rank them against the stored f32 vectors.
369        // Ablation (20k×768-D clustered): this recovers the precision-bound
370        // tail ranks that wider quantized traversal cannot — recall@10 went
371        // from −7 pp vs the f32 index to +1.4 pp, at ~f32 latency — and it is
372        // nearly free here because result enrichment fetches the stored
373        // vectors anyway.
374        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        // Enrich results with full data if needed
386        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        // Apply metadata filters if specified
412        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    /// Delete a vector by ID
428    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    /// Get a vector by ID
440    pub fn get(&self, id: &str) -> Result<Option<VectorEntry>> {
441        self.storage.get(id)
442    }
443
444    /// Get the number of vectors
445    pub fn len(&self) -> Result<usize> {
446        self.storage.len()
447    }
448
449    /// Check if database is empty
450    pub fn is_empty(&self) -> Result<bool> {
451        self.storage.is_empty()
452    }
453
454    /// Get database options
455    pub fn options(&self) -> &DbOptions {
456        &self.options
457    }
458
459    /// Persist a provenance value in the vector store itself, so it cannot be
460    /// removed without removing the vectors it describes.
461    #[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    /// Read a value written by [`save_config_value`](Self::save_config_value).
467    #[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    /// Get all vector IDs (for iteration/serialization)
473    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; // Use Euclidean for clearer test
525        options.hnsw_config = None; // Use flat index for testing
526
527        let db = VectorDB::new(options)?;
528
529        // Insert vectors
530        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        // Search for exact match
549        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    /// Turbo4 quantization end-to-end: insert, search, restart-rebuild (ADR-296).
567    #[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        // Deterministic, pairwise-distinct pseudo-random vectors (SplitMix64
589        // hash of (i, j) — a modular pattern here can silently produce
590        // identical vectors, making "self is nearest" ambiguous).
591        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        // Restart: config (incl. quantization) restored from storage, index
623        // rebuilt by re-encoding persisted vectors.
624        {
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    /// Provenance record (ADR-297 §7): written on create, readable, and a
645    /// tampered rotation seed refuses to open.
646    #[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        // Clean reopen with the same parameters succeeds.
677        {
678            let db = VectorDB::new(mk_options())?;
679            // Tamper: claim the codes were made with a different seed.
680            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        // Reopen must now refuse: stored contract disagrees with the seed
687        // the (restored) config asks for.
688        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 that search works after simulated restart (new VectorDB instance)
697    /// This verifies the fix for issue #30: HNSW index not rebuilt from storage
698    #[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        // Phase 1: Create database and insert vectors
705        {
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            // Verify search works before "restart"
733            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        // db is dropped here, simulating application shutdown
742
743        // Phase 2: Create new database instance (simulates restart)
744        {
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            // Verify vectors are still accessible
754            assert_eq!(db.len()?, 3, "Should have 3 vectors after restart");
755
756            // Verify get() works
757            let v1 = db.get("v1")?;
758            assert!(v1.is_some(), "get() should work after restart");
759
760            // Verify search() works - THIS WAS THE BUG
761            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            // v3 should be closest to query [0.8, 0.6, 0.0]
775            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}