Skip to main content

lc_rag/loaders/
markdown.rs

1// src/retrieval/loaders/markdown.rs
2//! Markdown document loader implementation
3//!
4//! Loads content from Markdown files, supporting splitting by heading.
5
6use super::{Document, DocumentLoader, LoaderError};
7use async_trait::async_trait;
8use std::path::PathBuf;
9
10/// L11: pre-compile heading regexes for all 6 levels using LazyLock
11fn heading_regex(level: usize) -> &'static regex::Regex {
12    use std::sync::LazyLock;
13    static HEADING_RE: [LazyLock<regex::Regex>; 6] = [
14        LazyLock::new(|| {
15            regex::Regex::new(r"^#[ \t]+(.+)").expect("static regex literal must compile")
16        }),
17        LazyLock::new(|| {
18            regex::Regex::new(r"^##[ \t]+(.+)").expect("static regex literal must compile")
19        }),
20        LazyLock::new(|| {
21            regex::Regex::new(r"^###[ \t]+(.+)").expect("static regex literal must compile")
22        }),
23        LazyLock::new(|| {
24            regex::Regex::new(r"^####[ \t]+(.+)").expect("static regex literal must compile")
25        }),
26        LazyLock::new(|| {
27            regex::Regex::new(r"^#####[ \t]+(.+)").expect("static regex literal must compile")
28        }),
29        LazyLock::new(|| {
30            regex::Regex::new(r"^######[ \t]+(.+)").expect("static regex literal must compile")
31        }),
32    ];
33    &HEADING_RE[level.saturating_sub(1).min(5)]
34}
35
36/// Markdown document loader
37///
38/// Supports loading Markdown files, optionally splitting them into multiple documents by heading.
39pub struct MarkdownLoader {
40    /// Markdown file path
41    pub path: PathBuf,
42
43    /// Whether to split by heading
44    /// If true, splits into multiple documents by `#` heading
45    pub split_by_heading: bool,
46
47    /// The heading level to split on (1-6)
48    /// For example, heading_level=2 splits on `##`
49    pub heading_level: usize,
50}
51
52impl MarkdownLoader {
53    /// Creates a new Markdown loader
54    ///
55    /// # Arguments
56    /// * `path` - the Markdown file path
57    pub fn new(path: impl Into<PathBuf>) -> Self {
58        Self {
59            path: path.into(),
60            split_by_heading: false,
61            heading_level: 1,
62        }
63    }
64
65    /// Creates a heading-splitting Markdown loader
66    ///
67    /// # Arguments
68    /// * `path` - the Markdown file path
69    /// * `heading_level` - the heading level to split on (1-6)
70    pub fn new_with_heading_split(path: impl Into<PathBuf>, heading_level: usize) -> Self {
71        Self {
72            path: path.into(),
73            split_by_heading: true,
74            heading_level: heading_level.clamp(1, 6),
75        }
76    }
77
78    /// Sets whether to split by heading
79    pub fn with_split_by_heading(mut self, split: bool) -> Self {
80        self.split_by_heading = split;
81        self
82    }
83
84    /// Sets the heading level
85    pub fn with_heading_level(mut self, level: usize) -> Self {
86        self.heading_level = level.clamp(1, 6);
87        self
88    }
89}
90
91#[async_trait]
92impl DocumentLoader for MarkdownLoader {
93    async fn load(&self) -> Result<Vec<Document>, LoaderError> {
94        if !self.path.exists() {
95            return Err(LoaderError::Other(format!(
96                "markdown file does not exist: {}",
97                self.path.display()
98            )));
99        }
100
101        let content = std::fs::read_to_string(&self.path)?;
102
103        if self.split_by_heading {
104            self.split_by_headings(&content)
105        } else {
106            let mut doc = Document::new(content);
107            doc = doc.with_metadata("source", self.path.display().to_string());
108            doc = doc.with_metadata("format", "markdown".to_string());
109            Ok(vec![doc])
110        }
111    }
112}
113
114impl MarkdownLoader {
115    fn split_by_headings(&self, content: &str) -> Result<Vec<Document>, LoaderError> {
116        let heading_regex = heading_regex(self.heading_level);
117
118        let mut documents = Vec::new();
119        let mut sections: Vec<(String, String)> = Vec::new();
120        let mut current_title = "Untitled".to_string();
121        let mut current_content = String::new();
122
123        for line in content.lines() {
124            if let Some(caps) = heading_regex.captures(line) {
125                if !current_content.trim().is_empty() {
126                    sections.push((current_title.clone(), current_content.trim().to_string()));
127                }
128                current_title = caps
129                    .get(1)
130                    .map(|m| m.as_str().trim().to_string())
131                    .unwrap_or_else(|| "Untitled".to_string());
132                current_content = String::new();
133            } else if !line.trim().is_empty() {
134                current_content.push_str(line);
135                current_content.push('\n');
136            }
137        }
138
139        if !current_content.trim().is_empty() {
140            sections.push((current_title, current_content.trim().to_string()));
141        }
142
143        for (title, section_content) in sections {
144            if section_content.trim().is_empty() {
145                continue;
146            }
147
148            let mut doc = Document::new(section_content);
149            doc = doc.with_metadata("source", self.path.display().to_string());
150            doc = doc.with_metadata("format", "markdown".to_string());
151            doc = doc.with_metadata("heading", title);
152            doc = doc.with_metadata("heading_level", self.heading_level.to_string());
153
154            documents.push(doc);
155        }
156
157        Ok(documents)
158    }
159}
160
161#[cfg(test)]
162mod tests {
163    use super::*;
164    use std::io::Write;
165    use tempfile::NamedTempFile;
166
167    #[tokio::test]
168    async fn test_markdown_loader_nonexistent() {
169        let loader = MarkdownLoader::new("./nonexistent.md");
170        let result = loader.load().await;
171
172        assert!(result.is_err());
173    }
174
175    #[tokio::test]
176    async fn test_markdown_loader_single_document() {
177        let mut temp_file = NamedTempFile::new().unwrap();
178        write!(temp_file, "# Title\n\nContent here.").unwrap();
179
180        let loader = MarkdownLoader::new(temp_file.path());
181        let result = loader.load().await;
182
183        assert!(result.is_ok());
184        let docs = result.unwrap();
185        assert_eq!(docs.len(), 1);
186        assert!(docs[0].content.contains("Title"));
187        assert_eq!(
188            docs[0].metadata.get("format"),
189            Some(&serde_json::Value::String("markdown".to_string()))
190        );
191    }
192
193    #[tokio::test]
194    async fn test_markdown_loader_split_by_heading() {
195        let mut temp_file = NamedTempFile::new().unwrap();
196        writeln!(temp_file, "# Section 1").unwrap();
197        writeln!(temp_file, "Content for section 1.").unwrap();
198        writeln!(temp_file).unwrap();
199        writeln!(temp_file, "# Section 2").unwrap();
200        writeln!(temp_file, "Content for section 2.").unwrap();
201
202        let loader = MarkdownLoader::new_with_heading_split(temp_file.path(), 1);
203        let result = loader.load().await;
204
205        assert!(result.is_ok());
206        let docs = result.unwrap();
207        assert_eq!(docs.len(), 2);
208        assert_eq!(
209            docs[0].metadata.get("heading"),
210            Some(&serde_json::Value::String("Section 1".to_string()))
211        );
212        assert_eq!(
213            docs[1].metadata.get("heading"),
214            Some(&serde_json::Value::String("Section 2".to_string()))
215        );
216    }
217
218    #[tokio::test]
219    async fn test_markdown_loader_heading_level_2() {
220        let mut temp_file = NamedTempFile::new().unwrap();
221        writeln!(temp_file, "# Main Title").unwrap();
222        writeln!(temp_file, "Intro.").unwrap();
223        writeln!(temp_file).unwrap();
224        writeln!(temp_file, "## Subsection 1").unwrap();
225        writeln!(temp_file, "Sub content 1.").unwrap();
226        writeln!(temp_file).unwrap();
227        writeln!(temp_file, "## Subsection 2").unwrap();
228        writeln!(temp_file, "Sub content 2.").unwrap();
229
230        let loader = MarkdownLoader::new_with_heading_split(temp_file.path(), 2);
231        let result = loader.load().await;
232
233        assert!(result.is_ok());
234        let docs = result.unwrap();
235        assert_eq!(docs.len(), 3);
236        assert!(docs[0].content.contains("Main Title"));
237    }
238}