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(Clone, Serialize, Deserialize)]
10pub struct MemoryConfig {
11 pub base_dir: PathBuf,
14
15 pub embedding: EmbeddingConfig,
17
18 pub search: SearchConfig,
20
21 pub chunking: ChunkingConfig,
23
24 pub pool: PoolConfig,
26
27 pub limits: MemoryLimits,
29
30 #[serde(default)]
32 pub journal_device_id: Option<String>,
33
34 #[serde(default)]
36 pub journal_store_id: Option<String>,
37
38 #[serde(skip)]
40 pub token_counter: Option<Arc<dyn TokenCounter>>,
41
42 #[cfg(feature = "hnsw")]
44 #[serde(skip)]
45 pub hnsw: crate::hnsw::HnswConfig,
46}
47
48impl std::fmt::Debug for MemoryConfig {
49 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
50 let mut s = f.debug_struct("MemoryConfig");
51 s.field("base_dir", &self.base_dir)
52 .field("embedding", &self.embedding)
53 .field("search", &self.search)
54 .field("chunking", &self.chunking)
55 .field("pool", &self.pool)
56 .field("limits", &self.limits)
57 .field("journal_device_id", &self.journal_device_id)
58 .field("journal_store_id", &self.journal_store_id)
59 .field(
60 "token_counter",
61 &self.token_counter.as_ref().map(|_| "custom"),
62 );
63 #[cfg(feature = "hnsw")]
64 s.field("hnsw", &self.hnsw);
65 s.finish()
66 }
67}
68
69impl Default for MemoryConfig {
70 fn default() -> Self {
71 Self {
72 base_dir: PathBuf::from("memory"),
73 embedding: EmbeddingConfig::default(),
74 search: SearchConfig::default(),
75 chunking: ChunkingConfig::default(),
76 pool: PoolConfig::default(),
77 limits: MemoryLimits::default(),
78 journal_device_id: None,
79 journal_store_id: None,
80 token_counter: None,
81 #[cfg(feature = "hnsw")]
82 hnsw: crate::hnsw::HnswConfig::default(),
83 }
84 }
85}
86
87impl MemoryConfig {
88 pub fn normalize_and_validate(mut self) -> Result<Self, MemoryError> {
92 self.embedding.normalize_and_validate()?;
93 self.limits = self.limits.normalize_and_validate()?;
94 let timeout_cap_secs = self.limits.embedding_timeout.as_secs().max(1);
95 self.embedding.timeout_secs = self.embedding.timeout_secs.min(timeout_cap_secs);
96 self.search
97 .normalize_and_validate(self.embedding.dimensions)?;
98 self.chunking.normalize_and_validate()?;
99 self.pool.normalize_and_validate()?;
100 #[cfg(feature = "hnsw")]
101 {
102 self.hnsw.dimensions = self.embedding.dimensions;
103 }
104 Ok(self)
105 }
106}
107
108#[derive(Debug, Clone, Serialize, Deserialize)]
110pub struct EmbeddingConfig {
111 pub ollama_url: String,
115
116 pub model: String,
118
119 pub dimensions: usize,
121
122 pub batch_size: usize,
124
125 pub timeout_secs: u64,
127}
128
129impl Default for EmbeddingConfig {
130 fn default() -> Self {
131 Self {
132 ollama_url: "http://localhost:11434".to_string(),
133 model: "nomic-embed-text".to_string(),
134 dimensions: 768,
135 batch_size: 32,
136 timeout_secs: 30,
137 }
138 }
139}
140
141impl EmbeddingConfig {
142 fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
143 if self.dimensions == 0 {
144 return Err(MemoryError::InvalidConfig {
145 field: "embedding.dimensions",
146 reason: "dimensions must be at least 1".to_string(),
147 });
148 }
149 if self.batch_size == 0 {
150 self.batch_size = 1;
151 }
152 if self.timeout_secs == 0 {
153 self.timeout_secs = 1;
154 }
155 #[cfg(not(feature = "candle-embedder"))]
159 {
160 let parsed =
161 reqwest::Url::parse(&self.ollama_url).map_err(|_| MemoryError::InvalidConfig {
162 field: "embedding.ollama_url",
163 reason: "must be an absolute http:// or https:// URL".to_string(),
164 })?;
165 match parsed.scheme() {
166 "http" | "https" if parsed.host_str().is_some() => {}
167 _ => {
168 return Err(MemoryError::InvalidConfig {
169 field: "embedding.ollama_url",
170 reason: "must be an absolute http:// or https:// URL".to_string(),
171 })
172 }
173 }
174 }
175 #[cfg(feature = "candle-embedder")]
179 {
180 let _ = &self.ollama_url; }
182 Ok(())
183 }
184}
185
186#[derive(Debug, Clone, Serialize, Deserialize)]
188pub struct SearchConfig {
189 pub bm25_weight: f64,
191
192 pub vector_weight: f64,
194
195 #[serde(default = "default_zero")]
198 pub sparse_weight: f64,
199
200 #[serde(default = "default_sparse_top_k")]
202 pub sparse_top_k: usize,
203
204 #[serde(default = "default_zero")]
206 pub sparse_min_score: f64,
207
208 #[serde(default)]
211 pub derive_sparse_from_dense: bool,
212
213 #[serde(default = "default_sparse_derive_top_k")]
215 pub sparse_derive_top_k: usize,
216
217 #[serde(default = "default_sparse_derive_min_weight")]
219 pub sparse_derive_min_weight: f32,
220
221 #[serde(default = "default_zero")]
224 pub late_interaction_weight: f64,
225
226 pub bm25_k1: f64,
229
230 pub bm25_b: f64,
233
234 pub namespace_weights: std::collections::HashMap<String, f64>,
237
238 pub rrf_k: f64,
240
241 pub candidate_pool_size: usize,
243
244 pub default_top_k: usize,
246
247 pub min_similarity: f64,
249
250 pub recency_half_life_days: Option<f64>,
255
256 pub recency_weight: f64,
260
261 pub rerank_from_f32: bool,
266
267 #[serde(default)]
270 pub derived_vector_backend: DerivedVectorBackendPolicy,
271
272 #[serde(default = "default_turbo_quant_bits")]
274 pub turbo_quant_bits: u8,
275
276 #[serde(default = "default_turbo_quant_projections")]
278 pub turbo_quant_projections: usize,
279
280 #[serde(default)]
282 pub turbo_quant_seed: u64,
283
284 #[serde(default = "default_true")]
286 pub turbo_quant_require_exact_rerank: bool,
287
288 #[serde(default = "default_candidate_dims")]
294 pub candidate_dims: Option<usize>,
295
296 #[serde(default)]
300 pub compress_results: bool,
301}
302
303#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
305#[serde(rename_all = "snake_case")]
306pub enum DerivedVectorBackendPolicy {
307 #[default]
309 Disabled,
310 TurboQuantCandidateOnly,
312 ProveKvPoolCandidateOnly,
318}
319
320const fn default_turbo_quant_bits() -> u8 {
321 8
322}
323
324const fn default_turbo_quant_projections() -> usize {
325 64
326}
327
328const fn default_true() -> bool {
329 true
330}
331
332const fn default_zero() -> f64 {
333 0.0
334}
335
336const fn default_sparse_top_k() -> usize {
337 50
338}
339
340const fn default_sparse_derive_top_k() -> usize {
341 128
342}
343
344const MAX_SEARCH_CANDIDATE_POOL_SIZE: usize = 2_000;
345const MAX_SEARCH_DEFAULT_TOP_K: usize = 200;
346const MAX_SPARSE_TOP_K: usize = 1_000;
347const MAX_SPARSE_DERIVE_TOP_K: usize = 1_000;
348
349const fn default_sparse_derive_min_weight() -> f32 {
350 0.01
351}
352
353const fn default_candidate_dims() -> Option<usize> {
354 None
355}
356
357impl Default for SearchConfig {
358 fn default() -> Self {
359 Self {
360 bm25_weight: 1.0,
361 vector_weight: 1.0,
362 sparse_weight: 0.0,
363 sparse_top_k: default_sparse_top_k(),
364 sparse_min_score: 0.0,
365 derive_sparse_from_dense: false,
366 sparse_derive_top_k: default_sparse_derive_top_k(),
367 sparse_derive_min_weight: default_sparse_derive_min_weight(),
368 late_interaction_weight: 0.15,
369 bm25_k1: 1.2,
370 bm25_b: 0.75,
371 namespace_weights: std::collections::HashMap::new(),
372 rrf_k: 60.0,
373 candidate_pool_size: 50,
374 default_top_k: 5,
375 min_similarity: 0.3,
376 recency_half_life_days: None,
377 recency_weight: 0.5,
378 rerank_from_f32: true,
379 derived_vector_backend: DerivedVectorBackendPolicy::Disabled,
380 turbo_quant_bits: default_turbo_quant_bits(),
381 turbo_quant_projections: default_turbo_quant_projections(),
382 turbo_quant_seed: 0,
383 turbo_quant_require_exact_rerank: true,
384 candidate_dims: default_candidate_dims(),
385 compress_results: false,
386 }
387 }
388}
389
390impl SearchConfig {
391 pub(crate) fn uses_turbo_quant_backend(&self) -> bool {
392 self.derived_vector_backend == DerivedVectorBackendPolicy::TurboQuantCandidateOnly
393 }
394
395 pub(crate) fn uses_provekv_pool_backend(&self) -> bool {
396 self.derived_vector_backend == DerivedVectorBackendPolicy::ProveKvPoolCandidateOnly
397 }
398
399 pub(crate) fn uses_derived_vector_backend(&self) -> bool {
400 self.uses_turbo_quant_backend() || self.uses_provekv_pool_backend()
401 }
402
403 fn normalize_and_validate(&mut self, embedding_dimensions: usize) -> Result<(), MemoryError> {
404 #[cfg(not(feature = "turbo-quant-codec"))]
405 let _ = embedding_dimensions;
406 self.candidate_pool_size = self
407 .candidate_pool_size
408 .clamp(1, MAX_SEARCH_CANDIDATE_POOL_SIZE);
409 self.default_top_k = self.default_top_k.clamp(1, MAX_SEARCH_DEFAULT_TOP_K);
410 self.candidate_pool_size = self.candidate_pool_size.max(self.default_top_k);
411 self.sparse_top_k = self.sparse_top_k.clamp(1, MAX_SPARSE_TOP_K);
412 self.sparse_derive_top_k = self.sparse_derive_top_k.clamp(1, MAX_SPARSE_DERIVE_TOP_K);
413 if !self.rrf_k.is_finite() || self.rrf_k <= 0.0 {
414 return Err(MemoryError::InvalidConfig {
415 field: "search.rrf_k",
416 reason: "rrf_k must be finite and > 0".to_string(),
417 });
418 }
419 if !self.bm25_weight.is_finite() || self.bm25_weight < 0.0 {
420 return Err(MemoryError::InvalidConfig {
421 field: "search.bm25_weight",
422 reason: "bm25_weight must be finite and >= 0".to_string(),
423 });
424 }
425 if !self.vector_weight.is_finite() || self.vector_weight < 0.0 {
426 return Err(MemoryError::InvalidConfig {
427 field: "search.vector_weight",
428 reason: "vector_weight must be finite and >= 0".to_string(),
429 });
430 }
431 if !self.sparse_weight.is_finite() || self.sparse_weight < 0.0 {
432 return Err(MemoryError::InvalidConfig {
433 field: "search.sparse_weight",
434 reason: "sparse_weight must be finite and >= 0".to_string(),
435 });
436 }
437 if !self.sparse_min_score.is_finite() {
438 return Err(MemoryError::InvalidConfig {
439 field: "search.sparse_min_score",
440 reason: "sparse_min_score must be finite".to_string(),
441 });
442 }
443 if !self.sparse_derive_min_weight.is_finite() || self.sparse_derive_min_weight < 0.0 {
444 return Err(MemoryError::InvalidConfig {
445 field: "search.sparse_derive_min_weight",
446 reason: "sparse_derive_min_weight must be finite and >= 0".to_string(),
447 });
448 }
449 if !self.recency_weight.is_finite() || self.recency_weight < 0.0 {
450 return Err(MemoryError::InvalidConfig {
451 field: "search.recency_weight",
452 reason: "recency_weight must be finite and >= 0".to_string(),
453 });
454 }
455 if !self.min_similarity.is_finite() || !(-1.0..=1.0).contains(&self.min_similarity) {
456 return Err(MemoryError::InvalidConfig {
457 field: "search.min_similarity",
458 reason: "min_similarity must be finite and within [-1.0, 1.0]".to_string(),
459 });
460 }
461 if matches!(self.recency_half_life_days, Some(v) if !v.is_finite()) {
462 return Err(MemoryError::InvalidConfig {
463 field: "search.recency_half_life_days",
464 reason: "recency_half_life_days must be finite".to_string(),
465 });
466 }
467 if matches!(self.recency_half_life_days, Some(v) if v <= 0.0) {
468 return Err(MemoryError::InvalidConfig {
469 field: "search.recency_half_life_days",
470 reason: "recency_half_life_days must be > 0 when enabled".to_string(),
471 });
472 }
473 if self.uses_turbo_quant_backend() {
474 #[cfg(not(feature = "turbo-quant-codec"))]
475 {
476 return Err(MemoryError::InvalidConfig {
477 field: "search.derived_vector_backend",
478 reason: "turbo_quant_candidate_only requires the turbo-quant-codec feature"
479 .to_string(),
480 });
481 }
482 #[cfg(feature = "turbo-quant-codec")]
483 {
484 if embedding_dimensions % 2 != 0 {
485 return Err(MemoryError::InvalidConfig {
486 field: "embedding.dimensions",
487 reason: "TurboQuant requires even embedding dimensions".to_string(),
488 });
489 }
490 if self.turbo_quant_projections == 0 {
491 return Err(MemoryError::InvalidConfig {
492 field: "search.turbo_quant_projections",
493 reason: "TurboQuant projections must be at least 1".to_string(),
494 });
495 }
496 if !(2..=16).contains(&self.turbo_quant_bits) {
497 return Err(MemoryError::InvalidConfig {
498 field: "search.turbo_quant_bits",
499 reason: "TurboQuant bits must be within 2..=16".to_string(),
500 });
501 }
502 }
503 }
504 if self.uses_derived_vector_backend() && !self.turbo_quant_require_exact_rerank {
505 return Err(MemoryError::InvalidConfig {
506 field: "search.turbo_quant_require_exact_rerank",
507 reason: "derived vector candidate backends require exact f32 rerank".to_string(),
508 });
509 }
510 Ok(())
511 }
512}
513
514#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
516#[serde(rename_all = "snake_case")]
517pub enum ChunkingStrategy {
518 #[default]
520 Plain,
521 Sentence,
523 Code,
526 Markdown,
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize)]
532pub struct ChunkingConfig {
533 pub target_size: usize,
535
536 pub min_size: usize,
538
539 pub max_size: usize,
541
542 pub overlap: usize,
544
545 #[serde(default)]
548 pub strategy: ChunkingStrategy,
549}
550
551impl Default for ChunkingConfig {
552 fn default() -> Self {
553 Self {
554 target_size: 1000,
555 min_size: 100,
556 max_size: 2000,
557 overlap: 200,
558 strategy: ChunkingStrategy::default(),
559 }
560 }
561}
562
563impl ChunkingConfig {
564 fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
565 if self.min_size == 0 {
566 self.min_size = 1;
567 }
568 if self.max_size == 0 {
569 return Err(MemoryError::InvalidConfig {
570 field: "chunking.max_size",
571 reason: "max_size must be at least 1".to_string(),
572 });
573 }
574 if self.max_size < self.min_size {
575 return Err(MemoryError::InvalidConfig {
576 field: "chunking.max_size",
577 reason: "max_size must be >= min_size".to_string(),
578 });
579 }
580 if self.target_size < self.min_size {
581 self.target_size = self.min_size;
582 }
583 if self.target_size > self.max_size {
584 self.target_size = self.max_size;
585 }
586 if self.overlap >= self.min_size {
587 self.overlap = self.min_size.saturating_sub(1);
588 }
589 Ok(())
590 }
591}
592
593#[derive(Debug, Clone, Serialize, Deserialize)]
598pub struct PoolConfig {
599 pub busy_timeout_ms: u32,
602
603 pub wal_autocheckpoint: u32,
606
607 pub enable_wal: bool,
610
611 pub max_read_connections: usize,
616
617 pub reader_timeout_secs: u64,
620}
621
622impl Default for PoolConfig {
623 fn default() -> Self {
624 Self {
625 busy_timeout_ms: 5000,
626 wal_autocheckpoint: 1000,
627 enable_wal: true,
628 max_read_connections: 4,
629 reader_timeout_secs: 30,
630 }
631 }
632}
633
634impl PoolConfig {
635 fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
636 if self.busy_timeout_ms == 0 {
637 self.busy_timeout_ms = 1;
638 }
639 if self.wal_autocheckpoint == 0 {
640 self.wal_autocheckpoint = 1;
641 }
642 if self.max_read_connections == 0 {
643 return Err(MemoryError::InvalidConfig {
644 field: "pool.max_read_connections",
645 reason: "set pool.max_read_connections to at least 1".to_string(),
646 });
647 }
648 if self.reader_timeout_secs == 0 {
649 self.reader_timeout_secs = 1;
650 }
651 self.reader_timeout_secs = self.reader_timeout_secs.min(300);
652 Ok(())
653 }
654}
655
656#[derive(Debug, Clone, Serialize, Deserialize)]
661pub struct MemoryLimits {
662 pub max_facts_per_namespace: usize,
665
666 pub max_chunks_per_document: usize,
669
670 pub max_content_bytes: usize,
673
674 pub max_embedding_concurrency: usize,
678
679 pub max_db_size_bytes: u64,
682
683 #[serde(with = "duration_secs")]
686 pub embedding_timeout: Duration,
687}
688
689impl Default for MemoryLimits {
690 fn default() -> Self {
691 Self {
692 max_facts_per_namespace: 100_000,
693 max_chunks_per_document: 1_000,
694 max_content_bytes: 1_048_576,
695 max_embedding_concurrency: 8,
696 max_db_size_bytes: 0,
697 embedding_timeout: Duration::from_secs(30),
698 }
699 }
700}
701
702impl MemoryLimits {
703 pub fn normalize_and_validate(mut self) -> Result<Self, MemoryError> {
705 if self.max_facts_per_namespace == 0 {
706 return Err(MemoryError::InvalidConfig {
707 field: "limits.max_facts_per_namespace",
708 reason: "must be at least 1".to_string(),
709 });
710 }
711 if self.max_chunks_per_document == 0 {
712 return Err(MemoryError::InvalidConfig {
713 field: "limits.max_chunks_per_document",
714 reason: "must be at least 1".to_string(),
715 });
716 }
717 if self.max_content_bytes == 0 {
718 return Err(MemoryError::InvalidConfig {
719 field: "limits.max_content_bytes",
720 reason: "must be at least 1".to_string(),
721 });
722 }
723 if self.max_embedding_concurrency > 32 {
725 self.max_embedding_concurrency = 32;
726 }
727 if self.max_embedding_concurrency == 0 {
728 self.max_embedding_concurrency = 1;
729 }
730 if self.embedding_timeout.is_zero() {
731 self.embedding_timeout = Duration::from_secs(1);
732 }
733 Ok(self)
734 }
735
736 pub fn validated(self) -> Self {
741 self.normalize_and_validate().unwrap_or_else(|err| {
742 tracing::warn!(
743 error = %err,
744 "invalid MemoryLimits supplied to validated(); using defaults"
745 );
746 let defaults = Self::default();
748 Self {
749 max_facts_per_namespace: defaults.max_facts_per_namespace,
750 max_chunks_per_document: defaults.max_chunks_per_document,
751 max_content_bytes: defaults.max_content_bytes,
752 max_embedding_concurrency: defaults.max_embedding_concurrency.clamp(1, 32),
753 max_db_size_bytes: defaults.max_db_size_bytes,
754 embedding_timeout: if defaults.embedding_timeout.is_zero() {
755 std::time::Duration::from_secs(1)
756 } else {
757 defaults.embedding_timeout
758 },
759 }
760 })
761 }
762}
763
764mod duration_secs {
765 use serde::{Deserialize, Deserializer, Serializer};
766 use std::time::Duration;
767
768 pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
769 s.serialize_u64(d.as_secs())
770 }
771
772 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
773 let secs = u64::deserialize(d)?;
774 Ok(Duration::from_secs(secs))
775 }
776}