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