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