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 bm25_cache: Arc<crate::retrieve::bm25::Bm25Cache>,
27}
28
29#[derive(Debug, Clone, PartialEq, Eq)]
31pub enum IngestOutcome {
32 Ingested(usize),
34 Skipped,
36}
37
38#[derive(Debug, Clone, Default)]
40pub struct IngestReport {
41 pub documents_ingested: usize,
42 pub documents_skipped: usize,
43 pub documents_failed: usize,
44 pub chunks_added: usize,
45}
46
47#[derive(Debug, Clone)]
49pub struct Answer {
50 pub text: String,
51 pub sources: Vec<Scored>,
52}
53
54#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
58pub struct ConvertOptions {
59 pub enrich_pictures: bool,
61 pub enrich_code: bool,
63 pub enrich_formulas: bool,
65}
66
67impl ConvertOptions {
68 fn converter(self) -> DocumentConverter {
70 DocumentConverter::new()
71 .do_picture_classification(self.enrich_pictures)
72 .do_code_enrichment(self.enrich_code)
73 .do_formula_enrichment(self.enrich_formulas)
74 }
75}
76
77struct StagedOutcome {
80 pages: Option<usize>,
81 parse_secs: f64,
82 chunk_secs: f64,
83 embed_secs: f64,
84 embedded_words: usize,
85 chunks: usize,
86 markdown: String,
87}
88
89impl Pipeline {
90 pub async fn from_config(cfg: &RagConfig) -> Result<Self> {
93 if cfg.ocr_lang == crate::config::OcrLang::Ch
98 && docling_core::env::nonempty("DOCLING_RS_OCR_LANG").is_none()
99 {
100 std::env::set_var("DOCLING_RS_OCR_LANG", "ch");
101 }
102 let source = source::from_config(cfg)?;
103 let embedder = embed::from_config(cfg)?;
104 let store = store::from_config(cfg).await?;
105 let chat = match cfg.openrouter_api_key {
106 Some(_) => Some(llm::from_config(cfg)?),
107 None => None,
108 };
109 let chunker = Chunker::from_config(cfg);
110 Ok(Pipeline {
111 cfg: cfg.clone(),
112 source,
113 embedder,
114 store,
115 chat,
116 chunker,
117 bm25_cache: Arc::new(crate::retrieve::bm25::Bm25Cache::new()),
118 })
119 }
120
121 pub fn store(&self) -> &Arc<dyn VectorStore> {
123 &self.store
124 }
125
126 pub fn has_llm(&self) -> bool {
132 self.chat.is_some()
133 }
134
135 pub fn config(&self) -> &RagConfig {
136 &self.cfg
137 }
138
139 pub fn retriever(&self) -> Retriever {
145 Retriever::new(self.store.clone(), self.embedder.clone(), self.chat.clone())
146 .with_rrf_k(self.cfg.rrf_k)
147 .with_multiquery_n(self.cfg.multiquery_n)
148 .with_bm25_params(crate::retrieve::bm25::Bm25Params {
149 k1: self.cfg.bm25_k1,
150 b: self.cfg.bm25_b,
151 })
152 .with_bm25_cache(self.bm25_cache.clone())
153 }
154
155 pub fn invalidate_keyword_index(&self) {
157 self.bm25_cache.invalidate();
158 }
159
160 pub async fn ingest_ref(&self, r: &SourceRef) -> Result<IngestOutcome> {
169 let bytes = self.source.fetch(r).await?;
170 self.ingest_bytes(r, bytes).await
171 }
172
173 pub async fn ingest_bytes(&self, r: &SourceRef, bytes: Vec<u8>) -> Result<IngestOutcome> {
179 self.ingest_bytes_with(r, bytes, ConvertOptions::default())
180 .await
181 }
182
183 pub async fn ingest_bytes_with(
185 &self,
186 r: &SourceRef,
187 bytes: Vec<u8>,
188 opts: ConvertOptions,
189 ) -> Result<IngestOutcome> {
190 let hash = content_hash(&bytes);
191 if self.store.find_document_by_hash(&hash).await?.is_some() {
192 tracing::debug!(uri = %r.uri, "skipping unchanged document");
193 return Ok(IngestOutcome::Skipped);
194 }
195 let file_bytes = bytes.len() as u64;
196 tracing::info!(
197 uri = %r.uri,
198 name = %r.name,
199 bytes = file_bytes,
200 "processing document"
201 );
202
203 self.store.delete_documents_by_source(&r.uri).await?;
206 self.invalidate_keyword_index();
211
212 let mut doc = Document::new(&r.uri, stem(&r.name), format!("pending:{hash}"))
218 .with_metadata(serde_json::json!({ "source": r.uri }));
219 self.store.upsert_document(&doc).await?;
220
221 let staged = match self.cfg.chunker {
225 crate::config::ChunkerKind::Window => {
226 self.ingest_streaming(r, &doc.id, bytes, opts).await
227 }
228 _ => self.ingest_docling(r, &doc.id, bytes, opts).await,
232 };
233 let out = match staged {
234 Ok(out) => out,
235 Err(e) => {
236 if let Err(del) = self.store.delete_document(&doc.id).await {
237 tracing::warn!(uri = %r.uri, error = %del, "rollback of failed ingest also failed");
238 }
239 return Err(e);
240 }
241 };
242 let StagedOutcome {
243 pages,
244 parse_secs,
245 chunk_secs,
246 embed_secs,
247 embedded_words,
248 chunks: n,
249 markdown,
250 } = out;
251
252 let words = markdown.split_whitespace().count();
253 let title = first_heading(&markdown).unwrap_or_else(|| stem(&r.name));
254
255 if let Some(dir) = &self.cfg.documents_output {
260 if let Err(e) = dump_markdown(dir, &r.rel_path, &markdown).await {
261 tracing::warn!(uri = %r.uri, error = %e, "failed to write markdown dump");
262 }
263 }
264
265 let m = ProcessingMetrics::compute(
266 file_bytes,
267 pages,
268 words,
269 n,
270 embedded_words,
271 Timings {
272 parse_secs,
273 chunk_secs,
274 embed_secs,
275 },
276 );
277 tracing::info!(
278 uri = %r.uri,
279 pages = ?m.pages,
280 words = m.words,
281 chunks = m.chunks,
282 parse_wps = ?m.parsing.words_per_sec,
283 embed_wps = ?m.embedding.words_per_sec,
284 "ingested document"
285 );
286 doc.title = title;
287 doc.hash = hash; doc.metadata =
291 serde_json::json!({ "source": r.uri, "metrics": m.to_json(), "markdown": markdown });
292 self.store.upsert_document(&doc).await?;
293 Ok(IngestOutcome::Ingested(n))
294 }
295
296 async fn ingest_docling(
302 &self,
303 r: &SourceRef,
304 doc_id: &str,
305 bytes: Vec<u8>,
306 opts: ConvertOptions,
307 ) -> Result<StagedOutcome> {
308 let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel::<Vec<crate::model::Chunk>>(4);
309 let embed_worker = self.spawn_embed_worker(chunk_rx);
310
311 let name = r.name.clone();
312 let kind = self.cfg.chunker;
313 let tokenizer = self.cfg.chunk_tokenizer.clone();
314 let max_tokens = self.cfg.chunk_size;
315 let doc_id_owned = doc_id.to_string();
316 type Converted = (Option<usize>, f64, f64, String);
317 let producer = tokio::task::spawn_blocking(move || -> Result<Converted> {
318 let ext = name.rsplit('.').next().unwrap_or("");
319 let fmt = InputFormat::from_extension(ext)
320 .ok_or_else(|| RagError::Conversion(format!("unsupported extension '.{ext}'")))?;
321 let pages = metrics::count_pages(fmt, &bytes);
322 let src = SourceDocument::from_bytes(name, fmt, bytes);
323 let t = std::time::Instant::now();
324 let result = opts
325 .converter()
326 .convert(src)
327 .map_err(|e| RagError::Conversion(e.to_string()))?;
328 let parse_secs = t.elapsed().as_secs_f64();
329 let markdown = result.document.export_to_markdown();
330
331 const BATCH: usize = 64;
332 let mut backlog: Vec<crate::model::Chunk> = Vec::with_capacity(BATCH);
333 let t = std::time::Instant::now();
334 let mut send_secs = 0.0f64;
338 let mut disconnected = false;
339 crate::chunk::docling_chunks_with(
340 &doc_id_owned,
341 &result.document,
342 kind,
343 tokenizer.as_deref(),
344 max_tokens,
345 &mut |chunk| {
346 backlog.push(chunk);
347 if backlog.len() < BATCH {
348 return true;
349 }
350 let ts = std::time::Instant::now();
351 disconnected = chunk_tx
353 .blocking_send(std::mem::take(&mut backlog))
354 .is_err();
355 send_secs += ts.elapsed().as_secs_f64();
356 !disconnected
357 },
358 )?;
359 if !disconnected && !backlog.is_empty() {
360 let _ = chunk_tx.blocking_send(backlog);
361 }
362 let chunk_secs = (t.elapsed().as_secs_f64() - send_secs).max(0.0);
363 Ok((pages, parse_secs, chunk_secs, markdown))
364 });
365
366 let (pages, parse_secs, chunk_secs, markdown) = producer
368 .await
369 .map_err(|e| RagError::Conversion(format!("convert join: {e}")))??;
370 let (embed_secs, embedded_words, n) = embed_worker
371 .await
372 .map_err(|e| RagError::Embedding(format!("embed join: {e}")))??;
373
374 Ok(StagedOutcome {
375 pages,
376 parse_secs,
377 chunk_secs,
378 embed_secs,
379 embedded_words,
380 chunks: n,
381 markdown,
382 })
383 }
384
385 fn spawn_embed_worker(
389 &self,
390 mut rx: tokio::sync::mpsc::Receiver<Vec<crate::model::Chunk>>,
391 ) -> tokio::task::JoinHandle<Result<(f64, usize, usize)>> {
392 let embedder = self.embedder.clone();
393 let store = self.store.clone();
394 tokio::spawn(async move {
395 let (mut embed_secs, mut embedded_words, mut n_chunks) = (0.0f64, 0usize, 0usize);
396 while let Some(mut batch) = rx.recv().await {
397 let texts: Vec<String> = batch.iter().map(|c| c.text.clone()).collect();
398 let t = std::time::Instant::now();
399 let embeddings = embedder.embed(&texts).await?;
400 embed_secs += t.elapsed().as_secs_f64();
401 if embeddings.len() != batch.len() {
402 return Err(RagError::Embedding("embedding count mismatch".into()));
403 }
404 for (chunk, emb) in batch.iter_mut().zip(embeddings) {
405 chunk.embedding = Some(emb);
406 }
407 embedded_words += texts
408 .iter()
409 .map(|t| t.split_whitespace().count())
410 .sum::<usize>();
411 n_chunks += batch.len();
412 store.insert_chunks(&batch).await?;
413 }
414 Ok((embed_secs, embedded_words, n_chunks))
415 })
416 }
417
418 async fn ingest_streaming(
420 &self,
421 r: &SourceRef,
422 doc_id: &str,
423 bytes: Vec<u8>,
424 opts: ConvertOptions,
425 ) -> Result<StagedOutcome> {
426 let (md_tx, mut md_rx) = tokio::sync::mpsc::channel::<String>(16);
429 let name = r.name.clone();
430 let parser = tokio::task::spawn_blocking(move || -> Result<(Option<usize>, f64)> {
431 let ext = name.rsplit('.').next().unwrap_or("");
432 let fmt = InputFormat::from_extension(ext)
433 .ok_or_else(|| RagError::Conversion(format!("unsupported extension '.{ext}'")))?;
434 let pages = metrics::count_pages(fmt, &bytes);
435 let src = SourceDocument::from_bytes(name, fmt, bytes);
436 let mut stream = opts
437 .converter()
438 .convert_streaming(src)
439 .map_err(|e| RagError::Conversion(e.to_string()))?;
440 let mut parse_secs = 0.0;
443 loop {
444 let t = std::time::Instant::now();
445 let item = stream.next();
446 parse_secs += t.elapsed().as_secs_f64();
447 match item {
448 Some(Ok(piece)) => {
449 if md_tx.blocking_send(piece).is_err() {
450 break; }
452 }
453 Some(Err(e)) => return Err(RagError::Conversion(e.to_string())),
454 None => break,
455 }
456 }
457 Ok((pages, parse_secs))
458 });
459
460 let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel::<Vec<crate::model::Chunk>>(4);
463 let embed_worker = self.spawn_embed_worker(chunk_rx);
464
465 let mut streaming = self.chunker.streaming(doc_id);
466 let mut markdown = String::new();
467 let mut chunk_secs = 0.0f64;
468 let mut backlog: Vec<crate::model::Chunk> = Vec::new();
469 const BATCH: usize = 64;
470 let mut consume_failed = false;
471 while let Some(piece) = md_rx.recv().await {
472 let t = std::time::Instant::now();
473 let ready = streaming.push(&piece);
474 chunk_secs += t.elapsed().as_secs_f64();
475 markdown.push_str(&piece);
476 backlog.extend(ready);
477 while backlog.len() >= BATCH {
478 let batch: Vec<_> = backlog.drain(..BATCH).collect();
479 if chunk_tx.send(batch).await.is_err() {
480 consume_failed = true; break;
482 }
483 }
484 if consume_failed {
485 break;
486 }
487 }
488 drop(md_rx);
490 if !consume_failed {
491 let t = std::time::Instant::now();
492 backlog.extend(streaming.finish());
493 chunk_secs += t.elapsed().as_secs_f64();
494 for batch in backlog.chunks(BATCH) {
495 if chunk_tx.send(batch.to_vec()).await.is_err() {
496 break;
497 }
498 }
499 }
500 drop(chunk_tx);
501
502 let (pages, parse_secs) = parser
504 .await
505 .map_err(|e| RagError::Conversion(format!("convert join: {e}")))??;
506 let (embed_secs, embedded_words, n) = embed_worker
507 .await
508 .map_err(|e| RagError::Embedding(format!("embed join: {e}")))??;
509
510 Ok(StagedOutcome {
511 pages,
512 parse_secs,
513 chunk_secs,
514 embed_secs,
515 embedded_words,
516 chunks: n,
517 markdown,
518 })
519 }
520
521 pub async fn ingest_all(&self) -> Result<IngestReport> {
523 let refs = self.source.list().await?;
524 let mut report = IngestReport::default();
525 for r in &refs {
526 match self.ingest_ref(r).await {
527 Ok(IngestOutcome::Ingested(n)) => {
528 report.documents_ingested += 1;
529 report.chunks_added += n;
530 }
531 Ok(IngestOutcome::Skipped) => report.documents_skipped += 1,
532 Err(e) => {
533 report.documents_failed += 1;
534 tracing::warn!(uri = %r.uri, error = %e, "failed to ingest document");
535 }
536 }
537 }
538 Ok(report)
539 }
540
541 pub async fn query(&self, mode: RetrievalMode, query: &str, k: usize) -> Result<Vec<Scored>> {
543 self.retriever().retrieve(mode, query, k).await
544 }
545
546 pub async fn answer(&self, query: &str, mode: RetrievalMode, k: usize) -> Result<Answer> {
548 let chat = self.chat.as_ref().ok_or_else(|| {
549 RagError::Llm("answering needs an LLM; set OPENROUTER_API_KEY".into())
550 })?;
551 let hits = self.query(mode, query, k).await?;
552 let context = hits
553 .iter()
554 .enumerate()
555 .map(|(i, h)| format!("[{}] {}", i + 1, h.chunk.text))
556 .collect::<Vec<_>>()
557 .join("\n\n");
558 let system = "Answer the user's question using only the provided context passages. \
559 Cite the passage numbers you used like [1]. If the context does not \
560 contain the answer, say so.";
561 let user = format!("Context:\n{context}\n\nQuestion: {query}");
562 let text = chat
563 .complete(&[Message::system(system), Message::user(&user)])
564 .await?;
565 Ok(Answer {
566 text,
567 sources: hits,
568 })
569 }
570}
571
572async fn dump_markdown(dir: &str, rel_path: &str, markdown: &str) -> Result<()> {
576 let rel: std::path::PathBuf = std::path::Path::new(rel_path)
578 .components()
579 .filter(|c| matches!(c, std::path::Component::Normal(_)))
580 .collect();
581 let file_name = if rel.as_os_str().is_empty() {
582 std::path::PathBuf::from("document")
583 } else {
584 rel
585 };
586 let path = std::path::Path::new(dir).join(format!("{}.md", file_name.display()));
587 if let Some(parent) = path.parent() {
588 tokio::fs::create_dir_all(parent).await?;
589 }
590 tokio::fs::write(&path, markdown).await?;
591 tracing::debug!(path = %path.display(), "wrote markdown dump");
592 Ok(())
593}
594
595fn first_heading(md: &str) -> Option<String> {
597 for line in md.lines() {
598 let t = line.trim_start();
599 if let Some(rest) = t.strip_prefix('#') {
600 let heading = rest.trim_start_matches('#').trim();
601 if !heading.is_empty() {
602 return Some(heading.to_string());
603 }
604 }
605 }
606 None
607}
608
609fn stem(name: &str) -> String {
611 let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
612 base.rsplit_once('.')
613 .map(|(s, _)| s)
614 .unwrap_or(base)
615 .to_string()
616}
617
618#[cfg(test)]
619mod tests {
620 use super::*;
621
622 #[test]
623 fn extracts_title_and_stem() {
624 assert_eq!(
625 first_heading("intro\n# Real Title\nbody"),
626 Some("Real Title".into())
627 );
628 assert_eq!(first_heading("no headings here"), None);
629 assert_eq!(stem("/a/b/report.md"), "report");
630 assert_eq!(stem("noext"), "noext");
631 }
632}