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