1use 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#[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#[derive(Debug, Clone, PartialEq, Eq)]
29pub enum IngestOutcome {
30 Ingested(usize),
32 Skipped,
34}
35
36#[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#[derive(Debug, Clone)]
47pub struct Answer {
48 pub text: String,
49 pub sources: Vec<Scored>,
50}
51
52#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
56pub struct ConvertOptions {
57 pub enrich_pictures: bool,
59 pub enrich_code: bool,
61 pub enrich_formulas: bool,
63}
64
65impl ConvertOptions {
66 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
75struct 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 pub async fn from_config(cfg: &RagConfig) -> Result<Self> {
91 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 pub fn store(&self) -> &Arc<dyn VectorStore> {
123 &self.store
124 }
125
126 pub fn config(&self) -> &RagConfig {
128 &self.cfg
129 }
130
131 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 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 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 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 self.store.delete_documents_by_source(&r.uri).await?;
184
185 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 let staged = match self.cfg.chunker {
198 crate::config::ChunkerKind::Window => {
199 self.ingest_streaming(r, &doc.id, bytes, opts).await
200 }
201 _ => 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 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; 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 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 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 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 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 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 async fn ingest_streaming(
393 &self,
394 r: &SourceRef,
395 doc_id: &str,
396 bytes: Vec<u8>,
397 opts: ConvertOptions,
398 ) -> Result<StagedOutcome> {
399 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 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; }
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 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; break;
455 }
456 }
457 if consume_failed {
458 break;
459 }
460 }
461 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 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 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 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 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
545async fn dump_markdown(dir: &str, rel_path: &str, markdown: &str) -> Result<()> {
549 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
568fn 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
582fn 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}