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::Ch
96 && std::env::var_os("DOCLING_RS_OCR_LANG").is_none()
97 {
98 std::env::set_var("DOCLING_RS_OCR_LANG", "ch");
99 }
100 let source = source::from_config(cfg)?;
101 let embedder = embed::from_config(cfg)?;
102 let store = store::from_config(cfg).await?;
103 let chat = match cfg.openrouter_api_key {
104 Some(_) => Some(llm::from_config(cfg)?),
105 None => None,
106 };
107 let chunker = Chunker::from_config(cfg);
108 Ok(Pipeline {
109 cfg: cfg.clone(),
110 source,
111 embedder,
112 store,
113 chat,
114 chunker,
115 })
116 }
117
118 pub fn store(&self) -> &Arc<dyn VectorStore> {
120 &self.store
121 }
122
123 pub fn config(&self) -> &RagConfig {
125 &self.cfg
126 }
127
128 pub fn retriever(&self) -> Retriever {
130 Retriever::new(self.store.clone(), self.embedder.clone(), self.chat.clone())
131 .with_rrf_k(self.cfg.rrf_k)
132 .with_multiquery_n(self.cfg.multiquery_n)
133 }
134
135 pub async fn ingest_ref(&self, r: &SourceRef) -> Result<IngestOutcome> {
144 let bytes = self.source.fetch(r).await?;
145 self.ingest_bytes(r, bytes).await
146 }
147
148 pub async fn ingest_bytes(&self, r: &SourceRef, bytes: Vec<u8>) -> Result<IngestOutcome> {
154 self.ingest_bytes_with(r, bytes, ConvertOptions::default())
155 .await
156 }
157
158 pub async fn ingest_bytes_with(
160 &self,
161 r: &SourceRef,
162 bytes: Vec<u8>,
163 opts: ConvertOptions,
164 ) -> Result<IngestOutcome> {
165 let hash = content_hash(&bytes);
166 if self.store.find_document_by_hash(&hash).await?.is_some() {
167 tracing::debug!(uri = %r.uri, "skipping unchanged document");
168 return Ok(IngestOutcome::Skipped);
169 }
170 let file_bytes = bytes.len() as u64;
171 tracing::info!(
172 uri = %r.uri,
173 name = %r.name,
174 bytes = file_bytes,
175 "processing document"
176 );
177
178 self.store.delete_documents_by_source(&r.uri).await?;
181
182 let mut doc = Document::new(&r.uri, stem(&r.name), format!("pending:{hash}"))
188 .with_metadata(serde_json::json!({ "source": r.uri }));
189 self.store.upsert_document(&doc).await?;
190
191 let staged = match self.cfg.chunker {
195 crate::config::ChunkerKind::Window => {
196 self.ingest_streaming(r, &doc.id, bytes, opts).await
197 }
198 _ => self.ingest_docling(r, &doc.id, bytes, opts).await,
202 };
203 let out = match staged {
204 Ok(out) => out,
205 Err(e) => {
206 if let Err(del) = self.store.delete_document(&doc.id).await {
207 tracing::warn!(uri = %r.uri, error = %del, "rollback of failed ingest also failed");
208 }
209 return Err(e);
210 }
211 };
212 let StagedOutcome {
213 pages,
214 parse_secs,
215 chunk_secs,
216 embed_secs,
217 embedded_words,
218 chunks: n,
219 markdown,
220 } = out;
221
222 let words = markdown.split_whitespace().count();
223 let title = first_heading(&markdown).unwrap_or_else(|| stem(&r.name));
224
225 if let Some(dir) = &self.cfg.documents_output {
230 if let Err(e) = dump_markdown(dir, &r.rel_path, &markdown).await {
231 tracing::warn!(uri = %r.uri, error = %e, "failed to write markdown dump");
232 }
233 }
234
235 let m = ProcessingMetrics::compute(
236 file_bytes,
237 pages,
238 words,
239 n,
240 embedded_words,
241 Timings {
242 parse_secs,
243 chunk_secs,
244 embed_secs,
245 },
246 );
247 tracing::info!(
248 uri = %r.uri,
249 pages = ?m.pages,
250 words = m.words,
251 chunks = m.chunks,
252 parse_wps = ?m.parsing.words_per_sec,
253 embed_wps = ?m.embedding.words_per_sec,
254 "ingested document"
255 );
256 doc.title = title;
257 doc.hash = hash; doc.metadata =
261 serde_json::json!({ "source": r.uri, "metrics": m.to_json(), "markdown": markdown });
262 self.store.upsert_document(&doc).await?;
263 Ok(IngestOutcome::Ingested(n))
264 }
265
266 async fn ingest_docling(
272 &self,
273 r: &SourceRef,
274 doc_id: &str,
275 bytes: Vec<u8>,
276 opts: ConvertOptions,
277 ) -> Result<StagedOutcome> {
278 let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel::<Vec<crate::model::Chunk>>(4);
279 let embed_worker = self.spawn_embed_worker(chunk_rx);
280
281 let name = r.name.clone();
282 let kind = self.cfg.chunker;
283 let tokenizer = self.cfg.chunk_tokenizer.clone();
284 let max_tokens = self.cfg.chunk_size;
285 let doc_id_owned = doc_id.to_string();
286 type Converted = (Option<usize>, f64, f64, String);
287 let producer = tokio::task::spawn_blocking(move || -> Result<Converted> {
288 let ext = name.rsplit('.').next().unwrap_or("");
289 let fmt = InputFormat::from_extension(ext)
290 .ok_or_else(|| RagError::Conversion(format!("unsupported extension '.{ext}'")))?;
291 let pages = metrics::count_pages(fmt, &bytes);
292 let src = SourceDocument::from_bytes(name, fmt, bytes);
293 let t = std::time::Instant::now();
294 let result = opts
295 .converter()
296 .convert(src)
297 .map_err(|e| RagError::Conversion(e.to_string()))?;
298 let parse_secs = t.elapsed().as_secs_f64();
299 let markdown = result.document.export_to_markdown();
300
301 const BATCH: usize = 64;
302 let mut backlog: Vec<crate::model::Chunk> = Vec::with_capacity(BATCH);
303 let t = std::time::Instant::now();
304 let mut send_secs = 0.0f64;
308 let mut disconnected = false;
309 crate::chunk::docling_chunks_with(
310 &doc_id_owned,
311 &result.document,
312 kind,
313 tokenizer.as_deref(),
314 max_tokens,
315 &mut |chunk| {
316 backlog.push(chunk);
317 if backlog.len() < BATCH {
318 return true;
319 }
320 let ts = std::time::Instant::now();
321 disconnected = chunk_tx
323 .blocking_send(std::mem::take(&mut backlog))
324 .is_err();
325 send_secs += ts.elapsed().as_secs_f64();
326 !disconnected
327 },
328 )?;
329 if !disconnected && !backlog.is_empty() {
330 let _ = chunk_tx.blocking_send(backlog);
331 }
332 let chunk_secs = (t.elapsed().as_secs_f64() - send_secs).max(0.0);
333 Ok((pages, parse_secs, chunk_secs, markdown))
334 });
335
336 let (pages, parse_secs, chunk_secs, markdown) = producer
338 .await
339 .map_err(|e| RagError::Conversion(format!("convert join: {e}")))??;
340 let (embed_secs, embedded_words, n) = embed_worker
341 .await
342 .map_err(|e| RagError::Embedding(format!("embed join: {e}")))??;
343
344 Ok(StagedOutcome {
345 pages,
346 parse_secs,
347 chunk_secs,
348 embed_secs,
349 embedded_words,
350 chunks: n,
351 markdown,
352 })
353 }
354
355 fn spawn_embed_worker(
359 &self,
360 mut rx: tokio::sync::mpsc::Receiver<Vec<crate::model::Chunk>>,
361 ) -> tokio::task::JoinHandle<Result<(f64, usize, usize)>> {
362 let embedder = self.embedder.clone();
363 let store = self.store.clone();
364 tokio::spawn(async move {
365 let (mut embed_secs, mut embedded_words, mut n_chunks) = (0.0f64, 0usize, 0usize);
366 while let Some(mut batch) = rx.recv().await {
367 let texts: Vec<String> = batch.iter().map(|c| c.text.clone()).collect();
368 let t = std::time::Instant::now();
369 let embeddings = embedder.embed(&texts).await?;
370 embed_secs += t.elapsed().as_secs_f64();
371 if embeddings.len() != batch.len() {
372 return Err(RagError::Embedding("embedding count mismatch".into()));
373 }
374 for (chunk, emb) in batch.iter_mut().zip(embeddings) {
375 chunk.embedding = Some(emb);
376 }
377 embedded_words += texts
378 .iter()
379 .map(|t| t.split_whitespace().count())
380 .sum::<usize>();
381 n_chunks += batch.len();
382 store.insert_chunks(&batch).await?;
383 }
384 Ok((embed_secs, embedded_words, n_chunks))
385 })
386 }
387
388 async fn ingest_streaming(
390 &self,
391 r: &SourceRef,
392 doc_id: &str,
393 bytes: Vec<u8>,
394 opts: ConvertOptions,
395 ) -> Result<StagedOutcome> {
396 let (md_tx, mut md_rx) = tokio::sync::mpsc::channel::<String>(16);
399 let name = r.name.clone();
400 let parser = tokio::task::spawn_blocking(move || -> Result<(Option<usize>, f64)> {
401 let ext = name.rsplit('.').next().unwrap_or("");
402 let fmt = InputFormat::from_extension(ext)
403 .ok_or_else(|| RagError::Conversion(format!("unsupported extension '.{ext}'")))?;
404 let pages = metrics::count_pages(fmt, &bytes);
405 let src = SourceDocument::from_bytes(name, fmt, bytes);
406 let mut stream = opts
407 .converter()
408 .convert_streaming(src)
409 .map_err(|e| RagError::Conversion(e.to_string()))?;
410 let mut parse_secs = 0.0;
413 loop {
414 let t = std::time::Instant::now();
415 let item = stream.next();
416 parse_secs += t.elapsed().as_secs_f64();
417 match item {
418 Some(Ok(piece)) => {
419 if md_tx.blocking_send(piece).is_err() {
420 break; }
422 }
423 Some(Err(e)) => return Err(RagError::Conversion(e.to_string())),
424 None => break,
425 }
426 }
427 Ok((pages, parse_secs))
428 });
429
430 let (chunk_tx, chunk_rx) = tokio::sync::mpsc::channel::<Vec<crate::model::Chunk>>(4);
433 let embed_worker = self.spawn_embed_worker(chunk_rx);
434
435 let mut streaming = self.chunker.streaming(doc_id);
436 let mut markdown = String::new();
437 let mut chunk_secs = 0.0f64;
438 let mut backlog: Vec<crate::model::Chunk> = Vec::new();
439 const BATCH: usize = 64;
440 let mut consume_failed = false;
441 while let Some(piece) = md_rx.recv().await {
442 let t = std::time::Instant::now();
443 let ready = streaming.push(&piece);
444 chunk_secs += t.elapsed().as_secs_f64();
445 markdown.push_str(&piece);
446 backlog.extend(ready);
447 while backlog.len() >= BATCH {
448 let batch: Vec<_> = backlog.drain(..BATCH).collect();
449 if chunk_tx.send(batch).await.is_err() {
450 consume_failed = true; break;
452 }
453 }
454 if consume_failed {
455 break;
456 }
457 }
458 drop(md_rx);
460 if !consume_failed {
461 let t = std::time::Instant::now();
462 backlog.extend(streaming.finish());
463 chunk_secs += t.elapsed().as_secs_f64();
464 for batch in backlog.chunks(BATCH) {
465 if chunk_tx.send(batch.to_vec()).await.is_err() {
466 break;
467 }
468 }
469 }
470 drop(chunk_tx);
471
472 let (pages, parse_secs) = parser
474 .await
475 .map_err(|e| RagError::Conversion(format!("convert join: {e}")))??;
476 let (embed_secs, embedded_words, n) = embed_worker
477 .await
478 .map_err(|e| RagError::Embedding(format!("embed join: {e}")))??;
479
480 Ok(StagedOutcome {
481 pages,
482 parse_secs,
483 chunk_secs,
484 embed_secs,
485 embedded_words,
486 chunks: n,
487 markdown,
488 })
489 }
490
491 pub async fn ingest_all(&self) -> Result<IngestReport> {
493 let refs = self.source.list().await?;
494 let mut report = IngestReport::default();
495 for r in &refs {
496 match self.ingest_ref(r).await {
497 Ok(IngestOutcome::Ingested(n)) => {
498 report.documents_ingested += 1;
499 report.chunks_added += n;
500 }
501 Ok(IngestOutcome::Skipped) => report.documents_skipped += 1,
502 Err(e) => {
503 report.documents_failed += 1;
504 tracing::warn!(uri = %r.uri, error = %e, "failed to ingest document");
505 }
506 }
507 }
508 Ok(report)
509 }
510
511 pub async fn query(&self, mode: RetrievalMode, query: &str, k: usize) -> Result<Vec<Scored>> {
513 self.retriever().retrieve(mode, query, k).await
514 }
515
516 pub async fn answer(&self, query: &str, mode: RetrievalMode, k: usize) -> Result<Answer> {
518 let chat = self.chat.as_ref().ok_or_else(|| {
519 RagError::Llm("answering needs an LLM; set OPENROUTER_API_KEY".into())
520 })?;
521 let hits = self.query(mode, query, k).await?;
522 let context = hits
523 .iter()
524 .enumerate()
525 .map(|(i, h)| format!("[{}] {}", i + 1, h.chunk.text))
526 .collect::<Vec<_>>()
527 .join("\n\n");
528 let system = "Answer the user's question using only the provided context passages. \
529 Cite the passage numbers you used like [1]. If the context does not \
530 contain the answer, say so.";
531 let user = format!("Context:\n{context}\n\nQuestion: {query}");
532 let text = chat
533 .complete(&[Message::system(system), Message::user(&user)])
534 .await?;
535 Ok(Answer {
536 text,
537 sources: hits,
538 })
539 }
540}
541
542async fn dump_markdown(dir: &str, rel_path: &str, markdown: &str) -> Result<()> {
546 let rel: std::path::PathBuf = std::path::Path::new(rel_path)
548 .components()
549 .filter(|c| matches!(c, std::path::Component::Normal(_)))
550 .collect();
551 let file_name = if rel.as_os_str().is_empty() {
552 std::path::PathBuf::from("document")
553 } else {
554 rel
555 };
556 let path = std::path::Path::new(dir).join(format!("{}.md", file_name.display()));
557 if let Some(parent) = path.parent() {
558 tokio::fs::create_dir_all(parent).await?;
559 }
560 tokio::fs::write(&path, markdown).await?;
561 tracing::debug!(path = %path.display(), "wrote markdown dump");
562 Ok(())
563}
564
565fn first_heading(md: &str) -> Option<String> {
567 for line in md.lines() {
568 let t = line.trim_start();
569 if let Some(rest) = t.strip_prefix('#') {
570 let heading = rest.trim_start_matches('#').trim();
571 if !heading.is_empty() {
572 return Some(heading.to_string());
573 }
574 }
575 }
576 None
577}
578
579fn stem(name: &str) -> String {
581 let base = name.rsplit(['/', '\\']).next().unwrap_or(name);
582 base.rsplit_once('.')
583 .map(|(s, _)| s)
584 .unwrap_or(base)
585 .to_string()
586}
587
588#[cfg(test)]
589mod tests {
590 use super::*;
591
592 #[test]
593 fn extracts_title_and_stem() {
594 assert_eq!(
595 first_heading("intro\n# Real Title\nbody"),
596 Some("Real Title".into())
597 );
598 assert_eq!(first_heading("no headings here"), None);
599 assert_eq!(stem("/a/b/report.md"), "report");
600 assert_eq!(stem("noext"), "noext");
601 }
602}