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    // --- chunking ---
80    pub chunker: ChunkerKind,
81    pub chunk_size: usize,
82    pub chunk_overlap: f32,
83    pub chunk_unit: ChunkUnit,
84    /// Path to a HuggingFace `tokenizer.json` for the `hybrid` chunker's token
85    /// counts (`RAG_CHUNK_TOKENIZER`). Required when `chunker = hybrid`.
86    pub chunk_tokenizer: Option<String>,
87
88    // --- retrieval ---
89    pub retrieval_mode: RetrievalMode,
90    pub top_k: usize,
91    pub rrf_k: f32,
92    pub multiquery_n: usize,
93
94    // --- sources ---
95    pub source: SourceKind,
96    pub source_path: String,
97    pub source_url: Option<String>,
98    pub source_user: Option<String>,
99    pub source_password: Option<String>,
100    /// Optional folder to dump each ingested document's converted Markdown into
101    /// (for debugging / re-ingestion). `None` disables the dump.
102    pub documents_output: Option<String>,
103
104    // --- queue ---
105    pub queue: QueueKind,
106    pub rabbitmq_url: Option<String>,
107    pub redis_url: Option<String>,
108
109    // --- REST API ---
110    /// Bind address for `serve` (default `127.0.0.1:8080`).
111    pub http_addr: String,
112    /// Accepted API keys (comma-separated in `RAG_API_KEYS`). The server refuses
113    /// to start with an empty list — auth is fail-closed.
114    pub api_keys: Vec<String>,
115}
116
117/// Which chunker ingestion runs (`RAG_CHUNKER`).
118#[derive(Debug, Clone, Copy, PartialEq, Eq)]
119pub enum ChunkerKind {
120    /// The Markdown sliding-window chunker (`chunk_size`/`chunk_overlap`/
121    /// `chunk_unit`), streaming — chunks pages while later pages still convert.
122    /// Default.
123    Window,
124    /// docling's structure-driven `HierarchicalChunker`: one chunk per document
125    /// item with its heading path (buffered — needs the whole document).
126    Hierarchical,
127    /// docling's `HybridChunker`: hierarchical chunks refined against a real
128    /// tokenizer budget (`chunk_size` tokens, `chunk_tokenizer` counts them).
129    Hybrid,
130}
131
132/// The unit used to measure chunk size / overlap.
133#[derive(Debug, Clone, Copy, PartialEq, Eq)]
134pub enum ChunkUnit {
135    /// Whitespace-delimited words (fast, no tokenizer). Default.
136    Word,
137    /// Approximate subword tokens (`chars/4` heuristic — no tokenizer dependency).
138    Token,
139}
140
141impl Default for RagConfig {
142    fn default() -> Self {
143        RagConfig {
144            db_backend: DbBackend::Sqlite,
145            database_url: "sqlite://data/rag.db".to_string(),
146            embed_provider: EmbedProvider::Ollama,
147            embed_model: "bge-m3".to_string(),
148            embed_dim: 1024,
149            ollama_base_url: "http://localhost:11434".to_string(),
150            gemini_api_key: None,
151            gemini_model: "gemini-embedding-001".to_string(),
152            embed_onnx_path: "models/embed/bge-m3.onnx".to_string(),
153            embed_tokenizer_path: "models/embed/tokenizer.json".to_string(),
154            openrouter_api_key: None,
155            openrouter_base_url: "https://openrouter.ai/api/v1".to_string(),
156            llm_model: "deepseek/deepseek-chat".to_string(),
157            chunker: ChunkerKind::Window,
158            chunk_size: 300,
159            chunk_overlap: 0.05,
160            chunk_unit: ChunkUnit::Word,
161            chunk_tokenizer: None,
162            retrieval_mode: RetrievalMode::Hybrid,
163            top_k: 5,
164            rrf_k: 60.0,
165            multiquery_n: 4,
166            source: SourceKind::Folder,
167            source_path: "./input".to_string(),
168            source_url: None,
169            source_user: None,
170            source_password: None,
171            documents_output: None,
172            queue: QueueKind::Memory,
173            rabbitmq_url: None,
174            redis_url: None,
175            http_addr: "127.0.0.1:8080".to_string(),
176            api_keys: Vec::new(),
177        }
178    }
179}
180
181impl RagConfig {
182    /// Load a `.env` file (if present) then resolve config from the environment.
183    ///
184    /// Missing keys fall back to the [`Default`] values, so this never fails on an
185    /// empty environment; it only errors on an *invalid* value (bad number, unknown
186    /// backend name).
187    pub fn from_env() -> Result<Self> {
188        // Best-effort: a missing .env is not an error.
189        let _ = dotenvy::dotenv();
190        Self::from_env_inner()
191    }
192
193    fn from_env_inner() -> Result<Self> {
194        let d = RagConfig::default();
195        let cfg = RagConfig {
196            db_backend: match env_str("RAG_DB_BACKEND") {
197                Some(s) => parse_db_backend(&s)?,
198                None => d.db_backend,
199            },
200            database_url: env_str("RAG_DATABASE_URL").unwrap_or(d.database_url),
201            embed_provider: match env_str("RAG_EMBED_PROVIDER") {
202                Some(s) => parse_embed_provider(&s)?,
203                None => d.embed_provider,
204            },
205            embed_model: env_str("RAG_EMBED_MODEL").unwrap_or(d.embed_model),
206            embed_dim: env_parse("RAG_EMBED_DIM", d.embed_dim)?,
207            ollama_base_url: env_str("OLLAMA_BASE_URL").unwrap_or(d.ollama_base_url),
208            gemini_api_key: env_str("GEMINI_API_KEY"),
209            gemini_model: env_str("RAG_GEMINI_MODEL").unwrap_or(d.gemini_model),
210            embed_onnx_path: env_str("RAG_EMBED_ONNX_PATH").unwrap_or(d.embed_onnx_path),
211            embed_tokenizer_path: env_str("RAG_EMBED_TOKENIZER").unwrap_or(d.embed_tokenizer_path),
212            openrouter_api_key: env_str("OPENROUTER_API_KEY"),
213            openrouter_base_url: env_str("OPENROUTER_BASE_URL").unwrap_or(d.openrouter_base_url),
214            llm_model: env_str("RAG_LLM_MODEL").unwrap_or(d.llm_model),
215            chunker: match env_str("RAG_CHUNKER") {
216                Some(s) => parse_chunker_kind(&s)?,
217                None => d.chunker,
218            },
219            chunk_size: env_parse("RAG_CHUNK_SIZE", d.chunk_size)?,
220            chunk_overlap: env_parse("RAG_CHUNK_OVERLAP", d.chunk_overlap)?,
221            chunk_unit: match env_str("RAG_CHUNK_UNIT") {
222                Some(s) => parse_chunk_unit(&s)?,
223                None => d.chunk_unit,
224            },
225            chunk_tokenizer: env_str("RAG_CHUNK_TOKENIZER"),
226            retrieval_mode: match env_str("RAG_RETRIEVAL_MODE") {
227                Some(s) => RetrievalMode::from_str(&s)?,
228                None => d.retrieval_mode,
229            },
230            top_k: env_parse("RAG_TOP_K", d.top_k)?,
231            rrf_k: env_parse("RAG_RRF_K", d.rrf_k)?,
232            multiquery_n: env_parse("RAG_MULTIQUERY_N", d.multiquery_n)?,
233            source: match env_str("RAG_SOURCE") {
234                Some(s) => parse_source_kind(&s)?,
235                None => d.source,
236            },
237            source_path: env_str("RAG_SOURCE_PATH").unwrap_or(d.source_path),
238            source_url: env_str("RAG_SOURCE_URL"),
239            source_user: env_str("RAG_SOURCE_USER"),
240            source_password: env_str("RAG_SOURCE_PASSWORD"),
241            documents_output: env_str("RAG_DOCUMENTS_OUTPUT"),
242            queue: match env_str("RAG_QUEUE") {
243                Some(s) => parse_queue_kind(&s)?,
244                None => d.queue,
245            },
246            rabbitmq_url: env_str("RABBITMQ_URL"),
247            redis_url: env_str("REDIS_URL"),
248            http_addr: env_str("RAG_HTTP_ADDR").unwrap_or(d.http_addr),
249            api_keys: env_str("RAG_API_KEYS")
250                .map(|s| {
251                    s.split(',')
252                        .map(|k| k.trim().to_string())
253                        .filter(|k| !k.is_empty())
254                        .collect()
255                })
256                .unwrap_or_default(),
257        };
258        cfg.validate()?;
259        Ok(cfg)
260    }
261
262    /// Sanity-check numeric ranges. Returns the first violation.
263    pub fn validate(&self) -> Result<()> {
264        if self.embed_dim == 0 {
265            return Err(RagError::config("RAG_EMBED_DIM must be > 0"));
266        }
267        if self.chunk_size == 0 {
268            return Err(RagError::config("RAG_CHUNK_SIZE must be > 0"));
269        }
270        if !(0.0..0.95).contains(&self.chunk_overlap) {
271            return Err(RagError::config("RAG_CHUNK_OVERLAP must be in [0.0, 0.95)"));
272        }
273        if self.chunker == ChunkerKind::Hybrid
274            && self.chunk_tokenizer.is_none()
275            && !std::path::Path::new(docling::chunker::DEFAULT_TOKENIZER_PATH).exists()
276        {
277            return Err(RagError::config(format!(
278                "RAG_CHUNKER=hybrid needs a HuggingFace tokenizer.json: set RAG_CHUNK_TOKENIZER \
279                 or run scripts/install/download_dependencies.sh (populates {})",
280                docling::chunker::DEFAULT_TOKENIZER_PATH
281            )));
282        }
283        if self.top_k == 0 {
284            return Err(RagError::config("RAG_TOP_K must be > 0"));
285        }
286        Ok(())
287    }
288}
289
290fn env_str(key: &str) -> Option<String> {
291    match std::env::var(key) {
292        Ok(v) if !v.trim().is_empty() => Some(v.trim().to_string()),
293        _ => None,
294    }
295}
296
297fn env_parse<T>(key: &str, default: T) -> Result<T>
298where
299    T: FromStr,
300    T::Err: std::fmt::Display,
301{
302    match env_str(key) {
303        Some(s) => s
304            .parse::<T>()
305            .map_err(|e| RagError::config(format!("{key}: invalid value '{s}': {e}"))),
306        None => Ok(default),
307    }
308}
309
310fn parse_db_backend(s: &str) -> Result<DbBackend> {
311    match s.to_ascii_lowercase().as_str() {
312        "sqlite" => Ok(DbBackend::Sqlite),
313        "postgres" | "postgresql" | "pg" => Ok(DbBackend::Postgres),
314        "memory" | "mem" | "inmemory" => Ok(DbBackend::Memory),
315        other => Err(RagError::config(format!(
316            "unknown RAG_DB_BACKEND '{other}'"
317        ))),
318    }
319}
320
321fn parse_embed_provider(s: &str) -> Result<EmbedProvider> {
322    match s.to_ascii_lowercase().as_str() {
323        "ollama" => Ok(EmbedProvider::Ollama),
324        "gemini" | "google" => Ok(EmbedProvider::Gemini),
325        "onnx" | "local" => Ok(EmbedProvider::Onnx),
326        "hash" | "test" | "fake" => Ok(EmbedProvider::Hash),
327        other => Err(RagError::config(format!(
328            "unknown RAG_EMBED_PROVIDER '{other}'"
329        ))),
330    }
331}
332
333fn parse_chunker_kind(s: &str) -> Result<ChunkerKind> {
334    match s.to_ascii_lowercase().as_str() {
335        "window" => Ok(ChunkerKind::Window),
336        "hierarchical" => Ok(ChunkerKind::Hierarchical),
337        "hybrid" => Ok(ChunkerKind::Hybrid),
338        other => Err(RagError::config(format!(
339            "unknown RAG_CHUNKER '{other}' (expected: window, hierarchical, hybrid)"
340        ))),
341    }
342}
343
344fn parse_chunk_unit(s: &str) -> Result<ChunkUnit> {
345    match s.to_ascii_lowercase().as_str() {
346        "word" | "words" => Ok(ChunkUnit::Word),
347        "token" | "tokens" => Ok(ChunkUnit::Token),
348        other => Err(RagError::config(format!(
349            "unknown RAG_CHUNK_UNIT '{other}'"
350        ))),
351    }
352}
353
354fn parse_source_kind(s: &str) -> Result<SourceKind> {
355    match s.to_ascii_lowercase().as_str() {
356        "folder" | "dir" | "directory" | "local" => Ok(SourceKind::Folder),
357        "ftp" => Ok(SourceKind::Ftp),
358        "sftp" => Ok(SourceKind::Sftp),
359        other => Err(RagError::config(format!("unknown RAG_SOURCE '{other}'"))),
360    }
361}
362
363fn parse_queue_kind(s: &str) -> Result<QueueKind> {
364    match s.to_ascii_lowercase().as_str() {
365        "memory" | "mem" | "inproc" => Ok(QueueKind::Memory),
366        "rabbitmq" | "amqp" | "rabbit" => Ok(QueueKind::RabbitMq),
367        "redis" => Ok(QueueKind::Redis),
368        other => Err(RagError::config(format!("unknown RAG_QUEUE '{other}'"))),
369    }
370}
371
372#[cfg(test)]
373mod tests {
374    use super::*;
375
376    #[test]
377    fn defaults_are_valid_and_match_spec() {
378        let c = RagConfig::default();
379        c.validate().unwrap();
380        assert_eq!(c.chunk_size, 300);
381        assert!((c.chunk_overlap - 0.05).abs() < 1e-6);
382        assert_eq!(c.embed_dim, 1024);
383        assert_eq!(c.embed_model, "bge-m3");
384        assert_eq!(c.llm_model, "deepseek/deepseek-chat");
385        assert_eq!(c.retrieval_mode, RetrievalMode::Hybrid);
386    }
387
388    #[test]
389    fn validate_rejects_bad_overlap() {
390        let c = RagConfig {
391            chunk_overlap: 0.99,
392            ..Default::default()
393        };
394        assert!(c.validate().is_err());
395        let c = RagConfig {
396            chunk_size: 0,
397            ..Default::default()
398        };
399        assert!(c.validate().is_err());
400    }
401
402    #[test]
403    fn backend_parsers() {
404        assert_eq!(parse_db_backend("Postgres").unwrap(), DbBackend::Postgres);
405        assert_eq!(parse_embed_provider("HASH").unwrap(), EmbedProvider::Hash);
406        assert_eq!(parse_queue_kind("amqp").unwrap(), QueueKind::RabbitMq);
407        assert!(parse_source_kind("carrier-pigeon").is_err());
408    }
409}