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
102    // --- sources ---
103    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    /// Optional folder to dump each ingested document's converted Markdown into
109    /// (for debugging / re-ingestion). `None` disables the dump.
110    pub documents_output: Option<String>,
111
112    // --- queue ---
113    pub queue: QueueKind,
114    pub rabbitmq_url: Option<String>,
115    pub redis_url: Option<String>,
116
117    // --- REST API ---
118    /// Bind address for `serve` (default `127.0.0.1:8080`).
119    pub http_addr: String,
120    /// Accepted API keys (comma-separated in `RAG_API_KEYS`). The server refuses
121    /// to start with an empty list — auth is fail-closed.
122    pub api_keys: Vec<String>,
123}
124
125/// OCR recognition language (`RAG_OCR_LANG`).
126#[derive(Debug, Clone, Copy, PartialEq, Eq)]
127pub enum OcrLang {
128    /// ch_PP-OCRv3 — multilingual; what docling conformance is measured with.
129    Ch,
130    /// en_PP-OCRv3 (default) — English-only, proper Latin word spacing.
131    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/// Which chunker ingestion runs (`RAG_CHUNKER`).
145#[derive(Debug, Clone, Copy, PartialEq, Eq)]
146pub enum ChunkerKind {
147    /// The Markdown sliding-window chunker (`chunk_size`/`chunk_overlap`/
148    /// `chunk_unit`), streaming — chunks pages while later pages still convert.
149    /// Default.
150    Window,
151    /// docling's structure-driven `HierarchicalChunker`: one chunk per document
152    /// item with its heading path (buffered — needs the whole document).
153    Hierarchical,
154    /// docling's `HybridChunker`: hierarchical chunks refined against a real
155    /// tokenizer budget (`chunk_size` tokens, `chunk_tokenizer` counts them).
156    Hybrid,
157}
158
159/// The unit used to measure chunk size / overlap.
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
161pub enum ChunkUnit {
162    /// Whitespace-delimited words (fast, no tokenizer). Default.
163    Word,
164    /// Approximate subword tokens (`chars/4` heuristic — no tokenizer dependency).
165    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    /// Load a `.env` file (if present) then resolve config from the environment.
211    ///
212    /// Missing keys fall back to the [`Default`] values, so this never fails on an
213    /// empty environment; it only errors on an *invalid* value (bad number, unknown
214    /// backend name).
215    pub fn from_env() -> Result<Self> {
216        // Best-effort: a missing .env is not an error.
217        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    /// Sanity-check numeric ranges. Returns the first violation.
295    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}