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`): `en` (default — English
81    /// PP-OCRv3, docling-pdf's own default) or `ch` (the multilingual
82    /// conformance-validated model; weak Latin word spacing). Maps onto
83    /// docling-pdf's `DOCLING_RS_OCR_LANG` — explicit `DOCLING_OCR_*` env
84    /// overrides always win.
85    pub ocr_lang: OcrLang,
86
87    // --- chunking ---
88    pub chunker: ChunkerKind,
89    pub chunk_size: usize,
90    pub chunk_overlap: f32,
91    pub chunk_unit: ChunkUnit,
92    /// Path to a HuggingFace `tokenizer.json` for the `hybrid` chunker's token
93    /// counts (`RAG_CHUNK_TOKENIZER`). Required when `chunker = hybrid`.
94    pub chunk_tokenizer: Option<String>,
95
96    // --- retrieval ---
97    pub retrieval_mode: RetrievalMode,
98    pub top_k: usize,
99    pub rrf_k: f32,
100    pub multiquery_n: usize,
101    /// BM25 term-frequency saturation (`RAG_BM25_K1`, default 1.2).
102    pub bm25_k1: f32,
103    /// BM25 length normalization (`RAG_BM25_B`, default 0.75).
104    pub bm25_b: f32,
105
106    // --- sources ---
107    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    /// Optional folder to dump each ingested document's converted Markdown into
113    /// (for debugging / re-ingestion). `None` disables the dump.
114    pub documents_output: Option<String>,
115
116    // --- queue ---
117    pub queue: QueueKind,
118    pub rabbitmq_url: Option<String>,
119    pub redis_url: Option<String>,
120
121    // --- REST API ---
122    /// Bind address for `serve` (default `127.0.0.1:8080`).
123    pub http_addr: String,
124    /// Accepted API keys (comma-separated in `RAG_API_KEYS`). The server refuses
125    /// to start with an empty list — auth is fail-closed.
126    pub api_keys: Vec<String>,
127}
128
129/// OCR recognition language (`RAG_OCR_LANG`).
130#[derive(Debug, Clone, Copy, PartialEq, Eq)]
131pub enum OcrLang {
132    /// ch_PP-OCRv3 — multilingual; what docling conformance is measured with.
133    Ch,
134    /// en_PP-OCRv3 (default) — English-only, proper Latin word spacing.
135    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/// Which chunker ingestion runs (`RAG_CHUNKER`).
149#[derive(Debug, Clone, Copy, PartialEq, Eq)]
150pub enum ChunkerKind {
151    /// The Markdown sliding-window chunker (`chunk_size`/`chunk_overlap`/
152    /// `chunk_unit`), streaming — chunks pages while later pages still convert.
153    /// Default.
154    Window,
155    /// docling's structure-driven `HierarchicalChunker`: one chunk per document
156    /// item with its heading path (buffered — needs the whole document).
157    Hierarchical,
158    /// docling's `HybridChunker`: hierarchical chunks refined against a real
159    /// tokenizer budget (`chunk_size` tokens, `chunk_tokenizer` counts them).
160    Hybrid,
161}
162
163/// The unit used to measure chunk size / overlap.
164#[derive(Debug, Clone, Copy, PartialEq, Eq)]
165pub enum ChunkUnit {
166    /// Whitespace-delimited words (fast, no tokenizer). Default.
167    Word,
168    /// Approximate subword tokens (`chars/4` heuristic — no tokenizer dependency).
169    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    /// Load a `.env` file (if present) then resolve config from the environment.
217    ///
218    /// Missing keys fall back to the [`Default`] values, so this never fails on an
219    /// empty environment; it only errors on an *invalid* value (bad number, unknown
220    /// backend name).
221    pub fn from_env() -> Result<Self> {
222        // Best-effort: a missing .env is not an error.
223        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    /// Sanity-check numeric ranges. Returns the first violation.
303    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}