Skip to main content

agentdb/vectors/
collection.rs

1use crate::error::{AgentDbError, Result};
2use crate::filter;
3use crate::fts::FullTextStore;
4use crate::schema::now_ms;
5use crate::vectors::hnsw::{DistanceMetric, HnswIndex};
6use rusqlite::{params, Connection};
7use serde_json::Value;
8use std::sync::{Arc, Mutex};
9use uuid::Uuid;
10
11/// A single vector entry
12#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
13pub struct VectorEntry {
14    pub id: String,
15    pub vector: Vec<f32>,
16    pub metadata: Option<Value>,
17}
18
19/// A single vector search result
20#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
21pub struct SearchResult {
22    pub id: String,
23    pub score: f32,
24    pub metadata: Option<Value>,
25}
26
27/// Options controlling a vector search
28#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
29pub struct SearchOptions {
30    pub top_k: usize,
31    pub metric: DistanceMetric,
32    pub filter: Option<Value>,
33}
34
35impl Default for SearchOptions {
36    fn default() -> Self {
37        Self {
38            top_k: 10,
39            metric: DistanceMetric::Cosine,
40            filter: None,
41        }
42    }
43}
44
45/// An entry for batch upsert
46#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
47pub struct BatchEntry {
48    pub id: String,
49    pub vector: Vec<f32>,
50    pub metadata: Option<Value>,
51}
52
53/// A named vector collection
54pub struct Collection {
55    pub id: String,
56    pub name: String,
57    pub dim: usize,
58    pub(crate) conn: Arc<Mutex<Connection>>,
59    index: Mutex<Option<HnswIndex>>,
60    metric: DistanceMetric,
61}
62
63impl Collection {
64    pub(crate) fn new(
65        id: String,
66        name: String,
67        dim: usize,
68        metric: DistanceMetric,
69        conn: Arc<Mutex<Connection>>,
70    ) -> Self {
71        Self {
72            id,
73            name,
74            dim,
75            conn,
76            index: Mutex::new(None),
77            metric,
78        }
79    }
80
81    /// Insert or update a single vector
82    pub fn upsert(&self, entry: VectorEntry) -> Result<()> {
83        if entry.vector.len() != self.dim {
84            return Err(AgentDbError::DimensionMismatch {
85                expected: self.dim,
86                got: entry.vector.len(),
87            });
88        }
89        let blob: Vec<u8> = entry.vector.iter().flat_map(|f| f.to_le_bytes()).collect();
90        let meta = entry.metadata.as_ref().map(|m| m.to_string());
91        let now = now_ms();
92        let conn = self.conn.lock().unwrap();
93        let tx = conn.unchecked_transaction()?;
94        // INSERT OR IGNORE returns changes()=1 for a new row, 0 for a duplicate.
95        let inserted = tx.execute(
96            "INSERT OR IGNORE INTO _adb_vectors
97                 (id, collection_id, vector, metadata, created_at, updated_at)
98             VALUES (?1, ?2, ?3, ?4, ?5, ?5)",
99            params![entry.id, self.id, blob, meta, now],
100        )?;
101        if inserted == 0 {
102            tx.execute(
103                "UPDATE _adb_vectors SET vector = ?1, metadata = ?2, updated_at = ?5
104                 WHERE id = ?3 AND collection_id = ?4",
105                params![blob, meta, entry.id, self.id, now],
106            )?;
107        }
108        tx.execute(
109            "INSERT INTO _adb_hnsw_index (collection_id, index_blob, built_at, is_dirty)
110             VALUES (?1, X'', ?2, 1)
111             ON CONFLICT(collection_id) DO UPDATE SET is_dirty = 1",
112            params![self.id, now_ms()],
113        )?;
114        if inserted > 0 {
115            tx.execute(
116                "UPDATE _adb_collections SET count = count + 1 WHERE id = ?1",
117                params![self.id],
118            )?;
119        }
120        tx.commit()?;
121        *self.index.lock().unwrap() = None;
122        Ok(())
123    }
124
125    /// Insert or update multiple vectors in a single transaction
126    pub fn upsert_batch(&self, entries: Vec<BatchEntry>) -> Result<usize> {
127        if entries.is_empty() {
128            return Ok(0);
129        }
130        for e in &entries {
131            if e.vector.len() != self.dim {
132                return Err(AgentDbError::DimensionMismatch {
133                    expected: self.dim,
134                    got: e.vector.len(),
135                });
136            }
137        }
138        let conn = self.conn.lock().unwrap();
139        conn.execute_batch("BEGIN")?;
140        let result: Result<usize> = (|| {
141            let mut new_rows: usize = 0;
142            for e in &entries {
143                let blob: Vec<u8> = e.vector.iter().flat_map(|f| f.to_le_bytes()).collect();
144                let meta = e.metadata.as_ref().map(|m| m.to_string());
145                let now = now_ms();
146                // INSERT OR IGNORE: changes()=1 for new, 0 for existing
147                let inserted = conn.execute(
148                    "INSERT OR IGNORE INTO _adb_vectors
149                         (id, collection_id, vector, metadata, created_at, updated_at)
150                     VALUES (?1, ?2, ?3, ?4, ?5, ?5)",
151                    params![e.id, self.id, blob, meta, now],
152                )?;
153                if inserted == 0 {
154                    conn.execute(
155                        "UPDATE _adb_vectors SET vector = ?1, metadata = ?2, updated_at = ?5
156                         WHERE id = ?3 AND collection_id = ?4",
157                        params![blob, meta, e.id, self.id, now],
158                    )?;
159                } else {
160                    new_rows += 1;
161                }
162            }
163            conn.execute(
164                "INSERT INTO _adb_hnsw_index (collection_id, index_blob, built_at, is_dirty)
165                 VALUES (?1, X'', ?2, 1)
166                 ON CONFLICT(collection_id) DO UPDATE SET is_dirty = 1",
167                params![self.id, now_ms()],
168            )?;
169            if new_rows > 0 {
170                conn.execute(
171                    "UPDATE _adb_collections SET count = count + ?1 WHERE id = ?2",
172                    params![new_rows as i64, self.id],
173                )?;
174            }
175            Ok(new_rows)
176        })();
177        match result {
178            Ok(new_rows) => {
179                conn.execute_batch("COMMIT")?;
180                *self.index.lock().unwrap() = None;
181                Ok(new_rows)
182            }
183            Err(e) => {
184                let _ = conn.execute_batch("ROLLBACK");
185                Err(e)
186            }
187        }
188    }
189
190    /// ANN search with optional advanced metadata filtering
191    pub fn search(&self, query: &[f32], opts: SearchOptions) -> Result<Vec<SearchResult>> {
192        if query.len() != self.dim {
193            return Err(AgentDbError::DimensionMismatch {
194                expected: self.dim,
195                got: query.len(),
196            });
197        }
198        self.ensure_index()?;
199        let guard = self.index.lock().unwrap();
200        let index = guard.as_ref().unwrap();
201        let fetch_k = if opts.filter.is_some() {
202            (opts.top_k * 10).max(50)
203        } else {
204            opts.top_k
205        };
206        let raw = index.search(query, fetch_k);
207
208        if raw.is_empty() {
209            return Ok(vec![]);
210        }
211
212        let conn = self.conn.lock().unwrap();
213
214        // Batch-fetch metadata for all candidate IDs in one query.
215        let placeholders: String = (1..=raw.len())
216            .map(|i| format!("?{}", i))
217            .collect::<Vec<_>>()
218            .join(",");
219        let sql = format!(
220            "SELECT id, metadata FROM _adb_vectors WHERE collection_id = ?{} AND id IN ({})",
221            raw.len() + 1,
222            placeholders
223        );
224        let mut stmt = conn.prepare(&sql)?;
225        let mut param_values: Vec<Box<dyn rusqlite::ToSql>> = raw
226            .iter()
227            .map(|(id, _)| Box::new(id.clone()) as Box<dyn rusqlite::ToSql>)
228            .collect();
229        param_values.push(Box::new(self.id.clone()));
230        let param_refs: Vec<&dyn rusqlite::ToSql> =
231            param_values.iter().map(|p| p.as_ref()).collect();
232
233        let mut meta_map: std::collections::HashMap<String, Option<Value>> =
234            std::collections::HashMap::new();
235        let rows = stmt.query_map(param_refs.as_slice(), |row| {
236            Ok((row.get::<_, String>(0)?, row.get::<_, Option<String>>(1)?))
237        })?;
238        for row in rows {
239            let (id, meta_str) = row?;
240            let meta: Option<Value> = meta_str
241                .as_deref()
242                .and_then(|s| serde_json::from_str(s).ok());
243            meta_map.insert(id, meta);
244        }
245
246        let mut out = Vec::new();
247        for (id, score) in raw {
248            let meta = meta_map.get(&id).cloned().unwrap_or(None);
249            if let Some(ref f) = opts.filter {
250                match &meta {
251                    Some(m) if filter::matches(m, f) => {}
252                    _ => continue,
253                }
254            }
255            out.push(SearchResult {
256                id,
257                score,
258                metadata: meta,
259            });
260            if out.len() >= opts.top_k {
261                break;
262            }
263        }
264        Ok(out)
265    }
266
267    /// Insert or update a vector AND index its text content for FTS in one atomic call.
268    ///
269    /// This keeps the vector index and the FTS index in sync — callers no longer
270    /// need to call `col.upsert()` + `fts.index_text()` separately.
271    pub fn upsert_with_text(&self, entry: VectorEntry, text: &str) -> Result<()> {
272        let id = entry.id.clone();
273        self.upsert(entry)?;
274        let fts = FullTextStore::new(Arc::clone(&self.conn));
275        fts.index_text(&self.name, &id, &self.id, text)
276    }
277
278    /// Delete a vector by ID
279    pub fn delete(&self, id: &str) -> Result<()> {
280        let conn = self.conn.lock().unwrap();
281        let deleted = conn.execute(
282            "DELETE FROM _adb_vectors WHERE id = ?1 AND collection_id = ?2",
283            params![id, self.id],
284        )?;
285        if deleted > 0 {
286            conn.execute(
287                "UPDATE _adb_collections SET count = MAX(0, count - 1) WHERE id = ?1",
288                params![self.id],
289            )?;
290        }
291        conn.execute(
292            "UPDATE _adb_hnsw_index SET is_dirty = 1 WHERE collection_id = ?1",
293            params![self.id],
294        )?;
295        *self.index.lock().unwrap() = None;
296        Ok(())
297    }
298
299    /// Rebuild the HNSW index from stored vectors
300    pub fn reindex(&self) -> Result<()> {
301        let mut index = HnswIndex::new(16, 200, self.metric.clone());
302        // Scope the borrow of conn so stmt and rows are dropped before the second lock.
303        {
304            let conn = self.conn.lock().unwrap();
305            let mut stmt =
306                conn.prepare("SELECT id, vector FROM _adb_vectors WHERE collection_id = ?1")?;
307            let rows = stmt.query_map(params![self.id], |row| {
308                Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
309            })?;
310            for row in rows {
311                let (id, blob) = row?;
312                let vec: Vec<f32> = blob
313                    .chunks_exact(4)
314                    .map(|b| f32::from_le_bytes([b[0], b[1], b[2], b[3]]))
315                    .collect();
316                index.insert(&id, vec);
317            }
318        }
319        let serialized = index.serialize()?;
320        {
321            let conn = self.conn.lock().unwrap();
322            conn.execute(
323                "INSERT INTO _adb_hnsw_index (collection_id, index_blob, built_at, is_dirty)
324                 VALUES (?1, ?2, ?3, 0)
325                 ON CONFLICT(collection_id) DO UPDATE SET
326                   index_blob = excluded.index_blob,
327                   built_at   = excluded.built_at,
328                   is_dirty   = 0",
329                params![self.id, serialized, now_ms()],
330            )?;
331        }
332        *self.index.lock().unwrap() = Some(index);
333        Ok(())
334    }
335
336    fn ensure_index(&self) -> Result<()> {
337        if self.index.lock().unwrap().is_some() {
338            return Ok(());
339        }
340        // Try to deserialize the stored blob first; fall back to a full rebuild
341        // only when the blob is absent, empty, or corrupt.
342        let blob_opt: Option<Vec<u8>> = {
343            let conn = self.conn.lock().unwrap();
344            conn.query_row(
345                "SELECT index_blob FROM _adb_hnsw_index
346                 WHERE collection_id = ?1 AND is_dirty = 0",
347                params![self.id],
348                |r| r.get::<_, Vec<u8>>(0),
349            )
350            .ok()
351        };
352        if let Some(blob) = blob_opt {
353            if !blob.is_empty() {
354                if let Ok(index) = HnswIndex::deserialize(&blob) {
355                    *self.index.lock().unwrap() = Some(index);
356                    return Ok(());
357                }
358            }
359        }
360        self.reindex()
361    }
362
363    /// Number of vectors in this collection
364    pub fn count(&self) -> Result<i64> {
365        let conn = self.conn.lock().unwrap();
366        Ok(conn.query_row(
367            "SELECT count FROM _adb_collections WHERE id = ?1",
368            params![self.id],
369            |r| r.get(0),
370        )?)
371    }
372}
373
374/// Manages all vector collections
375pub struct VectorStore {
376    conn: Arc<Mutex<Connection>>,
377}
378
379impl VectorStore {
380    pub(crate) fn new(conn: Arc<Mutex<Connection>>) -> Self {
381        Self { conn }
382    }
383
384    pub fn collection(&self, name: &str, dim: usize) -> Result<Collection> {
385        self.collection_with_metric(name, dim, DistanceMetric::Cosine)
386    }
387
388    /// Open an existing collection by name. Returns an error if it doesn't exist.
389    pub fn get_collection(&self, name: &str) -> Result<Collection> {
390        let conn = self.conn.lock().unwrap();
391        let (id, dim, mstr): (String, usize, String) = conn
392            .query_row(
393                "SELECT id, dim, metric FROM _adb_collections WHERE name = ?1",
394                params![name],
395                |row| Ok((row.get(0)?, row.get::<_, i64>(1)? as usize, row.get(2)?)),
396            )
397            .map_err(|_| AgentDbError::InvalidArgument(format!("collection not found: {name}")))?;
398        let metric = match mstr.as_str() {
399            "euclidean" => DistanceMetric::Euclidean,
400            "dot" => DistanceMetric::DotProduct,
401            _ => DistanceMetric::Cosine,
402        };
403        Ok(Collection::new(
404            id,
405            name.to_string(),
406            dim,
407            metric,
408            Arc::clone(&self.conn),
409        ))
410    }
411
412    pub fn collection_with_metric(
413        &self,
414        name: &str,
415        dim: usize,
416        metric: DistanceMetric,
417    ) -> Result<Collection> {
418        let conn = self.conn.lock().unwrap();
419        let existing: Option<(String, usize, String)> = conn
420            .query_row(
421                "SELECT id, dim, metric FROM _adb_collections WHERE name = ?1",
422                params![name],
423                |row| Ok((row.get(0)?, row.get::<_, i64>(1)? as usize, row.get(2)?)),
424            )
425            .ok();
426        if let Some((id, edim, mstr)) = existing {
427            if edim != dim {
428                return Err(AgentDbError::DimensionMismatch {
429                    expected: edim,
430                    got: dim,
431                });
432            }
433            let m = match mstr.as_str() {
434                "euclidean" => DistanceMetric::Euclidean,
435                "dot" => DistanceMetric::DotProduct,
436                _ => DistanceMetric::Cosine,
437            };
438            return Ok(Collection::new(
439                id,
440                name.to_string(),
441                dim,
442                m,
443                Arc::clone(&self.conn),
444            ));
445        }
446        let id = Uuid::new_v4().to_string();
447        let mstr = match &metric {
448            DistanceMetric::Cosine => "cosine",
449            DistanceMetric::Euclidean => "euclidean",
450            DistanceMetric::DotProduct => "dot",
451        };
452        conn.execute(
453            "INSERT INTO _adb_collections (id, name, dim, metric, count, created_at)
454             VALUES (?1, ?2, ?3, ?4, 0, ?5)",
455            params![id, name, dim as i64, mstr, now_ms()],
456        )?;
457        Ok(Collection::new(
458            id,
459            name.to_string(),
460            dim,
461            metric,
462            Arc::clone(&self.conn),
463        ))
464    }
465
466    pub fn list_collections(&self) -> Result<Vec<(String, usize, i64)>> {
467        let conn = self.conn.lock().unwrap();
468        let mut stmt =
469            conn.prepare("SELECT name, dim, count FROM _adb_collections ORDER BY name")?;
470        let rows = stmt.query_map([], |row| {
471            Ok((
472                row.get::<_, String>(0)?,
473                row.get::<_, i64>(1)? as usize,
474                row.get::<_, i64>(2)?,
475            ))
476        })?;
477        rows.map(|r| r.map_err(AgentDbError::Sqlite)).collect()
478    }
479
480    pub fn drop_collection(&self, name: &str) -> Result<()> {
481        let conn = self.conn.lock().unwrap();
482        conn.execute(
483            "DELETE FROM _adb_collections WHERE name = ?1",
484            params![name],
485        )?;
486        Ok(())
487    }
488}