Skip to main content

docling_rag/
metrics.rs

1//! Per-document processing metrics, captured during ingestion and stored in the
2//! document's JSON `metadata` column under the `"metrics"` key — a plain JSON
3//! object, so new metrics can be added later without a schema migration.
4//!
5//! Shape:
6//!
7//! ```json
8//! {
9//!   "file_bytes": 48213,
10//!   "pages": 12,
11//!   "words": 5120,
12//!   "chunks": 18,
13//!   "embedded_words": 5490,
14//!   "parsing":   { "seconds": 1.42, "words_per_sec": 3605.6, "pages_per_sec": 8.45 },
15//!   "chunking":  { "seconds": 0.003, "words_per_sec": 1706666.7 },
16//!   "embedding": { "seconds": 4.81, "words_per_sec": 1141.4 }
17//! }
18//! ```
19
20use docling::InputFormat;
21use serde::Serialize;
22
23/// Timing and throughput for one processing phase.
24#[derive(Debug, Clone, Copy, Default, Serialize)]
25pub struct PhaseMetrics {
26    /// Wall-clock duration of the phase.
27    pub seconds: f64,
28    /// Words processed per second; absent when the duration was too small to measure.
29    #[serde(skip_serializing_if = "Option::is_none")]
30    pub words_per_sec: Option<f64>,
31    /// Pages per second (parsing only, when the page count is known).
32    #[serde(skip_serializing_if = "Option::is_none")]
33    pub pages_per_sec: Option<f64>,
34}
35
36impl PhaseMetrics {
37    fn new(words: usize, pages: Option<usize>, seconds: f64) -> Self {
38        PhaseMetrics {
39            seconds: round3(seconds),
40            words_per_sec: rate(words, seconds),
41            pages_per_sec: pages.and_then(|p| rate(p, seconds)),
42        }
43    }
44}
45
46/// All metrics recorded for one ingested document.
47#[derive(Debug, Clone, Default, Serialize)]
48pub struct ProcessingMetrics {
49    /// Size of the source file as fetched, in bytes.
50    pub file_bytes: u64,
51    /// Page/slide count, when the format has one (PDF pages, PPTX slides).
52    #[serde(skip_serializing_if = "Option::is_none")]
53    pub pages: Option<usize>,
54    /// Words in the converted Markdown.
55    pub words: usize,
56    /// Number of chunks produced.
57    pub chunks: usize,
58    /// Total words actually embedded (includes chunk overlap and heading context).
59    pub embedded_words: usize,
60    /// Conversion to Markdown (`docling.rs`).
61    pub parsing: PhaseMetrics,
62    /// Markdown → chunks.
63    pub chunking: PhaseMetrics,
64    /// Chunks → vectors.
65    pub embedding: PhaseMetrics,
66}
67
68/// Raw phase durations measured by the pipeline, in seconds.
69#[derive(Debug, Clone, Copy, Default)]
70pub struct Timings {
71    pub parse_secs: f64,
72    pub chunk_secs: f64,
73    pub embed_secs: f64,
74}
75
76impl ProcessingMetrics {
77    /// Combine counts and timings into rate metrics.
78    pub fn compute(
79        file_bytes: u64,
80        pages: Option<usize>,
81        words: usize,
82        chunks: usize,
83        embedded_words: usize,
84        t: Timings,
85    ) -> Self {
86        ProcessingMetrics {
87            file_bytes,
88            pages,
89            words,
90            chunks,
91            embedded_words,
92            parsing: PhaseMetrics::new(words, pages, t.parse_secs),
93            chunking: PhaseMetrics::new(words, None, t.chunk_secs),
94            embedding: PhaseMetrics::new(embedded_words, None, t.embed_secs),
95        }
96    }
97
98    /// The metrics as a JSON value, ready to embed in document metadata.
99    pub fn to_json(&self) -> serde_json::Value {
100        serde_json::to_value(self).unwrap_or(serde_json::Value::Null)
101    }
102}
103
104/// `count / secs`, rounded; `None` when the duration is too small to be meaningful.
105fn rate(count: usize, secs: f64) -> Option<f64> {
106    (secs > 1e-9).then(|| round1(count as f64 / secs))
107}
108
109fn round1(x: f64) -> f64 {
110    (x * 10.0).round() / 10.0
111}
112
113fn round3(x: f64) -> f64 {
114    (x * 1000.0).round() / 1000.0
115}
116
117/// Best-effort page/slide count for multi-page formats. Returns `None` for
118/// formats without a page notion or when the container can't be read.
119pub fn count_pages(format: InputFormat, bytes: &[u8]) -> Option<usize> {
120    match format {
121        // Real page count via pdfium (the same backend the converter uses).
122        InputFormat::Pdf => docling_pdf::pdfium_backend::page_count(bytes, None).ok(),
123        // One "page" per slide; count zip entries without decompressing anything.
124        InputFormat::Pptx => zip_entry_count(bytes, "ppt/slides/slide", ".xml"),
125        // One "page" per sheet.
126        InputFormat::Xlsx => zip_entry_count(bytes, "xl/worksheets/sheet", ".xml"),
127        _ => None,
128    }
129}
130
131fn zip_entry_count(bytes: &[u8], prefix: &str, suffix: &str) -> Option<usize> {
132    let archive = zip::ZipArchive::new(std::io::Cursor::new(bytes)).ok()?;
133    let n = archive
134        .file_names()
135        .filter(|name| name.starts_with(prefix) && name.ends_with(suffix))
136        .count();
137    (n > 0).then_some(n)
138}
139
140#[cfg(test)]
141mod tests {
142    use super::*;
143
144    #[test]
145    fn computes_rates_and_serializes() {
146        let m = ProcessingMetrics::compute(
147            1000,
148            Some(4),
149            2000,
150            10,
151            2200,
152            Timings {
153                parse_secs: 2.0,
154                chunk_secs: 0.5,
155                embed_secs: 4.0,
156            },
157        );
158        assert_eq!(m.parsing.words_per_sec, Some(1000.0));
159        assert_eq!(m.parsing.pages_per_sec, Some(2.0));
160        assert_eq!(m.chunking.words_per_sec, Some(4000.0));
161        assert_eq!(m.chunking.pages_per_sec, None);
162        assert_eq!(m.embedding.words_per_sec, Some(550.0));
163
164        let j = m.to_json();
165        assert_eq!(j["file_bytes"], 1000);
166        assert_eq!(j["pages"], 4);
167        assert_eq!(j["parsing"]["pages_per_sec"], 2.0);
168        // chunking has no pages_per_sec key at all (skipped when None).
169        assert!(j["chunking"].get("pages_per_sec").is_none());
170    }
171
172    #[test]
173    fn zero_duration_yields_no_rate() {
174        let m = ProcessingMetrics::compute(1, None, 100, 1, 100, Timings::default());
175        assert_eq!(m.parsing.words_per_sec, None);
176        assert_eq!(m.parsing.pages_per_sec, None);
177        // pages absent => key omitted from JSON.
178        assert!(m.to_json().get("pages").is_none());
179    }
180
181    #[test]
182    fn non_container_formats_have_no_pages() {
183        assert_eq!(count_pages(InputFormat::Md, b"# hi"), None);
184        assert_eq!(count_pages(InputFormat::Pptx, b"not a zip"), None);
185    }
186}