Skip to main content

docling_rag/
config.rs

1//! Configuration, loaded from the process environment (and a `.env` file).
2//!
3//! Every knob has a documented default so the crate runs out of the box with the
4//! offline-friendly stack (bundled SQLite + Ollama). See `.env.example` at the
5//! repo root for the full list.
6
7use crate::model::RetrievalMode;
8use crate::{RagError, Result};
9use std::str::FromStr;
10
11/// Which database backend backs the [`crate::store::VectorStore`].
12#[derive(Debug, Clone, Copy, PartialEq, Eq)]
13pub enum DbBackend {
14    /// Bundled SQLite (default, zero external services).
15    Sqlite,
16    /// PostgreSQL (+ pgvector). Requires the `postgres` cargo feature.
17    Postgres,
18    /// Pure in-memory store — never persisted; used by tests and quick evals.
19    Memory,
20}
21
22/// Which embedding provider produces vectors.
23#[derive(Debug, Clone, Copy, PartialEq, Eq)]
24pub enum EmbedProvider {
25    /// Ollama HTTP API (default; e.g. `bge-m3`, 1024-dim).
26    Ollama,
27    /// Google Gemini embeddings (`gemini-embedding-001`, truncated to `dim`).
28    Gemini,
29    /// Local ONNX model. Requires the `onnx-embed` cargo feature.
30    Onnx,
31    /// Deterministic hashing embedder — no network, used for offline tests/eval.
32    Hash,
33}
34
35/// Which document source feeds the ingestion pipeline.
36#[derive(Debug, Clone, Copy, PartialEq, Eq)]
37pub enum SourceKind {
38    /// A local directory (works over any FUSE / network mount).
39    Folder,
40    /// FTP. Requires the `remote-sources` cargo feature.
41    Ftp,
42    /// SFTP. Requires the `remote-sources` cargo feature.
43    Sftp,
44}
45
46/// Which message queue drives async ingestion.
47#[derive(Debug, Clone, Copy, PartialEq, Eq)]
48pub enum QueueKind {
49    /// In-process tokio channel (default).
50    Memory,
51    /// RabbitMQ (AMQP). Requires the `rabbitmq` cargo feature.
52    RabbitMq,
53    /// Redis pub/sub. Requires the `redis` cargo feature.
54    Redis,
55}
56
57/// The fully-resolved configuration for a RAG session.
58#[derive(Debug, Clone)]
59pub struct RagConfig {
60    // --- database ---
61    pub db_backend: DbBackend,
62    pub database_url: String,
63
64    // --- embedding ---
65    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    // --- llm (OpenRouter) ---
75    pub openrouter_api_key: Option<String>,
76    pub openrouter_base_url: String,
77    pub llm_model: String,
78
79    // --- OCR ---
80    /// OCR recognition language (`RAG_OCR_LANG`): `ch` (default — the
81    /// conformance-validated PP-OCRv3 model, multilingual incl. Latin) or
82    /// `en` (English-only PP-OCRv3: much better Latin word spacing on
83    /// English scans; fetch it with `download_dependencies.sh --ocr-en`).
84    /// Maps onto docling-pdf's `DOCLING_OCR_REC_ONNX`/`DOCLING_OCR_DICT` —
85    /// explicit env overrides always win.
86    pub ocr_lang: OcrLang,
87
88    // --- chunking ---
89    pub chunker: ChunkerKind,
90    pub chunk_size: usize,
91    pub chunk_overlap: f32,
92    pub chunk_unit: ChunkUnit,
93    /// Path to a HuggingFace `tokenizer.json` for the `hybrid` chunker's token
94    /// counts (`RAG_CHUNK_TOKENIZER`). Required when `chunker = hybrid`.
95    pub chunk_tokenizer: Option<String>,
96
97    // --- retrieval ---
98    pub retrieval_mode: RetrievalMode,
99    pub top_k: usize,
100    pub rrf_k: f32,
101    pub multiquery_n: usize,
102
103    // --- sources ---
104    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    /// Optional folder to dump each ingested document's converted Markdown into
110    /// (for debugging / re-ingestion). `None` disables the dump.
111    pub documents_output: Option<String>,
112
113    // --- queue ---
114    pub queue: QueueKind,
115    pub rabbitmq_url: Option<String>,
116    pub redis_url: Option<String>,
117
118    // --- REST API ---
119    /// Bind address for `serve` (default `127.0.0.1:8080`).
120    pub http_addr: String,
121    /// Accepted API keys (comma-separated in `RAG_API_KEYS`). The server refuses
122    /// to start with an empty list — auth is fail-closed.
123    pub api_keys: Vec<String>,
124}
125
126/// OCR recognition language (`RAG_OCR_LANG`).
127#[derive(Debug, Clone, Copy, PartialEq, Eq)]
128pub enum OcrLang {
129    /// The default ch_PP-OCRv3 model (multilingual; what docling conformance
130    /// is measured with).
131    Ch,
132    /// en_PP-OCRv3 — English-only, better Latin word spacing.
133    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/// Which chunker ingestion runs (`RAG_CHUNKER`).
147#[derive(Debug, Clone, Copy, PartialEq, Eq)]
148pub enum ChunkerKind {
149    /// The Markdown sliding-window chunker (`chunk_size`/`chunk_overlap`/
150    /// `chunk_unit`), streaming — chunks pages while later pages still convert.
151    /// Default.
152    Window,
153    /// docling's structure-driven `HierarchicalChunker`: one chunk per document
154    /// item with its heading path (buffered — needs the whole document).
155    Hierarchical,
156    /// docling's `HybridChunker`: hierarchical chunks refined against a real
157    /// tokenizer budget (`chunk_size` tokens, `chunk_tokenizer` counts them).
158    Hybrid,
159}
160
161/// The unit used to measure chunk size / overlap.
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum ChunkUnit {
164    /// Whitespace-delimited words (fast, no tokenizer). Default.
165    Word,
166    /// Approximate subword tokens (`chars/4` heuristic — no tokenizer dependency).
167    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    /// Load a `.env` file (if present) then resolve config from the environment.
213    ///
214    /// Missing keys fall back to the [`Default`] values, so this never fails on an
215    /// empty environment; it only errors on an *invalid* value (bad number, unknown
216    /// backend name).
217    pub fn from_env() -> Result<Self> {
218        // Best-effort: a missing .env is not an error.
219        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    /// Sanity-check numeric ranges. Returns the first violation.
297    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}