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}