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