Skip to main content

vecdb_core/storage/
mod.rs

1pub mod metadata;
2pub mod mmap;
3pub mod wal;
4
5pub use metadata::MetadataStore;
6pub use mmap::MmapVectorStore;
7pub use wal::{WalEntry, WriteAheadLog};
8
9use std::path::{Path, PathBuf};
10
11use crate::errors::{Result, VecDbError};
12use crate::hybrid::{FusionStrategy, HybridEngine};
13use crate::index::{AnyIndex, IndexBackend};
14use crate::planner::{PlanExecutor, QueryPlanner};
15use crate::sparse::SparseIndex;
16use crate::types::{
17    CollectionConfig, IndexStats, SearchRequest, SearchResult, Vector, VectorId, VectorRecord,
18};
19
20pub enum UpsertResult {
21    Inserted,
22    Updated,
23}
24
25pub struct Storage {
26    pub vectors: MmapVectorStore,
27    pub wal: WriteAheadLog,
28    pub metadata: MetadataStore,
29    pub index: AnyIndex,
30    pub sparse: SparseIndex,
31    pub hybrid: HybridEngine,
32    pub planner: QueryPlanner,
33    pub config: CollectionConfig,
34    data_dir: PathBuf,
35}
36
37impl Storage {
38    pub fn create(data_dir: &Path, config: &CollectionConfig) -> Result<Self> {
39        std::fs::create_dir_all(data_dir)
40            .map_err(|e| VecDbError::StorageError(format!("create_dir_all failed: {e}")))?;
41
42        let vectors_path = data_dir.join(format!("{}.vectors", config.name));
43        let wal_path = data_dir.join(format!("{}.wal", config.name));
44        let metadata_path = data_dir.join(format!("{}.db", config.name));
45        let sparse_path = data_dir.join(format!("{}.sparse.json", config.name));
46
47        let vectors = MmapVectorStore::create(&vectors_path, config.dimension, 1024)?;
48        let wal = WriteAheadLog::open(&wal_path)?;
49        let metadata = MetadataStore::open(&metadata_path)?;
50        metadata.save_collection(config)?;
51        metadata.ensure_payload_indexes(&config.indexed_payload_fields)?;
52
53        let index = AnyIndex::from_config(config);
54        let sparse = SparseIndex::create(&sparse_path);
55        let hybrid = HybridEngine::default();
56        let planner = QueryPlanner::new(config.clone());
57
58        tracing::info!("Storage created for collection '{}'", config.name);
59
60        Ok(Self {
61            vectors,
62            wal,
63            metadata,
64            index,
65            sparse,
66            hybrid,
67            planner,
68            config: config.clone(),
69            data_dir: data_dir.to_path_buf(),
70        })
71    }
72
73    pub fn open(data_dir: &Path, collection_name: &str) -> Result<Self> {
74        let vectors_path = data_dir.join(format!("{collection_name}.vectors"));
75        let wal_path = data_dir.join(format!("{collection_name}.wal"));
76        let metadata_path = data_dir.join(format!("{collection_name}.db"));
77        let sparse_path = data_dir.join(format!("{collection_name}.sparse.json"));
78
79        let vectors = MmapVectorStore::open(&vectors_path)?;
80        let wal = WriteAheadLog::open(&wal_path)?;
81        let metadata = MetadataStore::open(&metadata_path)?;
82        let config = metadata.load_collection(collection_name)?;
83        metadata.ensure_payload_indexes(&config.indexed_payload_fields)?;
84
85        let (index, saved_index_exists) =
86            AnyIndex::load_or_create(data_dir, collection_name, &config);
87
88        let sparse = SparseIndex::open(&sparse_path)?;
89        let hybrid = HybridEngine::default();
90        let planner = QueryPlanner::new(config.clone());
91
92        let mut storage = Self {
93            vectors,
94            wal,
95            metadata,
96            index,
97            sparse,
98            hybrid,
99            planner,
100            config,
101            data_dir: data_dir.to_path_buf(),
102        };
103
104        let recovered = storage.recover_from_wal()?;
105
106        // Rebuild dense index if no saved file or WAL entries were replayed.
107        if !saved_index_exists || recovered > 0 {
108            storage.rebuild_index()?;
109        }
110
111        tracing::info!(
112            "Storage opened for collection '{}', {} vectors ({} recovered from WAL)",
113            collection_name,
114            storage.vectors.len(),
115            recovered
116        );
117
118        Ok(storage)
119    }
120
121    pub fn upsert(&mut self, record: VectorRecord) -> Result<UpsertResult> {
122        if record.vector.len() != self.config.dimension {
123            return Err(VecDbError::DimensionMismatch {
124                expected: self.config.dimension,
125                got: record.vector.len(),
126            });
127        }
128
129        let id = record.id.clone();
130        let existing = self.metadata.get(&id);
131
132        let (mmap_index, result) = match existing {
133            Ok((idx, _)) => (idx, UpsertResult::Updated),
134            Err(VecDbError::NotFound { .. }) => {
135                let idx = self.vectors.append(&record.vector)?;
136                (idx, UpsertResult::Inserted)
137            }
138            Err(e) => return Err(e),
139        };
140
141        // WAL first.
142        self.wal.append(&WalEntry::Insert {
143            id: id.clone(),
144            mmap_index,
145            record: record.clone(),
146        })?;
147
148        // MMAP overwrite for updates (inserts already written via append above).
149        if matches!(result, UpsertResult::Updated) {
150            self.vectors.overwrite(mmap_index, &record.vector)?;
151        }
152
153        // Metadata.
154        self.metadata.upsert(&id, mmap_index, &record)?;
155
156        // Dense index.
157        self.index
158            .insert(record.id.clone(), record.vector.clone())?;
159
160        // Sparse index — only when text is present.
161        if let Some(ref text) = record.text {
162            if !text.is_empty() {
163                self.sparse.index_document(&id, text)?;
164            }
165        }
166
167        Ok(result)
168    }
169
170    /// Bulk-insert new records efficiently: append all vectors, write the WAL in
171    /// one batched fsync, insert metadata in one transaction, then build the
172    /// dense (and sparse) index once — instead of the per-insert index rebuilds
173    /// `upsert` triggers. Intended for loading a fresh/large collection.
174    ///
175    /// Assumes the ids are new (uses append semantics). A final checkpoint
176    /// truncates the WAL, since the data is durable in the vector store, the
177    /// metadata db, and the saved index.
178    pub fn bulk_upsert(&mut self, records: Vec<VectorRecord>) -> Result<usize> {
179        for r in &records {
180            if r.vector.len() != self.config.dimension {
181                return Err(VecDbError::DimensionMismatch {
182                    expected: self.config.dimension,
183                    got: r.vector.len(),
184                });
185            }
186        }
187
188        let mut wal_entries = Vec::with_capacity(records.len());
189        let mut items: Vec<(String, usize, VectorRecord)> = Vec::with_capacity(records.len());
190        for record in records {
191            let id = record.id.clone();
192            let mmap_index = self.vectors.append(&record.vector)?;
193            wal_entries.push(WalEntry::Insert {
194                id: id.clone(),
195                mmap_index,
196                record: record.clone(),
197            });
198            items.push((id, mmap_index, record));
199        }
200
201        self.wal.append_batch(&wal_entries)?;
202        drop(wal_entries);
203
204        let refs: Vec<(String, usize, &VectorRecord)> = items
205            .iter()
206            .map(|(id, idx, rec)| (id.clone(), *idx, rec))
207            .collect();
208        self.metadata.upsert_batch(&refs)?;
209        drop(refs);
210        let count = items.len();
211        drop(items);
212
213        // Build the dense + sparse indexes once from all active records.
214        self.rebuild_index()?;
215        // Data is durable in mmap + metadata; collapse the WAL.
216        self.checkpoint()?;
217        Ok(count)
218    }
219
220    pub fn delete(&mut self, id: &VectorId) -> Result<()> {
221        self.wal.append(&WalEntry::Delete { id: id.clone() })?;
222        self.metadata.delete(id)?;
223        // Soft-delete from dense index (ignore NotFound).
224        let _ = self.index.delete(id);
225        // Soft-delete from sparse index (ignore NotFound — may have no text).
226        let _ = self.sparse.remove_document(id);
227        Ok(())
228    }
229
230    /// Search the dense HNSW index and hydrate results with metadata payloads.
231    pub fn search(&self, query: &crate::types::Vector, k: usize) -> Result<Vec<SearchResult>> {
232        let hits = self.index.search(query, k)?;
233        let mut results = Vec::with_capacity(hits.len());
234        for (id, score) in hits {
235            match self.metadata.get(&id) {
236                Ok((_, rec)) => results.push(SearchResult {
237                    id,
238                    score,
239                    dense_score: Some(score),
240                    sparse_score: None,
241                    payload: rec.payload,
242                    text: rec.text,
243                }),
244                Err(_) => continue, // metadata inconsistency — skip
245            }
246        }
247        Ok(results)
248    }
249
250    /// Full-text BM25 search using the sparse index.
251    pub fn search_sparse(&self, query: &str, k: usize) -> Result<Vec<SearchResult>> {
252        let hits = self.sparse.search(query, k)?;
253        let mut results = Vec::with_capacity(hits.len());
254        for (id, score) in hits {
255            match self.metadata.get(&id) {
256                Ok((_, rec)) => results.push(SearchResult {
257                    id,
258                    score,
259                    dense_score: None,
260                    sparse_score: Some(score),
261                    payload: rec.payload,
262                    text: rec.text,
263                }),
264                Err(_) => continue,
265            }
266        }
267        Ok(results)
268    }
269
270    /// Parse and execute a SQL string against this collection.
271    ///
272    /// The SQL must include a `VECTOR_SIM(col, [...]) op threshold` predicate.
273    /// Optional scalar equality conditions (`AND field = 'value'`) are applied
274    /// as post-scan metadata filters.
275    pub fn execute_sql(&self, sql: &str) -> Result<Vec<SearchResult>> {
276        use crate::planner::sql::{AstConverter, SqlParser};
277
278        let vector_count = self.metadata.count_active()?;
279
280        // Parse once — extract query vector before conversion consumes the stmt.
281        let stmt = SqlParser::parse(sql)?;
282        let query_vector: Option<Vector> = AstConverter::extract_query_vector(&stmt);
283
284        // Convert AST → PhysicalPlan.
285        let converter = AstConverter::new(QueryPlanner::new(self.config.clone()));
286        let plan = converter.convert(stmt, vector_count)?;
287
288        tracing::debug!("SQL plan:\n{}", self.planner.explain(&plan));
289        PlanExecutor::new(self).execute(&plan, query_vector.as_ref(), None)
290    }
291
292    /// Route a `SearchRequest` through the query planner and execute the
293    /// resulting physical plan.
294    ///
295    /// This is the primary search entry point for callers that want the full
296    /// planner pipeline (cost model, hybrid decision, candidate oversampling).
297    pub fn execute_search(&self, request: &SearchRequest) -> Result<Vec<SearchResult>> {
298        let vector_count = self.metadata.count_active()?;
299        let plan = self.planner.plan_search(request, vector_count)?;
300        tracing::debug!("Query plan:\n{}", self.planner.explain(&plan));
301        let executor = PlanExecutor::new(self);
302        executor.execute(
303            &plan,
304            request.vector.as_ref(),
305            request.query_text.as_deref(),
306        )
307    }
308
309    /// Dense HNSW search with full metadata hydration.
310    ///
311    /// Equivalent to `search` but makes the intent explicit and is used by
312    /// callers that distinguish dense vs hybrid paths.
313    pub fn search_dense(&self, query_vector: &Vector, k: usize) -> Result<Vec<SearchResult>> {
314        if query_vector.len() != self.config.dimension {
315            return Err(VecDbError::DimensionMismatch {
316                expected: self.config.dimension,
317                got: query_vector.len(),
318            });
319        }
320        let hits = self.index.search(query_vector, k)?;
321        let mut results = Vec::with_capacity(hits.len());
322        for (id, score) in hits {
323            match self.metadata.get(&id) {
324                Ok((_, rec)) => results.push(SearchResult {
325                    id,
326                    score,
327                    dense_score: Some(score),
328                    sparse_score: None,
329                    payload: rec.payload,
330                    text: rec.text,
331                }),
332                Err(_) => continue,
333            }
334        }
335        Ok(results)
336    }
337
338    /// Dense k-NN search restricted to records whose payload matches `filter`,
339    /// with predicate pushdown.
340    ///
341    /// When the filter is selective (the allowed set is small relative to the
342    /// collection), candidates are generated **from the filter**: an exact,
343    /// full-precision brute-force scan over only the matching vectors. This
344    /// never scores the excluded majority, so a 95%-eliminating filter costs
345    /// roughly 5% of the work — and is exact, unlike oversample-then-discard.
346    ///
347    /// When the filter is weakly selective, it falls back to an ANN search with
348    /// a post-filter (few candidates are dropped, so recall stays high).
349    pub fn search_dense_filtered(
350        &self,
351        query: &Vector,
352        k: usize,
353        filter: &serde_json::Value,
354    ) -> Result<Vec<SearchResult>> {
355        if query.len() != self.config.dimension {
356            return Err(VecDbError::DimensionMismatch {
357                expected: self.config.dimension,
358                got: query.len(),
359            });
360        }
361
362        let allowed = self
363            .metadata
364            .filter_ids_indexed(filter, &self.config.indexed_payload_fields)?;
365        if allowed.is_empty() {
366            return Ok(vec![]);
367        }
368        let n = self.metadata.count_active()?;
369        // Brute-force the allowed set when it is small enough that scanning it
370        // is cheaper (and exact) than an oversampled ANN search. Covers all
371        // high-selectivity filters (the gate case) and small collections.
372        let bruteforce_threshold = (n / 4).max(8192);
373        let metric = &self.config.metric;
374
375        if allowed.len() <= bruteforce_threshold {
376            let mut scored: Vec<(VectorId, f32)> = Vec::with_capacity(allowed.len());
377            for (id, mmap_index) in &allowed {
378                let v = self.vectors.get(*mmap_index)?;
379                let dist = crate::index::compute_distance(query, &v, metric);
380                scored.push((id.clone(), crate::index::to_score(dist, metric)));
381            }
382            scored.sort_unstable_by(|a, b| {
383                b.1.partial_cmp(&a.1).unwrap_or(std::cmp::Ordering::Equal)
384            });
385            scored.truncate(k);
386
387            let mut results = Vec::with_capacity(scored.len());
388            for (id, score) in scored {
389                if let Ok((_, rec)) = self.metadata.get(&id) {
390                    results.push(SearchResult {
391                        id,
392                        score,
393                        dense_score: Some(score),
394                        sparse_score: None,
395                        payload: rec.payload,
396                        text: rec.text,
397                    });
398                }
399            }
400            return Ok(results);
401        }
402
403        // Weakly selective: ANN + post-filter.
404        let allowed_set: std::collections::HashSet<VectorId> =
405            allowed.into_iter().map(|(id, _)| id).collect();
406        let oversample = (k * 4).max(k + 50);
407        let hits = self.index.search(query, oversample)?;
408        let mut results = Vec::with_capacity(k);
409        for (id, score) in hits {
410            if !allowed_set.contains(&id) {
411                continue;
412            }
413            if let Ok((_, rec)) = self.metadata.get(&id) {
414                results.push(SearchResult {
415                    id,
416                    score,
417                    dense_score: Some(score),
418                    sparse_score: None,
419                    payload: rec.payload,
420                    text: rec.text,
421                });
422            }
423            if results.len() >= k {
424                break;
425            }
426        }
427        Ok(results)
428    }
429
430    /// Two-stage hybrid retrieval pipeline.
431    ///
432    /// At least one of `query_vector` / `query_text` must be provided.
433    ///
434    /// Stage 1 — Dense: fetch `k * 5` HNSW candidates (or empty if no vector).
435    /// Stage 2 — Sparse: re-score dense candidates with BM25 (or full-corpus
436    ///           search when no dense candidates exist).
437    /// Stage 3 — Fusion: combine via `strategy` and `alpha`.
438    /// Stage 4 — Hydrate: populate `payload` and `text` from metadata.
439    pub fn search_hybrid(
440        &self,
441        query_vector: Option<&Vector>,
442        query_text: Option<&str>,
443        k: usize,
444        alpha: f32,
445        strategy: FusionStrategy,
446    ) -> Result<Vec<SearchResult>> {
447        // Validate inputs.
448        if query_vector.is_none() && query_text.is_none() {
449            return Err(VecDbError::InvalidQuery(
450                "hybrid search requires at least one of query_vector or query_text".into(),
451            ));
452        }
453        if let Some(qv) = query_vector {
454            if qv.len() != self.config.dimension {
455                return Err(VecDbError::DimensionMismatch {
456                    expected: self.config.dimension,
457                    got: qv.len(),
458                });
459            }
460        }
461
462        let oversample_k = k * 5;
463
464        // Stage 1 — Dense retrieval.
465        let dense_results: Vec<(VectorId, f32)> = if let Some(qv) = query_vector {
466            self.index.search(qv, oversample_k)?
467        } else {
468            vec![]
469        };
470
471        // Stage 2 — Sparse re-scoring.
472        let sparse_results: Vec<(VectorId, f32)> = if let Some(qt) = query_text {
473            if !dense_results.is_empty() {
474                // Re-score only the dense candidates.
475                let candidate_ids: Vec<VectorId> =
476                    dense_results.iter().map(|(id, _)| id.clone()).collect();
477                self.sparse.score(qt, &candidate_ids)?
478            } else {
479                // No dense candidates — full sparse search.
480                self.sparse.score_all(qt, oversample_k)?
481            }
482        } else {
483            vec![]
484        };
485
486        // Stage 3 — Fusion.
487        let engine = HybridEngine::new(alpha, strategy, 5);
488        let mut fused = engine.fuse(dense_results, sparse_results, k)?;
489
490        // Stage 4 — Hydrate payloads + text from metadata.
491        for result in &mut fused {
492            match self.metadata.get(&result.id) {
493                Ok((_, rec)) => {
494                    result.payload = rec.payload;
495                    result.text = rec.text;
496                }
497                Err(_) => {
498                    tracing::warn!(
499                        "hybrid search: metadata not found for id '{}', keeping null payload",
500                        result.id
501                    );
502                }
503            }
504        }
505
506        Ok(fused)
507    }
508
509    /// Rebuild the dense index from the current metadata + mmap store.
510    /// Also rebuilds the sparse index from stored text payloads.
511    pub fn rebuild_index(&mut self) -> Result<()> {
512        let active = self.metadata.list_active()?;
513        let mut pairs: Vec<(VectorId, crate::types::Vector)> = Vec::with_capacity(active.len());
514        for (id, mmap_idx) in &active {
515            let vec = self.vectors.get(*mmap_idx)?;
516            pairs.push((id.clone(), vec));
517        }
518        let count = pairs.len();
519        self.index.build(pairs)?;
520
521        // Rebuild sparse from stored text.
522        self.sparse = SparseIndex::create(
523            &self
524                .data_dir
525                .join(format!("{}.sparse.json", self.config.name)),
526        );
527        for (id, _) in &active {
528            if let Ok((_, rec)) = self.metadata.get(id) {
529                if let Some(ref text) = rec.text {
530                    if !text.is_empty() {
531                        let _ = self.sparse.index_document(id, text);
532                    }
533                }
534            }
535        }
536
537        tracing::info!("Index rebuilt from {} vectors", count);
538        Ok(())
539    }
540
541    /// Persist both the dense index and the sparse index to disk.
542    pub fn save_indexes(&self) -> Result<()> {
543        self.index
544            .save_for_collection(&self.data_dir, &self.config.name)?;
545        self.sparse.save()?;
546        Ok(())
547    }
548
549    pub fn recover_from_wal(&mut self) -> Result<usize> {
550        let entries = self.wal.replay()?;
551        let mut applied = 0usize;
552
553        for entry in &entries {
554            match entry {
555                WalEntry::Insert {
556                    id,
557                    mmap_index,
558                    record,
559                } => match self.metadata.get(id) {
560                    Ok((existing_idx, _)) if existing_idx == *mmap_index => {
561                        // Already applied — skip.
562                    }
563                    _ => {
564                        self.metadata.upsert(id, *mmap_index, record)?;
565                        applied += 1;
566                    }
567                },
568                WalEntry::Delete { id } => match self.metadata.get(id) {
569                    Ok(_) => {
570                        self.metadata.delete(id)?;
571                        applied += 1;
572                    }
573                    Err(VecDbError::NotFound { .. }) => {}
574                    Err(e) => return Err(e),
575                },
576                WalEntry::Checkpoint { .. } => {}
577            }
578        }
579
580        tracing::info!("WAL recovery applied {} entries", applied);
581        Ok(applied)
582    }
583
584    pub fn checkpoint(&mut self) -> Result<()> {
585        self.wal.checkpoint()?;
586        self.wal.truncate_after_checkpoint()?;
587        tracing::info!(
588            "Storage checkpoint complete for collection '{}'",
589            self.config.name
590        );
591        Ok(())
592    }
593
594    pub fn stats(&self) -> Result<IndexStats> {
595        let vector_count = self.metadata.count_active()?;
596        let vectors_path = self.data_dir.join(format!("{}.vectors", self.config.name));
597        let disk_bytes = std::fs::metadata(&vectors_path)
598            .map(|m| m.len())
599            .unwrap_or(0);
600
601        Ok(IndexStats {
602            collection: self.config.name.clone(),
603            vector_count,
604            index_type: self.config.index_type.clone(),
605            dimension: self.config.dimension,
606            disk_bytes,
607            memory_bytes: 0,
608        })
609    }
610}
611
612#[cfg(test)]
613mod tests {
614    use super::*;
615    use chrono::Utc;
616    use serde_json::json;
617    use tempfile::tempdir;
618
619    use crate::types::{CollectionConfig, Vector, VectorRecord};
620
621    fn dummy_record(id: &str, vector: Vector) -> VectorRecord {
622        let now = Utc::now();
623        VectorRecord {
624            id: id.to_string(),
625            vector,
626            payload: json!({ "id": id }),
627            text: Some(format!("text for {id}")),
628            created_at: now,
629            updated_at: now,
630        }
631    }
632
633    #[test]
634    fn test_mmap_create_and_open() {
635        let dir = tempdir().unwrap();
636        let path = dir.path().join("test.vectors");
637
638        {
639            let mut store = MmapVectorStore::create(&path, 4, 10).unwrap();
640            store.append(&vec![1.0, 2.0, 3.0, 4.0]).unwrap();
641            store.append(&vec![5.0, 6.0, 7.0, 8.0]).unwrap();
642            store.append(&vec![9.0, 10.0, 11.0, 12.0]).unwrap();
643            assert_eq!(store.len(), 3);
644        }
645
646        let store = MmapVectorStore::open(&path).unwrap();
647        assert_eq!(store.len(), 3);
648        assert_eq!(store.get(0).unwrap(), vec![1.0f32, 2.0, 3.0, 4.0]);
649        assert_eq!(store.get(1).unwrap(), vec![5.0f32, 6.0, 7.0, 8.0]);
650        assert_eq!(store.get(2).unwrap(), vec![9.0f32, 10.0, 11.0, 12.0]);
651    }
652
653    #[test]
654    fn test_mmap_dimension_mismatch() {
655        let dir = tempdir().unwrap();
656        let path = dir.path().join("test.vectors");
657        let mut store = MmapVectorStore::create(&path, 4, 10).unwrap();
658        let result = store.append(&vec![1.0, 2.0, 3.0]);
659        assert!(matches!(
660            result,
661            Err(VecDbError::DimensionMismatch {
662                expected: 4,
663                got: 3
664            })
665        ));
666    }
667
668    #[test]
669    fn test_mmap_grow() {
670        let dir = tempdir().unwrap();
671        let path = dir.path().join("test.vectors");
672        let mut store = MmapVectorStore::create(&path, 2, 2).unwrap();
673        store.append(&vec![1.0f32, 2.0]).unwrap();
674        store.append(&vec![3.0f32, 4.0]).unwrap();
675        store.append(&vec![5.0f32, 6.0]).unwrap(); // forces grow
676        assert_eq!(store.len(), 3);
677        assert_eq!(store.get(0).unwrap(), vec![1.0f32, 2.0]);
678        assert_eq!(store.get(1).unwrap(), vec![3.0f32, 4.0]);
679        assert_eq!(store.get(2).unwrap(), vec![5.0f32, 6.0]);
680    }
681
682    #[test]
683    fn test_wal_append_and_replay() {
684        let dir = tempdir().unwrap();
685        let path = dir.path().join("test.wal");
686        let mut wal = WriteAheadLog::open(&path).unwrap();
687
688        wal.append(&WalEntry::Insert {
689            id: "doc1".into(),
690            mmap_index: 0,
691            record: dummy_record("doc1", vec![1.0f32, 2.0]),
692        })
693        .unwrap();
694        wal.append(&WalEntry::Insert {
695            id: "doc2".into(),
696            mmap_index: 1,
697            record: dummy_record("doc2", vec![3.0f32, 4.0]),
698        })
699        .unwrap();
700        wal.append(&WalEntry::Insert {
701            id: "doc3".into(),
702            mmap_index: 2,
703            record: dummy_record("doc3", vec![5.0f32, 6.0]),
704        })
705        .unwrap();
706        wal.append(&WalEntry::Delete { id: "doc1".into() }).unwrap();
707
708        let entries = wal.replay().unwrap();
709        assert_eq!(entries.len(), 4);
710        assert!(matches!(&entries[0], WalEntry::Insert { id, .. } if id == "doc1"));
711        assert!(matches!(&entries[3], WalEntry::Delete { id } if id == "doc1"));
712    }
713
714    #[test]
715    fn test_wal_checkpoint_and_truncate() {
716        let dir = tempdir().unwrap();
717        let path = dir.path().join("test.wal");
718        let mut wal = WriteAheadLog::open(&path).unwrap();
719
720        let r = dummy_record("x", vec![1.0f32]);
721        for i in 0..3usize {
722            wal.append(&WalEntry::Insert {
723                id: format!("doc{i}"),
724                mmap_index: i,
725                record: r.clone(),
726            })
727            .unwrap();
728        }
729        wal.checkpoint().unwrap();
730        for i in 3..5usize {
731            wal.append(&WalEntry::Insert {
732                id: format!("doc{i}"),
733                mmap_index: i,
734                record: r.clone(),
735            })
736            .unwrap();
737        }
738        wal.truncate_after_checkpoint().unwrap();
739
740        let entries = wal.replay().unwrap();
741        assert_eq!(entries.len(), 2);
742    }
743
744    #[test]
745    fn test_metadata_upsert_and_get() {
746        let dir = tempdir().unwrap();
747        let path = dir.path().join("test.db");
748        let store = MetadataStore::open(&path).unwrap();
749
750        let record = VectorRecord {
751            id: "doc1".into(),
752            vector: vec![],
753            payload: json!({ "title": "hello" }),
754            text: Some("hello world".into()),
755            created_at: Utc::now(),
756            updated_at: Utc::now(),
757        };
758
759        store.upsert("doc1", 0, &record).unwrap();
760        let (mmap_index, retrieved) = store.get("doc1").unwrap();
761
762        assert_eq!(mmap_index, 0);
763        assert_eq!(retrieved.id, "doc1");
764        assert_eq!(retrieved.text.as_deref(), Some("hello world"));
765        assert_eq!(retrieved.payload["title"], "hello");
766    }
767
768    #[test]
769    fn test_metadata_soft_delete() {
770        let dir = tempdir().unwrap();
771        let path = dir.path().join("test.db");
772        let store = MetadataStore::open(&path).unwrap();
773
774        store
775            .upsert("doc2", 0, &dummy_record("doc2", vec![]))
776            .unwrap();
777        store.delete("doc2").unwrap();
778
779        assert!(matches!(
780            store.get("doc2"),
781            Err(VecDbError::NotFound { .. })
782        ));
783        assert_eq!(store.count_active().unwrap(), 0);
784    }
785
786    #[test]
787    fn test_storage_full_roundtrip() {
788        let dir = tempdir().unwrap();
789        let config = CollectionConfig::new("testcol", 3);
790
791        {
792            let mut storage = Storage::create(dir.path(), &config).unwrap();
793            for i in 0..5u32 {
794                let r = dummy_record(
795                    &format!("doc{i}"),
796                    vec![i as f32, i as f32 + 1.0, i as f32 + 2.0],
797                );
798                storage.upsert(r).unwrap();
799            }
800            assert_eq!(storage.stats().unwrap().vector_count, 5);
801            storage.delete(&"doc0".to_string()).unwrap();
802            assert_eq!(storage.stats().unwrap().vector_count, 4);
803        }
804
805        let mut storage = Storage::open(dir.path(), "testcol").unwrap();
806        assert_eq!(storage.stats().unwrap().vector_count, 4);
807        storage.checkpoint().unwrap();
808    }
809
810    #[test]
811    fn test_wal_recovery() {
812        let dir = tempdir().unwrap();
813        let config = CollectionConfig::new("waltest", 2);
814
815        {
816            let mut storage = Storage::create(dir.path(), &config).unwrap();
817            storage
818                .upsert(dummy_record("r1", vec![1.0f32, 2.0]))
819                .unwrap();
820            storage
821                .upsert(dummy_record("r2", vec![3.0f32, 4.0]))
822                .unwrap();
823            storage
824                .upsert(dummy_record("r3", vec![5.0f32, 6.0]))
825                .unwrap();
826            // Drop without checkpoint — simulates crash.
827        }
828
829        let storage = Storage::open(dir.path(), "waltest").unwrap();
830        assert!(storage.metadata.get("r1").is_ok());
831        assert!(storage.metadata.get("r2").is_ok());
832        assert!(storage.metadata.get("r3").is_ok());
833    }
834
835    #[test]
836    fn test_sparse_search_after_upsert() {
837        let dir = tempdir().unwrap();
838        let config = CollectionConfig::new("sparsecol", 3);
839        let mut storage = Storage::create(dir.path(), &config).unwrap();
840
841        let now = Utc::now();
842        storage
843            .upsert(VectorRecord {
844                id: "v1".to_string(),
845                vector: vec![1.0, 0.0, 0.0],
846                payload: json!({}),
847                text: Some("rust vector database storage engine".to_string()),
848                created_at: now,
849                updated_at: now,
850            })
851            .unwrap();
852        storage
853            .upsert(VectorRecord {
854                id: "v2".to_string(),
855                vector: vec![0.0, 1.0, 0.0],
856                payload: json!({}),
857                text: Some("python machine learning numpy".to_string()),
858                created_at: now,
859                updated_at: now,
860            })
861            .unwrap();
862
863        let results = storage.search_sparse("rust database", 5).unwrap();
864        assert!(!results.is_empty());
865        assert_eq!(results[0].id, "v1");
866        assert!(results[0].sparse_score.is_some());
867        assert!(results[0].dense_score.is_none());
868    }
869
870    #[test]
871    fn test_sparse_delete_removes_from_index() {
872        let dir = tempdir().unwrap();
873        let config = CollectionConfig::new("delsparse", 2);
874        let mut storage = Storage::create(dir.path(), &config).unwrap();
875
876        let now = Utc::now();
877        storage
878            .upsert(VectorRecord {
879                id: "x1".to_string(),
880                vector: vec![1.0, 0.0],
881                payload: json!({}),
882                text: Some("neural network deep learning".to_string()),
883                created_at: now,
884                updated_at: now,
885            })
886            .unwrap();
887
888        storage.delete(&"x1".to_string()).unwrap();
889
890        let results = storage.search_sparse("neural", 5).unwrap();
891        assert!(results.is_empty());
892    }
893
894    // ── Hybrid tests ─────────────────────────────────────────────
895
896    #[test]
897    fn test_storage_search_hybrid_both_signals() {
898        let dir = tempdir().unwrap();
899        let config = CollectionConfig::new("hybcol", 4);
900        let mut storage = Storage::create(dir.path(), &config).unwrap();
901
902        let texts = [
903            "machine learning algorithms",
904            "deep neural networks",
905            "random forest trees",
906            "gradient boosting ensemble",
907            "support vector machines",
908        ];
909        let now = Utc::now();
910        for (i, text) in texts.iter().enumerate() {
911            storage
912                .upsert(VectorRecord {
913                    id: format!("hyb{i}"),
914                    vector: vec![i as f32, i as f32 + 1.0, i as f32 + 0.5, 1.0],
915                    payload: json!({ "i": i }),
916                    text: Some(text.to_string()),
917                    created_at: now,
918                    updated_at: now,
919                })
920                .unwrap();
921        }
922
923        let query_vec = vec![0.0_f32, 1.0, 0.5, 1.0];
924        let results = storage
925            .search_hybrid(
926                Some(&query_vec),
927                Some("machine learning"),
928                3,
929                0.7,
930                crate::hybrid::FusionStrategy::WeightedSum,
931            )
932            .unwrap();
933
934        assert!(
935            results.len() <= 3,
936            "expected <= 3 results, got {}",
937            results.len()
938        );
939        for r in &results {
940            assert!(
941                storage.metadata.get(&r.id).is_ok(),
942                "id {} not in metadata",
943                r.id
944            );
945            assert!(r.score >= 0.0, "score {} < 0.0", r.score);
946        }
947        // Sorted descending.
948        for i in 1..results.len() {
949            assert!(
950                results[i - 1].score >= results[i].score,
951                "results not sorted at index {i}"
952            );
953        }
954    }
955
956    #[test]
957    fn test_storage_search_hybrid_dense_only() {
958        let dir = tempdir().unwrap();
959        let config = CollectionConfig::new("denonly", 4);
960        let mut storage = Storage::create(dir.path(), &config).unwrap();
961
962        let now = Utc::now();
963        for i in 0..5usize {
964            storage
965                .upsert(VectorRecord {
966                    id: format!("d{i}"),
967                    vector: vec![i as f32, 1.0, 0.0, 0.0],
968                    payload: json!({}),
969                    text: None, // no text
970                    created_at: now,
971                    updated_at: now,
972                })
973                .unwrap();
974        }
975
976        let query_vec = vec![2.0_f32, 1.0, 0.0, 0.0];
977        let results = storage
978            .search_hybrid(
979                Some(&query_vec),
980                None,
981                3,
982                0.7,
983                crate::hybrid::FusionStrategy::WeightedSum,
984            )
985            .unwrap();
986
987        assert!(!results.is_empty(), "dense-only hybrid must return results");
988    }
989
990    #[test]
991    fn test_storage_search_hybrid_sparse_only() {
992        let dir = tempdir().unwrap();
993        let config = CollectionConfig::new("sparonly", 4);
994        let mut storage = Storage::create(dir.path(), &config).unwrap();
995
996        let now = Utc::now();
997        let texts = [
998            "neural network deep learning",
999            "gradient descent optimization",
1000            "convolutional neural network image",
1001            "recurrent network sequence model",
1002            "transformer attention mechanism",
1003        ];
1004        for (i, text) in texts.iter().enumerate() {
1005            storage
1006                .upsert(VectorRecord {
1007                    id: format!("sp{i}"),
1008                    vector: vec![i as f32, 0.0, 0.0, 1.0],
1009                    payload: json!({}),
1010                    text: Some(text.to_string()),
1011                    created_at: now,
1012                    updated_at: now,
1013                })
1014                .unwrap();
1015        }
1016
1017        let results = storage
1018            .search_hybrid(
1019                None,
1020                Some("neural network"),
1021                3,
1022                0.7,
1023                crate::hybrid::FusionStrategy::WeightedSum,
1024            )
1025            .unwrap();
1026
1027        assert!(
1028            !results.is_empty(),
1029            "sparse-only hybrid must return results"
1030        );
1031    }
1032
1033    #[test]
1034    fn test_storage_search_hybrid_no_inputs_errors() {
1035        let dir = tempdir().unwrap();
1036        let config = CollectionConfig::new("errtest", 4);
1037        let storage = Storage::create(dir.path(), &config).unwrap();
1038
1039        let result = storage.search_hybrid(
1040            None,
1041            None,
1042            3,
1043            0.7,
1044            crate::hybrid::FusionStrategy::WeightedSum,
1045        );
1046        assert!(
1047            matches!(result, Err(VecDbError::InvalidQuery(_))),
1048            "expected InvalidQuery error"
1049        );
1050    }
1051
1052    #[test]
1053    fn test_storage_search_dense_method() {
1054        let dir = tempdir().unwrap();
1055        let config = CollectionConfig::new("denmeth", 4);
1056        let mut storage = Storage::create(dir.path(), &config).unwrap();
1057
1058        let now = Utc::now();
1059        for i in 0..5usize {
1060            storage
1061                .upsert(VectorRecord {
1062                    id: format!("v{i}"),
1063                    vector: vec![i as f32, i as f32 + 1.0, 0.0, 1.0],
1064                    payload: json!({ "i": i }),
1065                    text: Some(format!("text {i}")),
1066                    created_at: now,
1067                    updated_at: now,
1068                })
1069                .unwrap();
1070        }
1071
1072        let query_vec = vec![2.0_f32, 3.0, 0.0, 1.0];
1073        let results = storage.search_dense(&query_vec, 3).unwrap();
1074
1075        assert!(!results.is_empty(), "search_dense must return results");
1076        for r in &results {
1077            assert!(r.dense_score.is_some(), "dense_score must be Some");
1078            assert!(r.sparse_score.is_none(), "sparse_score must be None");
1079        }
1080    }
1081
1082    #[test]
1083    fn test_save_indexes_and_reload() {
1084        let dir = tempdir().unwrap();
1085        let config = CollectionConfig::new("saveidx", 3);
1086
1087        {
1088            let mut storage = Storage::create(dir.path(), &config).unwrap();
1089            let now = Utc::now();
1090            storage
1091                .upsert(VectorRecord {
1092                    id: "s1".to_string(),
1093                    vector: vec![1.0, 2.0, 3.0],
1094                    payload: json!({}),
1095                    text: Some("inverted index bm25 scoring".to_string()),
1096                    created_at: now,
1097                    updated_at: now,
1098                })
1099                .unwrap();
1100            storage.save_indexes().unwrap();
1101        }
1102
1103        let storage = Storage::open(dir.path(), "saveidx").unwrap();
1104        let results = storage.search_sparse("bm25", 5).unwrap();
1105        assert!(!results.is_empty());
1106        assert_eq!(results[0].id, "s1");
1107    }
1108}