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
52struct 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 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 pub fn store(&self) -> &Arc<dyn VectorStore> {
88 &self.store
89 }
90
91 pub fn config(&self) -> &RagConfig {
93 &self.cfg
94 }
95
96 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 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 self.store.delete_documents_by_source(&r.uri).await?;
129
130 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 let staged = match self.cfg.chunker {
143 crate::config::ChunkerKind::Window => self.ingest_streaming(r, &doc.id, bytes).await,
144 _ => 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 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; 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 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 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 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 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 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 async fn ingest_streaming(
331 &self,
332 r: &SourceRef,
333 doc_id: &str,
334 bytes: Vec<u8>,
335 ) -> Result<StagedOutcome> {
336 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 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; }
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 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; break;
391 }
392 }
393 if consume_failed {
394 break;
395 }
396 }
397 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 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 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 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 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
481async fn dump_markdown(dir: &str, rel_path: &str, markdown: &str) -> Result<()> {
485 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
504fn 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
518fn 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}