1use anyhow::{Context, Result};
2use rusqlite::{params, Connection, OptionalExtension, Statement};
3
4use super::embedding::TextEmbedding;
5pub use super::vector_candidates::VECTOR_SEARCH_CANDIDATE_LIMIT;
6
7mod backfill;
8mod coverage;
9mod reindex;
10mod vec_index;
11
12pub(crate) use vec_index::ensure_vec_index;
13
14pub use super::embedding::{
15 LOCAL_EMBEDDING_DIMENSIONS as EMBEDDING_DIMENSIONS,
16 LOCAL_EMBEDDING_MODEL as DEFAULT_EMBEDDING_MODEL,
17};
18pub use backfill::{
19 backfill_missing_memory_embeddings, pending_memory_embedding_count,
20 pending_memory_embedding_reindex_count, pending_memory_embedding_reindex_count_for_target,
21 reindex_memory_embeddings, reindex_memory_embeddings_with_report, EmbeddingReindexReport,
22};
23pub(crate) use backfill::{
24 reindex_memory_embeddings_with_session_report, EmbeddingBackfillSession,
25};
26pub use coverage::{
27 active_embedding_coverage, active_embedding_coverage_for_status,
28 active_embedding_coverage_for_target, prune_inactive_memory_embeddings,
29 ActiveEmbeddingCoverage, InactiveEmbeddingPruneReport,
30};
31
32const EMBEDDING_REINDEX_WRITE_BATCH_SIZE: usize = 512;
33const UPSERT_EMBEDDING_SQL: &str = "INSERT INTO memory_embeddings
34 (memory_id, embedding, dimensions, model, content_hash, updated_at_epoch)
35 VALUES (?1, ?2, ?3, ?4, ?5, ?6)
36 ON CONFLICT(memory_id, model, dimensions) DO UPDATE SET
37 embedding = excluded.embedding,
38 content_hash = excluded.content_hash,
39 updated_at_epoch = excluded.updated_at_epoch";
40
41#[derive(Debug, Clone, PartialEq)]
42pub struct VectorHit {
43 pub memory_id: i64,
44 pub distance: f32,
45}
46
47#[derive(Debug, Clone, PartialEq)]
48pub struct VectorSearchOutcome {
49 pub hits: Vec<VectorHit>,
50 pub disabled_reason: Option<String>,
51 pub candidates_scanned: usize,
52 pub timings: Vec<crate::perf::PhaseTiming>,
53}
54
55impl VectorSearchOutcome {
56 pub fn disabled(reason: impl Into<String>) -> Self {
57 Self {
58 hits: vec![],
59 disabled_reason: Some(reason.into()),
60 candidates_scanned: 0,
61 timings: vec![],
62 }
63 }
64
65 fn disabled_with_timings(
66 reason: impl Into<String>,
67 timings: Vec<crate::perf::PhaseTiming>,
68 ) -> Self {
69 Self {
70 hits: vec![],
71 disabled_reason: Some(reason.into()),
72 candidates_scanned: 0,
73 timings,
74 }
75 }
76
77 pub fn ready(hits: Vec<VectorHit>) -> Self {
78 let candidates_scanned = hits.len();
79 Self::ready_with_scan_count(hits, candidates_scanned)
80 }
81
82 pub fn ready_with_scan_count(hits: Vec<VectorHit>, candidates_scanned: usize) -> Self {
83 Self::ready_with_scan_count_and_timings(hits, candidates_scanned, vec![])
84 }
85
86 fn ready_with_scan_count_and_timings(
87 hits: Vec<VectorHit>,
88 candidates_scanned: usize,
89 timings: Vec<crate::perf::PhaseTiming>,
90 ) -> Self {
91 Self {
92 hits,
93 disabled_reason: None,
94 candidates_scanned,
95 timings,
96 }
97 }
98}
99
100#[derive(Debug, Clone, Copy, Default)]
101pub struct VectorSearchFilters<'a> {
102 pub project: Option<&'a str>,
103 pub memory_type: Option<&'a str>,
104 pub branch: Option<&'a str>,
105 pub include_stale: bool,
106}
107
108pub fn load_vec_extension(conn: &Connection) -> Result<()> {
115 if vec_extension_loaded(conn) {
116 return Ok(());
117 }
118 type SqliteVecInit = unsafe extern "C" fn(
124 db: *mut rusqlite::ffi::sqlite3,
125 pz_err_msg: *mut *mut std::ffi::c_char,
126 p_api: *const std::ffi::c_void,
127 ) -> std::ffi::c_int;
128 let init: SqliteVecInit =
134 unsafe { std::mem::transmute(sqlite_vec::sqlite3_vec_init as *const ()) };
135 let rc = unsafe { init(conn.handle(), std::ptr::null_mut(), std::ptr::null()) };
136 if rc != rusqlite::ffi::SQLITE_OK {
137 crate::log::error(
138 "retrieval",
139 &format!(
140 "sqlite-vec init failed with rc={rc}; vector index disabled, brute-force scan remains"
141 ),
142 );
143 return Ok(());
144 }
145 if !vec_extension_loaded(conn) {
146 crate::log::error(
147 "retrieval",
148 "sqlite-vec init reported success but vec_version() is unavailable; brute-force scan remains",
149 );
150 }
151 Ok(())
152}
153
154pub(crate) fn vec_extension_loaded(conn: &Connection) -> bool {
156 conn.query_row("SELECT vec_version()", [], |row| row.get::<_, String>(0))
157 .is_ok()
158}
159
160pub fn ensure_vec_table(conn: &Connection) -> Result<()> {
161 create_embedding_table(conn)
162}
163
164pub fn upsert_embedding(conn: &Connection, memory_id: i64, embedding: &[f32]) -> Result<()> {
165 if super::embedding::provider_disabled_or_error()? {
166 return Ok(());
167 }
168 upsert_embedding_with_metadata(
169 conn,
170 memory_id,
171 DEFAULT_EMBEDDING_MODEL,
172 "",
173 embedding,
174 chrono::Utc::now().timestamp(),
175 )
176}
177
178pub fn upsert_memory_embedding(
182 conn: &Connection,
183 memory_id: i64,
184 title: &str,
185 content: &str,
186 memory_type: &str,
187 topic_key: Option<&str>,
188 search_context: &str,
189) -> Result<()> {
190 if super::embedding::provider_disabled_or_error()? {
191 return Ok(());
192 }
193 let embedding = match super::embedding::embed_memory_index(
194 title,
195 content,
196 memory_type,
197 topic_key,
198 search_context,
199 ) {
200 Ok(embedding) => embedding,
201 Err(error) if super::embedding::is_embedding_provider_off_error(&error) => return Ok(()),
202 Err(error) if super::embedding::is_local_embedding_model_unavailable_error(&error) => {
203 crate::log::error(
204 "embedding",
205 &format!("memory embedding deferred for memory id={memory_id}: {error}"),
206 );
207 return Ok(());
208 }
209 Err(error) => return Err(error),
210 };
211 let content_hash =
212 super::embedding::memory_index_hash(title, content, memory_type, topic_key, search_context);
213 upsert_embedding_with_metadata(
214 conn,
215 memory_id,
216 embedding.model(),
217 &content_hash,
218 embedding.values(),
219 chrono::Utc::now().timestamp(),
220 )
221 .with_context(|| format!("memory embedding upsert failed for memory id={memory_id}"))
222}
223
224pub(crate) fn upsert_index_embedding(
227 conn: &Connection,
228 memory_id: i64,
229 model: &str,
230 index_hash: &str,
231 values: &[f32],
232) -> Result<()> {
233 upsert_embedding_with_metadata(
234 conn,
235 memory_id,
236 model,
237 index_hash,
238 values,
239 chrono::Utc::now().timestamp(),
240 )
241 .with_context(|| format!("index embedding upsert failed for memory id={memory_id}"))
242}
243
244pub fn upsert_memory_embedding_for_row(conn: &Connection, memory_id: i64) -> Result<()> {
245 let row: (Option<String>, String, String, String, Option<String>) = conn
246 .query_row(
247 "SELECT topic_key, title, content, memory_type,
248 CASE WHEN search_context_source_hash IS NOT NULL
249 THEN search_context ELSE '' END
250 FROM memories
251 WHERE id = ?1",
252 [memory_id],
253 |row| {
254 Ok((
255 row.get(0)?,
256 row.get(1)?,
257 row.get(2)?,
258 row.get(3)?,
259 row.get(4)?,
260 ))
261 },
262 )
263 .with_context(|| format!("load memory row for embedding id={memory_id}"))?;
264 let (topic_key, title, content, memory_type, search_context) = row;
265 upsert_memory_embedding(
266 conn,
267 memory_id,
268 &title,
269 &content,
270 &memory_type,
271 topic_key.as_deref(),
272 search_context.as_deref().unwrap_or(""),
273 )
274}
275
276pub fn embedding_count(conn: &Connection) -> Result<i64> {
277 if !table_exists(conn, "memory_embeddings")? {
278 return Ok(0);
279 }
280 Ok(
281 conn.query_row("SELECT COUNT(*) FROM memory_embeddings", [], |row| {
282 row.get(0)
283 })?,
284 )
285}
286
287pub fn embed_query_text(query: &str) -> Vec<f32> {
288 super::embedding::embed_query_text_local(query)
289}
290
291pub fn embed_memory_text(
292 title: &str,
293 content: &str,
294 memory_type: &str,
295 topic_key: Option<&str>,
296) -> Vec<f32> {
297 super::embedding::embed_memory_text_local(title, content, memory_type, topic_key)
298}
299
300pub fn vector_search(
301 conn: &Connection,
302 query_embedding: &[f32],
303 limit: usize,
304) -> Result<Vec<(i64, f32)>> {
305 Ok(
306 vector_search_filtered(conn, query_embedding, VectorSearchFilters::default(), limit)?
307 .hits
308 .into_iter()
309 .map(|hit| (hit.memory_id, hit.distance))
310 .collect(),
311 )
312}
313
314pub fn vector_search_filtered(
315 conn: &Connection,
316 query_embedding: &[f32],
317 filters: VectorSearchFilters<'_>,
318 limit: usize,
319) -> Result<VectorSearchOutcome> {
320 if query_embedding.len() != EMBEDDING_DIMENSIONS {
321 anyhow::bail!(
322 "query embedding must be {} dimensions, got {}",
323 EMBEDDING_DIMENSIONS,
324 query_embedding.len()
325 );
326 }
327 let embedding = TextEmbedding::new(DEFAULT_EMBEDDING_MODEL, query_embedding.to_vec())?;
328 vector_search_embedding_filtered(conn, &embedding, filters, limit)
329}
330
331pub fn vector_search_embedding_filtered(
332 conn: &Connection,
333 query_embedding: &TextEmbedding,
334 filters: VectorSearchFilters<'_>,
335 limit: usize,
336) -> Result<VectorSearchOutcome> {
337 if limit == 0 {
338 return Ok(VectorSearchOutcome::ready(vec![]));
339 }
340 crate::memory::retrieval_enrichment::ensure_retrieval_open(conn)?;
341 if super::embedding::provider_disabled_or_error()? {
342 return Ok(VectorSearchOutcome::disabled("embedding provider is off"));
343 }
344 if !table_exists(conn, "memory_embeddings")? {
345 return Ok(VectorSearchOutcome::disabled(
346 "memory_embeddings table is missing; run migrations/backfill",
347 ));
348 }
349 let mut timings = Vec::new();
350 let profile = query_embedding.profile();
351 let knn_hits = crate::perf::time_result(&mut timings, "vector_knn_index", || {
352 vec_index::knn_candidates(
353 conn,
354 query_embedding.values(),
355 profile,
356 filters,
357 super::vector_candidates::vector_candidate_limit(limit),
358 )
359 })?;
360 if let Some(mut hits) = knn_hits {
361 if !hits.is_empty() {
364 let candidates_scanned = hits.len();
365 hits.truncate(limit);
366 return Ok(VectorSearchOutcome::ready_with_scan_count_and_timings(
367 hits,
368 candidates_scanned,
369 timings,
370 ));
371 }
372 }
373 let candidate_ids = crate::perf::time_result(&mut timings, "vector_select_candidates", || {
374 super::vector_candidates::select_candidate_ids(conn, filters, profile, limit)
375 })?;
376 let candidates_scanned = candidate_ids.len();
377 if candidate_ids.is_empty() {
378 if super::vector_candidates::matching_memory_count(conn, filters)? > 0 {
379 if embedding_count(conn)? == 0 {
380 return Ok(VectorSearchOutcome::disabled_with_timings(
381 "memory_embeddings table is empty; run `remem reindex-embeddings --limit 1000`",
382 timings,
383 ));
384 }
385 return Ok(VectorSearchOutcome::disabled_with_timings(
386 format!(
387 "memory_embeddings has no rows for model={} dimensions={}; run `remem reindex-embeddings --limit 1000`",
388 profile.model, profile.dimensions
389 ),
390 timings,
391 ));
392 }
393 return Ok(VectorSearchOutcome::ready_with_scan_count_and_timings(
394 vec![],
395 0,
396 timings,
397 ));
398 }
399 let placeholders = std::iter::repeat_n("?", candidate_ids.len())
400 .collect::<Vec<_>>()
401 .join(", ");
402 let sql = format!(
403 "SELECT memory_id, embedding, dimensions
404 FROM memory_embeddings INDEXED BY idx_memory_embeddings_profile_memory_id
405 WHERE model = ?
406 AND dimensions = ?
407 AND memory_id IN ({placeholders})"
408 );
409 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = vec![
410 Box::new(profile.model.to_string()),
411 Box::new(profile.dimensions as i64),
412 ];
413 param_values.extend(
414 candidate_ids
415 .iter()
416 .map(|id| Box::new(*id) as Box<dyn rusqlite::types::ToSql>),
417 );
418 let candidates = crate::perf::time_result(&mut timings, "vector_load_embeddings", || {
419 let refs = crate::db::to_sql_refs(¶m_values);
420 let mut stmt = conn.prepare(&sql)?;
421 let rows = stmt.query_map(refs.as_slice(), |row| {
422 Ok((
423 row.get::<_, i64>(0)?,
424 row.get::<_, Vec<u8>>(1)?,
425 row.get::<_, i64>(2)?,
426 ))
427 })?;
428 crate::db::query::collect_rows(rows)
429 })?;
430 let mut hits = crate::perf::time_result(&mut timings, "vector_decode_cosine", || {
431 let mut hits = Vec::new();
432 for (memory_id, blob, dimensions) in candidates {
433 let embedding = decode_embedding(&blob, dimensions)
434 .with_context(|| format!("invalid embedding blob for memory id={memory_id}"))?;
435 let distance = cosine_distance(query_embedding.values(), &embedding)?;
436 hits.push(VectorHit {
437 memory_id,
438 distance,
439 });
440 }
441 Ok(hits)
442 })?;
443 crate::perf::time_value(&mut timings, "vector_sort_truncate", || {
444 hits.sort_by(|a, b| {
445 a.distance
446 .partial_cmp(&b.distance)
447 .unwrap_or(std::cmp::Ordering::Equal)
448 .then_with(|| a.memory_id.cmp(&b.memory_id))
449 });
450 hits.truncate(limit);
451 });
452 Ok(VectorSearchOutcome::ready_with_scan_count_and_timings(
453 hits,
454 candidates_scanned,
455 timings,
456 ))
457}
458
459pub fn find_similar_observations(
460 conn: &Connection,
461 query_embedding: &[f32],
462 threshold: f32,
463 limit: usize,
464) -> Result<Vec<i64>> {
465 let candidates = vector_search(conn, query_embedding, limit)?;
466 let distance_threshold = 1.0 - threshold;
467 let similar: Vec<i64> = candidates
468 .into_iter()
469 .filter(|(_, dist)| *dist < distance_threshold)
470 .map(|(id, _)| id)
471 .collect();
472
473 Ok(similar)
474}
475
476fn create_embedding_table(conn: &Connection) -> Result<()> {
477 conn.execute_batch(
478 "CREATE TABLE IF NOT EXISTS memory_embeddings (
479 memory_id INTEGER NOT NULL,
480 embedding BLOB NOT NULL,
481 dimensions INTEGER NOT NULL,
482 model TEXT NOT NULL,
483 content_hash TEXT NOT NULL,
484 updated_at_epoch INTEGER NOT NULL,
485 PRIMARY KEY(memory_id, model, dimensions),
486 FOREIGN KEY(memory_id) REFERENCES memories(id) ON DELETE CASCADE
487 );
488 CREATE INDEX IF NOT EXISTS idx_memory_embeddings_model
489 ON memory_embeddings(model, updated_at_epoch);
490 CREATE INDEX IF NOT EXISTS idx_memory_embeddings_profile_memory_id
491 ON memory_embeddings(model, dimensions, memory_id);",
492 )?;
493 Ok(())
494}
495
496fn upsert_embedding_with_metadata(
497 conn: &Connection,
498 memory_id: i64,
499 model: &str,
500 content_hash: &str,
501 embedding: &[f32],
502 updated_at_epoch: i64,
503) -> Result<()> {
504 let mut stmt = conn.prepare(UPSERT_EMBEDDING_SQL)?;
505 execute_embedding_upsert(
506 &mut stmt,
507 memory_id,
508 model,
509 content_hash,
510 embedding,
511 updated_at_epoch,
512 )?;
513 vec_index::sync_vec_upsert(conn, memory_id, model, embedding.len())
514}
515
516fn execute_embedding_upsert(
517 stmt: &mut Statement<'_>,
518 memory_id: i64,
519 model: &str,
520 content_hash: &str,
521 embedding: &[f32],
522 updated_at_epoch: i64,
523) -> Result<()> {
524 if model.trim().is_empty() {
525 anyhow::bail!("embedding model must not be empty");
526 }
527 if embedding.is_empty() {
528 anyhow::bail!("embedding vector must not be empty");
529 }
530 if embedding.iter().any(|value| !value.is_finite()) {
531 anyhow::bail!("embedding vector contains non-finite values");
532 }
533 let blob = encode_embedding(embedding);
534 let dimensions = embedding.len() as i64;
535 stmt.execute(params![
536 memory_id,
537 blob,
538 dimensions,
539 model,
540 content_hash,
541 updated_at_epoch
542 ])?;
543 Ok(())
544}
545
546fn encode_embedding(embedding: &[f32]) -> Vec<u8> {
547 let mut out = Vec::with_capacity(std::mem::size_of_val(embedding));
548 for value in embedding {
549 out.extend_from_slice(&value.to_le_bytes());
550 }
551 out
552}
553
554pub(crate) fn decode_embedding(blob: &[u8], dimensions: i64) -> Result<Vec<f32>> {
555 if dimensions <= 0 {
556 anyhow::bail!("embedding dimensions must be positive, got {dimensions}");
557 }
558 let dimensions = dimensions as usize;
559 let expected_bytes = dimensions * std::mem::size_of::<f32>();
560 if blob.len() != expected_bytes {
561 anyhow::bail!(
562 "embedding blob must be {} bytes, got {}",
563 expected_bytes,
564 blob.len()
565 );
566 }
567 Ok(blob
568 .as_chunks::<{ std::mem::size_of::<f32>() }>()
569 .0
570 .iter()
571 .map(|chunk| f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]))
572 .collect())
573}
574
575pub(crate) fn cosine_distance(a: &[f32], b: &[f32]) -> Result<f32> {
576 if a.len() != b.len() {
577 anyhow::bail!(
578 "embedding dimensions differ: query={} stored={}",
579 a.len(),
580 b.len()
581 );
582 }
583 let mut dot = 0.0f32;
584 let mut a_norm = 0.0f32;
585 let mut b_norm = 0.0f32;
586 for (left, right) in a.iter().zip(b) {
587 dot += left * right;
588 a_norm += left * left;
589 b_norm += right * right;
590 }
591 if a_norm == 0.0 || b_norm == 0.0 {
592 return Ok(1.0);
593 }
594 Ok((1.0 - dot / (a_norm.sqrt() * b_norm.sqrt())).clamp(0.0, 2.0))
595}
596
597fn table_exists(conn: &Connection, table: &str) -> Result<bool> {
598 Ok(conn
599 .query_row(
600 "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = ?1 LIMIT 1",
601 params![table],
602 |_| Ok(()),
603 )
604 .optional()?
605 .is_some())
606}
607
608#[cfg(test)]
609mod tests;