use crate::error::MemoryError;
use crate::tokenizer::TokenCounter;
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
use std::sync::Arc;
use std::time::Duration;
#[derive(Clone, Serialize, Deserialize)]
pub struct MemoryConfig {
pub base_dir: PathBuf,
pub embedding: EmbeddingConfig,
pub search: SearchConfig,
pub chunking: ChunkingConfig,
pub pool: PoolConfig,
pub limits: MemoryLimits,
#[serde(skip)]
pub token_counter: Option<Arc<dyn TokenCounter>>,
#[cfg(feature = "hnsw")]
#[serde(skip)]
pub hnsw: crate::hnsw::HnswConfig,
}
impl std::fmt::Debug for MemoryConfig {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
let mut s = f.debug_struct("MemoryConfig");
s.field("base_dir", &self.base_dir)
.field("embedding", &self.embedding)
.field("search", &self.search)
.field("chunking", &self.chunking)
.field("pool", &self.pool)
.field("limits", &self.limits)
.field(
"token_counter",
&self.token_counter.as_ref().map(|_| "custom"),
);
#[cfg(feature = "hnsw")]
s.field("hnsw", &self.hnsw);
s.finish()
}
}
impl Default for MemoryConfig {
fn default() -> Self {
Self {
base_dir: PathBuf::from("memory"),
embedding: EmbeddingConfig::default(),
search: SearchConfig::default(),
chunking: ChunkingConfig::default(),
pool: PoolConfig::default(),
limits: MemoryLimits::default(),
token_counter: None,
#[cfg(feature = "hnsw")]
hnsw: crate::hnsw::HnswConfig::default(),
}
}
}
impl MemoryConfig {
pub fn normalize_and_validate(mut self) -> Result<Self, MemoryError> {
self.embedding.normalize_and_validate()?;
self.limits = self.limits.normalize_and_validate()?;
let timeout_cap_secs = self.limits.embedding_timeout.as_secs().max(1);
self.embedding.timeout_secs = self.embedding.timeout_secs.min(timeout_cap_secs);
self.search.normalize_and_validate()?;
self.chunking.normalize_and_validate()?;
self.pool.normalize_and_validate()?;
#[cfg(feature = "hnsw")]
{
self.hnsw.dimensions = self.embedding.dimensions;
}
Ok(self)
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct EmbeddingConfig {
pub ollama_url: String,
pub model: String,
pub dimensions: usize,
pub batch_size: usize,
pub timeout_secs: u64,
}
impl Default for EmbeddingConfig {
fn default() -> Self {
Self {
ollama_url: "http://localhost:11434".to_string(),
model: "nomic-embed-text".to_string(),
dimensions: 768,
batch_size: 32,
timeout_secs: 30,
}
}
}
impl EmbeddingConfig {
fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
if self.dimensions == 0 {
return Err(MemoryError::InvalidConfig {
field: "embedding.dimensions",
reason: "dimensions must be at least 1".to_string(),
});
}
if self.batch_size == 0 {
self.batch_size = 1;
}
if self.timeout_secs == 0 {
self.timeout_secs = 1;
}
let parsed =
reqwest::Url::parse(&self.ollama_url).map_err(|_| MemoryError::InvalidConfig {
field: "embedding.ollama_url",
reason: "must be an absolute http:// or https:// URL".to_string(),
})?;
match parsed.scheme() {
"http" | "https" if parsed.host_str().is_some() => {}
_ => {
return Err(MemoryError::InvalidConfig {
field: "embedding.ollama_url",
reason: "must be an absolute http:// or https:// URL".to_string(),
})
}
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SearchConfig {
pub bm25_weight: f64,
pub vector_weight: f64,
pub rrf_k: f64,
pub candidate_pool_size: usize,
pub default_top_k: usize,
pub min_similarity: f64,
pub recency_half_life_days: Option<f64>,
pub recency_weight: f64,
pub rerank_from_f32: bool,
}
impl Default for SearchConfig {
fn default() -> Self {
Self {
bm25_weight: 1.0,
vector_weight: 1.0,
rrf_k: 60.0,
candidate_pool_size: 50,
default_top_k: 5,
min_similarity: 0.3,
recency_half_life_days: None,
recency_weight: 0.5,
rerank_from_f32: true,
}
}
}
impl SearchConfig {
fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
if self.candidate_pool_size == 0 {
self.candidate_pool_size = 1;
}
if self.default_top_k == 0 {
self.default_top_k = 1;
}
self.candidate_pool_size = self.candidate_pool_size.max(self.default_top_k);
if !self.rrf_k.is_finite() || self.rrf_k <= 0.0 {
return Err(MemoryError::InvalidConfig {
field: "search.rrf_k",
reason: "rrf_k must be finite and > 0".to_string(),
});
}
if !self.bm25_weight.is_finite() || self.bm25_weight < 0.0 {
return Err(MemoryError::InvalidConfig {
field: "search.bm25_weight",
reason: "bm25_weight must be finite and >= 0".to_string(),
});
}
if !self.vector_weight.is_finite() || self.vector_weight < 0.0 {
return Err(MemoryError::InvalidConfig {
field: "search.vector_weight",
reason: "vector_weight must be finite and >= 0".to_string(),
});
}
if !self.recency_weight.is_finite() || self.recency_weight < 0.0 {
return Err(MemoryError::InvalidConfig {
field: "search.recency_weight",
reason: "recency_weight must be finite and >= 0".to_string(),
});
}
if !self.min_similarity.is_finite() || !(-1.0..=1.0).contains(&self.min_similarity) {
return Err(MemoryError::InvalidConfig {
field: "search.min_similarity",
reason: "min_similarity must be finite and within [-1.0, 1.0]".to_string(),
});
}
if matches!(self.recency_half_life_days, Some(v) if !v.is_finite()) {
return Err(MemoryError::InvalidConfig {
field: "search.recency_half_life_days",
reason: "recency_half_life_days must be finite".to_string(),
});
}
if matches!(self.recency_half_life_days, Some(v) if v <= 0.0) {
return Err(MemoryError::InvalidConfig {
field: "search.recency_half_life_days",
reason: "recency_half_life_days must be > 0 when enabled".to_string(),
});
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ChunkingConfig {
pub target_size: usize,
pub min_size: usize,
pub max_size: usize,
pub overlap: usize,
}
impl Default for ChunkingConfig {
fn default() -> Self {
Self {
target_size: 1000,
min_size: 100,
max_size: 2000,
overlap: 200,
}
}
}
impl ChunkingConfig {
fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
if self.min_size == 0 {
self.min_size = 1;
}
if self.max_size == 0 {
return Err(MemoryError::InvalidConfig {
field: "chunking.max_size",
reason: "max_size must be at least 1".to_string(),
});
}
if self.max_size < self.min_size {
return Err(MemoryError::InvalidConfig {
field: "chunking.max_size",
reason: "max_size must be >= min_size".to_string(),
});
}
if self.target_size < self.min_size {
self.target_size = self.min_size;
}
if self.target_size > self.max_size {
self.target_size = self.max_size;
}
if self.overlap >= self.min_size {
self.overlap = self.min_size.saturating_sub(1);
}
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PoolConfig {
pub busy_timeout_ms: u32,
pub wal_autocheckpoint: u32,
pub enable_wal: bool,
pub max_read_connections: usize,
pub reader_timeout_secs: u64,
}
impl Default for PoolConfig {
fn default() -> Self {
Self {
busy_timeout_ms: 5000,
wal_autocheckpoint: 1000,
enable_wal: true,
max_read_connections: 4,
reader_timeout_secs: 30,
}
}
}
impl PoolConfig {
fn normalize_and_validate(&mut self) -> Result<(), MemoryError> {
if self.busy_timeout_ms == 0 {
self.busy_timeout_ms = 1;
}
if self.wal_autocheckpoint == 0 {
self.wal_autocheckpoint = 1;
}
if self.max_read_connections == 0 {
return Err(MemoryError::InvalidConfig {
field: "pool.max_read_connections",
reason: "set pool.max_read_connections to at least 1".to_string(),
});
}
if self.reader_timeout_secs == 0 {
self.reader_timeout_secs = 1;
}
self.reader_timeout_secs = self.reader_timeout_secs.min(300);
Ok(())
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct MemoryLimits {
pub max_facts_per_namespace: usize,
pub max_chunks_per_document: usize,
pub max_content_bytes: usize,
pub max_embedding_concurrency: usize,
pub max_db_size_bytes: u64,
#[serde(with = "duration_secs")]
pub embedding_timeout: Duration,
}
impl Default for MemoryLimits {
fn default() -> Self {
Self {
max_facts_per_namespace: 100_000,
max_chunks_per_document: 1_000,
max_content_bytes: 1_048_576,
max_embedding_concurrency: 8,
max_db_size_bytes: 0,
embedding_timeout: Duration::from_secs(30),
}
}
}
impl MemoryLimits {
pub fn normalize_and_validate(mut self) -> Result<Self, MemoryError> {
if self.max_facts_per_namespace == 0 {
return Err(MemoryError::InvalidConfig {
field: "limits.max_facts_per_namespace",
reason: "must be at least 1".to_string(),
});
}
if self.max_chunks_per_document == 0 {
return Err(MemoryError::InvalidConfig {
field: "limits.max_chunks_per_document",
reason: "must be at least 1".to_string(),
});
}
if self.max_content_bytes == 0 {
return Err(MemoryError::InvalidConfig {
field: "limits.max_content_bytes",
reason: "must be at least 1".to_string(),
});
}
if self.max_embedding_concurrency > 32 {
self.max_embedding_concurrency = 32;
}
if self.max_embedding_concurrency == 0 {
self.max_embedding_concurrency = 1;
}
if self.embedding_timeout.is_zero() {
self.embedding_timeout = Duration::from_secs(1);
}
Ok(self)
}
pub fn validated(self) -> Self {
self.normalize_and_validate().unwrap_or_else(|err| {
tracing::warn!(
error = %err,
"invalid MemoryLimits supplied to validated(); using defaults"
);
let defaults = Self::default();
Self {
max_facts_per_namespace: defaults.max_facts_per_namespace,
max_chunks_per_document: defaults.max_chunks_per_document,
max_content_bytes: defaults.max_content_bytes,
max_embedding_concurrency: defaults.max_embedding_concurrency.clamp(1, 32),
max_db_size_bytes: defaults.max_db_size_bytes,
embedding_timeout: if defaults.embedding_timeout.is_zero() {
std::time::Duration::from_secs(1)
} else {
defaults.embedding_timeout
},
}
})
}
}
mod duration_secs {
use serde::{Deserialize, Deserializer, Serializer};
use std::time::Duration;
pub fn serialize<S: Serializer>(d: &Duration, s: S) -> Result<S::Ok, S::Error> {
s.serialize_u64(d.as_secs())
}
pub fn deserialize<'de, D: Deserializer<'de>>(d: D) -> Result<Duration, D::Error> {
let secs = u64::deserialize(d)?;
Ok(Duration::from_secs(secs))
}
}