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 #[serde(default = "default_zero")]
171 pub late_interaction_weight: f64,
172
173 pub rrf_k: f64,
175
176 pub candidate_pool_size: usize,
178
179 pub default_top_k: usize,
181
182 pub min_similarity: f64,
184
185 pub recency_half_life_days: Option<f64>,
190
191 pub recency_weight: f64,
195
196 pub rerank_from_f32: bool,
201
202 #[serde(default)]
205 pub derived_vector_backend: DerivedVectorBackendPolicy,
206
207 #[serde(default = "default_turbo_quant_bits")]
209 pub turbo_quant_bits: u8,
210
211 #[serde(default = "default_turbo_quant_projections")]
213 pub turbo_quant_projections: usize,
214
215 #[serde(default)]
217 pub turbo_quant_seed: u64,
218
219 #[serde(default = "default_true")]
221 pub turbo_quant_require_exact_rerank: bool,
222}
223
224#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
226#[serde(rename_all = "snake_case")]
227pub enum DerivedVectorBackendPolicy {
228 #[default]
230 Disabled,
231 TurboQuantCandidateOnly,
233 ProveKvPoolCandidateOnly,
239}
240
241const fn default_turbo_quant_bits() -> u8 {
242 8
243}
244
245const fn default_turbo_quant_projections() -> usize {
246 64
247}
248
249const fn default_true() -> bool {
250 true
251}
252
253const fn default_zero() -> f64 {
254 0.0
255}
256
257impl Default for SearchConfig {
258 fn default() -> Self {
259 Self {
260 bm25_weight: 1.0,
261 vector_weight: 1.0,
262 late_interaction_weight: 0.0,
263 rrf_k: 60.0,
264 candidate_pool_size: 50,
265 default_top_k: 5,
266 min_similarity: 0.3,
267 recency_half_life_days: None,
268 recency_weight: 0.5,
269 rerank_from_f32: true,
270 derived_vector_backend: DerivedVectorBackendPolicy::Disabled,
271 turbo_quant_bits: default_turbo_quant_bits(),
272 turbo_quant_projections: default_turbo_quant_projections(),
273 turbo_quant_seed: 0,
274 turbo_quant_require_exact_rerank: true,
275 }
276 }
277}
278
279impl SearchConfig {
280 pub(crate) fn uses_turbo_quant_backend(&self) -> bool {
281 self.derived_vector_backend == DerivedVectorBackendPolicy::TurboQuantCandidateOnly
282 }
283
284 pub(crate) fn uses_provekv_pool_backend(&self) -> bool {
285 self.derived_vector_backend == DerivedVectorBackendPolicy::ProveKvPoolCandidateOnly
286 }
287
288 pub(crate) fn uses_derived_vector_backend(&self) -> bool {
289 self.uses_turbo_quant_backend() || self.uses_provekv_pool_backend()
290 }
291
292 fn normalize_and_validate(&mut self, embedding_dimensions: usize) -> Result<(), MemoryError> {
293 #[cfg(not(feature = "turbo-quant-codec"))]
294 let _ = embedding_dimensions;
295 if self.candidate_pool_size == 0 {
296 self.candidate_pool_size = 1;
297 }
298 if self.default_top_k == 0 {
299 self.default_top_k = 1;
300 }
301 self.candidate_pool_size = self.candidate_pool_size.max(self.default_top_k);
302 if !self.rrf_k.is_finite() || self.rrf_k <= 0.0 {
303 return Err(MemoryError::InvalidConfig {
304 field: "search.rrf_k",
305 reason: "rrf_k must be finite and > 0".to_string(),
306 });
307 }
308 if !self.bm25_weight.is_finite() || self.bm25_weight < 0.0 {
309 return Err(MemoryError::InvalidConfig {
310 field: "search.bm25_weight",
311 reason: "bm25_weight must be finite and >= 0".to_string(),
312 });
313 }
314 if !self.vector_weight.is_finite() || self.vector_weight < 0.0 {
315 return Err(MemoryError::InvalidConfig {
316 field: "search.vector_weight",
317 reason: "vector_weight must be finite and >= 0".to_string(),
318 });
319 }
320 if !self.recency_weight.is_finite() || self.recency_weight < 0.0 {
321 return Err(MemoryError::InvalidConfig {
322 field: "search.recency_weight",
323 reason: "recency_weight must be finite and >= 0".to_string(),
324 });
325 }
326 if !self.min_similarity.is_finite() || !(-1.0..=1.0).contains(&self.min_similarity) {
327 return Err(MemoryError::InvalidConfig {
328 field: "search.min_similarity",
329 reason: "min_similarity must be finite and within [-1.0, 1.0]".to_string(),
330 });
331 }
332 if matches!(self.recency_half_life_days, Some(v) if !v.is_finite()) {
333 return Err(MemoryError::InvalidConfig {
334 field: "search.recency_half_life_days",
335 reason: "recency_half_life_days must be finite".to_string(),
336 });
337 }
338 if matches!(self.recency_half_life_days, Some(v) if v <= 0.0) {
339 return Err(MemoryError::InvalidConfig {
340 field: "search.recency_half_life_days",
341 reason: "recency_half_life_days must be > 0 when enabled".to_string(),
342 });
343 }
344 if self.uses_turbo_quant_backend() {
345 #[cfg(not(feature = "turbo-quant-codec"))]
346 {
347 return Err(MemoryError::InvalidConfig {
348 field: "search.derived_vector_backend",
349 reason: "turbo_quant_candidate_only requires the turbo-quant-codec feature"
350 .to_string(),
351 });
352 }
353 #[cfg(feature = "turbo-quant-codec")]
354 {
355 if embedding_dimensions % 2 != 0 {
356 return Err(MemoryError::InvalidConfig {
357 field: "embedding.dimensions",
358 reason: "TurboQuant requires even embedding dimensions".to_string(),
359 });
360 }
361 if self.turbo_quant_projections == 0 {
362 return Err(MemoryError::InvalidConfig {
363 field: "search.turbo_quant_projections",
364 reason: "TurboQuant projections must be at least 1".to_string(),
365 });
366 }
367 if !(2..=16).contains(&self.turbo_quant_bits) {
368 return Err(MemoryError::InvalidConfig {
369 field: "search.turbo_quant_bits",
370 reason: "TurboQuant bits must be within 2..=16".to_string(),
371 });
372 }
373 }
374 }
375 if self.uses_derived_vector_backend() && !self.turbo_quant_require_exact_rerank {
376 return Err(MemoryError::InvalidConfig {
377 field: "search.turbo_quant_require_exact_rerank",
378 reason: "derived vector candidate backends require exact f32 rerank".to_string(),
379 });
380 }
381 Ok(())
382 }
383}
384
385#[derive(Debug, Clone, Serialize, Deserialize)]
387pub struct ChunkingConfig {
388 pub target_size: usize,
390
391 pub min_size: usize,
393
394 pub max_size: usize,
396
397 pub overlap: usize,
399}
400
401impl Default for ChunkingConfig {
402 fn default() -> Self {
403 Self {
404 target_size: 1000,
405 min_size: 100,
406 max_size: 2000,
407 overlap: 200,
408 }
409 }
410}
411
412impl ChunkingConfig {
413 fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
414 if self.min_size == 0 {
415 self.min_size = 1;
416 }
417 if self.max_size == 0 {
418 return Err(MemoryError::InvalidConfig {
419 field: "chunking.max_size",
420 reason: "max_size must be at least 1".to_string(),
421 });
422 }
423 if self.max_size < self.min_size {
424 return Err(MemoryError::InvalidConfig {
425 field: "chunking.max_size",
426 reason: "max_size must be >= min_size".to_string(),
427 });
428 }
429 if self.target_size < self.min_size {
430 self.target_size = self.min_size;
431 }
432 if self.target_size > self.max_size {
433 self.target_size = self.max_size;
434 }
435 if self.overlap >= self.min_size {
436 self.overlap = self.min_size.saturating_sub(1);
437 }
438 Ok(())
439 }
440}
441
442#[derive(Debug, Clone, Serialize, Deserialize)]
447pub struct PoolConfig {
448 pub busy_timeout_ms: u32,
451
452 pub wal_autocheckpoint: u32,
455
456 pub enable_wal: bool,
459
460 pub max_read_connections: usize,
465
466 pub reader_timeout_secs: u64,
469}
470
471impl Default for PoolConfig {
472 fn default() -> Self {
473 Self {
474 busy_timeout_ms: 5000,
475 wal_autocheckpoint: 1000,
476 enable_wal: true,
477 max_read_connections: 4,
478 reader_timeout_secs: 30,
479 }
480 }
481}
482
483impl PoolConfig {
484 fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
485 if self.busy_timeout_ms == 0 {
486 self.busy_timeout_ms = 1;
487 }
488 if self.wal_autocheckpoint == 0 {
489 self.wal_autocheckpoint = 1;
490 }
491 if self.max_read_connections == 0 {
492 return Err(MemoryError::InvalidConfig {
493 field: "pool.max_read_connections",
494 reason: "set pool.max_read_connections to at least 1".to_string(),
495 });
496 }
497 if self.reader_timeout_secs == 0 {
498 self.reader_timeout_secs = 1;
499 }
500 self.reader_timeout_secs = self.reader_timeout_secs.min(300);
501 Ok(())
502 }
503}
504
505#[derive(Debug, Clone, Serialize, Deserialize)]
510pub struct MemoryLimits {
511 pub max_facts_per_namespace: usize,
514
515 pub max_chunks_per_document: usize,
518
519 pub max_content_bytes: usize,
522
523 pub max_embedding_concurrency: usize,
527
528 pub max_db_size_bytes: u64,
531
532 #[serde(with = "duration_secs")]
535 pub embedding_timeout: Duration,
536}
537
538impl Default for MemoryLimits {
539 fn default() -> Self {
540 Self {
541 max_facts_per_namespace: 100_000,
542 max_chunks_per_document: 1_000,
543 max_content_bytes: 1_048_576,
544 max_embedding_concurrency: 8,
545 max_db_size_bytes: 0,
546 embedding_timeout: Duration::from_secs(30),
547 }
548 }
549}
550
551impl MemoryLimits {
552 pub fn normalize_and_validate(mut self) -> Result<Self, MemoryError> {
554 if self.max_facts_per_namespace == 0 {
555 return Err(MemoryError::InvalidConfig {
556 field: "limits.max_facts_per_namespace",
557 reason: "must be at least 1".to_string(),
558 });
559 }
560 if self.max_chunks_per_document == 0 {
561 return Err(MemoryError::InvalidConfig {
562 field: "limits.max_chunks_per_document",
563 reason: "must be at least 1".to_string(),
564 });
565 }
566 if self.max_content_bytes == 0 {
567 return Err(MemoryError::InvalidConfig {
568 field: "limits.max_content_bytes",
569 reason: "must be at least 1".to_string(),
570 });
571 }
572 if self.max_embedding_concurrency > 32 {
574 self.max_embedding_concurrency = 32;
575 }
576 if self.max_embedding_concurrency == 0 {
577 self.max_embedding_concurrency = 1;
578 }
579 if self.embedding_timeout.is_zero() {
580 self.embedding_timeout = Duration::from_secs(1);
581 }
582 Ok(self)
583 }
584
585 pub fn validated(self) -> Self {
590 self.normalize_and_validate().unwrap_or_else(|err| {
591 tracing::warn!(
592 error = %err,
593 "invalid MemoryLimits supplied to validated(); using defaults"
594 );
595 let defaults = Self::default();
597 Self {
598 max_facts_per_namespace: defaults.max_facts_per_namespace,
599 max_chunks_per_document: defaults.max_chunks_per_document,
600 max_content_bytes: defaults.max_content_bytes,
601 max_embedding_concurrency: defaults.max_embedding_concurrency.clamp(1, 32),
602 max_db_size_bytes: defaults.max_db_size_bytes,
603 embedding_timeout: if defaults.embedding_timeout.is_zero() {
604 std::time::Duration::from_secs(1)
605 } else {
606 defaults.embedding_timeout
607 },
608 }
609 })
610 }
611}
612
613mod duration_secs {
614 use serde::{Deserialize, Deserializer, Serializer};
615 use std::time::Duration;
616
617 pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
618 s.serialize_u64(d.as_secs())
619 }
620
621 pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
622 let secs = u64::deserialize(d)?;
623 Ok(Duration::from_secs(secs))
624 }
625}