Skip to main content

lc_rag/loaders/
markdown.rs

1// src/retrieval/loaders/markdown.rs
2//! Markdown 文档加载器实现
3//!
4//! 提供从 Markdown 文件加载内容的功能,支持按标题分割。
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(|| regex::Regex::new(r"^#[ \t]+(.+)").unwrap()),
15        LazyLock::new(|| regex::Regex::new(r"^##[ \t]+(.+)").unwrap()),
16        LazyLock::new(|| regex::Regex::new(r"^###[ \t]+(.+)").unwrap()),
17        LazyLock::new(|| regex::Regex::new(r"^####[ \t]+(.+)").unwrap()),
18        LazyLock::new(|| regex::Regex::new(r"^#####[ \t]+(.+)").unwrap()),
19        LazyLock::new(|| regex::Regex::new(r"^######[ \t]+(.+)").unwrap()),
20    ];
21    &HEADING_RE[level.saturating_sub(1).min(5)]
22}
23
24/// Markdown 文档加载器
25///
26/// 支持加载 Markdown 文件,可按标题分割为多个文档。
27pub struct MarkdownLoader {
28    /// Markdown 文件路径
29    pub path: PathBuf,
30
31    /// 是否按标题分割
32    /// 如果为 true,按 `#` 标题分割为多个文档
33    pub split_by_heading: bool,
34
35    /// 分割的标题级别(1-6)
36    /// 例如 heading_level=2 表示按 `##` 分割
37    pub heading_level: usize,
38}
39
40impl MarkdownLoader {
41    /// 创建新的 Markdown 加载器
42    ///
43    /// # 参数
44    /// * `path` - Markdown 文件路径
45    pub fn new(path: impl Into<PathBuf>) -> Self {
46        Self {
47            path: path.into(),
48            split_by_heading: false,
49            heading_level: 1,
50        }
51    }
52
53    /// 创建按标题分割的 Markdown 加载器
54    ///
55    /// # 参数
56    /// * `path` - Markdown 文件路径
57    /// * `heading_level` - 分割的标题级别(1-6)
58    pub fn new_with_heading_split(path: impl Into<PathBuf>, heading_level: usize) -> Self {
59        Self {
60            path: path.into(),
61            split_by_heading: true,
62            heading_level: heading_level.clamp(1, 6),
63        }
64    }
65
66    /// 设置是否按标题分割
67    pub fn with_split_by_heading(mut self, split: bool) -> Self {
68        self.split_by_heading = split;
69        self
70    }
71
72    /// 设置标题级别
73    pub fn with_heading_level(mut self, level: usize) -> Self {
74        self.heading_level = level.clamp(1, 6);
75        self
76    }
77}
78
79#[async_trait]
80impl DocumentLoader for MarkdownLoader {
81    async fn load(&self) -> Result<Vec<Document>, LoaderError> {
82        if !self.path.exists() {
83            return Err(LoaderError::Other(format!(
84                "markdown file does not exist: {}",
85                self.path.display()
86            )));
87        }
88
89        let content = std::fs::read_to_string(&self.path)?;
90
91        if self.split_by_heading {
92            self.split_by_headings(&content)
93        } else {
94            let mut doc = Document::new(content);
95            doc = doc.with_metadata("source", self.path.display().to_string());
96            doc = doc.with_metadata("format", "markdown".to_string());
97            Ok(vec![doc])
98        }
99    }
100}
101
102impl MarkdownLoader {
103    fn split_by_headings(&self, content: &str) -> Result<Vec<Document>, LoaderError> {
104        let heading_regex = heading_regex(self.heading_level);
105
106        let mut documents = Vec::new();
107        let mut sections: Vec<(String, String)> = Vec::new();
108        let mut current_title = "Untitled".to_string();
109        let mut current_content = String::new();
110
111        for line in content.lines() {
112            if let Some(caps) = heading_regex.captures(line) {
113                if !current_content.trim().is_empty() {
114                    sections.push((current_title.clone(), current_content.trim().to_string()));
115                }
116                current_title = caps
117                    .get(1)
118                    .map(|m| m.as_str().trim().to_string())
119                    .unwrap_or_else(|| "Untitled".to_string());
120                current_content = String::new();
121            } else if !line.trim().is_empty() {
122                current_content.push_str(line);
123                current_content.push('\n');
124            }
125        }
126
127        if !current_content.trim().is_empty() {
128            sections.push((current_title, current_content.trim().to_string()));
129        }
130
131        for (title, section_content) in sections {
132            if section_content.trim().is_empty() {
133                continue;
134            }
135
136            let mut doc = Document::new(section_content);
137            doc = doc.with_metadata("source", self.path.display().to_string());
138            doc = doc.with_metadata("format", "markdown".to_string());
139            doc = doc.with_metadata("heading", title);
140            doc = doc.with_metadata("heading_level", self.heading_level.to_string());
141
142            documents.push(doc);
143        }
144
145        Ok(documents)
146    }
147}
148
149#[cfg(test)]
150mod tests {
151    use super::*;
152    use std::io::Write;
153    use tempfile::NamedTempFile;
154
155    #[tokio::test]
156    async fn test_markdown_loader_nonexistent() {
157        let loader = MarkdownLoader::new("./nonexistent.md");
158        let result = loader.load().await;
159
160        assert!(result.is_err());
161    }
162
163    #[tokio::test]
164    async fn test_markdown_loader_single_document() {
165        let mut temp_file = NamedTempFile::new().unwrap();
166        write!(temp_file, "# Title\n\nContent here.").unwrap();
167
168        let loader = MarkdownLoader::new(temp_file.path());
169        let result = loader.load().await;
170
171        assert!(result.is_ok());
172        let docs = result.unwrap();
173        assert_eq!(docs.len(), 1);
174        assert!(docs[0].content.contains("Title"));
175        assert_eq!(
176            docs[0].metadata.get("format"),
177            Some(&serde_json::Value::String("markdown".to_string()))
178        );
179    }
180
181    #[tokio::test]
182    async fn test_markdown_loader_split_by_heading() {
183        let mut temp_file = NamedTempFile::new().unwrap();
184        writeln!(temp_file, "# Section 1").unwrap();
185        writeln!(temp_file, "Content for section 1.").unwrap();
186        writeln!(temp_file).unwrap();
187        writeln!(temp_file, "# Section 2").unwrap();
188        writeln!(temp_file, "Content for section 2.").unwrap();
189
190        let loader = MarkdownLoader::new_with_heading_split(temp_file.path(), 1);
191        let result = loader.load().await;
192
193        assert!(result.is_ok());
194        let docs = result.unwrap();
195        assert_eq!(docs.len(), 2);
196        assert_eq!(
197            docs[0].metadata.get("heading"),
198            Some(&serde_json::Value::String("Section 1".to_string()))
199        );
200        assert_eq!(
201            docs[1].metadata.get("heading"),
202            Some(&serde_json::Value::String("Section 2".to_string()))
203        );
204    }
205
206    #[tokio::test]
207    async fn test_markdown_loader_heading_level_2() {
208        let mut temp_file = NamedTempFile::new().unwrap();
209        writeln!(temp_file, "# Main Title").unwrap();
210        writeln!(temp_file, "Intro.").unwrap();
211        writeln!(temp_file).unwrap();
212        writeln!(temp_file, "## Subsection 1").unwrap();
213        writeln!(temp_file, "Sub content 1.").unwrap();
214        writeln!(temp_file).unwrap();
215        writeln!(temp_file, "## Subsection 2").unwrap();
216        writeln!(temp_file, "Sub content 2.").unwrap();
217
218        let loader = MarkdownLoader::new_with_heading_split(temp_file.path(), 2);
219        let result = loader.load().await;
220
221        assert!(result.is_ok());
222        let docs = result.unwrap();
223        assert_eq!(docs.len(), 3);
224        assert!(docs[0].content.contains("Main Title"));
225    }
226}