feat: add Markdown splitter and document ingestor

- MarkdownSplitter: hybrid splitting by headings then paragraphs
- DocumentIngestor: read MD -> split -> embed -> store with dedup
- All 6 tests passing
This commit is contained in:
2026-07-05 01:03:14 +08:00
parent f9e5c37d3e
commit 757bd8b336
2 changed files with 322 additions and 0 deletions
+235
View File
@@ -0,0 +1,235 @@
"""Markdown 文档解析与入库模块."""
import re
from dataclasses import dataclass
from pathlib import Path
from src.core.db import VectorDB
from src.core.embedder import Embedder
class MarkdownSplitter:
"""Markdown 混合分块器:先按标题拆,超长再按段落拆."""
def __init__(self, max_size: int = 1000, overlap: int = 100):
self.max_size = max_size
self.overlap = overlap
def split(self, text: str, source_file: str = "") -> list[dict]:
"""将 Markdown 文本拆分为带元数据的 chunk 列表."""
if not text.strip():
return []
sections = self._split_by_headings(text)
chunks = []
for section in sections:
if len(section["content"]) <= self.max_size:
chunks.append(section)
else:
sub_chunks = self._split_by_paragraphs(
section["content"],
section["section_title"],
section["heading_level"],
)
chunks.extend(sub_chunks)
# 为所有 chunk 补充 source_file 和 chunk_index
for i, chunk in enumerate(chunks):
chunk["source_file"] = source_file or chunk.get("source_file", "")
chunk["chunk_index"] = i
return chunks
def _split_by_headings(self, text: str) -> list[dict]:
"""按 Markdown 标题拆分."""
heading_pattern = re.compile(r"^(#{1,6})\s+(.+)$", re.MULTILINE)
matches = list(heading_pattern.finditer(text))
if not matches:
return [{
"content": text.strip(),
"section_title": "",
"heading_level": 0,
}]
sections = []
for i, match in enumerate(matches):
level = len(match.group(1))
title = match.group(2).strip()
start = match.end()
end = matches[i + 1].start() if i + 1 < len(matches) else len(text)
content = text[start:end].strip()
if content:
sections.append({
"content": f"{match.group(0)}\n{content}",
"section_title": title,
"heading_level": level,
})
# 处理第一个标题之前的内容
if matches and matches[0].start() > 0:
preamble = text[:matches[0].start()].strip()
if preamble:
sections.insert(0, {
"content": preamble,
"section_title": "",
"heading_level": 0,
})
return sections
def _split_by_paragraphs(
self, text: str, section_title: str, heading_level: int
) -> list[dict]:
"""按段落边界拆分超长章节.
优先在段落边界拆分,若单个段落仍超长则按字符硬切。
"""
paragraphs = re.split(r"\n\n+", text)
chunks = []
current = ""
for para in paragraphs:
# 单一段落超出 max_size 时直接硬切
if len(para) > self.max_size:
# 先 flush 当前累积
if current.strip():
chunks.append({
"content": current.strip(),
"section_title": section_title,
"heading_level": heading_level,
})
current = ""
# 硬切该段落
for sub in self._split_single_paragraph(para):
chunks.append({
"content": sub,
"section_title": section_title,
"heading_level": heading_level,
})
continue
if len(current) + len(para) > self.max_size and current:
chunks.append({
"content": current.strip(),
"section_title": section_title,
"heading_level": heading_level,
})
# overlap: 保留上一块的末尾部分
if self.overlap > 0 and len(current) > self.overlap:
current = current[-self.overlap:] + "\n\n" + para
else:
current = para
else:
if current:
current += "\n\n" + para
else:
current = para
if current.strip():
chunks.append({
"content": current.strip(),
"section_title": section_title,
"heading_level": heading_level,
})
return chunks
def _split_single_paragraph(self, text: str) -> list[str]:
"""按字符边界拆分单个超长段落(带 overlap)。"""
parts = []
start = 0
while start < len(text):
end = start + self.max_size
if end >= len(text):
parts.append(text[start:].strip())
break
# 尝试在句号或空格处断开
break_point = end
for sep in ("", "", "", "\n", ". ", " "):
pos = text.rfind(sep, start, end)
if pos > start:
break_point = pos + len(sep)
break
part = text[start:break_point].strip()
if part:
parts.append(part)
start = break_point - self.overlap if self.overlap > 0 else break_point
return parts
class DocumentIngestor:
"""文档入库器: 读取 MD 文件 → 分块 → 嵌入 → 入库."""
def __init__(self, db: VectorDB, embedder: Embedder, collection_name: str):
self.db = db
self.embedder = embedder
self.collection_name = collection_name
self.splitter = MarkdownSplitter()
@property
def collection(self):
return self.db.get_or_create_collection(self.collection_name)
def ingest_file(self, file_path: str) -> int:
"""入库单个 Markdown 文件, 返回 chunk 数量."""
path = Path(file_path)
content = path.read_text(encoding="utf-8")
file_name = path.name
return self.ingest_content(content, file_name)
def ingest_content(self, content: str, file_name: str) -> int:
"""入库 Markdown 内容(无需实际文件)."""
# 去重:先删旧 chunks
self._remove_by_source(file_name)
# 分块
chunks = self.splitter.split(content, source_file=file_name)
if not chunks:
return 0
# 嵌入
texts = [c["content"] for c in chunks]
embeddings = self.embedder.embed(texts)
# 入库
ids = [f"{file_name}_{i}" for i in range(len(chunks))]
metadatas = [
{
"source_file": c.get("source_file", file_name),
"section_title": c.get("section_title", ""),
"heading_level": c.get("heading_level", 0),
"chunk_index": i,
}
for i, c in enumerate(chunks)
]
self.collection.add(
ids=ids,
embeddings=embeddings,
documents=texts,
metadatas=metadatas,
)
return len(chunks)
def ingest_directory(self, dir_path: str) -> dict[str, int]:
"""入库目录下所有 Markdown 文件."""
results = {}
for md_file in Path(dir_path).rglob("*.md"):
count = self.ingest_file(str(md_file))
results[md_file.name] = count
return results
def _remove_by_source(self, file_name: str) -> None:
"""按 source_file 删除已有 chunks."""
try:
existing = self.collection.get(
where={"source_file": file_name}
)
if existing and existing["ids"]:
self.collection.delete(ids=existing["ids"])
except Exception:
pass # collection 为空时 get 可能抛异常
+87
View File
@@ -0,0 +1,87 @@
"""文档入库测试."""
import tempfile
from pathlib import Path
import pytest
from src.core.ingest import MarkdownSplitter, DocumentIngestor
class TestMarkdownSplitter:
"""Markdown 分块器测试."""
@pytest.fixture
def splitter(self):
return MarkdownSplitter(max_size=1000, overlap=100)
def test_split_simple_document(self, splitter):
"""简单文档按标题拆分."""
md = """# 标题一
这是第一段内容。
## 标题二
这是第二段内容。
# 标题三
这是第三段内容。"""
chunks = splitter.split(md, source_file="test.md")
assert len(chunks) >= 3
# 每个 chunk 有元数据
for chunk in chunks:
assert "content" in chunk
assert chunk["source_file"] == "test.md"
def test_chunk_has_heading_metadata(self, splitter):
"""chunk 附带标题元数据."""
md = "# 配置指南\n这里是配置说明。"
chunks = splitter.split(md, source_file="config.md")
assert len(chunks) >= 1
title = chunks[0]["section_title"]
assert "配置指南" in title or title == ""
def test_long_section_is_split(self, splitter):
"""超长章节被进一步拆分."""
# 创建一个超过 max_size 的段落
long_text = "这是很长的文本。" * 300 # ~3000 字符
md = f"# 长章节\n{long_text}"
small_splitter = MarkdownSplitter(max_size=500, overlap=50)
chunks = small_splitter.split(md, source_file="long.md")
assert len(chunks) > 1
def test_empty_document(self, splitter):
"""空文档返回空列表."""
chunks = splitter.split("", source_file="empty.md")
assert chunks == []
def test_code_blocks_preserved(self, splitter):
"""代码块不被拆分."""
md = """# 代码示例
```python
def hello():
print("world")
```
"""
chunks = splitter.split(md, source_file="code.md")
assert len(chunks) >= 1
# 代码块内容应在某个 chunk 中
all_content = " ".join(c["content"] for c in chunks)
assert "def hello()" in all_content
class TestDocumentIngestor:
"""文档入库器测试."""
@pytest.fixture
def temp_md_dir(self):
with tempfile.TemporaryDirectory() as d:
# 创建测试 Markdown 文件
md_path = Path(d) / "test.md"
md_path.write_text("# 测试\n这是测试内容。", encoding="utf-8")
yield d
def test_read_markdown_file(self, temp_md_dir):
"""读取 Markdown 文件."""
content = Path(temp_md_dir + "/test.md").read_text(encoding="utf-8")
assert "测试" in content
assert "这是测试内容" in content