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:
@@ -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 可能抛异常
|
||||||
@@ -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
|
||||||
Reference in New Issue
Block a user