1use crate::error::MemoryError;
2use crate::tokenizer::TokenCounter;
3use serde::{Deserialize, Serialize};
4use std::path::PathBuf;
5use std::sync::Arc;
6use std::time::Duration;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
10#[serde(rename_all = "snake_case")]
11pub enum ReplicationMode {
12 #[default]
13 Disabled,
14 FactCreateRequired,
15}
16
17#[derive(Clone, Serialize, Deserialize)]
19pub struct MemoryConfig {
20 pub base_dir: PathBuf,
23
24 pub embedding: EmbeddingConfig,
26
27 pub search: SearchConfig,
29
30 pub chunking: ChunkingConfig,
32
33 pub pool: PoolConfig,
35
36 pub limits: MemoryLimits,
38
39 #[serde(default)]
41 pub journal_device_id: Option<String>,
42
43 #[serde(default)]
45 pub journal_store_id: Option<String>,
46
47 #[serde(default)]
49 pub replication_mode: ReplicationMode,
50
51 #[serde(default)]
53 pub replication_stream_epoch: u64,
54
55 #[serde(skip)]
57 pub token_counter: Option<Arc<dyn TokenCounter>>,
58
59 #[cfg(feature = "hnsw")]
61 #[serde(skip)]
62 pub hnsw: crate::hnsw::HnswConfig,
63}
64
65impl std::fmt::Debug for MemoryConfig {
66 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67 let mut s = f.debug_struct("MemoryConfig");
68 s.field("base_dir", &self.base_dir)
69 .field("embedding", &self.embedding)
70 .field("search", &self.search)
71 .field("chunking", &self.chunking)
72 .field("pool", &self.pool)
73 .field("limits", &self.limits)
74 .field("journal_device_id", &self.journal_device_id)
75 .field("journal_store_id", &self.journal_store_id)
76 .field("replication_mode", &self.replication_mode)
77 .field("replication_stream_epoch", &self.replication_stream_epoch)
78 .field(
79 "token_counter",
80 &self.token_counter.as_ref().map(|_| "custom"),
81 );
82 #[cfg(feature = "hnsw")]
83 s.field("hnsw", &self.hnsw);
84 s.finish()
85 }
86}
87
88impl Default for MemoryConfig {
89 fn default() -> Self {
90 Self {
91 base_dir: PathBuf::from("memory"),
92 embedding: EmbeddingConfig::default(),
93 search: SearchConfig::default(),
94 chunking: ChunkingConfig::default(),
95 pool: PoolConfig::default(),
96 limits: MemoryLimits::default(),
97 journal_device_id: None,
98 journal_store_id: None,
99 replication_mode: ReplicationMode::Disabled,
100 replication_stream_epoch: 0,
101 token_counter: None,
102 #[cfg(feature = "hnsw")]
103 hnsw: crate::hnsw::HnswConfig::default(),
104 }
105 }
106}
107
108impl MemoryConfig {
109 pub fn normalize_and_validate(mut self) -> Result<Self, MemoryError> {
113 self.embedding.normalize_and_validate()?;
114 self.limits = self.limits.normalize_and_validate()?;
115 let timeout_cap_secs = self.limits.embedding_timeout.as_secs().max(1);
116 self.embedding.timeout_secs = self.embedding.timeout_secs.min(timeout_cap_secs);
117 self.search
118 .normalize_and_validate(self.embedding.dimensions)?;
119 self.chunking.normalize_and_validate()?;
120 self.pool.normalize_and_validate()?;
121 self.validate_replication()?;
122 #[cfg(feature = "hnsw")]
123 {
124 self.hnsw.dimensions = self.embedding.dimensions;
125 }
126 Ok(self)
127 }
128
129 fn validate_replication(&self) -> Result<(), MemoryError> {
130 let identity = match (
131 self.journal_device_id.as_deref(),
132 self.journal_store_id.as_deref(),
133 ) {
134 (None, None) => None,
135 (Some(device_id), Some(store_id)) => Some((device_id, store_id)),
136 _ => {
137 return Err(MemoryError::InvalidConfig {
138 field: "journal_device_id/journal_store_id",
139 reason: "both identity fields must be set together".to_string(),
140 });
141 }
142 };
143
144 if let Some((device_id, store_id)) = identity {
145 for (field, value) in [
146 ("journal_device_id", device_id),
147 ("journal_store_id", store_id),
148 ] {
149 if value.is_empty()
150 || value.trim() != value
151 || value.chars().any(char::is_whitespace)
152 {
153 return Err(MemoryError::InvalidConfig {
154 field,
155 reason: "must be non-empty, trimmed, and contain no whitespace".to_string(),
156 });
157 }
158 }
159 }
160
161 if self.replication_mode == ReplicationMode::FactCreateRequired {
162 if identity.is_none() {
163 return Err(MemoryError::InvalidConfig {
164 field: "replication_mode",
165 reason: "FactCreateRequired requires device and store identity".to_string(),
166 });
167 }
168 if self.replication_stream_epoch == 0 {
169 return Err(MemoryError::InvalidConfig {
170 field: "replication_stream_epoch",
171 reason: "FactCreateRequired requires a positive epoch".to_string(),
172 });
173 }
174 }
175 Ok(())
176 }
177}
178
179#[derive(Debug, Clone, Serialize, Deserialize)]
181pub struct EmbeddingConfig {
182 pub ollama_url: String,
186
187 pub model: String,
189
190 pub dimensions: usize,
192
193 pub batch_size: usize,
195
196 pub timeout_secs: u64,
198}
199
200impl Default for EmbeddingConfig {
201 fn default() -> Self {
202 Self {
203 ollama_url: "http://localhost:11434".to_string(),
204 model: "nomic-embed-text".to_string(),
205 dimensions: 768,
206 batch_size: 32,
207 timeout_secs: 30,
208 }
209 }
210}
211
212impl EmbeddingConfig {
213 fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
214 if self.dimensions == 0 {
215 return Err(MemoryError::InvalidConfig {
216 field: "embedding.dimensions",
217 reason: "dimensions must be at least 1".to_string(),
218 });
219 }
220 if self.batch_size == 0 {
221 self.batch_size = 1;
222 }
223 if self.timeout_secs == 0 {
224 self.timeout_secs = 1;
225 }
226 #[cfg(not(feature = "candle-embedder"))]
230 {
231 let parsed =
232 reqwest::Url::parse(&self.ollama_url).map_err(|_| MemoryError::InvalidConfig {
233 field: "embedding.ollama_url",
234 reason: "must be an absolute http:// or https:// URL".to_string(),
235 })?;
236 match parsed.scheme() {
237 "http" | "https" if parsed.host_str().is_some() => {}
238 _ => {
239 return Err(MemoryError::InvalidConfig {
240 field: "embedding.ollama_url",
241 reason: "must be an absolute http:// or https:// URL".to_string(),
242 })
243 }
244 }
245 }
246 #[cfg(feature = "candle-embedder")]
250 {
251 let _ = &self.ollama_url; }
253 Ok(())
254 }
255}
256
257#[derive(Debug, Clone, Serialize, Deserialize)]
259pub struct SearchConfig {
260 pub bm25_weight: f64,
262
263 pub vector_weight: f64,
265
266 #[serde(default = "default_zero")]
269 pub sparse_weight: f64,
270
271 #[serde(default = "default_sparse_top_k")]
273 pub sparse_top_k: usize,
274
275 #[serde(default = "default_zero")]
277 pub sparse_min_score: f64,
278
279 #[serde(default)]
282 pub derive_sparse_from_dense: bool,
283
284 #[serde(default = "default_sparse_derive_top_k")]
286 pub sparse_derive_top_k: usize,
287
288 #[serde(default = "default_sparse_derive_min_weight")]
290 pub sparse_derive_min_weight: f32,
291
292 #[serde(default = "default_zero")]
295 pub late_interaction_weight: f64,
296
297 pub bm25_k1: f64,
300
301 pub bm25_b: f64,
304
305 pub namespace_weights: std::collections::HashMap<String, f64>,
308
309 pub rrf_k: f64,
311
312 pub candidate_pool_size: usize,
314
315 pub default_top_k: usize,
317
318 pub min_similarity: f64,
320
321 pub recency_half_life_days: Option<f64>,
326
327 pub recency_weight: f64,
331
332 pub rerank_from_f32: bool,
337
338 #[serde(default)]
341 pub derived_vector_backend: DerivedVectorBackendPolicy,
342
343 #[serde(default = "default_turbo_quant_bits")]
345 pub turbo_quant_bits: u8,
346
347 #[serde(default = "default_turbo_quant_projections")]
349 pub turbo_quant_projections: usize,
350
351 #[serde(default)]
353 pub turbo_quant_seed: u64,
354
355 #[serde(default = "default_true")]
357 pub turbo_quant_require_exact_rerank: bool,
358
359 #[serde(default = "default_candidate_dims")]
365 pub candidate_dims: Option<usize>,
366
367 #[serde(default)]
371 pub compress_results: bool,
372}
373
374#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
376#[serde(rename_all = "snake_case")]
377pub enum DerivedVectorBackendPolicy {
378 #[default]
380 Disabled,
381 TurboQuantCandidateOnly,
383 ProveKvPoolCandidateOnly,
389 FibQuantCandidateOnly,
395 PerDimCandidateOnly,
401}
402
403const fn default_turbo_quant_bits() -> u8 {
404 8
405}
406
407const fn default_turbo_quant_projections() -> usize {
408 64
409}
410
411const fn default_true() -> bool {
412 true
413}
414
415const fn default_zero() -> f64 {
416 0.0
417}
418
419const fn default_sparse_top_k() -> usize {
420 50
421}
422
423const fn default_sparse_derive_top_k() -> usize {
424 128
425}
426
427const MAX_SEARCH_CANDIDATE_POOL_SIZE: usize = 2_000;
428const MAX_SEARCH_DEFAULT_TOP_K: usize = 200;
429const MAX_SPARSE_TOP_K: usize = 1_000;
430const MAX_SPARSE_DERIVE_TOP_K: usize = 1_000;
431
432const fn default_sparse_derive_min_weight() -> f32 {
433 0.01
434}
435
436const fn default_candidate_dims() -> Option<usize> {
437 None
438}
439
440impl Default for SearchConfig {
441 fn default() -> Self {
442 Self {
443 bm25_weight: 1.0,
444 vector_weight: 1.0,
445 sparse_weight: 0.0,
446 sparse_top_k: default_sparse_top_k(),
447 sparse_min_score: 0.0,
448 derive_sparse_from_dense: false,
449 sparse_derive_top_k: default_sparse_derive_top_k(),
450 sparse_derive_min_weight: default_sparse_derive_min_weight(),
451 late_interaction_weight: 0.15,
452 bm25_k1: 1.2,
453 bm25_b: 0.75,
454 namespace_weights: std::collections::HashMap::new(),
455 rrf_k: 60.0,
456 candidate_pool_size: 50,
457 default_top_k: 5,
458 min_similarity: 0.3,
459 recency_half_life_days: None,
460 recency_weight: 0.5,
461 rerank_from_f32: true,
462 derived_vector_backend: DerivedVectorBackendPolicy::Disabled,
463 turbo_quant_bits: default_turbo_quant_bits(),
464 turbo_quant_projections: default_turbo_quant_projections(),
465 turbo_quant_seed: 0,
466 turbo_quant_require_exact_rerank: true,
467 candidate_dims: default_candidate_dims(),
468 compress_results: false,
469 }
470 }
471}
472
473impl SearchConfig {
474 pub(crate) fn uses_turbo_quant_backend(&self) -> bool {
475 self.derived_vector_backend == DerivedVectorBackendPolicy::TurboQuantCandidateOnly
476 }
477
478 pub(crate) fn uses_provekv_pool_backend(&self) -> bool {
479 self.derived_vector_backend == DerivedVectorBackendPolicy::ProveKvPoolCandidateOnly
480 }
481
482 pub(crate) fn uses_derived_vector_backend(&self) -> bool {
483 self.uses_turbo_quant_backend()
484 || self.uses_provekv_pool_backend()
485 || matches!(
486 self.derived_vector_backend,
487 DerivedVectorBackendPolicy::FibQuantCandidateOnly
488 | DerivedVectorBackendPolicy::PerDimCandidateOnly
489 )
490 }
491
492 fn normalize_and_validate(&mut self, embedding_dimensions: usize) -> Result<(), MemoryError> {
493 #[cfg(not(feature = "turbo-quant-codec"))]
494 let _ = embedding_dimensions;
495
496 match self.derived_vector_backend {
497 DerivedVectorBackendPolicy::FibQuantCandidateOnly => {
498 return Err(MemoryError::NotImplemented(
499 "FibQuant candidate generation is not implemented in this build".to_string(),
500 ));
501 }
502 DerivedVectorBackendPolicy::PerDimCandidateOnly => {
503 return Err(MemoryError::NotImplemented(
504 "per-dimension candidate generation is not implemented in this build"
505 .to_string(),
506 ));
507 }
508 _ => {}
509 }
510
511 self.candidate_pool_size = self
512 .candidate_pool_size
513 .clamp(1, MAX_SEARCH_CANDIDATE_POOL_SIZE);
514 self.default_top_k = self.default_top_k.clamp(1, MAX_SEARCH_DEFAULT_TOP_K);
515 self.candidate_pool_size = self.candidate_pool_size.max(self.default_top_k);
516 self.sparse_top_k = self.sparse_top_k.clamp(1, MAX_SPARSE_TOP_K);
517 self.sparse_derive_top_k = self.sparse_derive_top_k.clamp(1, MAX_SPARSE_DERIVE_TOP_K);
518 if !self.rrf_k.is_finite() || self.rrf_k <= 0.0 {
519 return Err(MemoryError::InvalidConfig {
520 field: "search.rrf_k",
521 reason: "rrf_k must be finite and > 0".to_string(),
522 });
523 }
524 if !self.bm25_weight.is_finite() || self.bm25_weight < 0.0 {
525 return Err(MemoryError::InvalidConfig {
526 field: "search.bm25_weight",
527 reason: "bm25_weight must be finite and >= 0".to_string(),
528 });
529 }
530 if !self.vector_weight.is_finite() || self.vector_weight < 0.0 {
531 return Err(MemoryError::InvalidConfig {
532 field: "search.vector_weight",
533 reason: "vector_weight must be finite and >= 0".to_string(),
534 });
535 }
536 if !self.sparse_weight.is_finite() || self.sparse_weight < 0.0 {
537 return Err(MemoryError::InvalidConfig {
538 field: "search.sparse_weight",
539 reason: "sparse_weight must be finite and >= 0".to_string(),
540 });
541 }
542 if !self.sparse_min_score.is_finite() {
543 return Err(MemoryError::InvalidConfig {
544 field: "search.sparse_min_score",
545 reason: "sparse_min_score must be finite".to_string(),
546 });
547 }
548 if !self.sparse_derive_min_weight.is_finite() || self.sparse_derive_min_weight < 0.0 {
549 return Err(MemoryError::InvalidConfig {
550 field: "search.sparse_derive_min_weight",
551 reason: "sparse_derive_min_weight must be finite and >= 0".to_string(),
552 });
553 }
554 if !self.recency_weight.is_finite() || self.recency_weight < 0.0 {
555 return Err(MemoryError::InvalidConfig {
556 field: "search.recency_weight",
557 reason: "recency_weight must be finite and >= 0".to_string(),
558 });
559 }
560 if !self.min_similarity.is_finite() || !(-1.0..=1.0).contains(&self.min_similarity) {
561 return Err(MemoryError::InvalidConfig {
562 field: "search.min_similarity",
563 reason: "min_similarity must be finite and within [-1.0, 1.0]".to_string(),
564 });
565 }
566 if matches!(self.recency_half_life_days, Some(v) if !v.is_finite()) {
567 return Err(MemoryError::InvalidConfig {
568 field: "search.recency_half_life_days",
569 reason: "recency_half_life_days must be finite".to_string(),
570 });
571 }
572 if matches!(self.recency_half_life_days, Some(v) if v <= 0.0) {
573 return Err(MemoryError::InvalidConfig {
574 field: "search.recency_half_life_days",
575 reason: "recency_half_life_days must be > 0 when enabled".to_string(),
576 });
577 }
578 if self.uses_turbo_quant_backend() {
579 #[cfg(not(feature = "turbo-quant-codec"))]
580 {
581 return Err(MemoryError::InvalidConfig {
582 field: "search.derived_vector_backend",
583 reason: "turbo_quant_candidate_only requires the turbo-quant-codec feature"
584 .to_string(),
585 });
586 }
587 #[cfg(feature = "turbo-quant-codec")]
588 {
589 if embedding_dimensions % 2 != 0 {
590 return Err(MemoryError::InvalidConfig {
591 field: "embedding.dimensions",
592 reason: "TurboQuant requires even embedding dimensions".to_string(),
593 });
594 }
595 if self.turbo_quant_projections == 0 {
596 return Err(MemoryError::InvalidConfig {
597 field: "search.turbo_quant_projections",
598 reason: "TurboQuant projections must be at least 1".to_string(),
599 });
600 }
601 if !(2..=16).contains(&self.turbo_quant_bits) {
602 return Err(MemoryError::InvalidConfig {
603 field: "search.turbo_quant_bits",
604 reason: "TurboQuant bits must be within 2..=16".to_string(),
605 });
606 }
607 }
608 }
609 if self.uses_derived_vector_backend() && !self.turbo_quant_require_exact_rerank {
610 return Err(MemoryError::InvalidConfig {
611 field: "search.turbo_quant_require_exact_rerank",
612 reason: "derived vector candidate backends require exact f32 rerank".to_string(),
613 });
614 }
615 Ok(())
616 }
617}
618
619#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
621#[serde(rename_all = "snake_case")]
622pub enum ChunkingStrategy {
623 #[default]
625 Plain,
626 Sentence,
628 Code,
631 Markdown,
633}
634
635#[derive(Debug, Clone, Serialize, Deserialize)]
637pub struct ChunkingConfig {
638 pub target_size: usize,
640
641 pub min_size: usize,
643
644 pub max_size: usize,
646
647 pub overlap: usize,
649
650 #[serde(default)]
653 pub strategy: ChunkingStrategy,
654}
655
656impl Default for ChunkingConfig {
657 fn default() -> Self {
658 Self {
659 target_size: 1000,
660 min_size: 100,
661 max_size: 2000,
662 overlap: 200,
663 strategy: ChunkingStrategy::default(),
664 }
665 }
666}
667
668impl ChunkingConfig {
669 fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
670 if self.min_size == 0 {
671 self.min_size = 1;
672 }
673 if self.max_size == 0 {
674 return Err(MemoryError::InvalidConfig {
675 field: "chunking.max_size",
676 reason: "max_size must be at least 1".to_string(),
677 });
678 }
679 if self.max_size < self.min_size {
680 return Err(MemoryError::InvalidConfig {
681 field: "chunking.max_size",
682 reason: "max_size must be >= min_size".to_string(),
683 });
684 }
685 if self.target_size < self.min_size {
686 self.target_size = self.min_size;
687 }
688 if self.target_size > self.max_size {
689 self.target_size = self.max_size;
690 }
691 if self.overlap >= self.min_size {
692 self.overlap = self.min_size.saturating_sub(1);
693 }
694 Ok(())
695 }
696}
697
698#[derive(Debug, Clone, Serialize, Deserialize)]
703pub struct PoolConfig {
704 pub busy_timeout_ms: u32,
707
708 pub wal_autocheckpoint: u32,
711
712 pub enable_wal: bool,
715
716 pub max_read_connections: usize,
721
722 pub reader_timeout_secs: u64,
725}
726
727impl Default for PoolConfig {
728 fn default() -> Self {
729 Self {
730 busy_timeout_ms: 5000,
731 wal_autocheckpoint: 1000,
732 enable_wal: true,
733 max_read_connections: 4,
734 reader_timeout_secs: 30,
735 }
736 }
737}
738
739impl PoolConfig {
740 fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
741 if self.busy_timeout_ms == 0 {
742 self.busy_timeout_ms = 1;
743 }
744 if self.wal_autocheckpoint == 0 {
745 self.wal_autocheckpoint = 1;
746 }
747 if self.max_read_connections == 0 {
748 return Err(MemoryError::InvalidConfig {
749 field: "pool.max_read_connections",
750 reason: "set pool.max_read_connections to at least 1".to_string(),
751 });
752 }
753 if self.reader_timeout_secs == 0 {
754 self.reader_timeout_secs = 1;
755 }
756 self.reader_timeout_secs = self.reader_timeout_secs.min(300);
757 Ok(())
758 }
759}
760
761#[derive(Debug, Clone, Serialize, Deserialize)]
766pub struct MemoryLimits {
767 pub max_facts_per_namespace: usize,
770
771 pub max_chunks_per_document: usize,
774
775 pub max_content_bytes: usize,
778
779 pub max_embedding_concurrency: usize,
783
784 pub max_db_size_bytes: u64,
787
788 #[serde(with = "duration_secs")]
791 pub embedding_timeout: Duration,
792}
793
794impl Default for MemoryLimits {
795 fn default() -> Self {
796 Self {
797 max_facts_per_namespace: 100_000,
798 max_chunks_per_document: 1_000,
799 max_content_bytes: 1_048_576,
800 max_embedding_concurrency: 8,
801 max_db_size_bytes: 0,
802 embedding_timeout: Duration::from_secs(30),
803 }
804 }
805}
806
807impl MemoryLimits {
808 pub fn normalize_and_validate(mut self) -> Result<Self, MemoryError> {
810 if self.max_facts_per_namespace == 0 {
811 return Err(MemoryError::InvalidConfig {
812 field: "limits.max_facts_per_namespace",
813 reason: "must be at least 1".to_string(),
814 });
815 }
816 if self.max_chunks_per_document == 0 {
817 return Err(MemoryError::InvalidConfig {
818 field: "limits.max_chunks_per_document",
819 reason: "must be at least 1".to_string(),
820 });
821 }
822 if self.max_content_bytes == 0 {
823 return Err(MemoryError::InvalidConfig {
824 field: "limits.max_content_bytes",
825 reason: "must be at least 1".to_string(),
826 });
827 }
828 if self.max_embedding_concurrency > 32 {
830 self.max_embedding_concurrency = 32;
831 }
832 if self.max_embedding_concurrency == 0 {
833 self.max_embedding_concurrency = 1;
834 }
835 if self.embedding_timeout.is_zero() {
836 self.embedding_timeout = Duration::from_secs(1);
837 }
838 Ok(self)
839 }
840
841 pub fn validated(self) -> Self {
846 self.normalize_and_validate().unwrap_or_else(|err| {
847 tracing::warn!(
848 error = %err,
849 "invalid MemoryLimits supplied to validated(); using defaults"
850 );
851 let defaults = Self::default();
853 Self {
854 max_facts_per_namespace: defaults.max_facts_per_namespace,
855 max_chunks_per_document: defaults.max_chunks_per_document,
856 max_content_bytes: defaults.max_content_bytes,
857 max_embedding_concurrency: defaults.max_embedding_concurrency.clamp(1, 32),
858 max_db_size_bytes: defaults.max_db_size_bytes,
859 embedding_timeout: if defaults.embedding_timeout.is_zero() {
860 std::time::Duration::from_secs(1)
861 } else {
862 defaults.embedding_timeout
863 },
864 }
865 })
866 }
867}
868
869mod duration_secs {
870 use serde::{Deserialize, Deserializer, Serializer};
871 use std::time::Duration;
872
873 pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
874 s.serialize_u64(d.as_secs())
875 }
876
877 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
878 let secs = u64::deserialize(d)?;
879 Ok(Duration::from_secs(secs))
880 }
881}