1use crate::model::RetrievalMode;
8use crate::{RagError, Result};
9use std::str::FromStr;
10
11#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum DbBackend {
14 Sqlite,
16 Postgres,
18 Memory,
20}
21
22#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum EmbedProvider {
25 Ollama,
27 Gemini,
29 Onnx,
31 Hash,
33}
34
35#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum SourceKind {
38 Folder,
40 Ftp,
42 Sftp,
44}
45
46#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum QueueKind {
49 Memory,
51 RabbitMq,
53 Redis,
55}
56
57#[derive(Debug, Clone)]
59pub struct RagConfig {
60 pub db_backend: DbBackend,
62 pub database_url: String,
63
64 pub embed_provider: EmbedProvider,
66 pub embed_model: String,
67 pub embed_dim: usize,
68 pub ollama_base_url: String,
69 pub gemini_api_key: Option<String>,
70 pub gemini_model: String,
71 pub embed_onnx_path: String,
72 pub embed_tokenizer_path: String,
73
74 pub openrouter_api_key: Option<String>,
76 pub openrouter_base_url: String,
77 pub llm_model: String,
78
79 pub chunker: ChunkerKind,
81 pub chunk_size: usize,
82 pub chunk_overlap: f32,
83 pub chunk_unit: ChunkUnit,
84 pub chunk_tokenizer: Option<String>,
87
88 pub retrieval_mode: RetrievalMode,
90 pub top_k: usize,
91 pub rrf_k: f32,
92 pub multiquery_n: usize,
93
94 pub source: SourceKind,
96 pub source_path: String,
97 pub source_url: Option<String>,
98 pub source_user: Option<String>,
99 pub source_password: Option<String>,
100 pub documents_output: Option<String>,
103
104 pub queue: QueueKind,
106 pub rabbitmq_url: Option<String>,
107 pub redis_url: Option<String>,
108
109 pub http_addr: String,
112 pub api_keys: Vec<String>,
115}
116
117#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum ChunkerKind {
120 Window,
124 Hierarchical,
127 Hybrid,
130}
131
132#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum ChunkUnit {
135 Word,
137 Token,
139}
140
141impl Default for RagConfig {
142 fn default() -> Self {
143 RagConfig {
144 db_backend: DbBackend::Sqlite,
145 database_url: "sqlite://data/rag.db".to_string(),
146 embed_provider: EmbedProvider::Ollama,
147 embed_model: "bge-m3".to_string(),
148 embed_dim: 1024,
149 ollama_base_url: "http://localhost:11434".to_string(),
150 gemini_api_key: None,
151 gemini_model: "gemini-embedding-001".to_string(),
152 embed_onnx_path: "models/embed/bge-m3.onnx".to_string(),
153 embed_tokenizer_path: "models/embed/tokenizer.json".to_string(),
154 openrouter_api_key: None,
155 openrouter_base_url: "https://openrouter.ai/api/v1".to_string(),
156 llm_model: "deepseek/deepseek-chat".to_string(),
157 chunker: ChunkerKind::Window,
158 chunk_size: 300,
159 chunk_overlap: 0.05,
160 chunk_unit: ChunkUnit::Word,
161 chunk_tokenizer: None,
162 retrieval_mode: RetrievalMode::Hybrid,
163 top_k: 5,
164 rrf_k: 60.0,
165 multiquery_n: 4,
166 source: SourceKind::Folder,
167 source_path: "./input".to_string(),
168 source_url: None,
169 source_user: None,
170 source_password: None,
171 documents_output: None,
172 queue: QueueKind::Memory,
173 rabbitmq_url: None,
174 redis_url: None,
175 http_addr: "127.0.0.1:8080".to_string(),
176 api_keys: Vec::new(),
177 }
178 }
179}
180
181impl RagConfig {
182 pub fn from_env() -> Result<Self> {
188 let _ = dotenvy::dotenv();
190 Self::from_env_inner()
191 }
192
193 fn from_env_inner() -> Result<Self> {
194 let d = RagConfig::default();
195 let cfg = RagConfig {
196 db_backend: match env_str("RAG_DB_BACKEND") {
197 Some(s) => parse_db_backend(&s)?,
198 None => d.db_backend,
199 },
200 database_url: env_str("RAG_DATABASE_URL").unwrap_or(d.database_url),
201 embed_provider: match env_str("RAG_EMBED_PROVIDER") {
202 Some(s) => parse_embed_provider(&s)?,
203 None => d.embed_provider,
204 },
205 embed_model: env_str("RAG_EMBED_MODEL").unwrap_or(d.embed_model),
206 embed_dim: env_parse("RAG_EMBED_DIM", d.embed_dim)?,
207 ollama_base_url: env_str("OLLAMA_BASE_URL").unwrap_or(d.ollama_base_url),
208 gemini_api_key: env_str("GEMINI_API_KEY"),
209 gemini_model: env_str("RAG_GEMINI_MODEL").unwrap_or(d.gemini_model),
210 embed_onnx_path: env_str("RAG_EMBED_ONNX_PATH").unwrap_or(d.embed_onnx_path),
211 embed_tokenizer_path: env_str("RAG_EMBED_TOKENIZER").unwrap_or(d.embed_tokenizer_path),
212 openrouter_api_key: env_str("OPENROUTER_API_KEY"),
213 openrouter_base_url: env_str("OPENROUTER_BASE_URL").unwrap_or(d.openrouter_base_url),
214 llm_model: env_str("RAG_LLM_MODEL").unwrap_or(d.llm_model),
215 chunker: match env_str("RAG_CHUNKER") {
216 Some(s) => parse_chunker_kind(&s)?,
217 None => d.chunker,
218 },
219 chunk_size: env_parse("RAG_CHUNK_SIZE", d.chunk_size)?,
220 chunk_overlap: env_parse("RAG_CHUNK_OVERLAP", d.chunk_overlap)?,
221 chunk_unit: match env_str("RAG_CHUNK_UNIT") {
222 Some(s) => parse_chunk_unit(&s)?,
223 None => d.chunk_unit,
224 },
225 chunk_tokenizer: env_str("RAG_CHUNK_TOKENIZER"),
226 retrieval_mode: match env_str("RAG_RETRIEVAL_MODE") {
227 Some(s) => RetrievalMode::from_str(&s)?,
228 None => d.retrieval_mode,
229 },
230 top_k: env_parse("RAG_TOP_K", d.top_k)?,
231 rrf_k: env_parse("RAG_RRF_K", d.rrf_k)?,
232 multiquery_n: env_parse("RAG_MULTIQUERY_N", d.multiquery_n)?,
233 source: match env_str("RAG_SOURCE") {
234 Some(s) => parse_source_kind(&s)?,
235 None => d.source,
236 },
237 source_path: env_str("RAG_SOURCE_PATH").unwrap_or(d.source_path),
238 source_url: env_str("RAG_SOURCE_URL"),
239 source_user: env_str("RAG_SOURCE_USER"),
240 source_password: env_str("RAG_SOURCE_PASSWORD"),
241 documents_output: env_str("RAG_DOCUMENTS_OUTPUT"),
242 queue: match env_str("RAG_QUEUE") {
243 Some(s) => parse_queue_kind(&s)?,
244 None => d.queue,
245 },
246 rabbitmq_url: env_str("RABBITMQ_URL"),
247 redis_url: env_str("REDIS_URL"),
248 http_addr: env_str("RAG_HTTP_ADDR").unwrap_or(d.http_addr),
249 api_keys: env_str("RAG_API_KEYS")
250 .map(|s| {
251 s.split(',')
252 .map(|k| k.trim().to_string())
253 .filter(|k| !k.is_empty())
254 .collect()
255 })
256 .unwrap_or_default(),
257 };
258 cfg.validate()?;
259 Ok(cfg)
260 }
261
262 pub fn validate(&self) -> Result<()> {
264 if self.embed_dim == 0 {
265 return Err(RagError::config("RAG_EMBED_DIM must be > 0"));
266 }
267 if self.chunk_size == 0 {
268 return Err(RagError::config("RAG_CHUNK_SIZE must be > 0"));
269 }
270 if !(0.0..0.95).contains(&self.chunk_overlap) {
271 return Err(RagError::config("RAG_CHUNK_OVERLAP must be in [0.0, 0.95)"));
272 }
273 if self.chunker == ChunkerKind::Hybrid
274 && self.chunk_tokenizer.is_none()
275 && !std::path::Path::new(docling::chunker::DEFAULT_TOKENIZER_PATH).exists()
276 {
277 return Err(RagError::config(format!(
278 "RAG_CHUNKER=hybrid needs a HuggingFace tokenizer.json: set RAG_CHUNK_TOKENIZER \
279 or run scripts/install/download_dependencies.sh (populates {})",
280 docling::chunker::DEFAULT_TOKENIZER_PATH
281 )));
282 }
283 if self.top_k == 0 {
284 return Err(RagError::config("RAG_TOP_K must be > 0"));
285 }
286 Ok(())
287 }
288}
289
290fn env_str(key: &str) -> Option<String> {
291 match std::env::var(key) {
292 Ok(v) if !v.trim().is_empty() => Some(v.trim().to_string()),
293 _ => None,
294 }
295}
296
297fn env_parse<T>(key: &str, default: T) -> Result<T>
298where
299 T: FromStr,
300 T::Err: std::fmt::Display,
301{
302 match env_str(key) {
303 Some(s) => s
304 .parse::<T>()
305 .map_err(|e| RagError::config(format!("{key}: invalid value '{s}': {e}"))),
306 None => Ok(default),
307 }
308}
309
310fn parse_db_backend(s: &str) -> Result<DbBackend> {
311 match s.to_ascii_lowercase().as_str() {
312 "sqlite" => Ok(DbBackend::Sqlite),
313 "postgres" | "postgresql" | "pg" => Ok(DbBackend::Postgres),
314 "memory" | "mem" | "inmemory" => Ok(DbBackend::Memory),
315 other => Err(RagError::config(format!(
316 "unknown RAG_DB_BACKEND '{other}'"
317 ))),
318 }
319}
320
321fn parse_embed_provider(s: &str) -> Result<EmbedProvider> {
322 match s.to_ascii_lowercase().as_str() {
323 "ollama" => Ok(EmbedProvider::Ollama),
324 "gemini" | "google" => Ok(EmbedProvider::Gemini),
325 "onnx" | "local" => Ok(EmbedProvider::Onnx),
326 "hash" | "test" | "fake" => Ok(EmbedProvider::Hash),
327 other => Err(RagError::config(format!(
328 "unknown RAG_EMBED_PROVIDER '{other}'"
329 ))),
330 }
331}
332
333fn parse_chunker_kind(s: &str) -> Result<ChunkerKind> {
334 match s.to_ascii_lowercase().as_str() {
335 "window" => Ok(ChunkerKind::Window),
336 "hierarchical" => Ok(ChunkerKind::Hierarchical),
337 "hybrid" => Ok(ChunkerKind::Hybrid),
338 other => Err(RagError::config(format!(
339 "unknown RAG_CHUNKER '{other}' (expected: window, hierarchical, hybrid)"
340 ))),
341 }
342}
343
344fn parse_chunk_unit(s: &str) -> Result<ChunkUnit> {
345 match s.to_ascii_lowercase().as_str() {
346 "word" | "words" => Ok(ChunkUnit::Word),
347 "token" | "tokens" => Ok(ChunkUnit::Token),
348 other => Err(RagError::config(format!(
349 "unknown RAG_CHUNK_UNIT '{other}'"
350 ))),
351 }
352}
353
354fn parse_source_kind(s: &str) -> Result<SourceKind> {
355 match s.to_ascii_lowercase().as_str() {
356 "folder" | "dir" | "directory" | "local" => Ok(SourceKind::Folder),
357 "ftp" => Ok(SourceKind::Ftp),
358 "sftp" => Ok(SourceKind::Sftp),
359 other => Err(RagError::config(format!("unknown RAG_SOURCE '{other}'"))),
360 }
361}
362
363fn parse_queue_kind(s: &str) -> Result<QueueKind> {
364 match s.to_ascii_lowercase().as_str() {
365 "memory" | "mem" | "inproc" => Ok(QueueKind::Memory),
366 "rabbitmq" | "amqp" | "rabbit" => Ok(QueueKind::RabbitMq),
367 "redis" => Ok(QueueKind::Redis),
368 other => Err(RagError::config(format!("unknown RAG_QUEUE '{other}'"))),
369 }
370}
371
372#[cfg(test)]
373mod tests {
374 use super::*;
375
376 #[test]
377 fn defaults_are_valid_and_match_spec() {
378 let c = RagConfig::default();
379 c.validate().unwrap();
380 assert_eq!(c.chunk_size, 300);
381 assert!((c.chunk_overlap - 0.05).abs() < 1e-6);
382 assert_eq!(c.embed_dim, 1024);
383 assert_eq!(c.embed_model, "bge-m3");
384 assert_eq!(c.llm_model, "deepseek/deepseek-chat");
385 assert_eq!(c.retrieval_mode, RetrievalMode::Hybrid);
386 }
387
388 #[test]
389 fn validate_rejects_bad_overlap() {
390 let c = RagConfig {
391 chunk_overlap: 0.99,
392 ..Default::default()
393 };
394 assert!(c.validate().is_err());
395 let c = RagConfig {
396 chunk_size: 0,
397 ..Default::default()
398 };
399 assert!(c.validate().is_err());
400 }
401
402 #[test]
403 fn backend_parsers() {
404 assert_eq!(parse_db_backend("Postgres").unwrap(), DbBackend::Postgres);
405 assert_eq!(parse_embed_provider("HASH").unwrap(), EmbedProvider::Hash);
406 assert_eq!(parse_queue_kind("amqp").unwrap(), QueueKind::RabbitMq);
407 assert!(parse_source_kind("carrier-pigeon").is_err());
408 }
409}