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,
101
102 pub model: String,
104
105 pub dimensions: usize,
107
108 pub batch_size: usize,
110
111 pub timeout_secs: u64,
113}
114
115impl Default for EmbeddingConfig {
116 fn default() -> Self {
117 Self {
118 ollama_url: "http://localhost:11434".to_string(),
119 model: "nomic-embed-text".to_string(),
120 dimensions: 768,
121 batch_size: 32,
122 timeout_secs: 30,
123 }
124 }
125}
126
127impl EmbeddingConfig {
128 fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
129 if self.dimensions == 0 {
130 return Err(MemoryError::InvalidConfig {
131 field: "embedding.dimensions",
132 reason: "dimensions must be at least 1".to_string(),
133 });
134 }
135 if self.batch_size == 0 {
136 self.batch_size = 1;
137 }
138 if self.timeout_secs == 0 {
139 self.timeout_secs = 1;
140 }
141 let parsed =
142 reqwest::Url::parse(&self.ollama_url).map_err(|_| MemoryError::InvalidConfig {
143 field: "embedding.ollama_url",
144 reason: "must be an absolute http:// or https:// URL".to_string(),
145 })?;
146 match parsed.scheme() {
147 "http" | "https" if parsed.host_str().is_some() => {}
148 _ => {
149 return Err(MemoryError::InvalidConfig {
150 field: "embedding.ollama_url",
151 reason: "must be an absolute http:// or https:// URL".to_string(),
152 })
153 }
154 }
155 Ok(())
156 }
157}
158
159#[derive(Debug, Clone, Serialize, Deserialize)]
161pub struct SearchConfig {
162 pub bm25_weight: f64,
164
165 pub vector_weight: f64,
167
168 pub rrf_k: f64,
170
171 pub candidate_pool_size: usize,
173
174 pub default_top_k: usize,
176
177 pub min_similarity: f64,
179
180 pub recency_half_life_days: Option<f64>,
185
186 pub recency_weight: f64,
190
191 pub rerank_from_f32: bool,
196
197 #[serde(default)]
200 pub derived_vector_backend: DerivedVectorBackendPolicy,
201
202 #[serde(default = "default_turbo_quant_bits")]
204 pub turbo_quant_bits: u8,
205
206 #[serde(default = "default_turbo_quant_projections")]
208 pub turbo_quant_projections: usize,
209
210 #[serde(default)]
212 pub turbo_quant_seed: u64,
213
214 #[serde(default = "default_true")]
216 pub turbo_quant_require_exact_rerank: bool,
217
218 #[serde(default = "default_true")]
220 pub fib_quant_require_exact_rerank: bool,
221
222 #[serde(default = "default_fib_quant_block_count")]
224 pub fib_quant_block_count: usize,
225
226 #[serde(default = "default_per_dim_bits")]
227 pub per_dim_bits: u8,
228 #[serde(default = "default_true")]
229 pub per_dim_require_exact_rerank: bool,
230}
231
232#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
234#[serde(rename_all = "snake_case")]
235pub enum DerivedVectorBackendPolicy {
236 #[default]
238 Disabled,
239 TurboQuantCandidateOnly,
241 FibQuantCandidateOnly,
243 PerDimCandidateOnly,
245}
246
247const fn default_per_dim_bits() -> u8 {
248 8
249}
250
251const fn default_turbo_quant_bits() -> u8 {
252 8
253}
254
255const fn default_turbo_quant_projections() -> usize {
256 64
257}
258
259const fn default_true() -> bool {
260 true
261}
262
263const fn default_fib_quant_block_count() -> usize {
264 12
265}
266
267impl Default for SearchConfig {
268 fn default() -> Self {
269 Self {
270 bm25_weight: 1.0,
271 vector_weight: 1.0,
272 rrf_k: 60.0,
273 candidate_pool_size: 50,
274 default_top_k: 5,
275 min_similarity: 0.3,
276 recency_half_life_days: None,
277 recency_weight: 0.5,
278 rerank_from_f32: true,
279 derived_vector_backend: DerivedVectorBackendPolicy::PerDimCandidateOnly,
280 turbo_quant_bits: default_turbo_quant_bits(),
281 turbo_quant_projections: default_turbo_quant_projections(),
282 turbo_quant_seed: 0,
283 turbo_quant_require_exact_rerank: true,
284 fib_quant_require_exact_rerank: true,
285 fib_quant_block_count: default_fib_quant_block_count(),
286 per_dim_bits: default_per_dim_bits(),
287 per_dim_require_exact_rerank: true,
288 }
289 }
290}
291
292impl SearchConfig {
293 pub(crate) fn uses_turbo_quant_backend(&self) -> bool {
294 self.derived_vector_backend == DerivedVectorBackendPolicy::TurboQuantCandidateOnly
295 }
296
297 fn normalize_and_validate(&mut self, embedding_dimensions: usize) -> Result<(), MemoryError> {
298 #[cfg(not(feature = "turbo-quant-codec"))]
299 let _ = embedding_dimensions;
300 if self.candidate_pool_size == 0 {
301 self.candidate_pool_size = 1;
302 }
303 if self.default_top_k == 0 {
304 self.default_top_k = 1;
305 }
306 self.candidate_pool_size = self.candidate_pool_size.max(self.default_top_k);
307 if !self.rrf_k.is_finite() || self.rrf_k <= 0.0 {
308 return Err(MemoryError::InvalidConfig {
309 field: "search.rrf_k",
310 reason: "rrf_k must be finite and > 0".to_string(),
311 });
312 }
313 if !self.bm25_weight.is_finite() || self.bm25_weight < 0.0 {
314 return Err(MemoryError::InvalidConfig {
315 field: "search.bm25_weight",
316 reason: "bm25_weight must be finite and >= 0".to_string(),
317 });
318 }
319 if !self.vector_weight.is_finite() || self.vector_weight < 0.0 {
320 return Err(MemoryError::InvalidConfig {
321 field: "search.vector_weight",
322 reason: "vector_weight must be finite and >= 0".to_string(),
323 });
324 }
325 if !self.recency_weight.is_finite() || self.recency_weight < 0.0 {
326 return Err(MemoryError::InvalidConfig {
327 field: "search.recency_weight",
328 reason: "recency_weight must be finite and >= 0".to_string(),
329 });
330 }
331 if !self.min_similarity.is_finite() || !(-1.0..=1.0).contains(&self.min_similarity) {
332 return Err(MemoryError::InvalidConfig {
333 field: "search.min_similarity",
334 reason: "min_similarity must be finite and within [-1.0, 1.0]".to_string(),
335 });
336 }
337 if matches!(self.recency_half_life_days, Some(v) if !v.is_finite()) {
338 return Err(MemoryError::InvalidConfig {
339 field: "search.recency_half_life_days",
340 reason: "recency_half_life_days must be finite".to_string(),
341 });
342 }
343 if matches!(self.recency_half_life_days, Some(v) if v <= 0.0) {
344 return Err(MemoryError::InvalidConfig {
345 field: "search.recency_half_life_days",
346 reason: "recency_half_life_days must be > 0 when enabled".to_string(),
347 });
348 }
349 if self.uses_turbo_quant_backend() {
350 #[cfg(not(feature = "turbo-quant-codec"))]
351 {
352 return Err(MemoryError::InvalidConfig {
353 field: "search.derived_vector_backend",
354 reason: "turbo_quant_candidate_only requires the turbo-quant-codec feature"
355 .to_string(),
356 });
357 }
358 #[cfg(feature = "turbo-quant-codec")]
359 {
360 if embedding_dimensions % 2 != 0 {
361 return Err(MemoryError::InvalidConfig {
362 field: "embedding.dimensions",
363 reason: "TurboQuant requires even embedding dimensions".to_string(),
364 });
365 }
366 if self.turbo_quant_projections == 0 {
367 return Err(MemoryError::InvalidConfig {
368 field: "search.turbo_quant_projections",
369 reason: "TurboQuant projections must be at least 1".to_string(),
370 });
371 }
372 if !(2..=16).contains(&self.turbo_quant_bits) {
373 return Err(MemoryError::InvalidConfig {
374 field: "search.turbo_quant_bits",
375 reason: "TurboQuant bits must be within 2..=16".to_string(),
376 });
377 }
378 if !self.turbo_quant_require_exact_rerank {
379 return Err(MemoryError::InvalidConfig {
380 field: "search.turbo_quant_require_exact_rerank",
381 reason: "TurboQuant candidate backend requires exact f32 rerank"
382 .to_string(),
383 });
384 }
385 }
386 }
387 if self.derived_vector_backend == DerivedVectorBackendPolicy::PerDimCandidateOnly {
388 #[cfg(not(feature = "per-dim-codec"))]
389 return Err(MemoryError::InvalidConfig {
390 field: "search.derived_vector_backend",
391 reason: "per_dim_candidate_only requires the per-dim-codec feature".into(),
392 });
393 if !(1..=8).contains(&self.per_dim_bits) {
394 return Err(MemoryError::InvalidConfig {
395 field: "search.per_dim_bits",
396 reason: "PerDim bits must be within 1..=8".into(),
397 });
398 }
399 if !self.per_dim_require_exact_rerank {
400 return Err(MemoryError::InvalidConfig {
401 field: "search.per_dim_require_exact_rerank",
402 reason: "PerDim candidate backend requires exact f32 rerank".into(),
403 });
404 }
405 }
406 Ok(())
407 }
408}
409
410#[derive(Debug, Clone, Serialize, Deserialize)]
412pub struct ChunkingConfig {
413 pub target_size: usize,
415
416 pub min_size: usize,
418
419 pub max_size: usize,
421
422 pub overlap: usize,
424}
425
426impl Default for ChunkingConfig {
427 fn default() -> Self {
428 Self {
429 target_size: 1000,
430 min_size: 100,
431 max_size: 2000,
432 overlap: 200,
433 }
434 }
435}
436
437impl ChunkingConfig {
438 fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
439 if self.min_size == 0 {
440 self.min_size = 1;
441 }
442 if self.max_size == 0 {
443 return Err(MemoryError::InvalidConfig {
444 field: "chunking.max_size",
445 reason: "max_size must be at least 1".to_string(),
446 });
447 }
448 if self.max_size < self.min_size {
449 return Err(MemoryError::InvalidConfig {
450 field: "chunking.max_size",
451 reason: "max_size must be >= min_size".to_string(),
452 });
453 }
454 if self.target_size < self.min_size {
455 self.target_size = self.min_size;
456 }
457 if self.target_size > self.max_size {
458 self.target_size = self.max_size;
459 }
460 if self.overlap >= self.min_size {
461 self.overlap = self.min_size.saturating_sub(1);
462 }
463 Ok(())
464 }
465}
466
467#[derive(Debug, Clone, Serialize, Deserialize)]
472pub struct PoolConfig {
473 pub busy_timeout_ms: u32,
476
477 pub wal_autocheckpoint: u32,
480
481 pub enable_wal: bool,
484
485 pub max_read_connections: usize,
490
491 pub reader_timeout_secs: u64,
494}
495
496impl Default for PoolConfig {
497 fn default() -> Self {
498 Self {
499 busy_timeout_ms: 5000,
500 wal_autocheckpoint: 1000,
501 enable_wal: true,
502 max_read_connections: 4,
503 reader_timeout_secs: 30,
504 }
505 }
506}
507
508impl PoolConfig {
509 fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
510 if self.busy_timeout_ms == 0 {
511 self.busy_timeout_ms = 1;
512 }
513 if self.wal_autocheckpoint == 0 {
514 self.wal_autocheckpoint = 1;
515 }
516 if self.max_read_connections == 0 {
517 return Err(MemoryError::InvalidConfig {
518 field: "pool.max_read_connections",
519 reason: "set pool.max_read_connections to at least 1".to_string(),
520 });
521 }
522 if self.reader_timeout_secs == 0 {
523 self.reader_timeout_secs = 1;
524 }
525 self.reader_timeout_secs = self.reader_timeout_secs.min(300);
526 Ok(())
527 }
528}
529
530#[derive(Debug, Clone, Serialize, Deserialize)]
535pub struct MemoryLimits {
536 pub max_facts_per_namespace: usize,
539
540 pub max_chunks_per_document: usize,
543
544 pub max_content_bytes: usize,
547
548 pub max_embedding_concurrency: usize,
552
553 pub max_db_size_bytes: u64,
556
557 #[serde(with = "duration_secs")]
560 pub embedding_timeout: Duration,
561}
562
563impl Default for MemoryLimits {
564 fn default() -> Self {
565 Self {
566 max_facts_per_namespace: 100_000,
567 max_chunks_per_document: 1_000,
568 max_content_bytes: 1_048_576,
569 max_embedding_concurrency: 8,
570 max_db_size_bytes: 0,
571 embedding_timeout: Duration::from_secs(30),
572 }
573 }
574}
575
576impl MemoryLimits {
577 pub fn normalize_and_validate(mut self) -> Result<Self, MemoryError> {
579 if self.max_facts_per_namespace == 0 {
580 return Err(MemoryError::InvalidConfig {
581 field: "limits.max_facts_per_namespace",
582 reason: "must be at least 1".to_string(),
583 });
584 }
585 if self.max_chunks_per_document == 0 {
586 return Err(MemoryError::InvalidConfig {
587 field: "limits.max_chunks_per_document",
588 reason: "must be at least 1".to_string(),
589 });
590 }
591 if self.max_content_bytes == 0 {
592 return Err(MemoryError::InvalidConfig {
593 field: "limits.max_content_bytes",
594 reason: "must be at least 1".to_string(),
595 });
596 }
597 if self.max_embedding_concurrency > 32 {
599 self.max_embedding_concurrency = 32;
600 }
601 if self.max_embedding_concurrency == 0 {
602 self.max_embedding_concurrency = 1;
603 }
604 if self.embedding_timeout.is_zero() {
605 self.embedding_timeout = Duration::from_secs(1);
606 }
607 Ok(self)
608 }
609
610 pub fn validated(self) -> Self {
615 self.normalize_and_validate().unwrap_or_else(|err| {
616 tracing::warn!(
617 error = %err,
618 "invalid MemoryLimits supplied to validated(); using defaults"
619 );
620 let defaults = Self::default();
622 Self {
623 max_facts_per_namespace: defaults.max_facts_per_namespace,
624 max_chunks_per_document: defaults.max_chunks_per_document,
625 max_content_bytes: defaults.max_content_bytes,
626 max_embedding_concurrency: defaults.max_embedding_concurrency.clamp(1, 32),
627 max_db_size_bytes: defaults.max_db_size_bytes,
628 embedding_timeout: if defaults.embedding_timeout.is_zero() {
629 std::time::Duration::from_secs(1)
630 } else {
631 defaults.embedding_timeout
632 },
633 }
634 })
635 }
636}
637
638mod duration_secs {
639 use serde::{Deserialize, Deserializer, Serializer};
640 use std::time::Duration;
641
642 pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
643 s.serialize_u64(d.as_secs())
644 }
645
646 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
647 let secs = u64::deserialize(d)?;
648 Ok(Duration::from_secs(secs))
649 }
650}