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