1#![allow(deprecated)]
2
3#[cfg(not(any(feature = "hnsw", feature = "brute-force", feature = "usearch-backend")))]
58compile_error!(
59 "At least one search backend feature must be enabled: 'hnsw', 'usearch-backend', or 'brute-force'"
60);
61
62pub mod chunker;
63pub mod config;
64pub(crate) mod conversation;
65pub(crate) mod db;
66pub use db::{bytes_to_embedding, decode_f32_le, embedding_to_bytes};
67pub(crate) mod documents;
68pub mod embedder;
69pub(crate) mod episodes;
70pub mod error;
71#[cfg(feature = "discord")]
73pub mod discord;
74#[cfg(feature = "decoder")]
76pub mod decoder;
77mod graph;
78pub(crate) mod graph_edges;
80#[cfg(feature = "hnsw")]
81pub mod hnsw;
82#[cfg(feature = "hnsw")]
83mod hnsw_backend;
84#[cfg(feature = "hnsw")]
85mod hnsw_ops;
86mod json_compat_import;
87pub(crate) mod knowledge;
88mod pool;
89#[cfg(feature = "provenance")]
91pub mod provenance;
92#[cfg(feature = "temporal")]
94pub mod temporal;
95mod projection_batch;
96mod projection_derivation;
97#[deprecated(
101 since = "0.6.0",
102 note = "Legacy V10 import path is migration-only. Use `import_projection_batch()` with `ProjectionImportBatchV3` on the canonical lane."
103)]
104#[doc(hidden)]
105pub mod projection_import;
106mod projection_lane;
107mod projection_legacy_compat;
108pub(crate) mod projection_storage;
109#[cfg(feature = "multiscale")]
111pub mod pipeline;
112pub mod quantize;
113pub mod quantize_governed;
114#[cfg(feature = "subtraction")]
116pub mod subtraction;
117#[cfg(feature = "compression-governor")]
119pub mod compression_governor;
120#[cfg(feature = "routing")]
122pub mod routing;
123#[cfg(feature = "benchmark")]
125pub mod benchmark;
126#[cfg(feature = "integration")]
128pub mod integration;
129#[cfg(feature = "integration")]
133pub mod factor_graph;
134#[cfg(feature = "late-interaction")]
136pub mod late_interaction;
137#[cfg(feature = "topology")]
139pub mod topology;
140#[cfg(feature = "matryoshka")]
142pub mod matryoshka;
143#[cfg(feature = "community")]
145pub mod community;
146#[cfg(feature = "rl-routing")]
148pub mod rl_routing;
149#[cfg(feature = "subgraph-pruning")]
151pub mod subgraph_pruning;
152pub mod search;
153pub mod storage;
154mod store_support;
155pub mod tokenizer;
156pub mod types;
157#[cfg(feature = "usearch-backend")]
158mod usearch_backend;
159pub mod vector_backend;
160pub mod vector_codec;
161pub mod vector_snapshot;
162
163pub use config::{
165 ChunkingConfig, ChunkingStrategy, DerivedVectorBackendPolicy, EmbeddingConfig, MemoryConfig,
166 MemoryLimits, PoolConfig, SearchConfig,
167};
168pub use db::{IntegrityReport, ReconcileAction, VerifyMode};
169pub use embedder::{
170 BgeM3DeriveConfig, BgeM3Embedder, Embedder, MockEmbedder, MultiEmbedBatchFuture,
171 MultiEmbedFuture, MultiFunctionEmbedder, MultiFunctionEmbedding, MultiVectorEmbedding,
172 OllamaEmbedder, SparseWeights,
173};
174#[cfg(feature = "candle-embedder")]
175pub use embedder::CandleEmbedder;
176pub use error::MemoryError;
177#[cfg(feature = "hnsw")]
178pub use hnsw::{HnswConfig, HnswHit, HnswIndex};
179pub(crate) use projection_lane::projection_import_failure_id;
182pub use projection_lane::{
183 ProjectionImportFailureReceiptEntry, ProjectionImportLogEntry, ProjectionImportResult,
184};
185pub use quantize::{pack_quantized, unpack_quantized, QuantizedVector, Quantizer};
186pub use storage::StoragePaths;
187pub use tokenizer::{EstimateTokenCounter, TokenCounter};
188pub use types::{
189 ChunkManifestChunkMapping, ChunkManifestEntry, ChunkManifestIngestOptions,
190 ChunkManifestIngestResult, DerivedCandidateReceiptV1, Document, EmbeddingDisplacement,
191 EpisodeAsOfReceiptV1, EpisodeMeta, EpisodeOutcome, ExactnessProfile, ExplainedResult,
192 ExplainedResultAnswerV1, ExplainedSearchResponse, Fact, GraphDirection, GraphEdge,
193 GraphEdgeType, GraphView, MemoryStats, Message, NamespaceDeleteReport, ProjectionClaimVersion,
194 ProjectionEntityAlias, ProjectionEpisode, ProjectionEvidenceRef, ProjectionQuery,
195 ProjectionRelationVersion, ProveKvPoolArtifactBuildReceiptV1, ProveKvPoolArtifactStatusV1,
196 ProveKvPoolGenerationStatus, ProveKvPoolGenerationV1, ProveKvPoolItemMapEntryV1, ReceiptMode,
197 Role, ScoreBreakdown, SearchContext, SearchReceiptAnswersV1, SearchReplayReportV1,
198 SearchResponse, SearchResult, SearchSource, SearchSourceType, Session, TextChunk,
199 VectorArtifactBuildReceiptV1, VectorSearchReceiptV1, VerificationStatus,
200};
201pub use graph_edges::{AddGraphEdgeParams, StoredGraphEdge};
202pub use vector_backend::{VectorBackend, VectorHit, VectorIndex, VectorIndexConfig};
203#[cfg(feature = "turbo-quant-codec")]
204pub use vector_codec::TurboQuantCodec;
205pub use vector_codec::{
206 RawF32Codec, Sq8Codec, VectorArtifactV1, VectorCodec, VectorCodecProfileV1,
207};
208pub use vector_snapshot::{build_embedding_snapshot, EmbeddingSnapshotRow, EmbeddingSnapshotV1};
209
210use std::sync::Arc;
211
212const MAX_TOP_K: usize = 1_000;
213#[cfg(feature = "hnsw")]
214const MAX_HNSW_CANDIDATES: usize = 10_000;
215
216pub(crate) use store_support::{
217 as_str_slice, build_episode_search_text, merge_trace_ctx, to_owned_string_vec,
218 verification_status_for_outcome,
219};
220
221fn dedup_by_content(results: Vec<types::SearchResult>) -> Vec<types::SearchResult> {
227 use std::collections::HashSet;
228 let mut seen: HashSet<String> = HashSet::new();
229 let deduped_result: Vec<types::SearchResult> = results
230 .into_iter()
231 .filter(|r| {
232 let fingerprint: String = r
233 .content
234 .split_whitespace()
235 .take(30)
236 .collect::<Vec<_>>()
237 .join(" ")
238 .to_lowercase();
239 let source_type = match &r.source {
243 types::SearchSource::Fact { .. } => "fact",
244 types::SearchSource::Chunk { .. } => "chunk",
245 types::SearchSource::Message { .. } => "message",
246 types::SearchSource::Episode { .. } => "episode",
247 types::SearchSource::Projection { .. } => "projection",
248 };
249 let key = format!("{}:{}", source_type, fingerprint);
250 seen.insert(key)
251 })
252 .collect::<Vec<_>>();
253 let mut deduped = deduped_result;
254
255 let mut doc_counts: std::collections::HashMap<String, usize> = std::collections::HashMap::new();
257 deduped.retain(|r| {
258 if let types::SearchSource::Chunk { document_id, .. } = &r.source {
259 let count = doc_counts.entry(document_id.clone()).or_insert(0);
260 if *count >= 2 {
261 return false;
262 }
263 *count += 1;
264 }
265 true
266 });
267
268 {
272 let word_set = |r: &types::SearchResult| -> std::collections::HashSet<String> {
273 r.content
274 .split_whitespace()
275 .take(30)
276 .map(|w| w.to_lowercase())
277 .collect()
278 };
279 let source_type_tag = |r: &types::SearchResult| -> &'static str {
280 match &r.source {
281 types::SearchSource::Fact { .. } => "fact",
282 types::SearchSource::Chunk { .. } => "chunk",
283 types::SearchSource::Message { .. } => "message",
284 types::SearchSource::Episode { .. } => "episode",
285 types::SearchSource::Projection { .. } => "projection",
286 }
287 };
288 let n = deduped.len();
289 let mut drop: std::collections::HashSet<usize> = std::collections::HashSet::new();
290 for i in 0..n {
291 if drop.contains(&i) {
292 continue;
293 }
294 for j in (i + 1)..n {
295 if drop.contains(&j) {
296 continue;
297 }
298 let ri = &deduped[i];
299 let rj = &deduped[j];
300 if source_type_tag(ri) != source_type_tag(rj) {
301 continue;
302 }
303 let (Some(ci), Some(cj)) = (ri.cosine_similarity, rj.cosine_similarity) else {
304 continue;
305 };
306 if (ci - cj).abs() > 0.01 {
307 continue;
308 }
309 let wi = word_set(ri);
310 let wj = word_set(rj);
311 let inter = wi.intersection(&wj).count();
312 let uni = wi.union(&wj).count();
313 if uni == 0 {
314 continue;
315 }
316 if inter as f64 / uni as f64 >= 0.8 {
317 if ri.score >= rj.score {
318 drop.insert(j);
319 } else {
320 drop.insert(i);
321 break;
322 }
323 }
324 }
325 }
326 if !drop.is_empty() {
327 let mut idx = 0usize;
328 deduped.retain(|_| {
329 let keep = !drop.contains(&idx);
330 idx += 1;
331 keep
332 });
333 }
334 }
335
336 deduped
337}
338
339pub fn compress_search_results(results: Vec<types::SearchResult>) -> Vec<types::SearchResult> {
351 results
352 .into_iter()
353 .map(|r| {
354 let compressed = compress_content(&r.content);
355 types::SearchResult {
356 content: compressed,
357 ..r
358 }
359 })
360 .collect()
361}
362
363fn compress_content(content: &str) -> String {
365 const MAX_CHARS: usize = 150;
366
367 let first_sentence = content
369 .find(|c| c == '.' || c == '!' || c == '?')
370 .map(|idx| {
371 let end = idx + 1;
373 &content[..end.min(content.len())]
374 })
375 .unwrap_or(content);
376
377 if first_sentence.len() <= MAX_CHARS {
378 return first_sentence.trim().to_string();
379 }
380
381 let truncated = &first_sentence[..MAX_CHARS];
383 if let Some(last_space) = truncated.rfind(' ') {
384 let at_word_boundary = &truncated[..last_space];
385 format!("{}…", at_word_boundary.trim())
386 } else {
387 format!("{}…", truncated.trim())
388 }
389}
390
391#[cfg(feature = "hnsw")]
392fn verify_hnsw_key_level_integrity(
393 conn: &rusqlite::Connection,
394 dimensions: usize,
395 node_vectors: &std::collections::HashMap<usize, Vec<f32>>,
396 sidecar_files_exist: bool,
397) -> Result<Vec<String>, MemoryError> {
398 let mut issues = Vec::new();
399 let mut live_rows: std::collections::HashMap<String, Vec<f32>> =
400 std::collections::HashMap::new();
401
402 let mut live_stmt = conn.prepare(
403 "SELECT 'fact:' || id, embedding FROM facts WHERE embedding IS NOT NULL
404 UNION ALL
405 SELECT 'chunk:' || id, embedding FROM chunks WHERE embedding IS NOT NULL
406 UNION ALL
407 SELECT 'msg:' || id, embedding FROM messages WHERE embedding IS NOT NULL
408 UNION ALL
409 SELECT 'episode:' || episode_id, embedding FROM episodes WHERE embedding IS NOT NULL",
410 )?;
411 let live_iter = live_stmt.query_map([], |row| {
412 Ok((row.get::<_, String>(0)?, row.get::<_, Vec<u8>>(1)?))
413 })?;
414 for row in live_iter {
415 let (key, blob) = row?;
416 match db::decode_f32_le(&blob, dimensions) {
417 Ok(vector) => {
418 live_rows.insert(key, vector);
419 }
420 Err(err) => issues.push(format!(
421 "HNSW live embedding row {key} has invalid vector: {err}"
422 )),
423 }
424 }
425
426 if !live_rows.is_empty() && !sidecar_files_exist {
427 issues.push(format!(
428 "HNSW sidecar files are missing while {} embedded rows exist in SQLite",
429 live_rows.len()
430 ));
431 }
432
433 let keymap_exists: bool = conn
434 .query_row(
435 "SELECT COUNT(*) > 0 FROM sqlite_master WHERE type='table' AND name='hnsw_keymap'",
436 [],
437 |row| row.get(0),
438 )
439 .unwrap_or(false);
440 if !keymap_exists {
441 if !live_rows.is_empty() {
442 issues.push("HNSW keymap table missing while embedded SQLite rows exist".to_string());
443 }
444 return Ok(issues);
445 }
446
447 let mut active_keymap: std::collections::HashMap<String, usize> =
448 std::collections::HashMap::new();
449 let mut keymap_stmt =
450 conn.prepare("SELECT node_id, item_key FROM hnsw_keymap WHERE deleted = 0")?;
451 let keymap_iter = keymap_stmt.query_map([], |row| {
452 Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?))
453 })?;
454 for row in keymap_iter {
455 let (node_id_raw, key) = row?;
456 let Some((domain, raw_id)) = key.split_once(':') else {
457 issues.push(format!("HNSW keymap entry has malformed key: {key}"));
458 continue;
459 };
460 if !matches!(domain, "fact" | "chunk" | "msg" | "episode") || raw_id.is_empty() {
461 issues.push(format!(
462 "HNSW keymap entry has unsupported key domain: {key}"
463 ));
464 continue;
465 }
466 if domain == "msg" && raw_id.parse::<i64>().is_err() {
467 issues.push(format!("HNSW message key has non-integer row id: {key}"));
468 continue;
469 }
470 let node_id = match usize::try_from(node_id_raw) {
471 Ok(node_id) => node_id,
472 Err(err) => {
473 issues.push(format!(
474 "HNSW keymap node_id {node_id_raw} is invalid: {err}"
475 ));
476 continue;
477 }
478 };
479 active_keymap.insert(key, node_id);
480 }
481
482 for key in live_rows.keys() {
483 if !active_keymap.contains_key(key) {
484 issues.push(format!(
485 "HNSW keymap missing live embedded SQLite row: {key}"
486 ));
487 }
488 }
489
490 for (key, node_id) in &active_keymap {
491 let Some(live_vector) = live_rows.get(key) else {
492 issues.push(format!(
493 "HNSW keymap has stale active entry without live embedded SQLite row: {key}"
494 ));
495 continue;
496 };
497 let Some(index_vector) = node_vectors.get(node_id) else {
498 issues.push(format!(
499 "HNSW keymap entry {key} points to missing in-memory node vector {node_id}"
500 ));
501 continue;
502 };
503 if index_vector.len() != live_vector.len()
504 || index_vector
505 .iter()
506 .zip(live_vector)
507 .any(|(left, right)| left.to_bits() != right.to_bits())
508 {
509 issues.push(format!(
510 "HNSW keymap entry {key} points to node {node_id} whose vector does not match the authoritative SQLite embedding"
511 ));
512 }
513 }
514
515 if active_keymap.len() != live_rows.len() {
516 issues.push(format!(
517 "HNSW keymap drift: {} active keymap rows vs {} embedded SQLite rows",
518 active_keymap.len(),
519 live_rows.len()
520 ));
521 }
522
523 Ok(issues)
524}
525
526#[doc(hidden)]
528pub mod compat {
529 #[deprecated(
530 since = "0.5.0",
531 note = "Legacy ImportEnvelope is migration-only. New integrations should use `ProjectionImportBatchV3` on the canonical lane."
532 )]
533 #[doc(hidden)]
534 #[allow(deprecated)]
535 pub mod legacy_import_envelope {
536 pub use crate::projection_import::{
537 ImportEnvelope, ImportProjectionFreshness, ImportReceipt, ImportRecord, ImportStatus,
538 };
539 pub use stack_ids::EnvelopeId;
540 }
541
542 #[deprecated(
543 since = "0.5.0",
544 note = "Legacy trace_id is migration-only. Use `stack_ids::TraceCtx`."
545 )]
546 #[doc(hidden)]
547 #[allow(deprecated)]
548 pub mod compat_trace_id {
549 pub use crate::types::TraceId;
550 }
551}
552
553#[derive(Clone)]
557pub struct MemoryStore {
558 inner: Arc<MemoryStoreInner>,
559}
560
561struct MemoryStoreInner {
562 pool: pool::SqlitePool,
563 embedder: Box<dyn Embedder>,
564 embedding_permits: Arc<tokio::sync::Semaphore>,
565 config: MemoryConfig,
566 paths: StoragePaths,
567 token_counter: Arc<dyn TokenCounter>,
568 embedding_cache: std::sync::Mutex<lru::LruCache<String, Vec<f32>>>,
571 search_cache: std::sync::Mutex<lru::LruCache<String, Vec<types::SearchResult>>>,
574 #[cfg(feature = "hnsw")]
575 hnsw_index: std::sync::RwLock<HnswIndex>,
576}
577
578#[cfg(feature = "hnsw")]
579impl Drop for MemoryStoreInner {
580 fn drop(&mut self) {
581 if !self.paths.hnsw_dir.exists() {
582 tracing::debug!(
583 path = %self.paths.hnsw_dir.display(),
584 "Skipping HNSW drop flush because the sidecar directory no longer exists"
585 );
586 return;
587 }
588
589 let pending_ops = match self.pool.with_read_conn(db::pending_index_op_count) {
590 Ok(count) => count,
591 Err(err) => {
592 tracing::warn!("Failed to inspect pending HNSW work on drop: {}", err);
593 0
594 }
595 };
596
597 if pending_ops > 0 {
598 if let Err(err) =
599 hnsw_ops::recover_hnsw_sidecar_sync(&self.pool, &self.paths, &self.config.hnsw)
600 {
601 tracing::error!("Failed to recover and flush HNSW on drop: {}", err);
602 }
603 return;
604 }
605
606 let hnsw_guard = match self.hnsw_index.read() {
607 Ok(g) => g,
608 Err(_) => {
609 tracing::warn!("HNSW RwLock poisoned on drop — skipping save");
610 return;
611 }
612 };
613
614 if let Err(err) = hnsw_ops::save_hnsw_sidecar(
615 &hnsw_guard,
616 &self.paths.hnsw_dir,
617 &self.paths.hnsw_basename,
618 ) {
619 tracing::error!("Failed to save HNSW index on drop: {}", err);
620 }
621
622 if let Err(e) = self
624 .pool
625 .with_write_conn(|conn| hnsw_guard.flush_keymap(conn))
626 {
627 tracing::error!("Failed to flush HNSW keymap on drop: {}", e);
628 }
629 }
630}
631
632impl MemoryStore {
633 async fn with_read_conn<F, T>(&self, f: F) -> Result<T, MemoryError>
638 where
639 F: FnOnce(&rusqlite::Connection) -> Result<T, MemoryError> + Send + 'static,
640 T: Send + 'static,
641 {
642 let inner = self.inner.clone();
643 tokio::task::spawn_blocking(move || -> Result<T, MemoryError> {
644 inner.pool.with_read_conn(f)
645 })
646 .await
647 .map_err(|e| MemoryError::Other(format!("Blocking task panicked: {}", e)))?
648 }
649
650 async fn with_write_conn<F, T>(&self, f: F) -> Result<T, MemoryError>
652 where
653 F: FnOnce(&rusqlite::Connection) -> Result<T, MemoryError> + Send + 'static,
654 T: Send + 'static,
655 {
656 let inner = self.inner.clone();
657 tokio::task::spawn_blocking(move || -> Result<T, MemoryError> {
658 inner.pool.with_write_conn(f)
659 })
660 .await
661 .map_err(|e| MemoryError::Other(format!("Blocking task panicked: {}", e)))?
662 }
663
664 pub(crate) fn clear_search_cache(&self) {
665 let mut cache = self.inner.search_cache.lock().expect("search cache lock poisoned");
666 cache.clear();
667 }
668
669 async fn persist_search_receipt(
670 &self,
671 receipt: &VectorSearchReceiptV1,
672 ) -> Result<(), MemoryError> {
673 let receipt = receipt.clone();
674 self.with_write_conn(move |conn| db::store_search_receipt(conn, &receipt))
675 .await
676 }
677
678 #[cfg(feature = "hnsw")]
681 async fn hnsw_search_blocking(
682 &self,
683 query_embedding: Vec<f32>,
684 candidates: usize,
685 ) -> Vec<HnswHit> {
686 let inner = self.inner.clone();
687 tokio::task::spawn_blocking(move || {
688 let guard = inner.hnsw_index.read().unwrap_or_else(|e| e.into_inner());
689 match guard.search(&query_embedding, candidates) {
690 Ok(hits) => hits,
691 Err(e) => {
692 tracing::error!(
693 "HNSW search failed, falling back to brute-force vector search: {}",
694 e
695 );
696 Vec::new()
697 }
698 }
699 })
700 .await
701 .unwrap_or_else(|e| {
702 tracing::error!("HNSW search blocking task panicked: {}", e);
703 Vec::new()
704 })
705 }
706
707 #[cfg(feature = "hnsw")]
708 fn sync_pending_hnsw_ops_blocking(&self) -> Result<usize, MemoryError> {
709 hnsw_ops::sync_pending_hnsw_sidecar(&self.inner)
710 }
711
712 #[cfg(feature = "hnsw")]
713 async fn sync_pending_hnsw_ops(&self) -> Result<usize, MemoryError> {
714 let inner = self.inner.clone();
715 tokio::task::spawn_blocking(move || hnsw_ops::sync_pending_hnsw_sidecar(&inner))
716 .await
717 .map_err(|e| MemoryError::Other(format!("Blocking task panicked: {}", e)))?
718 }
719
720 #[cfg(feature = "hnsw")]
721 async fn sync_pending_hnsw_ops_best_effort(&self, operation: &'static str) {
722 if let Err(err) = self.sync_pending_hnsw_ops().await {
723 tracing::warn!(
724 operation,
725 error = %err,
726 "SQLite write committed but HNSW sidecar sync is still pending"
727 );
728 } else {
729 self.maybe_flush_hnsw();
730 }
731 }
732
733 pub fn open(config: MemoryConfig) -> Result<Self, MemoryError> {
742 let config = config.normalize_and_validate()?;
743 #[cfg(feature = "candle-embedder")]
744 let embedder: Box<dyn Embedder> = Box::new(CandleEmbedder::try_new(&config.embedding)?);
745 #[cfg(not(feature = "candle-embedder"))]
746 let embedder: Box<dyn Embedder> = Box::new(OllamaEmbedder::try_new(&config.embedding)?);
747 Self::open_with_embedder(config, embedder)
748 }
749
750 #[allow(unused_mut)] pub fn open_with_embedder(
753 mut config: MemoryConfig,
754 embedder: Box<dyn Embedder>,
755 ) -> Result<Self, MemoryError> {
756 config = config.normalize_and_validate()?;
757 if embedder.dimensions() != config.embedding.dimensions {
758 return Err(MemoryError::DimensionMismatch {
759 expected: config.embedding.dimensions,
760 actual: embedder.dimensions(),
761 });
762 }
763 config.embedding.model = embedder.model_name().to_string();
764
765 let paths = StoragePaths::new(&config.base_dir);
766
767 std::fs::create_dir_all(&paths.base_dir).map_err(|e| {
769 MemoryError::StorageError(format!(
770 "Failed to create directory {}: {}",
771 paths.base_dir.display(),
772 e
773 ))
774 })?;
775
776 let pool = pool::SqlitePool::open(&paths.sqlite_path, &config.pool, &config.limits)?;
777 pool.with_write_conn(|conn| db::check_embedding_metadata(conn, &config.embedding))?;
778
779 #[cfg(feature = "hnsw")]
781 {
782 config.hnsw.dimensions = config.embedding.dimensions;
783 }
784
785 let token_counter = config
786 .token_counter
787 .clone()
788 .unwrap_or_else(tokenizer::default_token_counter);
789
790 #[cfg(feature = "hnsw")]
791 let hnsw_index = {
792 let hnsw_config = config.hnsw.clone();
793
794 let embeddings_dirty = pool.with_read_conn(db::is_embeddings_dirty)?;
795 let pending_index_ops = pool.with_read_conn(db::pending_index_op_count)?;
796
797 if embeddings_dirty {
798 tracing::warn!(
801 "Embedding model changed — creating fresh HNSW index (old index is stale)"
802 );
803 pool.with_write_conn(|conn| {
804 db::clear_all_pending_index_ops(conn)?;
805 db::set_sidecar_dirty(conn, false)?;
806 Ok(())
807 })?;
808 HnswIndex::new(hnsw_config)?
809 } else if pending_index_ops > 0 || pool.with_read_conn(db::is_sidecar_dirty)? {
810 tracing::warn!(
811 pending_index_ops,
812 "Recovering HNSW sidecar from SQLite because durable sidecar work exists"
813 );
814 hnsw_ops::recover_hnsw_sidecar_sync(&pool, &paths, &hnsw_config)?
815 } else if paths.hnsw_files_exist() {
816 tracing::info!("Loading HNSW index from {:?}", paths.hnsw_dir);
817 match HnswIndex::load(&paths.hnsw_dir, &paths.hnsw_basename, hnsw_config.clone()) {
818 Ok(index) => {
819 if let Err(e) = pool.with_write_conn(|conn| index.load_keymap(conn)) {
821 tracing::warn!("Failed to load HNSW key mappings: {}. Mappings will be empty until rebuild.", e);
822 }
823
824 let hnsw_count = index.len();
828 let sqlite_count: i64 = pool.with_read_conn(|conn| {
829 Ok(conn.query_row(
830 "SELECT (SELECT COUNT(*) FROM facts WHERE embedding IS NOT NULL) +
831 (SELECT COUNT(*) FROM chunks WHERE embedding IS NOT NULL) +
832 (SELECT COUNT(*) FROM messages WHERE embedding IS NOT NULL) +
833 (SELECT COUNT(*) FROM episodes WHERE embedding IS NOT NULL)",
834 [],
835 |row| row.get(0),
836 )?)
837 })?;
838
839 let drift = (sqlite_count - hnsw_count as i64).abs();
840 if drift > 0 {
841 tracing::warn!(
842 hnsw_count,
843 sqlite_count,
844 drift,
845 "HNSW index is stale — {} entries differ from SQLite. \
846 Likely caused by unclean shutdown. Triggering inline rebuild.",
847 drift
848 );
849 let rebuilt =
851 hnsw_ops::recover_hnsw_sidecar_sync(&pool, &paths, &hnsw_config)?;
852 tracing::info!(
853 active = rebuilt.len(),
854 "HNSW index rebuilt after stale detection"
855 );
856 rebuilt
857 } else {
858 tracing::info!(
859 "HNSW index loaded ({} active keys, in sync with SQLite)",
860 hnsw_count
861 );
862 index
863 }
864 }
865 Err(e) => {
866 tracing::warn!(
867 "Failed to load HNSW index: {}. Rebuilding sidecar from authoritative SQLite rows.",
868 e
869 );
870 hnsw_ops::recover_hnsw_sidecar_sync(&pool, &paths, &hnsw_config)?
871 }
872 }
873 } else {
874 let orphan_count: i64 = pool.with_read_conn(|conn| {
879 Ok(conn.query_row(
880 "SELECT (SELECT COUNT(*) FROM facts WHERE embedding IS NOT NULL) +
881 (SELECT COUNT(*) FROM chunks WHERE embedding IS NOT NULL) +
882 (SELECT COUNT(*) FROM messages WHERE embedding IS NOT NULL) +
883 (SELECT COUNT(*) FROM episodes WHERE embedding IS NOT NULL)",
884 [],
885 |row| row.get(0),
886 )?)
887 })?;
888
889 if orphan_count > 0 {
890 tracing::warn!(
891 orphan_count,
892 "HNSW sidecar files missing but {} embeddings exist in SQLite — \
893 rebuilding index inline",
894 orphan_count
895 );
896 let new_index =
897 hnsw_ops::recover_hnsw_sidecar_sync(&pool, &paths, &hnsw_config)?;
898 tracing::info!(
899 active = new_index.len(),
900 "HNSW index rebuilt from SQLite embeddings"
901 );
902 new_index
903 } else {
904 tracing::info!("Creating new empty HNSW index (no embeddings in SQLite)");
905 HnswIndex::new(hnsw_config)?
906 }
907 }
908 };
909
910 let store = Self {
911 inner: Arc::new(MemoryStoreInner {
912 pool,
913 embedder,
914 embedding_permits: Arc::new(tokio::sync::Semaphore::new(
915 config.limits.max_embedding_concurrency,
916 )),
917 config,
918 paths,
919 token_counter,
920 embedding_cache: std::sync::Mutex::new(
921 lru::LruCache::new(std::num::NonZeroUsize::new(256).expect("256 > 0")),
922 ),
923 search_cache: std::sync::Mutex::new(
924 lru::LruCache::new(std::num::NonZeroUsize::new(64).expect("64 > 0")),
925 ),
926 #[cfg(feature = "hnsw")]
927 hnsw_index: std::sync::RwLock::new(hnsw_index),
928 }),
929 };
930
931 #[cfg(feature = "hnsw")]
932 if let Err(err) = store.sync_pending_hnsw_ops_blocking() {
933 tracing::warn!(
934 error = %err,
935 "Failed to reconcile pending HNSW sidecar ops during open; sidecar replay remains pending"
936 );
937 }
938
939 Ok(store)
940 }
941
942 async fn with_embedding_permit(
943 &self,
944 ) -> Result<tokio::sync::OwnedSemaphorePermit, MemoryError> {
945 self.inner
946 .embedding_permits
947 .clone()
948 .acquire_owned()
949 .await
950 .map_err(|e| MemoryError::Other(format!("embedding semaphore closed: {e}")))
951 }
952
953 async fn embed_text_internal(&self, text: &str) -> Result<Vec<f32>, MemoryError> {
954 let cache_key = text.to_string();
956 {
957 let mut cache = self.inner.embedding_cache.lock().expect("cache lock poisoned");
958 if let Some(cached) = cache.get(&cache_key).cloned() {
959 return Ok(cached);
960 }
961 }
962
963 let _permit = self.with_embedding_permit().await?;
964 let prefixed = format!("search_query: {text}");
970 let embedding = self.inner.embedder.embed(&prefixed).await?;
971 db::validate_embedding(&embedding, self.inner.config.embedding.dimensions)?;
972
973 {
975 let mut cache = self.inner.embedding_cache.lock().expect("cache lock poisoned");
976 cache.put(cache_key, embedding.clone());
977 }
978
979 Ok(embedding)
980 }
981
982 async fn embed_batch_internal(&self, texts: Vec<String>) -> Result<Vec<Vec<f32>>, MemoryError> {
983 let requested = texts.len();
984
985 let mut results: Vec<Option<Vec<f32>>> = Vec::with_capacity(requested);
987 let mut misses: Vec<String> = Vec::new();
988 let mut miss_indices: Vec<usize> = Vec::new();
989
990 for (i, text) in texts.iter().enumerate() {
991 let mut cache = self.inner.embedding_cache.lock().expect("cache lock poisoned");
992 if let Some(cached) = cache.get(text).cloned() {
993 results.push(Some(cached));
994 } else {
995 results.push(None);
996 miss_indices.push(i);
997 misses.push(text.clone());
998 }
999 }
1000
1001 let _permit = self.with_embedding_permit().await?;
1002
1003 let prefixed_misses: Vec<String> = misses
1005 .iter()
1006 .map(|t| format!("search_document: {t}"))
1007 .collect();
1008
1009 let miss_embeddings = if prefixed_misses.is_empty() {
1010 Vec::new()
1011 } else {
1012 let embeddings = self.inner.embedder.embed_batch(prefixed_misses).await?;
1013 if embeddings.len() != misses.len() {
1015 return Err(MemoryError::EmbeddingBatchCountMismatch {
1016 requested: misses.len(),
1017 returned: embeddings.len(),
1018 });
1019 }
1020 let mut cache = self.inner.embedding_cache.lock().expect("cache lock poisoned");
1022 for (text, emb) in misses.iter().zip(embeddings.iter()) {
1023 cache.put(text.clone(), emb.clone());
1024 }
1025 embeddings
1026 };
1027
1028 let mut final_results = Vec::with_capacity(requested);
1030 let mut miss_idx = 0;
1031 for i in 0..requested {
1032 if let Some(emb) = &results[i] {
1033 final_results.push(emb.clone());
1034 } else {
1035 final_results.push(miss_embeddings[miss_idx].clone());
1036 miss_idx += 1;
1037 }
1038 }
1039
1040 db::validate_embedding_batch(
1041 &final_results,
1042 requested,
1043 self.inner.config.embedding.dimensions,
1044 )?;
1045 Ok(final_results)
1046 }
1047
1048 fn validate_embedding_dimensions(&self, embedding: &[f32]) -> Result<(), MemoryError> {
1049 db::validate_embedding(embedding, self.inner.config.embedding.dimensions)
1050 }
1051
1052 fn validate_content(&self, field: &'static str, content: &str) -> Result<(), MemoryError> {
1053 if content.is_empty() {
1054 return Err(MemoryError::InvalidConfig {
1055 field,
1056 reason: "content must not be empty".to_string(),
1057 });
1058 }
1059
1060 let limit = self.inner.config.limits.max_content_bytes;
1061 if content.len() > limit {
1062 return Err(MemoryError::ContentTooLarge {
1063 size: content.len(),
1064 limit,
1065 });
1066 }
1067
1068 Ok(())
1069 }
1070
1071 fn validate_confidence(confidence: f32) -> Result<(), MemoryError> {
1072 if !confidence.is_finite() || !(0.0..=1.0).contains(&confidence) {
1073 return Err(MemoryError::InvalidConfig {
1074 field: "episodes.confidence",
1075 reason: "confidence must be finite and within [0.0, 1.0]".to_string(),
1076 });
1077 }
1078 Ok(())
1079 }
1080
1081 #[cfg(feature = "turbo-quant-codec")]
1085 pub async fn rebuild_vector_artifacts(
1086 &self,
1087 ) -> Result<VectorArtifactBuildReceiptV1, MemoryError> {
1088 let dim = self.inner.config.embedding.dimensions;
1089 let search = self.inner.config.search.clone();
1090 self.with_write_conn(move |conn| {
1091 db::rebuild_turbo_quant_artifacts(
1092 conn,
1093 dim,
1094 search.turbo_quant_bits,
1095 search.turbo_quant_projections,
1096 search.turbo_quant_seed,
1097 )
1098 })
1099 .await
1100 }
1101
1102 #[cfg(feature = "hnsw")]
1106 pub async fn rebuild_hnsw_index(
1107 &self,
1108 ) -> Result<crate::types::VectorArtifactBuildReceiptV1, MemoryError> {
1109 tracing::info!("Rebuilding HNSW index from SQLite embeddings...");
1110 let hnsw_config = self.inner.config.hnsw.clone();
1111 let (new_index, build_receipt) = self
1112 .with_read_conn(move |conn| hnsw_ops::rebuild_hnsw_from_sqlite(conn, &hnsw_config))
1113 .await?;
1114
1115 {
1116 let mut guard = self
1117 .inner
1118 .hnsw_index
1119 .write()
1120 .unwrap_or_else(|e| e.into_inner());
1121 *guard = new_index.clone();
1122 }
1123
1124 hnsw_ops::save_hnsw_sidecar(
1125 &new_index,
1126 &self.inner.paths.hnsw_dir,
1127 &self.inner.paths.hnsw_basename,
1128 )?;
1129 self.inner.pool.with_write_conn(|conn| {
1130 new_index.flush_keymap(conn)?;
1131 db::clear_all_pending_index_ops(conn)?;
1132 db::set_sidecar_dirty(conn, false)?;
1133 Ok(())
1134 })?;
1135
1136 tracing::info!(active = new_index.len(), receipt_generation_id = ?build_receipt.generation_id, "HNSW index rebuilt");
1137
1138 Ok(build_receipt)
1139 }
1140
1141 #[cfg(feature = "hnsw")]
1146 fn maybe_flush_hnsw(&self) {
1147 if let Some(interval) = self.inner.config.hnsw.flush_interval_secs {
1148 let guard = self
1149 .inner
1150 .hnsw_index
1151 .read()
1152 .unwrap_or_else(|e| e.into_inner());
1153 if guard.should_flush(interval) {
1154 drop(guard); if let Err(e) = self.flush_hnsw() {
1156 tracing::warn!("Opportunistic HNSW flush failed: {}", e);
1157 } else {
1158 let guard = self
1159 .inner
1160 .hnsw_index
1161 .read()
1162 .unwrap_or_else(|e| e.into_inner());
1163 guard.update_last_flush_epoch();
1164 tracing::info!("Opportunistic HNSW flush completed");
1165 }
1166 }
1167 }
1168 }
1169
1170 #[cfg(feature = "hnsw")]
1174 pub fn flush_hnsw(&self) -> Result<(), MemoryError> {
1175 let pending_ops = self.inner.pool.with_read_conn(db::pending_index_op_count)?;
1176 if pending_ops > 0 {
1177 tracing::info!(
1178 pending_ops,
1179 "Flushing HNSW via authoritative SQLite rebuild because pending durable sidecar work exists"
1180 );
1181 let rebuilt = hnsw_ops::recover_hnsw_sidecar_sync(
1182 &self.inner.pool,
1183 &self.inner.paths,
1184 &self.inner.config.hnsw,
1185 )?;
1186 let mut guard = self
1187 .inner
1188 .hnsw_index
1189 .write()
1190 .unwrap_or_else(|e| e.into_inner());
1191 *guard = rebuilt;
1192 return Ok(());
1193 }
1194
1195 let index = self
1196 .inner
1197 .hnsw_index
1198 .write()
1199 .unwrap_or_else(|e| e.into_inner());
1200 hnsw_ops::save_hnsw_sidecar(
1201 &index,
1202 &self.inner.paths.hnsw_dir,
1203 &self.inner.paths.hnsw_basename,
1204 )?;
1205
1206 self.inner.pool.with_write_conn(|conn| {
1208 index.flush_keymap(conn)?;
1209 db::clear_all_pending_index_ops(conn)?;
1210 db::set_sidecar_dirty(conn, false)?;
1211 Ok(())
1212 })?;
1213 Ok(())
1214 }
1215
1216 #[cfg(feature = "hnsw")]
1220 pub async fn compact_hnsw(&self) -> Result<(), MemoryError> {
1221 if !self
1222 .inner
1223 .hnsw_index
1224 .read()
1225 .unwrap_or_else(|e| e.into_inner())
1226 .needs_compaction()
1227 {
1228 tracing::info!("HNSW compaction not needed (deleted ratio below threshold)");
1229 return Ok(());
1230 }
1231 let _receipt = self.rebuild_hnsw_index().await?;
1232 Ok(())
1233 }
1234
1235 pub async fn verify_integrity(
1242 &self,
1243 mode: db::VerifyMode,
1244 ) -> Result<db::IntegrityReport, MemoryError> {
1245 let use_writer = mode == db::VerifyMode::Full;
1246 let mut report = if use_writer {
1247 self.with_write_conn(move |conn| db::verify_integrity_sync(conn, mode))
1248 .await?
1249 } else {
1250 self.with_read_conn(move |conn| db::verify_integrity_sync(conn, mode))
1251 .await?
1252 };
1253
1254 #[cfg(feature = "hnsw")]
1255 {
1256 let hnsw_vectors = self
1257 .inner
1258 .hnsw_index
1259 .read()
1260 .unwrap_or_else(|e| e.into_inner())
1261 .vector_snapshot();
1262 let hnsw_dims = self.inner.config.embedding.dimensions;
1263 let hnsw_files_exist = self.inner.paths.hnsw_files_exist();
1264
1265 let hnsw_issues = if use_writer {
1266 let hnsw_vectors = hnsw_vectors.clone();
1267 self.with_write_conn(move |conn| {
1268 verify_hnsw_key_level_integrity(
1269 conn,
1270 hnsw_dims,
1271 &hnsw_vectors,
1272 hnsw_files_exist,
1273 )
1274 })
1275 .await?
1276 } else {
1277 let hnsw_vectors = hnsw_vectors.clone();
1278 self.with_read_conn(move |conn| {
1279 verify_hnsw_key_level_integrity(
1280 conn,
1281 hnsw_dims,
1282 &hnsw_vectors,
1283 hnsw_files_exist,
1284 )
1285 })
1286 .await?
1287 };
1288 report.issues.extend(hnsw_issues);
1289 }
1290
1291 report.ok = report.issues.is_empty();
1292 Ok(report)
1293 }
1294
1295 pub async fn reconcile(
1301 &self,
1302 action: db::ReconcileAction,
1303 ) -> Result<db::IntegrityReport, MemoryError> {
1304 match action {
1305 db::ReconcileAction::ReportOnly => self.verify_integrity(db::VerifyMode::Full).await,
1306 db::ReconcileAction::RebuildFts => {
1307 self.with_write_conn(db::reconcile_fts).await?;
1308 #[cfg(feature = "hnsw")]
1309 self.sync_pending_hnsw_ops_best_effort("reconcile_rebuild_fts")
1310 .await;
1311 self.verify_integrity(db::VerifyMode::Full).await
1312 }
1313 db::ReconcileAction::ReEmbed => {
1314 self.reembed_all().await?;
1315 self.verify_integrity(db::VerifyMode::Full).await
1316 }
1317 }
1318 }
1319
1320 pub fn config(&self) -> &MemoryConfig {
1322 &self.inner.config
1323 }
1324
1325 pub fn graph_view(&self) -> Arc<dyn GraphView> {
1328 graph::graph_view(self.inner.clone())
1329 }
1330
1331 pub async fn add_graph_edge(
1344 &self,
1345 source: &str,
1346 target: &str,
1347 edge_type: GraphEdgeType,
1348 weight: f64,
1349 metadata: Option<serde_json::Value>,
1350 ) -> Result<graph_edges::StoredGraphEdge, MemoryError> {
1351 let params = graph_edges::AddGraphEdgeParams {
1352 source: source.to_string(),
1353 target: target.to_string(),
1354 edge_type,
1355 weight,
1356 metadata,
1357 };
1358 self.with_write_conn(move |conn| graph_edges::insert_graph_edge(conn, ¶ms))
1359 .await
1360 }
1361
1362 pub async fn list_graph_edges_for_node(
1365 &self,
1366 node_id: &str,
1367 ) -> Result<Vec<graph_edges::StoredGraphEdge>, MemoryError> {
1368 let node_id = node_id.to_string();
1369 self.with_read_conn(move |conn| graph_edges::list_graph_edges_for_node(conn, &node_id))
1370 .await
1371 }
1372
1373 pub async fn list_all_graph_edges(
1375 &self,
1376 ) -> Result<Vec<graph_edges::StoredGraphEdge>, MemoryError> {
1377 self.with_read_conn(graph_edges::list_all_graph_edges)
1378 .await
1379 }
1380
1381 pub async fn list_graph_edges_for_neighborhood(
1391 &self,
1392 seed_ids: Vec<String>,
1393 max_hops: usize,
1394 max_nodes: usize,
1395 ) -> Result<Vec<graph_edges::StoredGraphEdge>, MemoryError> {
1396 self.with_read_conn(move |conn| {
1397 graph_edges::list_graph_edges_for_neighborhood(conn, &seed_ids, max_hops, max_nodes)
1398 })
1399 .await
1400 }
1401
1402 pub async fn invalidate_graph_edge(
1404 &self,
1405 edge_id: &str,
1406 reason: &str,
1407 ) -> Result<(), MemoryError> {
1408 let edge_id = edge_id.to_string();
1409 let reason = reason.to_string();
1410 self.with_write_conn(move |conn| {
1411 graph_edges::invalidate_graph_edge(conn, &edge_id, &reason)
1412 })
1413 .await
1414 }
1415
1416 pub async fn count_graph_edges(&self) -> Result<usize, MemoryError> {
1418 self.with_read_conn(graph_edges::count_graph_edges)
1419 .await
1420 }
1421
1422 pub async fn search(
1426 &self,
1427 query: &str,
1428 top_k: Option<usize>,
1429 namespaces: Option<&[&str]>,
1430 source_types: Option<&[SearchSourceType]>,
1431 ) -> Result<Vec<SearchResult>, MemoryError> {
1432 let compress = self.inner.config.search.compress_results;
1433 let results = self
1434 .search_with_context(
1435 query,
1436 top_k,
1437 namespaces,
1438 source_types,
1439 SearchContext::default_now(),
1440 )
1441 .await?
1442 .results;
1443 if compress {
1444 Ok(compress_search_results(results))
1445 } else {
1446 Ok(results)
1447 }
1448 }
1449
1450 pub async fn search_with_context(
1452 &self,
1453 query: &str,
1454 top_k: Option<usize>,
1455 namespaces: Option<&[&str]>,
1456 source_types: Option<&[SearchSourceType]>,
1457 context: SearchContext,
1458 ) -> Result<SearchResponse, MemoryError> {
1459 let k = top_k
1460 .unwrap_or(self.inner.config.search.default_top_k)
1461 .min(MAX_TOP_K);
1462
1463 let cache_key = if namespaces.is_none()
1468 && source_types.is_none()
1469 && context.receipt_mode != ReceiptMode::ReturnReceipt
1470 {
1471 Some(format!("{query}:{k}"))
1472 } else {
1473 None
1474 };
1475 if let Some(ref key) = cache_key {
1476 let mut cache = self.inner.search_cache.lock().expect("search cache lock poisoned");
1477 if let Some(cached) = cache.get(key).cloned() {
1478 return Ok(SearchResponse { results: cached, receipt: None });
1479 }
1480 }
1481
1482 let query_embedding = self.embed_text_internal(query).await?;
1483
1484 #[cfg(feature = "hnsw")]
1485 let hnsw_hits = if context.exactness_profile == ExactnessProfile::PreferExact
1486 || self.inner.config.search.uses_turbo_quant_backend()
1487 {
1488 Vec::new()
1489 } else {
1490 let candidates = self
1491 .inner
1492 .config
1493 .search
1494 .candidate_pool_size
1495 .max(k.saturating_mul(3))
1496 .min(MAX_HNSW_CANDIDATES);
1497 self.hnsw_search_blocking(query_embedding.clone(), candidates)
1498 .await
1499 };
1500
1501 let q = query.to_string();
1502 let config = self.inner.config.search.clone();
1503 let ns_owned = to_owned_string_vec(namespaces);
1504 let st_owned: Option<Vec<SearchSourceType>> = source_types.map(|s| s.to_vec());
1505 let context_owned = context.clone();
1506
1507 #[cfg(feature = "hnsw")]
1508 let hnsw_hits_owned = hnsw_hits;
1509
1510 let response = self
1511 .with_read_conn(move |conn| {
1512 if db::is_embeddings_dirty(conn)? {
1513 tracing::warn!(
1514 "Embeddings are stale after model change — search quality is degraded. \
1515 Call reembed_all() to regenerate embeddings."
1516 );
1517 }
1518 let ns_refs = as_str_slice(&ns_owned);
1519 let ns_slice: Option<&[&str]> = ns_refs.as_deref();
1520 let st_slice: Option<&[SearchSourceType]> = st_owned.as_deref();
1521
1522 #[cfg(feature = "hnsw")]
1523 {
1524 let mut execution = if hnsw_hits_owned.is_empty() {
1525 search::hybrid_search_detailed_with_context(
1526 conn,
1527 &q,
1528 &query_embedding,
1529 &config,
1530 &context_owned,
1531 k,
1532 ns_slice,
1533 st_slice,
1534 None,
1535 )
1536 } else {
1537 search::hybrid_search_with_hnsw_detailed_with_context(
1538 conn,
1539 &q,
1540 &query_embedding,
1541 &config,
1542 &context_owned,
1543 k,
1544 ns_slice,
1545 st_slice,
1546 None,
1547 &hnsw_hits_owned,
1548 )
1549 }?;
1550 if context_owned.receipts_enabled()
1551 && context_owned.exactness_profile == ExactnessProfile::PreferExact
1552 {
1553 if let Some(receipt) = execution.receipt.as_mut() {
1554 receipt.search_profile = "hybrid_prefer_exact".to_string();
1555 }
1556 }
1557 Ok(SearchResponse {
1558 results: dedup_by_content(
1559 execution
1560 .results
1561 .into_iter()
1562 .map(|result| result.result)
1563 .collect(),
1564 ),
1565 receipt: execution.receipt,
1566 })
1567 }
1568 #[cfg(not(feature = "hnsw"))]
1569 {
1570 let execution = search::hybrid_search_detailed_with_context(
1571 conn,
1572 &q,
1573 &query_embedding,
1574 &config,
1575 &context_owned,
1576 k,
1577 ns_slice,
1578 st_slice,
1579 None,
1580 )?;
1581 Ok(SearchResponse {
1582 results: dedup_by_content(
1583 execution
1584 .results
1585 .into_iter()
1586 .map(|result| result.result)
1587 .collect(),
1588 ),
1589 receipt: execution.receipt,
1590 })
1591 }
1592 })
1593 .await?;
1594 if let Some(receipt) = &response.receipt {
1595 self.persist_search_receipt(receipt).await?;
1596 }
1597 if let Some(ref key) = cache_key {
1598 let mut cache = self.inner.search_cache.lock().expect("search cache lock poisoned");
1599 cache.put(key.clone(), response.results.clone());
1600 }
1601 Ok(response)
1602 }
1603
1604 pub async fn search_fts_only(
1606 &self,
1607 query: &str,
1608 top_k: Option<usize>,
1609 namespaces: Option<&[&str]>,
1610 source_types: Option<&[SearchSourceType]>,
1611 ) -> Result<Vec<SearchResult>, MemoryError> {
1612 let k = top_k
1613 .unwrap_or(self.inner.config.search.default_top_k)
1614 .min(MAX_TOP_K);
1615 let q = query.to_string();
1616 let config = self.inner.config.search.clone();
1617 let ns_owned = to_owned_string_vec(namespaces);
1618 let st_owned: Option<Vec<SearchSourceType>> = source_types.map(|s| s.to_vec());
1619 self.with_read_conn(move |conn| {
1620 let ns_refs = as_str_slice(&ns_owned);
1621 let ns_slice: Option<&[&str]> = ns_refs.as_deref();
1622 let st_slice: Option<&[SearchSourceType]> = st_owned.as_deref();
1623 search::fts_only_search(conn, &q, &config, k, ns_slice, st_slice, None)
1624 })
1625 .await
1626 }
1627
1628 pub async fn search_vector_only(
1630 &self,
1631 query: &str,
1632 top_k: Option<usize>,
1633 namespaces: Option<&[&str]>,
1634 source_types: Option<&[SearchSourceType]>,
1635 ) -> Result<Vec<SearchResult>, MemoryError> {
1636 Ok(self
1637 .search_vector_only_with_context(
1638 query,
1639 top_k,
1640 namespaces,
1641 source_types,
1642 SearchContext::default_now(),
1643 )
1644 .await?
1645 .results)
1646 }
1647
1648 pub async fn search_vector_only_with_context(
1650 &self,
1651 query: &str,
1652 top_k: Option<usize>,
1653 namespaces: Option<&[&str]>,
1654 source_types: Option<&[SearchSourceType]>,
1655 context: SearchContext,
1656 ) -> Result<SearchResponse, MemoryError> {
1657 let k = top_k
1658 .unwrap_or(self.inner.config.search.default_top_k)
1659 .min(MAX_TOP_K);
1660 let query_embedding = self.embed_text_internal(query).await?;
1661
1662 #[cfg(feature = "hnsw")]
1663 let hnsw_hits = if context.exactness_profile == ExactnessProfile::PreferExact
1664 || self.inner.config.search.uses_turbo_quant_backend()
1665 {
1666 Vec::new()
1667 } else {
1668 let candidates = self
1669 .inner
1670 .config
1671 .search
1672 .candidate_pool_size
1673 .max(k.saturating_mul(3))
1674 .min(MAX_HNSW_CANDIDATES);
1675 self.hnsw_search_blocking(query_embedding.clone(), candidates)
1676 .await
1677 };
1678
1679 let config = self.inner.config.search.clone();
1680 let ns_owned = to_owned_string_vec(namespaces);
1681 let st_owned: Option<Vec<SearchSourceType>> = source_types.map(|s| s.to_vec());
1682 let context_owned = context.clone();
1683
1684 #[cfg(feature = "hnsw")]
1685 let hnsw_hits_owned = hnsw_hits;
1686
1687 let response = self
1688 .with_read_conn(move |conn| {
1689 if db::is_embeddings_dirty(conn)? {
1690 tracing::warn!(
1691 "Embeddings are stale after model change — search quality is degraded. \
1692 Call reembed_all() to regenerate embeddings."
1693 );
1694 }
1695 let ns_refs = as_str_slice(&ns_owned);
1696 let ns_slice: Option<&[&str]> = ns_refs.as_deref();
1697 let st_slice: Option<&[SearchSourceType]> = st_owned.as_deref();
1698
1699 #[cfg(feature = "hnsw")]
1700 {
1701 let mut execution = if hnsw_hits_owned.is_empty() {
1702 search::vector_only_search_detailed_with_context(
1703 conn,
1704 &query_embedding,
1705 &config,
1706 &context_owned,
1707 k,
1708 ns_slice,
1709 st_slice,
1710 None,
1711 )
1712 } else {
1713 search::vector_only_search_with_hnsw_detailed_with_context(
1714 conn,
1715 &query_embedding,
1716 &config,
1717 &context_owned,
1718 k,
1719 ns_slice,
1720 st_slice,
1721 None,
1722 &hnsw_hits_owned,
1723 )
1724 }?;
1725 if context_owned.receipts_enabled()
1726 && context_owned.exactness_profile == ExactnessProfile::PreferExact
1727 {
1728 if let Some(receipt) = execution.receipt.as_mut() {
1729 receipt.search_profile = "vector_only_prefer_exact".to_string();
1730 }
1731 }
1732 Ok(SearchResponse {
1733 results: execution
1734 .results
1735 .into_iter()
1736 .map(|result| result.result)
1737 .collect(),
1738 receipt: execution.receipt,
1739 })
1740 }
1741 #[cfg(not(feature = "hnsw"))]
1742 {
1743 let execution = search::vector_only_search_detailed_with_context(
1744 conn,
1745 &query_embedding,
1746 &config,
1747 &context_owned,
1748 k,
1749 ns_slice,
1750 st_slice,
1751 None,
1752 )?;
1753 Ok(SearchResponse {
1754 results: execution
1755 .results
1756 .into_iter()
1757 .map(|result| result.result)
1758 .collect(),
1759 receipt: execution.receipt,
1760 })
1761 }
1762 })
1763 .await?;
1764 if let Some(receipt) = &response.receipt {
1765 self.persist_search_receipt(receipt).await?;
1766 }
1767 Ok(response)
1768 }
1769
1770 pub async fn search_explained(
1774 &self,
1775 query: &str,
1776 top_k: Option<usize>,
1777 namespaces: Option<&[&str]>,
1778 source_types: Option<&[SearchSourceType]>,
1779 ) -> Result<Vec<types::ExplainedResult>, MemoryError> {
1780 Ok(self
1781 .search_explained_with_context(
1782 query,
1783 top_k,
1784 namespaces,
1785 source_types,
1786 SearchContext::default_now(),
1787 )
1788 .await?
1789 .results)
1790 }
1791
1792 pub async fn search_explained_with_context(
1794 &self,
1795 query: &str,
1796 top_k: Option<usize>,
1797 namespaces: Option<&[&str]>,
1798 source_types: Option<&[SearchSourceType]>,
1799 context: SearchContext,
1800 ) -> Result<types::ExplainedSearchResponse, MemoryError> {
1801 let k = top_k
1802 .unwrap_or(self.inner.config.search.default_top_k)
1803 .min(MAX_TOP_K);
1804 let query_embedding = self.embed_text_internal(query).await?;
1805
1806 #[cfg(feature = "hnsw")]
1807 let hnsw_hits = if context.exactness_profile == ExactnessProfile::PreferExact {
1808 Vec::new()
1809 } else {
1810 let candidates = self
1811 .inner
1812 .config
1813 .search
1814 .candidate_pool_size
1815 .max(k.saturating_mul(3))
1816 .min(MAX_HNSW_CANDIDATES);
1817 self.hnsw_search_blocking(query_embedding.clone(), candidates)
1818 .await
1819 };
1820
1821 let q = query.to_string();
1822 let config = self.inner.config.search.clone();
1823 let ns_owned = to_owned_string_vec(namespaces);
1824 let st_owned: Option<Vec<SearchSourceType>> = source_types.map(|value| value.to_vec());
1825 let context_owned = context.clone();
1826
1827 #[cfg(feature = "hnsw")]
1828 let hnsw_hits_owned = hnsw_hits;
1829
1830 let response = self
1831 .with_read_conn(move |conn| {
1832 let ns_refs = as_str_slice(&ns_owned);
1833 let ns_slice: Option<&[&str]> = ns_refs.as_deref();
1834 let st_slice: Option<&[SearchSourceType]> = st_owned.as_deref();
1835
1836 #[cfg(feature = "hnsw")]
1837 {
1838 let mut execution = if hnsw_hits_owned.is_empty() {
1839 search::hybrid_search_detailed_with_context(
1840 conn,
1841 &q,
1842 &query_embedding,
1843 &config,
1844 &context_owned,
1845 k,
1846 ns_slice,
1847 st_slice,
1848 None,
1849 )
1850 } else {
1851 search::hybrid_search_with_hnsw_detailed_with_context(
1852 conn,
1853 &q,
1854 &query_embedding,
1855 &config,
1856 &context_owned,
1857 k,
1858 ns_slice,
1859 st_slice,
1860 None,
1861 &hnsw_hits_owned,
1862 )
1863 }?;
1864 if context_owned.receipts_enabled()
1865 && context_owned.exactness_profile == ExactnessProfile::PreferExact
1866 {
1867 if let Some(receipt) = execution.receipt.as_mut() {
1868 receipt.search_profile = "hybrid_prefer_exact".to_string();
1869 }
1870 }
1871 Ok(types::ExplainedSearchResponse {
1872 results: execution.results,
1873 receipt: execution.receipt,
1874 })
1875 }
1876 #[cfg(not(feature = "hnsw"))]
1877 {
1878 let execution = search::hybrid_search_detailed_with_context(
1879 conn,
1880 &q,
1881 &query_embedding,
1882 &config,
1883 &context_owned,
1884 k,
1885 ns_slice,
1886 st_slice,
1887 None,
1888 )?;
1889 Ok(types::ExplainedSearchResponse {
1890 results: execution.results,
1891 receipt: execution.receipt,
1892 })
1893 }
1894 })
1895 .await?;
1896 if let Some(receipt) = &response.receipt {
1897 self.persist_search_receipt(receipt).await?;
1898 }
1899 Ok(response)
1900 }
1901
1902 pub async fn get_search_receipt(
1904 &self,
1905 receipt_id: &str,
1906 ) -> Result<Option<VectorSearchReceiptV1>, MemoryError> {
1907 let receipt_id = receipt_id.to_string();
1908 self.with_read_conn(move |conn| db::get_search_receipt(conn, &receipt_id))
1909 .await
1910 }
1911
1912 pub async fn replay_search_receipt(
1918 &self,
1919 receipt_id: &str,
1920 query: &str,
1921 top_k: Option<usize>,
1922 namespaces: Option<&[&str]>,
1923 source_types: Option<&[SearchSourceType]>,
1924 ) -> Result<SearchReplayReportV1, MemoryError> {
1925 let original_receipt = self.get_search_receipt(receipt_id).await?.ok_or_else(|| {
1926 MemoryError::SearchReceiptNotFound {
1927 receipt_id: receipt_id.to_string(),
1928 }
1929 })?;
1930
1931 let vector_only = original_receipt.search_profile.starts_with("vector_only");
1932 let replay_top_k = top_k.or_else(|| Some(original_receipt.result_ids.len().max(1)));
1933 let replay_receipt_id = format!("{receipt_id}:replay:{}", uuid::Uuid::new_v4());
1934 let mut context = SearchContext::at(original_receipt.evaluation_time);
1935 context.receipt_mode = ReceiptMode::ReturnReceipt;
1936 context.request_id = Some(replay_receipt_id.clone());
1937 context.trace_id = original_receipt.trace_id.clone();
1938 context.attempt_family_id = original_receipt
1939 .attempt_family_id
1940 .clone()
1941 .or_else(|| Some(original_receipt.receipt_id.clone()));
1942 context.attempt_id = Some(replay_receipt_id.clone());
1943 context.replay_of = Some(original_receipt.receipt_id.clone());
1944 context.query_text_digest = original_receipt.query_text_digest.clone();
1945 context.query_input_digest = original_receipt.query_input_digest.clone();
1946 context.filter_digest = original_receipt.filter_digest.clone();
1947 context.redaction_state = original_receipt.redaction_state.clone();
1948 context.budget_id = original_receipt.budget_id.clone();
1949 context.exactness_profile = if original_receipt.approximate {
1950 ExactnessProfile::AllowApproximate
1951 } else {
1952 ExactnessProfile::PreferExact
1953 };
1954
1955 let replay_response = if vector_only {
1956 self.search_vector_only_with_context(
1957 query,
1958 replay_top_k,
1959 namespaces,
1960 source_types,
1961 context,
1962 )
1963 .await?
1964 } else {
1965 self.search_with_context(query, replay_top_k, namespaces, source_types, context)
1966 .await?
1967 };
1968 let replay_receipt = replay_response
1969 .receipt
1970 .ok_or_else(|| MemoryError::Other("replay did not produce a receipt".to_string()))?;
1971
1972 let query_embedding_digest_matches =
1973 original_receipt.query_embedding_digest == replay_receipt.query_embedding_digest;
1974 let result_ids_match = original_receipt.result_ids == replay_receipt.result_ids;
1975 let missing_result_ids = original_receipt
1976 .result_ids
1977 .iter()
1978 .filter(|id| !replay_receipt.result_ids.contains(*id))
1979 .cloned()
1980 .collect();
1981 let added_result_ids = replay_receipt
1982 .result_ids
1983 .iter()
1984 .filter(|id| !original_receipt.result_ids.contains(*id))
1985 .cloned()
1986 .collect();
1987
1988 Ok(SearchReplayReportV1 {
1989 receipt_id: original_receipt.receipt_id.clone(),
1990 replay_receipt_id,
1991 original_receipt,
1992 replay_receipt,
1993 query_embedding_digest_matches,
1994 result_ids_match,
1995 missing_result_ids,
1996 added_result_ids,
1997 vector_only,
1998 })
1999 }
2000
2001 pub async fn embedding_displacement(
2005 &self,
2006 text_a: &str,
2007 text_b: &str,
2008 ) -> Result<types::EmbeddingDisplacement, MemoryError> {
2009 let emb_a = self.embed_text_internal(text_a).await?;
2010 let emb_b = self.embed_text_internal(text_b).await?;
2011 Self::embedding_displacement_from_vecs(&emb_a, &emb_b)
2012 }
2013
2014 pub fn embedding_displacement_from_vecs(
2016 a: &[f32],
2017 b: &[f32],
2018 ) -> Result<types::EmbeddingDisplacement, MemoryError> {
2019 if a.len() != b.len() {
2020 return Err(MemoryError::DimensionMismatch {
2021 expected: a.len(),
2022 actual: b.len(),
2023 });
2024 }
2025 let cosine_sim = search::cosine_similarity(a, b)?;
2026
2027 let euclidean_dist: f32 = a
2028 .iter()
2029 .zip(b.iter())
2030 .map(|(x, y)| (x - y) * (x - y))
2031 .sum::<f32>()
2032 .sqrt();
2033
2034 let mag_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
2035 let mag_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
2036
2037 Ok(types::EmbeddingDisplacement {
2038 cosine_similarity: cosine_sim,
2039 euclidean_distance: euclidean_dist,
2040 magnitude_a: mag_a,
2041 magnitude_b: mag_b,
2042 })
2043 }
2044
2045 pub fn chunk_text(&self, text: &str) -> Vec<TextChunk> {
2049 chunker::chunk_text(
2050 text,
2051 &self.inner.config.chunking,
2052 self.inner.token_counter.as_ref(),
2053 )
2054 }
2055
2056 pub async fn embed(&self, text: &str) -> Result<Vec<f32>, MemoryError> {
2058 self.embed_text_internal(text).await
2059 }
2060
2061 pub async fn embed_batch(&self, texts: &[&str]) -> Result<Vec<Vec<f32>>, MemoryError> {
2063 let owned: Vec<String> = texts.iter().map(|s| s.to_string()).collect();
2064 self.embed_batch_internal(owned).await
2065 }
2066
2067 pub async fn stats(&self) -> Result<MemoryStats, MemoryError> {
2069 let db_path = self.inner.paths.sqlite_path.clone();
2070 self.with_read_conn(move |conn| {
2071 let total_facts: u64 =
2072 conn.query_row("SELECT COUNT(*) FROM facts", [], |r| r.get(0))?;
2073 let total_documents: u64 =
2074 conn.query_row("SELECT COUNT(*) FROM documents", [], |r| r.get(0))?;
2075 let total_chunks: u64 =
2076 conn.query_row("SELECT COUNT(*) FROM chunks", [], |r| r.get(0))?;
2077 let total_sessions: u64 =
2078 conn.query_row("SELECT COUNT(*) FROM sessions", [], |r| r.get(0))?;
2079 let total_messages: u64 =
2080 conn.query_row("SELECT COUNT(*) FROM messages", [], |r| r.get(0))?;
2081
2082 let db_size = std::fs::metadata(&db_path).map(|m| m.len()).unwrap_or(0);
2083
2084 let (model, dims): (Option<String>, Option<usize>) = conn
2085 .query_row(
2086 "SELECT model_name, dimensions FROM embedding_metadata WHERE id = 1",
2087 [],
2088 |r| Ok((Some(r.get(0)?), Some(r.get(1)?))),
2089 )
2090 .unwrap_or((None, None));
2091
2092 Ok(MemoryStats {
2093 total_facts,
2094 total_documents,
2095 total_chunks,
2096 total_sessions,
2097 total_messages,
2098 database_size_bytes: db_size,
2099 embedding_model: model,
2100 embedding_dimensions: dims,
2101 })
2102 })
2103 .await
2104 }
2105
2106 pub async fn list_scope_domains(&self) -> Result<Vec<String>, MemoryError> {
2112 self.with_read_conn(|conn| {
2113 let mut stmt = conn.prepare(
2114 "SELECT DISTINCT json_extract(metadata, '$.scope_domain') \
2115 FROM documents \
2116 WHERE json_extract(metadata, '$.scope_domain') IS NOT NULL",
2117 )?;
2118 let domains: Vec<String> = stmt
2119 .query_map([], |row| row.get::<_, String>(0))?
2120 .filter_map(|r| r.ok())
2121 .collect();
2122 Ok(domains)
2123 })
2124 .await
2125 }
2126
2127 pub async fn embeddings_are_dirty(&self) -> Result<bool, MemoryError> {
2129 self.with_read_conn(db::is_embeddings_dirty).await
2130 }
2131
2132 pub async fn reembed_all(&self) -> Result<usize, MemoryError> {
2134 let mut count = 0usize;
2135 let batch_size = self.inner.config.embedding.batch_size;
2136 let dims = self.inner.config.embedding.dimensions;
2137
2138 let fact_contents: Vec<(String, String)> = self
2140 .with_read_conn(|conn| {
2141 let mut stmt = conn.prepare("SELECT id, content FROM facts")?;
2142 let result = stmt
2143 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
2144 .collect::<Result<Vec<_>, _>>()?;
2145 Ok(result)
2146 })
2147 .await?;
2148
2149 let mut fact_count = 0usize;
2150 for batch in fact_contents.chunks(batch_size) {
2151 let texts: Vec<String> = batch.iter().map(|(_, c)| c.clone()).collect();
2152 let embeddings = self.embed_batch_internal(texts).await?;
2153 for embedding in &embeddings {
2154 self.validate_embedding_dimensions(embedding)?;
2155 }
2156
2157 let quantizer = Quantizer::new(dims);
2158 let updates: Vec<(String, Vec<u8>, Option<Vec<u8>>)> = batch
2159 .iter()
2160 .zip(embeddings.iter())
2161 .map(|((id, _), emb)| {
2162 let q8 = quantizer
2164 .quantize(emb)
2165 .map(|qv| quantize::pack_quantized(&qv))
2166 .ok();
2167 (id.clone(), db::embedding_to_bytes(emb), q8)
2168 })
2169 .collect();
2170
2171 self.with_write_conn(move |conn| {
2172 db::with_transaction(conn, |tx| {
2173 for (fid, bytes, q8) in &updates {
2174 tx.execute(
2175 "UPDATE facts SET embedding = ?1, embedding_q8 = ?2, updated_at = datetime('now') WHERE id = ?3",
2176 rusqlite::params![bytes, q8.as_deref(), fid],
2177 )?;
2178 #[cfg(feature = "hnsw")]
2179 db::queue_pending_index_op(
2180 tx,
2181 &format!("fact:{fid}"),
2182 "fact",
2183 db::IndexOpKind::Upsert,
2184 )?;
2185 db::invalidate_derived_vector_artifact(tx, &format!("fact:{fid}"))?;
2186 }
2187 Ok(())
2188 })
2189 })
2190 .await?;
2191
2192 fact_count += batch.len();
2193 count += batch.len();
2194 if fact_count % 100 == 0 || fact_count == count {
2195 tracing::info!(fact_count, "Re-embedded {} facts so far", fact_count);
2196 }
2197 }
2198
2199 let chunk_data: Vec<(String, String)> = self
2201 .with_read_conn(|conn| {
2202 let mut stmt = conn.prepare("SELECT id, content FROM chunks")?;
2203 let result = stmt
2204 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
2205 .collect::<Result<Vec<_>, _>>()?;
2206 Ok(result)
2207 })
2208 .await?;
2209
2210 let mut chunk_count = 0usize;
2211 for batch in chunk_data.chunks(batch_size) {
2212 let texts: Vec<String> = batch.iter().map(|(_, c)| c.clone()).collect();
2213 let embeddings = self.embed_batch_internal(texts).await?;
2214 for embedding in &embeddings {
2215 self.validate_embedding_dimensions(embedding)?;
2216 }
2217
2218 let quantizer = Quantizer::new(dims);
2219 let updates: Vec<(String, Vec<u8>, Option<Vec<u8>>)> = batch
2220 .iter()
2221 .zip(embeddings.iter())
2222 .map(|((id, _), emb)| {
2223 let q8 = quantizer
2225 .quantize(emb)
2226 .map(|qv| quantize::pack_quantized(&qv))
2227 .ok();
2228 (id.clone(), db::embedding_to_bytes(emb), q8)
2229 })
2230 .collect();
2231
2232 self.with_write_conn(move |conn| {
2233 db::with_transaction(conn, |tx| {
2234 for (cid, bytes, q8) in &updates {
2235 tx.execute(
2236 "UPDATE chunks SET embedding = ?1, embedding_q8 = ?2 WHERE id = ?3",
2237 rusqlite::params![bytes, q8.as_deref(), cid],
2238 )?;
2239 #[cfg(feature = "hnsw")]
2240 db::queue_pending_index_op(
2241 tx,
2242 &format!("chunk:{cid}"),
2243 "chunk",
2244 db::IndexOpKind::Upsert,
2245 )?;
2246 db::invalidate_derived_vector_artifact(tx, &format!("chunk:{cid}"))?;
2247 }
2248 Ok(())
2249 })
2250 })
2251 .await?;
2252
2253 chunk_count += batch.len();
2254 count += batch.len();
2255 if chunk_count % 100 == 0 {
2256 tracing::info!(chunk_count, "Re-embedded {} chunks so far", chunk_count);
2257 }
2258 }
2259
2260 let message_data: Vec<(i64, String)> = self
2262 .with_read_conn(|conn| {
2263 let mut stmt = conn.prepare("SELECT id, content FROM messages")?;
2264 let result = stmt
2265 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
2266 .collect::<Result<Vec<_>, _>>()?;
2267 Ok(result)
2268 })
2269 .await?;
2270
2271 let mut msg_count = 0usize;
2272 for batch in message_data.chunks(batch_size) {
2273 let texts: Vec<String> = batch.iter().map(|(_, c)| c.clone()).collect();
2274 let embeddings = self.embed_batch_internal(texts).await?;
2275 for embedding in &embeddings {
2276 self.validate_embedding_dimensions(embedding)?;
2277 }
2278
2279 let quantizer = Quantizer::new(dims);
2280 let updates: Vec<(i64, Vec<u8>, Option<Vec<u8>>)> = batch
2281 .iter()
2282 .zip(embeddings.iter())
2283 .map(|((id, _), emb)| {
2284 let q8 = quantizer
2286 .quantize(emb)
2287 .map(|qv| quantize::pack_quantized(&qv))
2288 .ok();
2289 (*id, db::embedding_to_bytes(emb), q8)
2290 })
2291 .collect();
2292
2293 self.with_write_conn(move |conn| {
2294 db::with_transaction(conn, |tx| {
2295 for (mid, bytes, q8) in &updates {
2296 tx.execute(
2297 "UPDATE messages SET embedding = ?1, embedding_q8 = ?2 WHERE id = ?3",
2298 rusqlite::params![bytes, q8.as_deref(), mid],
2299 )?;
2300 #[cfg(feature = "hnsw")]
2301 db::queue_pending_index_op(
2302 tx,
2303 &format!("msg:{mid}"),
2304 "message",
2305 db::IndexOpKind::Upsert,
2306 )?;
2307 db::invalidate_derived_vector_artifact(tx, &format!("msg:{mid}"))?;
2308 }
2309 Ok(())
2310 })
2311 })
2312 .await?;
2313
2314 msg_count += batch.len();
2315 count += batch.len();
2316 if msg_count % 100 == 0 {
2317 tracing::info!(msg_count, "Re-embedded {} messages so far", msg_count);
2318 }
2319 }
2320
2321 let episode_data: Vec<(String, String)> = self
2323 .with_read_conn(|conn| {
2324 let mut stmt = conn.prepare("SELECT episode_id, search_text FROM episodes")?;
2325 let result = stmt
2326 .query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
2327 .collect::<Result<Vec<_>, _>>()?;
2328 Ok(result)
2329 })
2330 .await?;
2331
2332 let mut episode_count = 0usize;
2333 for batch in episode_data.chunks(batch_size) {
2334 let texts: Vec<String> = batch.iter().map(|(_, text)| text.clone()).collect();
2335 let embeddings = self.embed_batch_internal(texts).await?;
2336 for embedding in &embeddings {
2337 self.validate_embedding_dimensions(embedding)?;
2338 }
2339
2340 let quantizer = Quantizer::new(dims);
2341 let updates: Vec<(String, Vec<u8>, Option<Vec<u8>>)> = batch
2342 .iter()
2343 .zip(embeddings.iter())
2344 .map(|((episode_id, _), embedding)| {
2345 let q8 = quantizer
2347 .quantize(embedding)
2348 .map(|vector| quantize::pack_quantized(&vector))
2349 .ok();
2350 (episode_id.clone(), db::embedding_to_bytes(embedding), q8)
2351 })
2352 .collect();
2353
2354 self.with_write_conn(move |conn| {
2355 db::with_transaction(conn, |tx| {
2356 for (episode_id, bytes, q8) in &updates {
2357 tx.execute(
2358 "UPDATE episodes
2359 SET embedding = ?1,
2360 embedding_q8 = ?2,
2361 updated_at = datetime('now')
2362 WHERE episode_id = ?3",
2363 rusqlite::params![bytes, q8.as_deref(), episode_id],
2364 )?;
2365 #[cfg(feature = "hnsw")]
2366 db::queue_pending_index_op(
2367 tx,
2368 &episodes::episode_item_key(episode_id),
2369 "episode",
2370 db::IndexOpKind::Upsert,
2371 )?;
2372 db::invalidate_derived_vector_artifact(
2373 tx,
2374 &episodes::episode_item_key(episode_id),
2375 )?;
2376 }
2377 Ok(())
2378 })
2379 })
2380 .await?;
2381
2382 episode_count += batch.len();
2383 count += batch.len();
2384 if episode_count % 100 == 0 {
2385 tracing::info!(
2386 episode_count,
2387 "Re-embedded {} episodes so far",
2388 episode_count
2389 );
2390 }
2391 }
2392
2393 self.with_write_conn(db::clear_embeddings_dirty).await?;
2395
2396 tracing::info!(
2397 facts = fact_count,
2398 chunks = chunk_count,
2399 messages = msg_count,
2400 episodes = episode_count,
2401 total = count,
2402 "Re-embedding complete"
2403 );
2404
2405 #[cfg(feature = "hnsw")]
2407 {
2408 tracing::info!("Rebuilding HNSW index after re-embedding...");
2409 let _receipt = self.rebuild_hnsw_index().await?;
2410 }
2411
2412 Ok(count)
2413 }
2414
2415 pub async fn vacuum(&self) -> Result<(), MemoryError> {
2417 self.with_write_conn(|conn| {
2418 conn.execute_batch("VACUUM")?;
2419 Ok(())
2420 })
2421 .await
2422 }
2423
2424 #[deprecated(
2447 since = "0.5.0",
2448 note = "Legacy V10 import envelope path is compatibility-only. Use `import_projection_batch()` and `ProjectionImportBatchV3` on the canonical lane."
2449 )]
2450 #[doc(hidden)]
2451 #[allow(deprecated)]
2452 pub async fn import_envelope(
2453 &self,
2454 envelope: &projection_import::ImportEnvelope,
2455 ) -> Result<projection_import::ImportReceipt, MemoryError> {
2456 projection_legacy_compat::import_envelope(self, envelope).await
2457 }
2458
2459 #[deprecated(
2461 since = "0.5.0",
2462 note = "Legacy V10 import envelope status reads are compatibility-only. Prefer the projection import log."
2463 )]
2464 #[doc(hidden)]
2465 #[allow(deprecated)]
2466 pub async fn import_status(
2467 &self,
2468 envelope_id: &projection_import::EnvelopeId,
2469 ) -> Result<Vec<projection_import::ImportReceipt>, MemoryError> {
2470 projection_legacy_compat::import_status(self, envelope_id).await
2471 }
2472
2473 #[deprecated(
2475 since = "0.5.0",
2476 note = "Legacy V10 import log access is compatibility-only. Prefer new projection-import metadata."
2477 )]
2478 #[doc(hidden)]
2479 #[allow(deprecated)]
2480 pub async fn list_imports(
2481 &self,
2482 namespace: Option<&str>,
2483 limit: usize,
2484 ) -> Result<Vec<projection_import::ImportReceipt>, MemoryError> {
2485 projection_legacy_compat::list_imports(self, namespace, limit).await
2486 }
2487
2488 #[allow(deprecated)]
2490 pub async fn last_import_at(&self, namespace: &str) -> Result<Option<String>, MemoryError> {
2491 projection_legacy_compat::last_import_at(self, namespace).await
2492 }
2493
2494 pub async fn query_claim_versions(
2496 &self,
2497 query: ProjectionQuery,
2498 ) -> Result<Vec<ProjectionClaimVersion>, MemoryError> {
2499 self.with_read_conn(move |conn| projection_storage::query_claim_versions(conn, &query))
2500 .await
2501 }
2502
2503 pub async fn query_relation_versions(
2505 &self,
2506 query: ProjectionQuery,
2507 ) -> Result<Vec<ProjectionRelationVersion>, MemoryError> {
2508 self.with_read_conn(move |conn| projection_storage::query_relation_versions(conn, &query))
2509 .await
2510 }
2511
2512 pub async fn query_episodes(
2514 &self,
2515 query: ProjectionQuery,
2516 ) -> Result<Vec<ProjectionEpisode>, MemoryError> {
2517 self.with_read_conn(move |conn| projection_storage::query_episode_rows(conn, &query))
2518 .await
2519 }
2520
2521 pub async fn query_entity_aliases(
2523 &self,
2524 query: ProjectionQuery,
2525 ) -> Result<Vec<ProjectionEntityAlias>, MemoryError> {
2526 self.with_read_conn(move |conn| projection_storage::query_entity_aliases(conn, &query))
2527 .await
2528 }
2529
2530 pub async fn query_evidence_refs(
2532 &self,
2533 query: ProjectionQuery,
2534 ) -> Result<Vec<ProjectionEvidenceRef>, MemoryError> {
2535 self.with_read_conn(move |conn| projection_storage::query_evidence_refs(conn, &query))
2536 .await
2537 }
2538
2539 #[cfg(any(test, feature = "testing"))]
2541 pub async fn raw_execute(&self, sql: &str, params: Vec<String>) -> Result<usize, MemoryError> {
2542 let sql = sql.to_string();
2543 self.with_write_conn(move |conn| {
2544 let param_refs: Vec<&dyn rusqlite::types::ToSql> = params
2545 .iter()
2546 .map(|s| s as &dyn rusqlite::types::ToSql)
2547 .collect();
2548 Ok(conn.execute(&sql, &*param_refs)?)
2549 })
2550 .await
2551 }
2552}
2553
2554#[cfg(test)]
2555mod tests {
2556 use super::*;
2557 use crate::types::{SearchResult, SearchSource};
2558
2559 fn make_result(content: &str) -> SearchResult {
2560 SearchResult {
2561 content: content.to_string(),
2562 source: SearchSource::Fact {
2563 fact_id: "test".to_string(),
2564 namespace: "test".to_string(),
2565 },
2566 score: 1.0,
2567 bm25_rank: Some(1),
2568 vector_rank: Some(1),
2569 cosine_similarity: Some(0.9),
2570 }
2571 }
2572
2573 #[test]
2574 fn compress_search_results_shortens_long_content() {
2575 let long = "This is a very long sentence that definitely exceeds the one hundred fifty character limit. It goes on and on with lots of detail that should be truncated. More text here.";
2576 let results = vec![make_result(long)];
2577 let compressed = compress_search_results(results);
2578 assert!(
2579 compressed[0].content.len() <= 152, "compressed content should be at most ~150 chars, got {}",
2581 compressed[0].content.len()
2582 );
2583 assert!(
2584 compressed[0].content.ends_with('…') || compressed[0].content.ends_with('.'),
2585 "compressed content should end with ellipsis or sentence punctuation"
2586 );
2587 }
2588
2589 #[test]
2590 fn compress_search_results_preserves_short_content() {
2591 let short = "Short sentence.";
2592 let results = vec![make_result(short)];
2593 let compressed = compress_search_results(results);
2594 assert_eq!(compressed[0].content, "Short sentence.");
2595 }
2596
2597 #[test]
2598 fn compress_search_results_preserves_first_sentence() {
2599 let content = "First sentence. Second sentence that is longer.";
2600 let results = vec![make_result(content)];
2601 let compressed = compress_search_results(results);
2602 assert_eq!(compressed[0].content, "First sentence.");
2603 }
2604
2605 #[test]
2606 fn compress_search_results_empty_content() {
2607 let results = vec![make_result("")];
2608 let compressed = compress_search_results(results);
2609 assert_eq!(compressed[0].content, "");
2610 }
2611}