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 ocr_lang: OcrLang,
87
88 pub chunker: ChunkerKind,
90 pub chunk_size: usize,
91 pub chunk_overlap: f32,
92 pub chunk_unit: ChunkUnit,
93 pub chunk_tokenizer: Option<String>,
96
97 pub retrieval_mode: RetrievalMode,
99 pub top_k: usize,
100 pub rrf_k: f32,
101 pub multiquery_n: usize,
102
103 pub source: SourceKind,
105 pub source_path: String,
106 pub source_url: Option<String>,
107 pub source_user: Option<String>,
108 pub source_password: Option<String>,
109 pub documents_output: Option<String>,
112
113 pub queue: QueueKind,
115 pub rabbitmq_url: Option<String>,
116 pub redis_url: Option<String>,
117
118 pub http_addr: String,
121 pub api_keys: Vec<String>,
124}
125
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum OcrLang {
129 Ch,
132 En,
134}
135
136fn parse_ocr_lang(s: &str) -> Result<OcrLang> {
137 match s.trim().to_ascii_lowercase().as_str() {
138 "" | "ch" => Ok(OcrLang::Ch),
139 "en" => Ok(OcrLang::En),
140 other => Err(RagError::config(format!(
141 "RAG_OCR_LANG={other:?} is not supported (ch|en)"
142 ))),
143 }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum ChunkerKind {
149 Window,
153 Hierarchical,
156 Hybrid,
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum ChunkUnit {
164 Word,
166 Token,
168}
169
170impl Default for RagConfig {
171 fn default() -> Self {
172 RagConfig {
173 db_backend: DbBackend::Sqlite,
174 database_url: "sqlite://data/rag.db".to_string(),
175 embed_provider: EmbedProvider::Ollama,
176 embed_model: "bge-m3".to_string(),
177 embed_dim: 1024,
178 ollama_base_url: "http://localhost:11434".to_string(),
179 gemini_api_key: None,
180 gemini_model: "gemini-embedding-001".to_string(),
181 embed_onnx_path: "models/embed/bge-m3.onnx".to_string(),
182 embed_tokenizer_path: "models/embed/tokenizer.json".to_string(),
183 openrouter_api_key: None,
184 openrouter_base_url: "https://openrouter.ai/api/v1".to_string(),
185 llm_model: "deepseek/deepseek-chat".to_string(),
186 ocr_lang: OcrLang::Ch,
187 chunker: ChunkerKind::Window,
188 chunk_size: 300,
189 chunk_overlap: 0.05,
190 chunk_unit: ChunkUnit::Word,
191 chunk_tokenizer: None,
192 retrieval_mode: RetrievalMode::Hybrid,
193 top_k: 5,
194 rrf_k: 60.0,
195 multiquery_n: 4,
196 source: SourceKind::Folder,
197 source_path: "./input".to_string(),
198 source_url: None,
199 source_user: None,
200 source_password: None,
201 documents_output: None,
202 queue: QueueKind::Memory,
203 rabbitmq_url: None,
204 redis_url: None,
205 http_addr: "127.0.0.1:8080".to_string(),
206 api_keys: Vec::new(),
207 }
208 }
209}
210
211impl RagConfig {
212 pub fn from_env() -> Result<Self> {
218 let _ = dotenvy::dotenv();
220 Self::from_env_inner()
221 }
222
223 fn from_env_inner() -> Result<Self> {
224 let d = RagConfig::default();
225 let cfg = RagConfig {
226 db_backend: match env_str("RAG_DB_BACKEND") {
227 Some(s) => parse_db_backend(&s)?,
228 None => d.db_backend,
229 },
230 database_url: env_str("RAG_DATABASE_URL").unwrap_or(d.database_url),
231 embed_provider: match env_str("RAG_EMBED_PROVIDER") {
232 Some(s) => parse_embed_provider(&s)?,
233 None => d.embed_provider,
234 },
235 embed_model: env_str("RAG_EMBED_MODEL").unwrap_or(d.embed_model),
236 embed_dim: env_parse("RAG_EMBED_DIM", d.embed_dim)?,
237 ollama_base_url: env_str("OLLAMA_BASE_URL").unwrap_or(d.ollama_base_url),
238 gemini_api_key: env_str("GEMINI_API_KEY"),
239 gemini_model: env_str("RAG_GEMINI_MODEL").unwrap_or(d.gemini_model),
240 embed_onnx_path: env_str("RAG_EMBED_ONNX_PATH").unwrap_or(d.embed_onnx_path),
241 embed_tokenizer_path: env_str("RAG_EMBED_TOKENIZER").unwrap_or(d.embed_tokenizer_path),
242 openrouter_api_key: env_str("OPENROUTER_API_KEY"),
243 openrouter_base_url: env_str("OPENROUTER_BASE_URL").unwrap_or(d.openrouter_base_url),
244 llm_model: env_str("RAG_LLM_MODEL").unwrap_or(d.llm_model),
245 ocr_lang: match env_str("RAG_OCR_LANG") {
246 Some(s) => parse_ocr_lang(&s)?,
247 None => d.ocr_lang,
248 },
249 chunker: match env_str("RAG_CHUNKER") {
250 Some(s) => parse_chunker_kind(&s)?,
251 None => d.chunker,
252 },
253 chunk_size: env_parse("RAG_CHUNK_SIZE", d.chunk_size)?,
254 chunk_overlap: env_parse("RAG_CHUNK_OVERLAP", d.chunk_overlap)?,
255 chunk_unit: match env_str("RAG_CHUNK_UNIT") {
256 Some(s) => parse_chunk_unit(&s)?,
257 None => d.chunk_unit,
258 },
259 chunk_tokenizer: env_str("RAG_CHUNK_TOKENIZER"),
260 retrieval_mode: match env_str("RAG_RETRIEVAL_MODE") {
261 Some(s) => RetrievalMode::from_str(&s)?,
262 None => d.retrieval_mode,
263 },
264 top_k: env_parse("RAG_TOP_K", d.top_k)?,
265 rrf_k: env_parse("RAG_RRF_K", d.rrf_k)?,
266 multiquery_n: env_parse("RAG_MULTIQUERY_N", d.multiquery_n)?,
267 source: match env_str("RAG_SOURCE") {
268 Some(s) => parse_source_kind(&s)?,
269 None => d.source,
270 },
271 source_path: env_str("RAG_SOURCE_PATH").unwrap_or(d.source_path),
272 source_url: env_str("RAG_SOURCE_URL"),
273 source_user: env_str("RAG_SOURCE_USER"),
274 source_password: env_str("RAG_SOURCE_PASSWORD"),
275 documents_output: env_str("RAG_DOCUMENTS_OUTPUT"),
276 queue: match env_str("RAG_QUEUE") {
277 Some(s) => parse_queue_kind(&s)?,
278 None => d.queue,
279 },
280 rabbitmq_url: env_str("RABBITMQ_URL"),
281 redis_url: env_str("REDIS_URL"),
282 http_addr: env_str("RAG_HTTP_ADDR").unwrap_or(d.http_addr),
283 api_keys: env_str("RAG_API_KEYS")
284 .map(|s| {
285 s.split(',')
286 .map(|k| k.trim().to_string())
287 .filter(|k| !k.is_empty())
288 .collect()
289 })
290 .unwrap_or_default(),
291 };
292 cfg.validate()?;
293 Ok(cfg)
294 }
295
296 pub fn validate(&self) -> Result<()> {
298 if self.embed_dim == 0 {
299 return Err(RagError::config("RAG_EMBED_DIM must be > 0"));
300 }
301 if self.chunk_size == 0 {
302 return Err(RagError::config("RAG_CHUNK_SIZE must be > 0"));
303 }
304 if !(0.0..0.95).contains(&self.chunk_overlap) {
305 return Err(RagError::config("RAG_CHUNK_OVERLAP must be in [0.0, 0.95)"));
306 }
307 if self.chunker == ChunkerKind::Hybrid
308 && self.chunk_tokenizer.is_none()
309 && !std::path::Path::new(docling::chunker::DEFAULT_TOKENIZER_PATH).exists()
310 {
311 return Err(RagError::config(format!(
312 "RAG_CHUNKER=hybrid needs a HuggingFace tokenizer.json: set RAG_CHUNK_TOKENIZER \
313 or run scripts/install/download_dependencies.sh (populates {})",
314 docling::chunker::DEFAULT_TOKENIZER_PATH
315 )));
316 }
317 if self.top_k == 0 {
318 return Err(RagError::config("RAG_TOP_K must be > 0"));
319 }
320 Ok(())
321 }
322}
323
324fn env_str(key: &str) -> Option<String> {
325 match std::env::var(key) {
326 Ok(v) if !v.trim().is_empty() => Some(v.trim().to_string()),
327 _ => None,
328 }
329}
330
331fn env_parse<T>(key: &str, default: T) -> Result<T>
332where
333 T: FromStr,
334 T::Err: std::fmt::Display,
335{
336 match env_str(key) {
337 Some(s) => s
338 .parse::<T>()
339 .map_err(|e| RagError::config(format!("{key}: invalid value '{s}': {e}"))),
340 None => Ok(default),
341 }
342}
343
344fn parse_db_backend(s: &str) -> Result<DbBackend> {
345 match s.to_ascii_lowercase().as_str() {
346 "sqlite" => Ok(DbBackend::Sqlite),
347 "postgres" | "postgresql" | "pg" => Ok(DbBackend::Postgres),
348 "memory" | "mem" | "inmemory" => Ok(DbBackend::Memory),
349 other => Err(RagError::config(format!(
350 "unknown RAG_DB_BACKEND '{other}'"
351 ))),
352 }
353}
354
355fn parse_embed_provider(s: &str) -> Result<EmbedProvider> {
356 match s.to_ascii_lowercase().as_str() {
357 "ollama" => Ok(EmbedProvider::Ollama),
358 "gemini" | "google" => Ok(EmbedProvider::Gemini),
359 "onnx" | "local" => Ok(EmbedProvider::Onnx),
360 "hash" | "test" | "fake" => Ok(EmbedProvider::Hash),
361 other => Err(RagError::config(format!(
362 "unknown RAG_EMBED_PROVIDER '{other}'"
363 ))),
364 }
365}
366
367fn parse_chunker_kind(s: &str) -> Result<ChunkerKind> {
368 match s.to_ascii_lowercase().as_str() {
369 "window" => Ok(ChunkerKind::Window),
370 "hierarchical" => Ok(ChunkerKind::Hierarchical),
371 "hybrid" => Ok(ChunkerKind::Hybrid),
372 other => Err(RagError::config(format!(
373 "unknown RAG_CHUNKER '{other}' (expected: window, hierarchical, hybrid)"
374 ))),
375 }
376}
377
378fn parse_chunk_unit(s: &str) -> Result<ChunkUnit> {
379 match s.to_ascii_lowercase().as_str() {
380 "word" | "words" => Ok(ChunkUnit::Word),
381 "token" | "tokens" => Ok(ChunkUnit::Token),
382 other => Err(RagError::config(format!(
383 "unknown RAG_CHUNK_UNIT '{other}'"
384 ))),
385 }
386}
387
388fn parse_source_kind(s: &str) -> Result<SourceKind> {
389 match s.to_ascii_lowercase().as_str() {
390 "folder" | "dir" | "directory" | "local" => Ok(SourceKind::Folder),
391 "ftp" => Ok(SourceKind::Ftp),
392 "sftp" => Ok(SourceKind::Sftp),
393 other => Err(RagError::config(format!("unknown RAG_SOURCE '{other}'"))),
394 }
395}
396
397fn parse_queue_kind(s: &str) -> Result<QueueKind> {
398 match s.to_ascii_lowercase().as_str() {
399 "memory" | "mem" | "inproc" => Ok(QueueKind::Memory),
400 "rabbitmq" | "amqp" | "rabbit" => Ok(QueueKind::RabbitMq),
401 "redis" => Ok(QueueKind::Redis),
402 other => Err(RagError::config(format!("unknown RAG_QUEUE '{other}'"))),
403 }
404}
405
406#[cfg(test)]
407mod tests {
408 use super::*;
409
410 #[test]
411 fn ocr_lang_parses_and_rejects_unknown() {
412 assert_eq!(parse_ocr_lang("").unwrap(), OcrLang::Ch);
413 assert_eq!(parse_ocr_lang("ch").unwrap(), OcrLang::Ch);
414 assert_eq!(parse_ocr_lang(" EN ").unwrap(), OcrLang::En);
415 assert!(parse_ocr_lang("de").is_err());
416 }
417
418 #[test]
419 fn defaults_are_valid_and_match_spec() {
420 let c = RagConfig::default();
421 c.validate().unwrap();
422 assert_eq!(c.chunk_size, 300);
423 assert!((c.chunk_overlap - 0.05).abs() < 1e-6);
424 assert_eq!(c.embed_dim, 1024);
425 assert_eq!(c.embed_model, "bge-m3");
426 assert_eq!(c.llm_model, "deepseek/deepseek-chat");
427 assert_eq!(c.retrieval_mode, RetrievalMode::Hybrid);
428 }
429
430 #[test]
431 fn validate_rejects_bad_overlap() {
432 let c = RagConfig {
433 chunk_overlap: 0.99,
434 ..Default::default()
435 };
436 assert!(c.validate().is_err());
437 let c = RagConfig {
438 chunk_size: 0,
439 ..Default::default()
440 };
441 assert!(c.validate().is_err());
442 }
443
444 #[test]
445 fn backend_parsers() {
446 assert_eq!(parse_db_backend("Postgres").unwrap(), DbBackend::Postgres);
447 assert_eq!(parse_embed_provider("HASH").unwrap(), EmbedProvider::Hash);
448 assert_eq!(parse_queue_kind("amqp").unwrap(), QueueKind::RabbitMq);
449 assert!(parse_source_kind("carrier-pigeon").is_err());
450 }
451}