Skip to main content

sqlite_graphrag/storage/memories/
vectors.rs

1//! Vector companion of `memories`: upsert, delete and KNN search.
2//!
3//! Owns every statement touching `memory_embeddings`, including the
4//! `sqlite-vec` KNN query the recall path issues.
5
6use crate::embedder::f32_to_bytes;
7use crate::errors::AppError;
8use crate::storage::utils::with_busy_retry;
9use rusqlite::{params, Connection};
10
11/// Replaces the vector row for a memory in `memory_embeddings`.
12///
13/// v1.0.76: sqlite-vec was removed. Embeddings live in a regular BLOB-backed
14/// table; cosine similarity is computed in pure Rust on demand. The
15/// `memory_type`, `name`, and `snippet` arguments are accepted for API
16/// compatibility but are not stored — the FTS5 shadow table is the
17/// source of truth for textual metadata.
18///
19/// # Errors
20///
21/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
22pub fn upsert_vec(
23    conn: &Connection,
24    memory_id: i64,
25    namespace: &str,
26    _memory_type: &str,
27    embedding: &[f32],
28    _name: &str,
29    _snippet: &str,
30) -> Result<(), AppError> {
31    // v1.1.1 (P1): skip empty vectors so the memory stays visible to the
32    // re-embed backfill scanner instead of persisting a vector-less row.
33    if embedding.is_empty() {
34        tracing::debug!(
35            memory_id,
36            "empty memory embedding: skipping memory_embeddings row (backfill via enrich re-embed)"
37        );
38        return Ok(());
39    }
40    let embedding_bytes = f32_to_bytes(embedding);
41    with_busy_retry(|| {
42        conn.execute(
43            "DELETE FROM memory_embeddings WHERE memory_id = ?1",
44            params![memory_id],
45        )?;
46        conn.execute(
47            "INSERT INTO memory_embeddings(memory_id, namespace, embedding, source, model, dim)
48             VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
49            params![
50                memory_id,
51                namespace,
52                &embedding_bytes,
53                "llm-headless",
54                crate::constants::SQLITE_GRAPHRAG_VERSION,
55                crate::constants::embedding_dim() as i64,
56            ],
57        )?;
58        Ok(())
59    })
60}
61
62/// Deletes the vector row for `memory_id` from `memory_embeddings`.
63///
64/// Called during `forget` and `purge` to keep the embeddings table
65/// consistent with the logical state of `memories`. FK CASCADE on
66/// `memory_embeddings.memory_id` handles the common case, but this
67/// function exists so callers can delete the embedding first
68/// (preserving the row in `memories` for audit).
69///
70/// # Errors
71///
72/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
73pub fn delete_vec(conn: &Connection, memory_id: i64) -> Result<(), AppError> {
74    conn.execute(
75        "DELETE FROM memory_embeddings WHERE memory_id = ?1",
76        params![memory_id],
77    )?;
78    Ok(())
79}
80
81/// Runs a KNN search over `memory_embeddings`, optionally restricted to namespaces.
82///
83/// # Arguments
84///
85/// - `embedding` — query vector of length [`crate::constants::embedding_dim()`].
86/// - `namespaces` — namespaces to search. Empty slice means "all namespaces".
87/// - `memory_type` — optional filter on the `type` column.
88/// - `k` — maximum number of hits to return.
89///
90/// # Returns
91///
92/// A vector of `(memory_id, distance)` pairs sorted by ascending distance.
93///
94/// # Errors
95///
96/// Returns `Err(AppError::Database)` on any `rusqlite` failure.
97pub fn knn_search(
98    conn: &Connection,
99    embedding: &[f32],
100    namespaces: &[String],
101    memory_type: Option<&str>,
102    k: usize,
103) -> Result<Vec<(i64, f32)>, AppError> {
104    if embedding.len() != crate::constants::embedding_dim() {
105        return Err(AppError::Embedding(
106            crate::i18n::validation::embedding_knn_search_dim_mismatch(
107                embedding.len(),
108                crate::constants::embedding_dim(),
109            ),
110        ));
111    }
112    // v1.0.76: full table scan + in-process cosine similarity. The
113    // `memory_embeddings` table no longer has a `distance` column or a
114    // `type` column (the namespace/type filters were dropped for the
115    // BLOB-backed table — they live on the `memories` table). The
116    // cosine result is converted to a "distance" so callers that read
117    // `distance` keep working unchanged.
118
119    // Build the SQL once with the namespace IN clause shape.
120    //
121    // GAP-SG-268: when `memory_type` is set, the type filter is pushed into
122    // this single statement through a `LEFT JOIN` on `memories`. The previous
123    // code ran one `SELECT type FROM memories WHERE id = ?1` per surviving
124    // candidate, so the cost grew linearly with the candidate set. A `LEFT
125    // JOIN` (rather than an inner one) keeps `memory_embeddings` as the
126    // driving table of the scan, so the row order the loop below sees is the
127    // same one it saw before. Rows whose `memories` parent is missing yield a
128    // NULL `type`, which the comparison rejects — matching the old behaviour,
129    // where the failed `query_row` produced `None`.
130    let placeholders = (0..namespaces.len())
131        .map(|_| "?")
132        .collect::<Vec<_>>()
133        .join(",");
134    let ns_clause = if namespaces.is_empty() {
135        String::new()
136    } else {
137        format!(" WHERE e.namespace IN ({placeholders})")
138    };
139    let sql = if memory_type.is_some() {
140        let type_clause = if namespaces.is_empty() {
141            " WHERE m.type = ?"
142        } else {
143            " AND m.type = ?"
144        };
145        format!(
146            "SELECT e.memory_id, e.embedding, e.namespace FROM memory_embeddings e \
147             LEFT JOIN memories m ON m.id = e.memory_id{ns_clause}{type_clause}"
148        )
149    } else {
150        format!("SELECT e.memory_id, e.embedding, e.namespace FROM memory_embeddings e{ns_clause}")
151    };
152    let mut stmt = conn.prepare(&sql)?;
153    let mut raw_params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
154    for ns in namespaces {
155        raw_params.push(Box::new(ns.clone()));
156    }
157    if let Some(mt) = memory_type {
158        raw_params.push(Box::new(mt.to_string()));
159    }
160    let param_refs: Vec<&dyn rusqlite::ToSql> = raw_params.iter().map(|b| b.as_ref()).collect();
161    let rows = stmt.query_map(param_refs.as_slice(), |r| {
162        let id: i64 = r.get(0)?;
163        let bytes: Vec<u8> = r.get(1)?;
164        let ns: String = r.get(2)?;
165        Ok((id, bytes, ns))
166    })?;
167
168    // The optional `type` restriction is already applied by the statement
169    // above, so this loop only scores the rows SQLite handed back.
170    let mut candidates: Vec<(i64, f32)> = Vec::new();
171    for row in rows {
172        let (id, bytes, ns) = row?;
173        let stored = crate::embedder::bytes_to_f32(&bytes);
174        if stored.len() != embedding.len() {
175            continue;
176        }
177        let sim = crate::similarity::cosine_similarity(embedding, &stored);
178        let dist = crate::similarity::similarity_to_distance(sim);
179        let _ = ns; // namespace already filtered at SQL level
180        candidates.push((id, dist));
181    }
182    // Sort by distance ascending (best matches first).
183    candidates.sort_by(|a, b| a.1.partial_cmp(&b.1).unwrap_or(std::cmp::Ordering::Equal));
184    candidates.truncate(k);
185    Ok(candidates)
186}