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(skip)]
32 pub token_counter: Option<Arc<dyn TokenCounter>>,
33
34 #[cfg(feature = "hnsw")]
36 #[serde(skip)]
37 pub hnsw: crate::hnsw::HnswConfig,
38}
39
40impl std::fmt::Debug for MemoryConfig {
41 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
42 let mut s = f.debug_struct("MemoryConfig");
43 s.field("base_dir", &self.base_dir)
44 .field("embedding", &self.embedding)
45 .field("search", &self.search)
46 .field("chunking", &self.chunking)
47 .field("pool", &self.pool)
48 .field("limits", &self.limits)
49 .field(
50 "token_counter",
51 &self.token_counter.as_ref().map(|_| "custom"),
52 );
53 #[cfg(feature = "hnsw")]
54 s.field("hnsw", &self.hnsw);
55 s.finish()
56 }
57}
58
59impl Default for MemoryConfig {
60 fn default() -> Self {
61 Self {
62 base_dir: PathBuf::from("memory"),
63 embedding: EmbeddingConfig::default(),
64 search: SearchConfig::default(),
65 chunking: ChunkingConfig::default(),
66 pool: PoolConfig::default(),
67 limits: MemoryLimits::default(),
68 token_counter: None,
69 #[cfg(feature = "hnsw")]
70 hnsw: crate::hnsw::HnswConfig::default(),
71 }
72 }
73}
74
75impl MemoryConfig {
76 pub fn normalize_and_validate(mut self) -> Result<Self, MemoryError> {
80 self.embedding.normalize_and_validate()?;
81 self.limits = self.limits.normalize_and_validate()?;
82 let timeout_cap_secs = self.limits.embedding_timeout.as_secs().max(1);
83 self.embedding.timeout_secs = self.embedding.timeout_secs.min(timeout_cap_secs);
84 self.search
85 .normalize_and_validate(self.embedding.dimensions)?;
86 self.chunking.normalize_and_validate()?;
87 self.pool.normalize_and_validate()?;
88 #[cfg(feature = "hnsw")]
89 {
90 self.hnsw.dimensions = self.embedding.dimensions;
91 }
92 Ok(self)
93 }
94}
95
96#[derive(Debug, Clone, Serialize, Deserialize)]
98pub struct EmbeddingConfig {
99 pub ollama_url: String,
103
104 pub model: String,
106
107 pub dimensions: usize,
109
110 pub batch_size: usize,
112
113 pub timeout_secs: u64,
115}
116
117impl Default for EmbeddingConfig {
118 fn default() -> Self {
119 Self {
120 ollama_url: "http://localhost:11434".to_string(),
121 model: "nomic-embed-text".to_string(),
122 dimensions: 768,
123 batch_size: 32,
124 timeout_secs: 30,
125 }
126 }
127}
128
129impl EmbeddingConfig {
130 fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
131 if self.dimensions == 0 {
132 return Err(MemoryError::InvalidConfig {
133 field: "embedding.dimensions",
134 reason: "dimensions must be at least 1".to_string(),
135 });
136 }
137 if self.batch_size == 0 {
138 self.batch_size = 1;
139 }
140 if self.timeout_secs == 0 {
141 self.timeout_secs = 1;
142 }
143 #[cfg(not(feature = "candle-embedder"))]
147 {
148 let parsed =
149 reqwest::Url::parse(&self.ollama_url).map_err(|_| MemoryError::InvalidConfig {
150 field: "embedding.ollama_url",
151 reason: "must be an absolute http:// or https:// URL".to_string(),
152 })?;
153 match parsed.scheme() {
154 "http" | "https" if parsed.host_str().is_some() => {}
155 _ => {
156 return Err(MemoryError::InvalidConfig {
157 field: "embedding.ollama_url",
158 reason: "must be an absolute http:// or https:// URL".to_string(),
159 })
160 }
161 }
162 }
163 #[cfg(feature = "candle-embedder")]
167 {
168 let _ = &self.ollama_url; }
170 Ok(())
171 }
172}
173
174#[derive(Debug, Clone, Serialize, Deserialize)]
176pub struct SearchConfig {
177 pub bm25_weight: f64,
179
180 pub vector_weight: f64,
182
183 #[serde(default = "default_zero")]
186 pub late_interaction_weight: f64,
187
188 pub rrf_k: f64,
190
191 pub candidate_pool_size: usize,
193
194 pub default_top_k: usize,
196
197 pub min_similarity: f64,
199
200 pub recency_half_life_days: Option<f64>,
205
206 pub recency_weight: f64,
210
211 pub rerank_from_f32: bool,
216
217 #[serde(default)]
220 pub derived_vector_backend: DerivedVectorBackendPolicy,
221
222 #[serde(default = "default_turbo_quant_bits")]
224 pub turbo_quant_bits: u8,
225
226 #[serde(default = "default_turbo_quant_projections")]
228 pub turbo_quant_projections: usize,
229
230 #[serde(default)]
232 pub turbo_quant_seed: u64,
233
234 #[serde(default = "default_true")]
236 pub turbo_quant_require_exact_rerank: bool,
237}
238
239#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
241#[serde(rename_all = "snake_case")]
242pub enum DerivedVectorBackendPolicy {
243 #[default]
245 Disabled,
246 TurboQuantCandidateOnly,
248 ProveKvPoolCandidateOnly,
254}
255
256const fn default_turbo_quant_bits() -> u8 {
257 8
258}
259
260const fn default_turbo_quant_projections() -> usize {
261 64
262}
263
264const fn default_true() -> bool {
265 true
266}
267
268const fn default_zero() -> f64 {
269 0.0
270}
271
272impl Default for SearchConfig {
273 fn default() -> Self {
274 Self {
275 bm25_weight: 1.0,
276 vector_weight: 1.0,
277 late_interaction_weight: 0.0,
278 rrf_k: 60.0,
279 candidate_pool_size: 50,
280 default_top_k: 5,
281 min_similarity: 0.3,
282 recency_half_life_days: None,
283 recency_weight: 0.5,
284 rerank_from_f32: true,
285 derived_vector_backend: DerivedVectorBackendPolicy::Disabled,
286 turbo_quant_bits: default_turbo_quant_bits(),
287 turbo_quant_projections: default_turbo_quant_projections(),
288 turbo_quant_seed: 0,
289 turbo_quant_require_exact_rerank: true,
290 }
291 }
292}
293
294impl SearchConfig {
295 pub(crate) fn uses_turbo_quant_backend(&self) -> bool {
296 self.derived_vector_backend == DerivedVectorBackendPolicy::TurboQuantCandidateOnly
297 }
298
299 pub(crate) fn uses_provekv_pool_backend(&self) -> bool {
300 self.derived_vector_backend == DerivedVectorBackendPolicy::ProveKvPoolCandidateOnly
301 }
302
303 pub(crate) fn uses_derived_vector_backend(&self) -> bool {
304 self.uses_turbo_quant_backend() || self.uses_provekv_pool_backend()
305 }
306
307 fn normalize_and_validate(&mut self, embedding_dimensions: usize) -> Result<(), MemoryError> {
308 #[cfg(not(feature = "turbo-quant-codec"))]
309 let _ = embedding_dimensions;
310 if self.candidate_pool_size == 0 {
311 self.candidate_pool_size = 1;
312 }
313 if self.default_top_k == 0 {
314 self.default_top_k = 1;
315 }
316 self.candidate_pool_size = self.candidate_pool_size.max(self.default_top_k);
317 if !self.rrf_k.is_finite() || self.rrf_k <= 0.0 {
318 return Err(MemoryError::InvalidConfig {
319 field: "search.rrf_k",
320 reason: "rrf_k must be finite and > 0".to_string(),
321 });
322 }
323 if !self.bm25_weight.is_finite() || self.bm25_weight < 0.0 {
324 return Err(MemoryError::InvalidConfig {
325 field: "search.bm25_weight",
326 reason: "bm25_weight must be finite and >= 0".to_string(),
327 });
328 }
329 if !self.vector_weight.is_finite() || self.vector_weight < 0.0 {
330 return Err(MemoryError::InvalidConfig {
331 field: "search.vector_weight",
332 reason: "vector_weight must be finite and >= 0".to_string(),
333 });
334 }
335 if !self.recency_weight.is_finite() || self.recency_weight < 0.0 {
336 return Err(MemoryError::InvalidConfig {
337 field: "search.recency_weight",
338 reason: "recency_weight must be finite and >= 0".to_string(),
339 });
340 }
341 if !self.min_similarity.is_finite() || !(-1.0..=1.0).contains(&self.min_similarity) {
342 return Err(MemoryError::InvalidConfig {
343 field: "search.min_similarity",
344 reason: "min_similarity must be finite and within [-1.0, 1.0]".to_string(),
345 });
346 }
347 if matches!(self.recency_half_life_days, Some(v) if !v.is_finite()) {
348 return Err(MemoryError::InvalidConfig {
349 field: "search.recency_half_life_days",
350 reason: "recency_half_life_days must be finite".to_string(),
351 });
352 }
353 if matches!(self.recency_half_life_days, Some(v) if v <= 0.0) {
354 return Err(MemoryError::InvalidConfig {
355 field: "search.recency_half_life_days",
356 reason: "recency_half_life_days must be > 0 when enabled".to_string(),
357 });
358 }
359 if self.uses_turbo_quant_backend() {
360 #[cfg(not(feature = "turbo-quant-codec"))]
361 {
362 return Err(MemoryError::InvalidConfig {
363 field: "search.derived_vector_backend",
364 reason: "turbo_quant_candidate_only requires the turbo-quant-codec feature"
365 .to_string(),
366 });
367 }
368 #[cfg(feature = "turbo-quant-codec")]
369 {
370 if embedding_dimensions % 2 != 0 {
371 return Err(MemoryError::InvalidConfig {
372 field: "embedding.dimensions",
373 reason: "TurboQuant requires even embedding dimensions".to_string(),
374 });
375 }
376 if self.turbo_quant_projections == 0 {
377 return Err(MemoryError::InvalidConfig {
378 field: "search.turbo_quant_projections",
379 reason: "TurboQuant projections must be at least 1".to_string(),
380 });
381 }
382 if !(2..=16).contains(&self.turbo_quant_bits) {
383 return Err(MemoryError::InvalidConfig {
384 field: "search.turbo_quant_bits",
385 reason: "TurboQuant bits must be within 2..=16".to_string(),
386 });
387 }
388 }
389 }
390 if self.uses_derived_vector_backend() && !self.turbo_quant_require_exact_rerank {
391 return Err(MemoryError::InvalidConfig {
392 field: "search.turbo_quant_require_exact_rerank",
393 reason: "derived vector candidate backends require exact f32 rerank".to_string(),
394 });
395 }
396 Ok(())
397 }
398}
399
400#[derive(Debug, Clone, Serialize, Deserialize)]
402pub struct ChunkingConfig {
403 pub target_size: usize,
405
406 pub min_size: usize,
408
409 pub max_size: usize,
411
412 pub overlap: usize,
414}
415
416impl Default for ChunkingConfig {
417 fn default() -> Self {
418 Self {
419 target_size: 1000,
420 min_size: 100,
421 max_size: 2000,
422 overlap: 200,
423 }
424 }
425}
426
427impl ChunkingConfig {
428 fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
429 if self.min_size == 0 {
430 self.min_size = 1;
431 }
432 if self.max_size == 0 {
433 return Err(MemoryError::InvalidConfig {
434 field: "chunking.max_size",
435 reason: "max_size must be at least 1".to_string(),
436 });
437 }
438 if self.max_size < self.min_size {
439 return Err(MemoryError::InvalidConfig {
440 field: "chunking.max_size",
441 reason: "max_size must be >= min_size".to_string(),
442 });
443 }
444 if self.target_size < self.min_size {
445 self.target_size = self.min_size;
446 }
447 if self.target_size > self.max_size {
448 self.target_size = self.max_size;
449 }
450 if self.overlap >= self.min_size {
451 self.overlap = self.min_size.saturating_sub(1);
452 }
453 Ok(())
454 }
455}
456
457#[derive(Debug, Clone, Serialize, Deserialize)]
462pub struct PoolConfig {
463 pub busy_timeout_ms: u32,
466
467 pub wal_autocheckpoint: u32,
470
471 pub enable_wal: bool,
474
475 pub max_read_connections: usize,
480
481 pub reader_timeout_secs: u64,
484}
485
486impl Default for PoolConfig {
487 fn default() -> Self {
488 Self {
489 busy_timeout_ms: 5000,
490 wal_autocheckpoint: 1000,
491 enable_wal: true,
492 max_read_connections: 4,
493 reader_timeout_secs: 30,
494 }
495 }
496}
497
498impl PoolConfig {
499 fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
500 if self.busy_timeout_ms == 0 {
501 self.busy_timeout_ms = 1;
502 }
503 if self.wal_autocheckpoint == 0 {
504 self.wal_autocheckpoint = 1;
505 }
506 if self.max_read_connections == 0 {
507 return Err(MemoryError::InvalidConfig {
508 field: "pool.max_read_connections",
509 reason: "set pool.max_read_connections to at least 1".to_string(),
510 });
511 }
512 if self.reader_timeout_secs == 0 {
513 self.reader_timeout_secs = 1;
514 }
515 self.reader_timeout_secs = self.reader_timeout_secs.min(300);
516 Ok(())
517 }
518}
519
520#[derive(Debug, Clone, Serialize, Deserialize)]
525pub struct MemoryLimits {
526 pub max_facts_per_namespace: usize,
529
530 pub max_chunks_per_document: usize,
533
534 pub max_content_bytes: usize,
537
538 pub max_embedding_concurrency: usize,
542
543 pub max_db_size_bytes: u64,
546
547 #[serde(with = "duration_secs")]
550 pub embedding_timeout: Duration,
551}
552
553impl Default for MemoryLimits {
554 fn default() -> Self {
555 Self {
556 max_facts_per_namespace: 100_000,
557 max_chunks_per_document: 1_000,
558 max_content_bytes: 1_048_576,
559 max_embedding_concurrency: 8,
560 max_db_size_bytes: 0,
561 embedding_timeout: Duration::from_secs(30),
562 }
563 }
564}
565
566impl MemoryLimits {
567 pub fn normalize_and_validate(mut self) -> Result<Self, MemoryError> {
569 if self.max_facts_per_namespace == 0 {
570 return Err(MemoryError::InvalidConfig {
571 field: "limits.max_facts_per_namespace",
572 reason: "must be at least 1".to_string(),
573 });
574 }
575 if self.max_chunks_per_document == 0 {
576 return Err(MemoryError::InvalidConfig {
577 field: "limits.max_chunks_per_document",
578 reason: "must be at least 1".to_string(),
579 });
580 }
581 if self.max_content_bytes == 0 {
582 return Err(MemoryError::InvalidConfig {
583 field: "limits.max_content_bytes",
584 reason: "must be at least 1".to_string(),
585 });
586 }
587 if self.max_embedding_concurrency > 32 {
589 self.max_embedding_concurrency = 32;
590 }
591 if self.max_embedding_concurrency == 0 {
592 self.max_embedding_concurrency = 1;
593 }
594 if self.embedding_timeout.is_zero() {
595 self.embedding_timeout = Duration::from_secs(1);
596 }
597 Ok(self)
598 }
599
600 pub fn validated(self) -> Self {
605 self.normalize_and_validate().unwrap_or_else(|err| {
606 tracing::warn!(
607 error = %err,
608 "invalid MemoryLimits supplied to validated(); using defaults"
609 );
610 let defaults = Self::default();
612 Self {
613 max_facts_per_namespace: defaults.max_facts_per_namespace,
614 max_chunks_per_document: defaults.max_chunks_per_document,
615 max_content_bytes: defaults.max_content_bytes,
616 max_embedding_concurrency: defaults.max_embedding_concurrency.clamp(1, 32),
617 max_db_size_bytes: defaults.max_db_size_bytes,
618 embedding_timeout: if defaults.embedding_timeout.is_zero() {
619 std::time::Duration::from_secs(1)
620 } else {
621 defaults.embedding_timeout
622 },
623 }
624 })
625 }
626}
627
628mod duration_secs {
629 use serde::{Deserialize, Deserializer, Serializer};
630 use std::time::Duration;
631
632 pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
633 s.serialize_u64(d.as_secs())
634 }
635
636 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
637 let secs = u64::deserialize(d)?;
638 Ok(Duration::from_secs(secs))
639 }
640}