Skip to main content

docling_rag/
pipeline.rs

1//! End-to-end orchestration: ingestion (source → convert → chunk → embed → store)
2//! and querying (retrieve → optional LLM answer synthesis).
3
4use crate::chunk::Chunker;
5use crate::embed::{self, Embedder};
6use crate::llm::{self, ChatModel, Message};
7use crate::metrics::{self, ProcessingMetrics, Timings};
8use crate::model::{content_hash, Document, RetrievalMode, Scored};
9use crate::retrieve::Retriever;
10use crate::source::{self, DocumentSource, SourceRef};
11use crate::store::{self, VectorStore};
12use crate::{RagConfig, RagError, Result};
13use docling::{DocumentConverter, InputFormat, SourceDocument};
14use std::sync::Arc;
15
16/// A fully-wired RAG pipeline built from a [`RagConfig`].
17#[derive(Clone)]
18pub struct Pipeline {
19    cfg: RagConfig,
20    source: Arc<dyn DocumentSource>,
21    embedder: Arc<dyn Embedder>,
22    store: Arc<dyn VectorStore>,
23    chat: Option<Arc<dyn ChatModel>>,
24    chunker: Chunker,
25}
26
27/// What happened to one document during ingestion.
28#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum IngestOutcome {
30    /// Ingested; carries the number of chunks stored.
31    Ingested(usize),
32    /// Skipped because an identical document (same hash) was already stored.
33    Skipped,
34}
35
36/// Aggregate ingestion result over a whole source.
37#[derive(Debug, Clone, Default)]
38pub struct IngestReport {
39    pub documents_ingested: usize,
40    pub documents_skipped: usize,
41    pub documents_failed: usize,
42    pub chunks_added: usize,
43}
44
45/// A synthesized answer plus the chunks it was grounded in.
46#[derive(Debug, Clone)]
47pub struct Answer {
48    pub text: String,
49    pub sources: Vec<Scored>,
50}
51
52/// Per-ingest conversion switches — docling's optional enrichment models
53/// (each needs its model files on disk; see download_dependencies.sh).
54/// Off by default: enrichment multiplies conversion time.
55#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
56pub struct ConvertOptions {
57    /// Classify pictures (chart/logo/…) — `models/picture_classifier.onnx`.
58    pub enrich_pictures: bool,
59    /// Transcribe code blocks with the CodeFormula VLM (`--enrich` download).
60    pub enrich_code: bool,
61    /// Transcribe formulas to LaTeX with the CodeFormula VLM.
62    pub enrich_formulas: bool,
63}
64
65impl ConvertOptions {
66    /// A converter with these enrichments enabled.
67    fn converter(self) -> DocumentConverter {
68        DocumentConverter::new()
69            .do_picture_classification(self.enrich_pictures)
70            .do_code_enrichment(self.enrich_code)
71            .do_formula_enrichment(self.enrich_formulas)
72    }
73}
74
75/// What the overlapped parse/chunk/embed stages produced for one document.
76/// Phase seconds are busy time (the stages overlap on the wall clock).
77struct StagedOutcome {
78    pages: Option<usize>,
79    parse_secs: f64,
80    chunk_secs: f64,
81    embed_secs: f64,
82    embedded_words: usize,
83    chunks: usize,
84    markdown: String,
85}
86
87impl Pipeline {
88    /// Build every component from config. The LLM client is created only if
89    /// `OPENROUTER_API_KEY` is set (LLM-backed modes error otherwise).
90    pub async fn from_config(cfg: &RagConfig) -> Result<Self> {
91        // RAG_OCR_LANG=en swaps the OCR recognition model for the English
92        // PP-OCRv3 export (download_dependencies.sh --ocr-en) via docling-pdf's
93        // model env vars — resolved once per process at first PDF use, and an
94        // explicit DOCLING_OCR_* override always wins over this mapping.
95        if cfg.ocr_lang == crate::config::OcrLang::En {
96            if std::env::var_os("DOCLING_OCR_REC_ONNX").is_none() {
97                std::env::set_var("DOCLING_OCR_REC_ONNX", "models/ocr_rec_en.onnx");
98            }
99            if std::env::var_os("DOCLING_OCR_DICT").is_none() {
100                std::env::set_var("DOCLING_OCR_DICT", "models/en_dict.txt");
101            }
102        }
103        let source = source::from_config(cfg)?;
104        let embedder = embed::from_config(cfg)?;
105        let store = store::from_config(cfg).await?;
106        let chat = match cfg.openrouter_api_key {
107            Some(_) => Some(llm::from_config(cfg)?),
108            None => None,
109        };
110        let chunker = Chunker::from_config(cfg);
111        Ok(Pipeline {
112            cfg: cfg.clone(),
113            source,
114            embedder,
115            store,
116            chat,
117            chunker,
118        })
119    }
120
121    /// The underlying store (for counts, admin, tests).
122    pub fn store(&self) -> &Arc<dyn VectorStore> {
123        &self.store
124    }
125
126    /// The resolved configuration this pipeline was built from.
127    pub fn config(&self) -> &RagConfig {
128        &self.cfg
129    }
130
131    /// A retriever over this pipeline's store/embedder/LLM.
132    pub fn retriever(&self) -> Retriever {
133        Retriever::new(self.store.clone(), self.embedder.clone(), self.chat.clone())
134            .with_rrf_k(self.cfg.rrf_k)
135            .with_multiquery_n(self.cfg.multiquery_n)
136    }
137
138    /// Ingest a single document reference. Deduplicates on content hash, and
139    /// records per-phase processing metrics in the document's JSON metadata.
140    ///
141    /// Processing is **streaming**: `docling.rs`'s `convert_streaming` emits
142    /// Markdown as it is produced (per page for PDF), and chunking + embedding
143    /// run concurrently on the pieces — parsing of page N overlaps embedding of
144    /// pages < N. Phase timings measure busy time, so throughput metrics stay
145    /// meaningful even though the phases overlap on the wall clock.
146    pub async fn ingest_ref(&self, r: &SourceRef) -> Result<IngestOutcome> {
147        let bytes = self.source.fetch(r).await?;
148        self.ingest_bytes(r, bytes).await
149    }
150
151    /// Ingest a document from in-memory bytes — the same staged pipeline as
152    /// [`Self::ingest_ref`] minus the source fetch. Used by the REST API's
153    /// upload endpoint, where the bytes arrive in the request body; `r.uri`
154    /// still identifies the document (`upload:///<name>` by convention) for
155    /// dedup and stale-row cleanup.
156    pub async fn ingest_bytes(&self, r: &SourceRef, bytes: Vec<u8>) -> Result<IngestOutcome> {
157        self.ingest_bytes_with(r, bytes, ConvertOptions::default())
158            .await
159    }
160
161    /// [`Self::ingest_bytes`] with explicit conversion options (enrichments).
162    pub async fn ingest_bytes_with(
163        &self,
164        r: &SourceRef,
165        bytes: Vec<u8>,
166        opts: ConvertOptions,
167    ) -> Result<IngestOutcome> {
168        let hash = content_hash(&bytes);
169        if self.store.find_document_by_hash(&hash).await?.is_some() {
170            tracing::debug!(uri = %r.uri, "skipping unchanged document");
171            return Ok(IngestOutcome::Skipped);
172        }
173        let file_bytes = bytes.len() as u64;
174        tracing::info!(
175            uri = %r.uri,
176            name = %r.name,
177            bytes = file_bytes,
178            "processing document"
179        );
180
181        // Remove stale rows for this source first: leftovers from interrupted
182        // runs, or previous versions of a file whose content changed.
183        self.store.delete_documents_by_source(&r.uri).await?;
184
185        // The document row must exist before its chunks (FK). It is inserted
186        // with a sentinel hash — the real hash is written only on success, so an
187        // interrupted run can never satisfy the dedup check above and the
188        // document is reprocessed next time. Title is refined (first heading)
189        // and metrics attached with the final upsert.
190        let mut doc = Document::new(&r.uri, stem(&r.name), format!("pending:{hash}"))
191            .with_metadata(serde_json::json!({ "source": r.uri }));
192        self.store.upsert_document(&doc).await?;
193
194        // Run the staged pipeline; on failure roll back the document row and any
195        // partially-inserted chunks so a retry reprocesses from scratch instead
196        // of being skipped by the hash dedup.
197        let staged = match self.cfg.chunker {
198            crate::config::ChunkerKind::Window => {
199                self.ingest_streaming(r, &doc.id, bytes, opts).await
200            }
201            // docling's chunkers walk the finished document tree, so conversion
202            // is whole-document — but the chunks stream into embedding as the
203            // chunkers produce them.
204            _ => self.ingest_docling(r, &doc.id, bytes, opts).await,
205        };
206        let out = match staged {
207            Ok(out) => out,
208            Err(e) => {
209                if let Err(del) = self.store.delete_document(&doc.id).await {
210                    tracing::warn!(uri = %r.uri, error = %del, "rollback of failed ingest also failed");
211                }
212                return Err(e);
213            }
214        };
215        let StagedOutcome {
216            pages,
217            parse_secs,
218            chunk_secs,
219            embed_secs,
220            embedded_words,
221            chunks: n,
222            markdown,
223        } = out;
224
225        let words = markdown.split_whitespace().count();
226        let title = first_heading(&markdown).unwrap_or_else(|| stem(&r.name));
227
228        // Optional local FS mirror of the parsed documents (RAG_DOCUMENTS_OUTPUT):
229        // same directory structure as the source, `.md` appended to every name
230        // (also for original .md inputs — conversion may reformat them).
231        // Best-effort: a failed write never fails ingest.
232        if let Some(dir) = &self.cfg.documents_output {
233            if let Err(e) = dump_markdown(dir, &r.rel_path, &markdown).await {
234                tracing::warn!(uri = %r.uri, error = %e, "failed to write markdown dump");
235            }
236        }
237
238        let m = ProcessingMetrics::compute(
239            file_bytes,
240            pages,
241            words,
242            n,
243            embedded_words,
244            Timings {
245                parse_secs,
246                chunk_secs,
247                embed_secs,
248            },
249        );
250        tracing::info!(
251            uri = %r.uri,
252            pages = ?m.pages,
253            words = m.words,
254            chunks = m.chunks,
255            parse_wps = ?m.parsing.words_per_sec,
256            embed_wps = ?m.embedding.words_per_sec,
257            "ingested document"
258        );
259        doc.title = title;
260        doc.hash = hash; // success: replace the sentinel with the real hash
261                         // The parsed Markdown rides along in the metadata so the API can serve
262                         // it back (GET /api/documents/{id}/markdown) without re-converting.
263        doc.metadata =
264            serde_json::json!({ "source": r.uri, "metrics": m.to_json(), "markdown": markdown });
265        self.store.upsert_document(&doc).await?;
266        Ok(IngestOutcome::Ingested(n))
267    }
268
269    /// The docling-chunker variant of [`Self::ingest_streaming`]
270    /// (`RAG_CHUNKER=hierarchical|hybrid`): the chunkers need the complete
271    /// document tree, so conversion runs whole-document on a blocking thread —
272    /// but the chunks *stream*: batches are handed to the embed/insert worker
273    /// as the chunkers produce them, overlapping chunking with embedding.
274    async fn ingest_docling(
275        &self,
276        r: &SourceRef,
277        doc_id: &str,
278        bytes: Vec<u8>,
279        opts: ConvertOptions,
280    ) -> Result<StagedOutcome> {
281        let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel::<Vec<crate::model::Chunk>>(4);
282        let embed_worker = self.spawn_embed_worker(chunk_rx);
283
284        let name = r.name.clone();
285        let kind = self.cfg.chunker;
286        let tokenizer = self.cfg.chunk_tokenizer.clone();
287        let max_tokens = self.cfg.chunk_size;
288        let doc_id_owned = doc_id.to_string();
289        type Converted = (Option<usize>, f64, f64, String);
290        let producer = tokio::task::spawn_blocking(move || -> Result<Converted> {
291            let ext = name.rsplit('.').next().unwrap_or("");
292            let fmt = InputFormat::from_extension(ext)
293                .ok_or_else(|| RagError::Conversion(format!("unsupported extension '.{ext}'")))?;
294            let pages = metrics::count_pages(fmt, &bytes);
295            let src = SourceDocument::from_bytes(name, fmt, bytes);
296            let t = std::time::Instant::now();
297            let result = opts
298                .converter()
299                .convert(src)
300                .map_err(|e| RagError::Conversion(e.to_string()))?;
301            let parse_secs = t.elapsed().as_secs_f64();
302            let markdown = result.document.export_to_markdown();
303
304            const BATCH: usize = 64;
305            let mut backlog: Vec<crate::model::Chunk> = Vec::with_capacity(BATCH);
306            let t = std::time::Instant::now();
307            // chunk_secs counts chunker busy time only: time blocked handing a
308            // full batch to the embed worker is subtracted (that would bill
309            // embedding slowness to chunking).
310            let mut send_secs = 0.0f64;
311            let mut disconnected = false;
312            crate::chunk::docling_chunks_with(
313                &doc_id_owned,
314                &result.document,
315                kind,
316                tokenizer.as_deref(),
317                max_tokens,
318                &mut |chunk| {
319                    backlog.push(chunk);
320                    if backlog.len() < BATCH {
321                        return true;
322                    }
323                    let ts = std::time::Instant::now();
324                    // A send failure means the embed worker died; its error wins.
325                    disconnected = chunk_tx
326                        .blocking_send(std::mem::take(&mut backlog))
327                        .is_err();
328                    send_secs += ts.elapsed().as_secs_f64();
329                    !disconnected
330                },
331            )?;
332            if !disconnected && !backlog.is_empty() {
333                let _ = chunk_tx.blocking_send(backlog);
334            }
335            let chunk_secs = (t.elapsed().as_secs_f64() - send_secs).max(0.0);
336            Ok((pages, parse_secs, chunk_secs, markdown))
337        });
338
339        // Join stages; producer errors (bad document) take precedence.
340        let (pages, parse_secs, chunk_secs, markdown) = producer
341            .await
342            .map_err(|e| RagError::Conversion(format!("convert join: {e}")))??;
343        let (embed_secs, embedded_words, n) = embed_worker
344            .await
345            .map_err(|e| RagError::Embedding(format!("embed join: {e}")))??;
346
347        Ok(StagedOutcome {
348            pages,
349            parse_secs,
350            chunk_secs,
351            embed_secs,
352            embedded_words,
353            chunks: n,
354            markdown,
355        })
356    }
357
358    /// Spawn the embed + insert worker: chunk batches from `rx` are embedded
359    /// and stored concurrently with whatever stage produces them. Resolves to
360    /// `(embed_secs, embedded_words, chunks_inserted)` once `rx` closes.
361    fn spawn_embed_worker(
362        &self,
363        mut rx: tokio::sync::mpsc::Receiver<Vec<crate::model::Chunk>>,
364    ) -> tokio::task::JoinHandle<Result<(f64, usize, usize)>> {
365        let embedder = self.embedder.clone();
366        let store = self.store.clone();
367        tokio::spawn(async move {
368            let (mut embed_secs, mut embedded_words, mut n_chunks) = (0.0f64, 0usize, 0usize);
369            while let Some(mut batch) = rx.recv().await {
370                let texts: Vec<String> = batch.iter().map(|c| c.text.clone()).collect();
371                let t = std::time::Instant::now();
372                let embeddings = embedder.embed(&texts).await?;
373                embed_secs += t.elapsed().as_secs_f64();
374                if embeddings.len() != batch.len() {
375                    return Err(RagError::Embedding("embedding count mismatch".into()));
376                }
377                for (chunk, emb) in batch.iter_mut().zip(embeddings) {
378                    chunk.embedding = Some(emb);
379                }
380                embedded_words += texts
381                    .iter()
382                    .map(|t| t.split_whitespace().count())
383                    .sum::<usize>();
384                n_chunks += batch.len();
385                store.insert_chunks(&batch).await?;
386            }
387            Ok((embed_secs, embedded_words, n_chunks))
388        })
389    }
390
391    /// The overlapped parse → chunk → embed/insert stages for one document.
392    async fn ingest_streaming(
393        &self,
394        r: &SourceRef,
395        doc_id: &str,
396        bytes: Vec<u8>,
397        opts: ConvertOptions,
398    ) -> Result<StagedOutcome> {
399        // --- Stage 1: parser thread. Streams Markdown pieces as converted.
400        // Bounded channel: a slow consumer applies backpressure to the converter.
401        let (md_tx, mut md_rx) = tokio::sync::mpsc::channel::<String>(16);
402        let name = r.name.clone();
403        let parser = tokio::task::spawn_blocking(move || -> Result<(Option<usize>, f64)> {
404            let ext = name.rsplit('.').next().unwrap_or("");
405            let fmt = InputFormat::from_extension(ext)
406                .ok_or_else(|| RagError::Conversion(format!("unsupported extension '.{ext}'")))?;
407            let pages = metrics::count_pages(fmt, &bytes);
408            let src = SourceDocument::from_bytes(name, fmt, bytes);
409            let mut stream = opts
410                .converter()
411                .convert_streaming(src)
412                .map_err(|e| RagError::Conversion(e.to_string()))?;
413            // parse_secs counts time inside the converter only, not time blocked
414            // on a full channel (that would bill consumer slowness to parsing).
415            let mut parse_secs = 0.0;
416            loop {
417                let t = std::time::Instant::now();
418                let item = stream.next();
419                parse_secs += t.elapsed().as_secs_f64();
420                match item {
421                    Some(Ok(piece)) => {
422                        if md_tx.blocking_send(piece).is_err() {
423                            break; // consumer failed; its error wins
424                        }
425                    }
426                    Some(Err(e)) => return Err(RagError::Conversion(e.to_string())),
427                    None => break,
428                }
429            }
430            Ok((pages, parse_secs))
431        });
432
433        // --- Stage 2: incremental chunking; completed chunks go to the embedder.
434        // --- Stage 3: embed + insert worker, concurrent with stages 1 and 2.
435        let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel::<Vec<crate::model::Chunk>>(4);
436        let embed_worker = self.spawn_embed_worker(chunk_rx);
437
438        let mut streaming = self.chunker.streaming(doc_id);
439        let mut markdown = String::new();
440        let mut chunk_secs = 0.0f64;
441        let mut backlog: Vec<crate::model::Chunk> = Vec::new();
442        const BATCH: usize = 64;
443        let mut consume_failed = false;
444        while let Some(piece) = md_rx.recv().await {
445            let t = std::time::Instant::now();
446            let ready = streaming.push(&piece);
447            chunk_secs += t.elapsed().as_secs_f64();
448            markdown.push_str(&piece);
449            backlog.extend(ready);
450            while backlog.len() >= BATCH {
451                let batch: Vec<_> = backlog.drain(..BATCH).collect();
452                if chunk_tx.send(batch).await.is_err() {
453                    consume_failed = true; // embed worker died; surface its error
454                    break;
455                }
456            }
457            if consume_failed {
458                break;
459            }
460        }
461        // Drain: remaining markdown lands in a final section, then flush backlog.
462        drop(md_rx);
463        if !consume_failed {
464            let t = std::time::Instant::now();
465            backlog.extend(streaming.finish());
466            chunk_secs += t.elapsed().as_secs_f64();
467            for batch in backlog.chunks(BATCH) {
468                if chunk_tx.send(batch.to_vec()).await.is_err() {
469                    break;
470                }
471            }
472        }
473        drop(chunk_tx);
474
475        // Join stages; parser errors (bad document) take precedence.
476        let (pages, parse_secs) = parser
477            .await
478            .map_err(|e| RagError::Conversion(format!("convert join: {e}")))??;
479        let (embed_secs, embedded_words, n) = embed_worker
480            .await
481            .map_err(|e| RagError::Embedding(format!("embed join: {e}")))??;
482
483        Ok(StagedOutcome {
484            pages,
485            parse_secs,
486            chunk_secs,
487            embed_secs,
488            embedded_words,
489            chunks: n,
490            markdown,
491        })
492    }
493
494    /// Ingest every document the configured source lists.
495    pub async fn ingest_all(&self) -> Result<IngestReport> {
496        let refs = self.source.list().await?;
497        let mut report = IngestReport::default();
498        for r in &refs {
499            match self.ingest_ref(r).await {
500                Ok(IngestOutcome::Ingested(n)) => {
501                    report.documents_ingested += 1;
502                    report.chunks_added += n;
503                }
504                Ok(IngestOutcome::Skipped) => report.documents_skipped += 1,
505                Err(e) => {
506                    report.documents_failed += 1;
507                    tracing::warn!(uri = %r.uri, error = %e, "failed to ingest document");
508                }
509            }
510        }
511        Ok(report)
512    }
513
514    /// Retrieve the top `k` chunks for a query under `mode`.
515    pub async fn query(&self, mode: RetrievalMode, query: &str, k: usize) -> Result<Vec<Scored>> {
516        self.retriever().retrieve(mode, query, k).await
517    }
518
519    /// Retrieve, then ask the LLM to answer grounded in the retrieved chunks.
520    pub async fn answer(&self, query: &str, mode: RetrievalMode, k: usize) -> Result<Answer> {
521        let chat = self.chat.as_ref().ok_or_else(|| {
522            RagError::Llm("answering needs an LLM; set OPENROUTER_API_KEY".into())
523        })?;
524        let hits = self.query(mode, query, k).await?;
525        let context = hits
526            .iter()
527            .enumerate()
528            .map(|(i, h)| format!("[{}] {}", i + 1, h.chunk.text))
529            .collect::<Vec<_>>()
530            .join("\n\n");
531        let system = "Answer the user's question using only the provided context passages. \
532                      Cite the passage numbers you used like [1]. If the context does not \
533                      contain the answer, say so.";
534        let user = format!("Context:\n{context}\n\nQuestion: {query}");
535        let text = chat
536            .complete(&[Message::system(system), Message::user(&user)])
537            .await?;
538        Ok(Answer {
539            text,
540            sources: hits,
541        })
542    }
543}
544
545/// Mirror a parsed document into the output folder: `<dir>/<rel_path>.md`, with
546/// the source's directory structure preserved and `.md` always appended
547/// (`report.pdf` → `report.pdf.md`, `notes.md` → `notes.md.md`).
548async fn dump_markdown(dir: &str, rel_path: &str, markdown: &str) -> Result<()> {
549    // Never let a hostile rel_path escape the output root.
550    let rel: std::path::PathBuf = std::path::Path::new(rel_path)
551        .components()
552        .filter(|c| matches!(c, std::path::Component::Normal(_)))
553        .collect();
554    let file_name = if rel.as_os_str().is_empty() {
555        std::path::PathBuf::from("document")
556    } else {
557        rel
558    };
559    let path = std::path::Path::new(dir).join(format!("{}.md", file_name.display()));
560    if let Some(parent) = path.parent() {
561        tokio::fs::create_dir_all(parent).await?;
562    }
563    tokio::fs::write(&path, markdown).await?;
564    tracing::debug!(path = %path.display(), "wrote markdown dump");
565    Ok(())
566}
567
568/// First `# `/`## ` heading text in a Markdown string.
569fn first_heading(md: &str) -> Option<String> {
570    for line in md.lines() {
571        let t = line.trim_start();
572        if let Some(rest) = t.strip_prefix('#') {
573            let heading = rest.trim_start_matches('#').trim();
574            if !heading.is_empty() {
575                return Some(heading.to_string());
576            }
577        }
578    }
579    None
580}
581
582/// File stem of a name (`report.md` → `report`).
583fn stem(name: &str) -> String {
584    let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
585    base.rsplit_once('.')
586        .map(|(s, _)| s)
587        .unwrap_or(base)
588        .to_string()
589}
590
591#[cfg(test)]
592mod tests {
593    use super::*;
594
595    #[test]
596    fn extracts_title_and_stem() {
597        assert_eq!(
598            first_heading("intro\n# Real Title\nbody"),
599            Some("Real Title".into())
600        );
601        assert_eq!(first_heading("no headings here"), None);
602        assert_eq!(stem("/a/b/report.md"), "report");
603        assert_eq!(stem("noext"), "noext");
604    }
605}