Skip to main content

vecdb_core/
db.rs

1//! Embedded, no-server API — the primary way to use vecdb.
2//!
3//! Open a directory as a [`Db`], create or open a [`Collection`], then
4//! insert and query in-process. No network, no daemon, no docker.
5//!
6//! ```no_run
7//! use vecdb_core::Db;
8//! use serde_json::json;
9//!
10//! let db = Db::open("data.vecdb")?;
11//! let mut docs = db.open_or_create("docs", 4)?;
12//! docs.insert("a", vec![0.1, 0.2, 0.3, 0.4], json!({ "title": "hello" }))?;
13//! let hits = docs.query(&[0.1, 0.2, 0.3, 0.4], 5)?;
14//! # Ok::<(), vecdb_core::VecDbError>(())
15//! ```
16
17use std::path::{Path, PathBuf};
18
19use chrono::Utc;
20use serde_json::Value;
21
22use crate::errors::{Result, VecDbError};
23use crate::hybrid::FusionStrategy;
24use crate::storage::{Storage, UpsertResult};
25use crate::types::{CollectionConfig, IndexStats, SearchResult, Vector, VectorId, VectorRecord};
26
27/// A vecdb database: a handle to a directory that holds one or more
28/// collections. Cheap to clone-by-reopen; owns no file handles itself —
29/// each [`Collection`] it hands out owns its own storage.
30#[derive(Debug, Clone)]
31pub struct Db {
32    root: PathBuf,
33}
34
35impl Db {
36    /// Open (creating if absent) a database rooted at `path`.
37    ///
38    /// `path` is a directory. A `.vecdb` suffix is conventional but not
39    /// required — the directory holds the per-collection files.
40    pub fn open(path: impl AsRef<Path>) -> Result<Self> {
41        let root = path.as_ref().to_path_buf();
42        std::fs::create_dir_all(&root).map_err(|e| {
43            VecDbError::StorageError(format!("create_dir_all({root:?}) failed: {e}"))
44        })?;
45        Ok(Self { root })
46    }
47
48    /// Root directory of this database.
49    pub fn path(&self) -> &Path {
50        &self.root
51    }
52
53    /// Create a new collection with default HNSW config. Errors if a
54    /// collection of that name already exists.
55    pub fn create_collection(
56        &self,
57        name: impl Into<String>,
58        dimension: usize,
59    ) -> Result<Collection> {
60        self.create_collection_with(CollectionConfig::new(name, dimension))
61    }
62
63    /// Create a new collection from a full [`CollectionConfig`].
64    pub fn create_collection_with(&self, config: CollectionConfig) -> Result<Collection> {
65        if self.collection_exists(&config.name) {
66            return Err(VecDbError::CollectionAlreadyExists(config.name));
67        }
68        let storage = Storage::create(&self.root, &config)?;
69        Ok(Collection { storage })
70    }
71
72    /// Create a new collection whose in-memory index uses int8 scalar
73    /// quantization (~4× smaller index; on-disk vectors stay float32).
74    pub fn create_collection_quantized(
75        &self,
76        name: impl Into<String>,
77        dimension: usize,
78    ) -> Result<Collection> {
79        self.create_collection_with(
80            CollectionConfig::new(name, dimension)
81                .with_quantization(crate::types::Quantization::ScalarInt8),
82        )
83    }
84
85    /// Create a new collection whose in-memory index uses 1-bit binary
86    /// quantization (~32× smaller; coarse Hamming distance — best paired with a
87    /// full-precision rerank of top candidates).
88    pub fn create_collection_binary(
89        &self,
90        name: impl Into<String>,
91        dimension: usize,
92    ) -> Result<Collection> {
93        self.create_collection_with(
94            CollectionConfig::new(name, dimension)
95                .with_quantization(crate::types::Quantization::Binary),
96        )
97    }
98
99    /// Open an existing collection. Errors with `CollectionNotFound` if absent.
100    pub fn collection(&self, name: &str) -> Result<Collection> {
101        if !self.collection_exists(name) {
102            return Err(VecDbError::CollectionNotFound(name.to_string()));
103        }
104        let storage = Storage::open(&self.root, name)?;
105        Ok(Collection { storage })
106    }
107
108    /// Open the collection if it exists, otherwise create it with default
109    /// HNSW config at `dimension`.
110    pub fn open_or_create(&self, name: &str, dimension: usize) -> Result<Collection> {
111        if self.collection_exists(name) {
112            self.collection(name)
113        } else {
114            self.create_collection(name, dimension)
115        }
116    }
117
118    /// Whether a collection with this name exists on disk (by its `.db` file).
119    pub fn collection_exists(&self, name: &str) -> bool {
120        self.root.join(format!("{name}.db")).exists()
121    }
122
123    /// Sorted names of all collections in this database (scans `*.db` files).
124    pub fn list_collections(&self) -> Result<Vec<String>> {
125        let mut names = Vec::new();
126        let entries = std::fs::read_dir(&self.root)
127            .map_err(|e| VecDbError::StorageError(format!("read_dir failed: {e}")))?;
128        for entry in entries.flatten() {
129            let path = entry.path();
130            if path.extension().map(|e| e == "db").unwrap_or(false) {
131                if let Some(stem) = path.file_stem().and_then(|s| s.to_str()) {
132                    names.push(stem.to_string());
133                }
134            }
135        }
136        names.sort();
137        Ok(names)
138    }
139
140    /// Delete a collection and all its files from disk.
141    pub fn drop_collection(&self, name: &str) -> Result<()> {
142        if !self.collection_exists(name) {
143            return Err(VecDbError::CollectionNotFound(name.to_string()));
144        }
145        for suffix in &[
146            ".db",
147            ".wal",
148            ".vectors",
149            ".hnsw.json",
150            ".ivf.json",
151            ".sq.json",
152            ".bq.json",
153            ".sparse.json",
154        ] {
155            let path = self.root.join(format!("{name}{suffix}"));
156            if path.exists() {
157                std::fs::remove_file(&path).map_err(|e| {
158                    VecDbError::StorageError(format!("remove {path:?} failed: {e}"))
159                })?;
160            }
161        }
162        Ok(())
163    }
164}
165
166/// A single collection: an owned, in-process handle to one collection's
167/// storage. Insert with `&mut`, query with `&`. Data is durable via the WAL
168/// on every insert; call [`Collection::flush`] (or drop the handle) to also
169/// persist the dense/sparse indexes for a fast reopen.
170pub struct Collection {
171    storage: Storage,
172}
173
174impl Collection {
175    /// Insert or update a vector with JSON metadata and no text. Timestamps
176    /// are set to now.
177    pub fn insert(
178        &mut self,
179        id: impl Into<VectorId>,
180        vector: Vector,
181        payload: Value,
182    ) -> Result<UpsertResult> {
183        self.upsert(id, vector, payload, None)
184    }
185
186    /// Insert or update a vector with JSON metadata and full-text (enables
187    /// BM25 sparse and hybrid search for this record).
188    pub fn insert_text(
189        &mut self,
190        id: impl Into<VectorId>,
191        vector: Vector,
192        payload: Value,
193        text: impl Into<String>,
194    ) -> Result<UpsertResult> {
195        self.upsert(id, vector, payload, Some(text.into()))
196    }
197
198    fn upsert(
199        &mut self,
200        id: impl Into<VectorId>,
201        vector: Vector,
202        payload: Value,
203        text: Option<String>,
204    ) -> Result<UpsertResult> {
205        let now = Utc::now();
206        self.storage.upsert(VectorRecord {
207            id: id.into(),
208            vector,
209            payload,
210            text,
211            created_at: now,
212            updated_at: now,
213        })
214    }
215
216    /// Insert or update a fully-specified record.
217    pub fn upsert_record(&mut self, record: VectorRecord) -> Result<UpsertResult> {
218        self.storage.upsert(record)
219    }
220
221    /// Bulk-insert many `(id, vector, payload)` triples, building the index once
222    /// at the end. Far faster than repeated [`Collection::insert`] for loading a
223    /// large collection. Returns the number inserted.
224    pub fn insert_batch(&mut self, items: Vec<(VectorId, Vector, Value)>) -> Result<usize> {
225        let now = Utc::now();
226        let records = items
227            .into_iter()
228            .map(|(id, vector, payload)| VectorRecord {
229                id,
230                vector,
231                payload,
232                text: None,
233                created_at: now,
234                updated_at: now,
235            })
236            .collect();
237        self.storage.bulk_upsert(records)
238    }
239
240    /// Delete a record by id.
241    pub fn delete(&mut self, id: &str) -> Result<()> {
242        self.storage.delete(&id.to_string())
243    }
244
245    /// Dense k-NN search over the vector index.
246    pub fn query(&self, vector: &[f32], k: usize) -> Result<Vec<SearchResult>> {
247        self.storage.search_dense(&vector.to_vec(), k)
248    }
249
250    /// Dense k-NN search restricted to records whose payload matches `filter`,
251    /// with predicate pushdown: a selective filter scans only the matching
252    /// vectors (exact, ~proportional to the matches) rather than searching the
253    /// whole index and discarding non-matches.
254    ///
255    /// `filter` is the same JSON predicate shape accepted elsewhere, e.g.
256    /// `json!({ "genre": "sci-fi" })` or `json!({ "year": { "$gte": 2000 } })`.
257    pub fn query_filtered(
258        &self,
259        vector: &[f32],
260        k: usize,
261        filter: &Value,
262    ) -> Result<Vec<SearchResult>> {
263        self.storage
264            .search_dense_filtered(&vector.to_vec(), k, filter)
265    }
266
267    /// Full-text BM25 search over the sparse index.
268    pub fn query_text(&self, text: &str, k: usize) -> Result<Vec<SearchResult>> {
269        self.storage.search_sparse(text, k)
270    }
271
272    /// Two-stage hybrid search. At least one of `vector` / `text` required.
273    /// `alpha` weights dense vs sparse (`alpha * dense + (1-alpha) * sparse`).
274    pub fn query_hybrid(
275        &self,
276        vector: Option<&[f32]>,
277        text: Option<&str>,
278        k: usize,
279        alpha: f32,
280    ) -> Result<Vec<SearchResult>> {
281        let owned = vector.map(|v| v.to_vec());
282        self.storage
283            .search_hybrid(owned.as_ref(), text, k, alpha, FusionStrategy::WeightedSum)
284    }
285
286    /// Run a `VECTOR_SIM` SQL query against this collection.
287    pub fn sql(&self, sql: &str) -> Result<Vec<SearchResult>> {
288        self.storage.execute_sql(sql)
289    }
290
291    /// Number of active (non-deleted) vectors.
292    pub fn len(&self) -> Result<usize> {
293        Ok(self.storage.stats()?.vector_count)
294    }
295
296    /// Whether the collection has no active vectors.
297    pub fn is_empty(&self) -> Result<bool> {
298        Ok(self.len()? == 0)
299    }
300
301    /// Collection statistics (vector count, on-disk bytes, dimension, …).
302    pub fn stats(&self) -> Result<IndexStats> {
303        self.storage.stats()
304    }
305
306    /// Collection name.
307    pub fn name(&self) -> &str {
308        &self.storage.config.name
309    }
310
311    /// Persist indexes and checkpoint the WAL. Data is already durable via the
312    /// WAL without this; flushing makes the next reopen fast (no index rebuild).
313    pub fn flush(&mut self) -> Result<()> {
314        self.storage.save_indexes()?;
315        self.storage.checkpoint()
316    }
317}
318
319impl Drop for Collection {
320    fn drop(&mut self) {
321        // Best-effort: persist indexes so reopen skips the rebuild. WAL already
322        // guarantees durability, so failures here are non-fatal.
323        let _ = self.storage.save_indexes();
324    }
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use serde_json::json;
331    use tempfile::tempdir;
332
333    #[test]
334    fn open_create_insert_query() {
335        let dir = tempdir().unwrap();
336        let db = Db::open(dir.path()).unwrap();
337        let mut c = db.create_collection("docs", 3).unwrap();
338        c.insert("a", vec![1.0, 0.0, 0.0], json!({ "n": 1 }))
339            .unwrap();
340        c.insert("b", vec![0.0, 1.0, 0.0], json!({ "n": 2 }))
341            .unwrap();
342        let hits = c.query(&[1.0, 0.0, 0.0], 1).unwrap();
343        assert_eq!(hits[0].id, "a");
344    }
345
346    #[test]
347    fn persist_and_reopen() {
348        let dir = tempdir().unwrap();
349        {
350            let db = Db::open(dir.path()).unwrap();
351            let mut c = db.create_collection("k", 2).unwrap();
352            c.insert("x", vec![1.0, 2.0], json!({})).unwrap();
353            c.flush().unwrap();
354        }
355        let db = Db::open(dir.path()).unwrap();
356        let c = db.collection("k").unwrap();
357        assert_eq!(c.len().unwrap(), 1);
358        assert_eq!(c.query(&[1.0, 2.0], 1).unwrap()[0].id, "x");
359    }
360
361    #[test]
362    fn open_or_create_is_idempotent() {
363        let dir = tempdir().unwrap();
364        let db = Db::open(dir.path()).unwrap();
365        {
366            let mut c = db.open_or_create("c", 2).unwrap();
367            c.insert("1", vec![0.0, 1.0], json!({})).unwrap();
368            c.flush().unwrap();
369        }
370        let c = db.open_or_create("c", 2).unwrap();
371        assert_eq!(c.len().unwrap(), 1);
372    }
373
374    #[test]
375    fn create_existing_errors() {
376        let dir = tempdir().unwrap();
377        let db = Db::open(dir.path()).unwrap();
378        let _c = db.create_collection("dup", 2).unwrap();
379        drop(_c);
380        assert!(matches!(
381            db.create_collection("dup", 2),
382            Err(VecDbError::CollectionAlreadyExists(_))
383        ));
384    }
385
386    #[test]
387    fn list_and_drop() {
388        let dir = tempdir().unwrap();
389        let db = Db::open(dir.path()).unwrap();
390        db.create_collection("a", 2).unwrap().flush().unwrap();
391        db.create_collection("b", 2).unwrap().flush().unwrap();
392        assert_eq!(db.list_collections().unwrap(), vec!["a", "b"]);
393        db.drop_collection("a").unwrap();
394        assert_eq!(db.list_collections().unwrap(), vec!["b"]);
395        assert!(!db.collection_exists("a"));
396    }
397
398    #[test]
399    fn hybrid_and_text_query() {
400        let dir = tempdir().unwrap();
401        let db = Db::open(dir.path()).unwrap();
402        let mut c = db.create_collection("h", 3).unwrap();
403        c.insert_text("d1", vec![1.0, 0.0, 0.0], json!({}), "rust vector database")
404            .unwrap();
405        c.insert_text(
406            "d2",
407            vec![0.0, 1.0, 0.0],
408            json!({}),
409            "python machine learning",
410        )
411        .unwrap();
412        let text_hits = c.query_text("rust database", 5).unwrap();
413        assert_eq!(text_hits[0].id, "d1");
414        let hybrid = c
415            .query_hybrid(Some(&[1.0, 0.0, 0.0]), Some("rust"), 2, 0.5)
416            .unwrap();
417        assert!(!hybrid.is_empty());
418    }
419}