use std::collections::HashSet;
use crate::pipeline::chunk_metadata::ChunkMetadata;
use crate::pipeline::element::Element;
use crate::pipeline::hybrid_chunking::{ContextFormat, ContextMode, HybridChunk};
use crate::pipeline::{DocumentSource, ElementBBox};
#[cfg(feature = "semantic")]
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone)]
#[cfg_attr(feature = "semantic", derive(Serialize, Deserialize))]
#[non_exhaustive]
pub struct RagChunk {
pub chunk_index: usize,
pub text: String,
pub full_text: String,
pub page_numbers: Vec<u32>,
pub bounding_boxes: Vec<ElementBBox>,
pub element_types: Vec<String>,
pub heading_context: Option<String>,
pub token_estimate: usize,
pub is_oversized: bool,
pub metadata: ChunkMetadata,
}
impl RagChunk {
pub fn from_hybrid_chunk(chunk_index: usize, chunk: &HybridChunk) -> Self {
Self::from_hybrid_chunk_inner(chunk_index, chunk, None, ContextMode::Heading)
}
pub fn from_hybrid_chunk_with_source(
chunk_index: usize,
chunk: &HybridChunk,
source: &DocumentSource,
) -> Self {
Self::from_hybrid_chunk_with_source_and_mode(
chunk_index,
chunk,
source,
ContextMode::Heading,
)
}
pub fn from_hybrid_chunk_with_mode(
chunk_index: usize,
chunk: &HybridChunk,
context_mode: ContextMode,
) -> Self {
Self::from_hybrid_chunk_inner(chunk_index, chunk, None, context_mode)
}
pub fn from_hybrid_chunk_with_source_and_mode(
chunk_index: usize,
chunk: &HybridChunk,
source: &DocumentSource,
context_mode: ContextMode,
) -> Self {
let mut c = Self::from_hybrid_chunk_inner(chunk_index, chunk, Some(source), context_mode);
c.metadata.source = Some(source.clone());
c
}
fn from_hybrid_chunk_inner(
chunk_index: usize,
chunk: &HybridChunk,
source: Option<&DocumentSource>,
context_mode: ContextMode,
) -> Self {
let elements = chunk.elements();
let page_numbers = collect_pages(elements);
let bounding_boxes = elements.iter().map(|e| *e.bbox()).collect();
let element_types: Vec<String> =
elements.iter().map(|e| e.type_name().to_string()).collect();
let text = chunk.text();
let full_text = match context_mode {
ContextMode::None => text.clone(),
ContextMode::Heading => chunk.full_text(),
ContextMode::Contextual(format) => {
let heading_path = elements
.first()
.map(|e| e.metadata().heading_path.clone())
.unwrap_or_default();
let page_span = (!page_numbers.is_empty())
.then(|| (page_numbers[0], page_numbers[page_numbers.len() - 1]));
match build_context_prefix(format, source, &heading_path, page_span) {
Some(prefix) => format!("{prefix}\n\n{text}"),
None => text.clone(),
}
}
};
let doc_hash = source.and_then(|s| s.doc_hash.as_deref());
let metadata =
ChunkMetadata::from_elements(elements, &text, &full_text, chunk_index, doc_hash);
Self {
chunk_index,
text,
full_text,
page_numbers,
bounding_boxes,
element_types,
heading_context: chunk.heading_context.clone(),
token_estimate: chunk.token_estimate(),
is_oversized: chunk.is_oversized(),
metadata,
}
}
#[cfg(feature = "semantic")]
pub fn to_json(&self) -> Result<String, serde_json::Error> {
serde_json::to_string(self)
}
}
fn collect_pages(elements: &[Element]) -> Vec<u32> {
if elements.is_empty() {
return Vec::new();
}
let first_page = elements[0].page();
if elements.iter().all(|e| e.page() == first_page) {
return vec![first_page];
}
let mut seen = HashSet::new();
let mut pages = Vec::new();
for e in elements {
let p = e.page();
if seen.insert(p) {
pages.push(p);
}
}
pages.sort_unstable();
pages
}
fn render_page(span: (u32, u32)) -> String {
if span.0 == span.1 {
format!("p. {}", span.0)
} else {
format!("p. {}\u{2013}{}", span.0, span.1)
}
}
fn build_context_prefix(
format: ContextFormat,
source: Option<&DocumentSource>,
heading_path: &[String],
page_span: Option<(u32, u32)>,
) -> Option<String> {
let doc_name = source.and_then(|s| s.title.clone().or_else(|| s.filename.clone()));
let author = source.and_then(|s| s.author.clone());
let section = (!heading_path.is_empty()).then(|| heading_path.join(" \u{203A} "));
let page = page_span.map(render_page);
if doc_name.is_none() && section.is_none() {
return None;
}
let prefix = match format {
ContextFormat::Labeled => {
let mut lines: Vec<String> = Vec::new();
if let Some(name) = &doc_name {
lines.push(match &author {
Some(a) => format!("Document: {name} — {a}"),
None => format!("Document: {name}"),
});
}
if let Some(sec) = §ion {
lines.push(format!("Section: {sec}"));
}
if let (Some(pg), Some(last)) = (&page, lines.last_mut()) {
last.push_str(&format!(" ({pg})"));
}
lines.join("\n")
}
ContextFormat::Prose => {
let mut s = String::from("This chunk is from ");
if let Some(name) = &doc_name {
s.push_str(&format!("\"{name}\""));
if let Some(a) = &author {
s.push_str(&format!(" by {a}"));
}
if let Some(sec) = §ion {
s.push_str(&format!(", section \"{sec}\""));
}
} else if let Some(sec) = §ion {
s.push_str(&format!("section \"{sec}\""));
}
if let Some(pg) = &page {
s.push_str(&format!(" ({pg})"));
}
s.push('.');
s
}
};
Some(prefix)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::pipeline::{ContextFormat, DocumentSource};
fn src(title: Option<&str>, author: Option<&str>, filename: Option<&str>) -> DocumentSource {
DocumentSource {
title: title.map(str::to_string),
author: author.map(str::to_string),
filename: filename.map(str::to_string),
..Default::default()
}
}
fn hp(parts: &[&str]) -> Vec<String> {
parts.iter().map(|s| s.to_string()).collect()
}
#[test]
fn labeled_full_fields() {
let s = src(Some("Annual Report"), Some("Acme Corp"), None);
let p = build_context_prefix(
ContextFormat::Labeled,
Some(&s),
&hp(&["1 Intro", "1.2 Scope"]),
Some((3, 4)),
);
assert_eq!(
p.as_deref(),
Some("Document: Annual Report — Acme Corp\nSection: 1 Intro › 1.2 Scope (p. 3–4)")
);
}
#[test]
fn labeled_title_only() {
let s = src(Some("Doc"), None, None);
let p = build_context_prefix(ContextFormat::Labeled, Some(&s), &[], None);
assert_eq!(p.as_deref(), Some("Document: Doc"));
}
#[test]
fn labeled_filename_fallback_when_no_title() {
let s = src(None, None, Some("report.pdf"));
let p = build_context_prefix(ContextFormat::Labeled, Some(&s), &[], None);
assert_eq!(p.as_deref(), Some("Document: report.pdf"));
}
#[test]
fn labeled_section_only_without_source() {
let p = build_context_prefix(ContextFormat::Labeled, None, &hp(&["A", "B"]), None);
assert_eq!(p.as_deref(), Some("Section: A › B"));
}
#[test]
fn labeled_single_page() {
let p = build_context_prefix(ContextFormat::Labeled, None, &hp(&["A"]), Some((7, 7)));
assert_eq!(p.as_deref(), Some("Section: A (p. 7)"));
}
#[test]
fn labeled_nothing_is_none() {
let s = src(None, None, None);
assert!(
build_context_prefix(ContextFormat::Labeled, Some(&s), &[], Some((3, 3))).is_none()
);
assert!(build_context_prefix(ContextFormat::Labeled, None, &[], None).is_none());
}
#[test]
fn prose_full_fields() {
let s = src(Some("Annual Report"), Some("Acme Corp"), None);
let p = build_context_prefix(
ContextFormat::Prose,
Some(&s),
&hp(&["1 Intro", "1.2 Scope"]),
Some((3, 4)),
);
assert_eq!(
p.as_deref(),
Some(
"This chunk is from \"Annual Report\" by Acme Corp, section \"1 Intro › 1.2 Scope\" (p. 3–4)."
)
);
}
#[test]
fn prose_title_only() {
let s = src(Some("Doc"), None, None);
let p = build_context_prefix(ContextFormat::Prose, Some(&s), &[], None);
assert_eq!(p.as_deref(), Some("This chunk is from \"Doc\"."));
}
#[test]
fn prose_section_only_without_source() {
let p = build_context_prefix(ContextFormat::Prose, None, &hp(&["A", "B"]), Some((5, 5)));
assert_eq!(
p.as_deref(),
Some("This chunk is from section \"A › B\" (p. 5).")
);
}
#[test]
fn prose_filename_fallback_and_no_section() {
let s = src(None, Some("Ann"), Some("f.pdf"));
let p = build_context_prefix(ContextFormat::Prose, Some(&s), &[], None);
assert_eq!(p.as_deref(), Some("This chunk is from \"f.pdf\" by Ann."));
}
#[test]
fn prose_nothing_is_none() {
assert!(build_context_prefix(ContextFormat::Prose, None, &[], Some((1, 2))).is_none());
}
}