339 lines
19 KiB
Python
339 lines
19 KiB
Python
"""文档解析与切分(纯本地字节 → 文本 → 切块,不调用模型、不写数据库)。
|
||
|
||
需求(修改版 F1.2):支持 txt/md/docx;512/块、overlap 64;保留标题层级作为 metadata。
|
||
**切分单位是「字符」而非 token**:项目当前没有引入任何 tokenizer 依赖(如 tiktoken),
|
||
中文场景下 1 字≈1 token 是保守估计,按字符切分不会超出 token 预算。
|
||
|
||
切块保证(供 Task 5 的向量化/入库依赖,按实测口径如实描述):
|
||
|
||
- 每个块的 `text` 由「最多一个 overlap 前导片段 + 块的正文」组成,两段之间用 `\n\n` 分隔,
|
||
因此不会熔接出原文不存在的字符串;`index` 从 0 连续递增。
|
||
- **正文按原文分隔符还原**:块的正文是原文的连续切片,切片之间用**原文里那一处的分隔符**拼接
|
||
(原文是单换行就保持单换行,原文是空行就保持空行),所以块文本里不会出现原文没有的连续分隔符。
|
||
唯一的例外是上面那个**刻意**插入的 `\n\n`:它隔开 overlap 前导片段与正文,是结构标记而非正文。
|
||
块的正文从**段首**开始:块边界那一处的分隔符不属于任何块。
|
||
- **重叠是刻意的上下文重复,确实存在重复入库**:前一块尾部的 `chunk_overlap` 个字符会在
|
||
下一块头部**再出现一次**(它是下一块的前导片段,跨两块重复存在)。Task 5 估算向量条数时
|
||
必须计入这一膨胀:`chunk_overlap` 越大块数膨胀越明显(重叠窗口跨块重复),必要时入库前
|
||
去重或调低 `chunk_overlap`。口径可复现的一组实测(`chunk_size=80`,输入 = 63 个段落,
|
||
每段 `"内容" * 20` = 40 字,段间一个空行):
|
||
`chunk_overlap=0` → 63 块、`7` → 63 块、`64` → 94 块;
|
||
`chunk_overlap=64` 的 94 / 63 ≈ 1.49 倍即上述重复的代价(`co=0` 时一长段恰好一块)。
|
||
**不存在**"同一份文档 `co=64` 的块数是 `co=0` 的 4 倍以上"这种量级:
|
||
膨胀倍率的上界是 `1 + chunk_overlap / 每块净新增字符数`,随段落变长而下降。
|
||
- **跨章节不携带重叠**:`heading_path` 变化时丢弃重叠窗口,保证每块的元数据与其文本所属章节一致
|
||
(不会把上一章的残留字符标成下一章)。
|
||
- 覆盖完整:把所有块按序拼接后必然包含原文的每一段文本与每个标题行(重叠部分按上述设计重复出现)。
|
||
- 必然终止:`0 <= chunk_overlap < chunk_size`,切分步长恒为正,超长单段也会被硬切。
|
||
- 每块 `len(text) <= chunk_size`:前导片段与它前面的分隔符都计入预算。
|
||
- **`chunk_overlap` 接近 `chunk_size` 时窗口会被压缩**:`_hard_split` 在硬切前把传入的前导片段
|
||
裁到「装得下 1 字正文」(`chunk_size - 3`),剩余内容不足一整块时再缩到「给剩余正文留够位置」。
|
||
因此「窗口 + `\n\n` + 1 字正文」恒 `<= chunk_size`,不会出现窗口占满 `chunk_size`、
|
||
正文为零的块,也不会出现块长超过 `chunk_size` 的块。实测(`co = chunk_size - 1`,纯长段落):
|
||
`cs=10`/源 30 字 → `(窗口, 正文)` = `(7,1)(1,7)(7,1)(1,7)(4,4)`;
|
||
`cs=512`/源 1200 字 → `(509,1)(1,509)(332,178)`,每块恰好 512 字、无超长块。
|
||
默认 512/64 下该上界是 509 > 64、且硬切剩余总是一整块,所以窗口仍取完整的 64 字
|
||
(实测默认参数下块数与块内容与改动前**逐字节一致**)。
|
||
但**极端配置的块内重复率仍然很高**(`cs=512`/`co=511` 时约 99.6%):
|
||
这些块里确实有上一块没有的新字符(所以不能丢,丢了就是丢字),
|
||
Task 5 若要靠它压向量条数,应把 `chunk_overlap` 设在 `chunk_size` 的 1/4 以内。
|
||
- 标题层级:md 按 `#` 层级、docx 按 Heading 样式层级维护 `heading_path`;
|
||
同一标题下的正文按块切分时共享该路径。
|
||
|
||
已知限制:
|
||
1. 块边界处的段落分隔符会被丢弃(块的正文从段首或续切位置开始),因此跨块的分隔符不在任何块里;
|
||
若块边界正好落在同一段落组内两行之间,那一处的单换行也会随之丢弃(原文其余位置的单换行都保留)。
|
||
2. 段落组之间的空行只要含连续两个换行(`\n\n`、`\n\n\n` 等),块内统一压成一个 `\n\n`:
|
||
空行不是内容,压缩不会新增原文里没有的串(`\n\n` 一定是该处空行的子串),
|
||
也让块内只存在一种段落分隔符。
|
||
3. md 标题识别为逐行 `#` 前缀,**不跳过代码围栏内的 `#` 行**:代码块里以 `#` 开头的行会被当成
|
||
标题并压栈,可能伪造出并不存在的标题层级,并污染其后合法标题的祖先链。
|
||
4. 不识别 Setext 标题(`标题` + 下一行 `====`),较老的 markdown 文档会丢失该层级。
|
||
5. `chunk_overlap` 接近 `chunk_size` 时块内重复率极高(见上),且**没有**丢弃「正文完全落进
|
||
重叠窗口」的块:实测那样丢弃会让正文永久消失(`cs=7`/`co=1` 等配置下 6928 组里有 777 组丢字),
|
||
所以这里只压缩窗口、不丢块。要控制重复请调低 `chunk_overlap`。
|
||
6. 重复入库量在「原文本身有大量相同段落」时会被放大:`cs=64`/`co=16` + 40 个 `'甲' * 12`
|
||
段落 → 13 块、正文合计 702 字(原文 `'甲'` 只有 480 字)。原因是重叠窗口可能整段落在
|
||
下一块的正文起点之内(窗口与正文有交叉),此时同一段字符串会在两块里各出现一次。
|
||
不是新增内容、也不丢字(实测原文每个字符都至少出现一次),但重复率高于
|
||
`1 + chunk_overlap / chunk_size` 的粗略估计,评估向量条数时请按实测口径。
|
||
"""
|
||
|
||
import importlib.util
|
||
import re
|
||
from dataclasses import dataclass
|
||
from pathlib import PurePosixPath
|
||
|
||
SUPPORTED_EXTENSIONS = frozenset({".txt", ".md", ".docx"})
|
||
|
||
_CHUNK_SEPARATOR = "\n\n" # 段落分隔符;同时用于隔开 overlap 前导片段与正文
|
||
_BLOCK_SEPARATOR = re.compile(r"\n\s*\n")
|
||
_MARKDOWN_HEADING = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
|
||
_DOCX_HEADING_STYLE = re.compile(r"^Heading\s*(\d*)$", re.IGNORECASE)
|
||
OCR_NOTE = "docx 解析依赖未安装:python-docx"
|
||
|
||
|
||
class UnsupportedDocumentError(ValueError):
|
||
"""不支持的文件类型,或该类型的解析依赖不可用。"""
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ParsedChunk:
|
||
text: str
|
||
heading_path: tuple[str, ...]
|
||
index: int
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class _Block:
|
||
"""原文里的一个非空段落(md/txt 是一行,docx 是一个非空段落)及其所属标题路径。
|
||
|
||
`separator` 是**原文里紧邻该段落之前**的分隔符(同一段落组内是换行,跨段落组是空行;
|
||
docx 没有原文分隔符可用,固定为 `\\n\\n`)。它让 `_split()` 能原样还原段落间距,
|
||
而不是用固定的 `\\n\\n` 重新拼接。
|
||
"""
|
||
|
||
text: str
|
||
heading_path: tuple[str, ...]
|
||
separator: str
|
||
|
||
|
||
def _paragraph_separator(raw: str) -> str:
|
||
"""规范化段落组之间的分隔符:原文该处已含 `\\n\\n` 时统一成一个 `\\n\\n`,其余原样保留。
|
||
|
||
`_BLOCK_SEPARATOR` 会把 `\\n\\n\\n`、`\\n \\n` 这类空行整段匹配走。空行不是内容:
|
||
压成一个 `\\n\\n` 不会新增原文里没有的串(`\\n\\n` 一定是原串的子串),
|
||
同时让块内只存在一种段落分隔符。不含 `\\n\\n` 的分隔符(如 `\\n \\n`)保持原样,避免伪造。
|
||
"""
|
||
return _CHUNK_SEPARATOR if _CHUNK_SEPARATOR in raw else raw
|
||
|
||
|
||
class DocumentParser:
|
||
def __init__(self, *, chunk_size: int = 512, chunk_overlap: int = 64) -> None:
|
||
if chunk_size <= 0:
|
||
raise ValueError("chunk_size 必须为正")
|
||
if not 0 <= chunk_overlap < chunk_size:
|
||
raise ValueError("chunk_overlap 必须满足 0 <= overlap < chunk_size")
|
||
self.chunk_size = chunk_size
|
||
self.chunk_overlap = chunk_overlap
|
||
|
||
def parse(self, *, filename: str, content: bytes) -> list[ParsedChunk]:
|
||
suffix = PurePosixPath(filename).suffix.lower()
|
||
if suffix not in SUPPORTED_EXTENSIONS:
|
||
raise UnsupportedDocumentError(f"不支持的文件类型:{suffix or filename}")
|
||
if suffix == ".docx":
|
||
blocks = self._read_docx(content)
|
||
else:
|
||
blocks = self._markdown_blocks(self._decode(content))
|
||
return self._split(blocks)
|
||
|
||
@staticmethod
|
||
def _decode(content: bytes) -> str:
|
||
return content.decode("utf-8-sig", errors="replace")
|
||
|
||
@staticmethod
|
||
def _paragraph_groups(text: str) -> list[tuple[str, str]]:
|
||
"""按 `_BLOCK_SEPARATOR` 把原文切成段落组,返回 (组内文本, 该组之前的原文分隔符)。"""
|
||
groups: list[tuple[str, str]] = []
|
||
position = 0
|
||
pending = "" # 第一个组之前没有分隔符
|
||
for match in _BLOCK_SEPARATOR.finditer(text):
|
||
segment = text[position : match.start()]
|
||
groups.append((segment, pending))
|
||
pending = _paragraph_separator(match.group())
|
||
position = match.end()
|
||
groups.append((text[position:], pending))
|
||
return groups
|
||
|
||
@staticmethod
|
||
def _markdown_blocks(text: str) -> list[_Block]:
|
||
"""把文本切成 (段落, 所属标题路径);`#` 行同时是段落本身与层级来源。
|
||
|
||
每个块带上它在原文中**前置的分隔符**:段落组内是换行,跨段落组是原文的空行。
|
||
"""
|
||
heading_path: list[str] = []
|
||
blocks: list[_Block] = []
|
||
for group, group_separator in DocumentParser._paragraph_groups(text):
|
||
separator = group_separator
|
||
for raw_line in group.split("\n"):
|
||
line = raw_line.strip()
|
||
if not line:
|
||
continue
|
||
match = _MARKDOWN_HEADING.match(line)
|
||
if match is not None:
|
||
level = len(match.group(1))
|
||
del heading_path[level - 1 :]
|
||
heading_path.append(match.group(2).strip())
|
||
blocks.append(_Block(line, tuple(heading_path), separator))
|
||
separator = "\n" # 同一段落组内的行间分隔符:原文就是一个换行
|
||
return blocks
|
||
|
||
@staticmethod
|
||
def _read_docx(content: bytes) -> list[_Block]:
|
||
if importlib.util.find_spec("docx") is None:
|
||
raise UnsupportedDocumentError(OCR_NOTE)
|
||
import io
|
||
|
||
from docx import Document # 可选依赖,缺失时上面已抛错(`find_spec` 已守卫)
|
||
|
||
try:
|
||
document = Document(io.BytesIO(content))
|
||
except Exception as exc: # python-docx 对损坏文件抛出多种异常
|
||
raise UnsupportedDocumentError("docx 文件无法解析:文件损坏或格式不正确") from exc
|
||
heading_path: list[str] = []
|
||
blocks: list[_Block] = []
|
||
for paragraph in document.paragraphs:
|
||
text = paragraph.text.strip()
|
||
if not text:
|
||
continue
|
||
level = DocumentParser._heading_level(paragraph.style.name if paragraph.style else "")
|
||
if level is None:
|
||
blocks.append(_Block(text, tuple(heading_path), _CHUNK_SEPARATOR))
|
||
continue
|
||
del heading_path[level - 1 :]
|
||
heading_path.append(text)
|
||
blocks.append(_Block(text, tuple(heading_path), _CHUNK_SEPARATOR))
|
||
return blocks
|
||
|
||
@staticmethod
|
||
def _heading_level(style_name: str) -> int | None:
|
||
match = _DOCX_HEADING_STYLE.match(style_name.strip())
|
||
if match is None:
|
||
return None
|
||
return int(match.group(1)) if match.group(1) else 1
|
||
|
||
@staticmethod
|
||
def _join_parts(*parts: str) -> str:
|
||
"""用 `\\n\\n` 拼接片段:只用来隔开 overlap 前导片段与正文,正文内部按原文分隔符拼接。"""
|
||
return _CHUNK_SEPARATOR.join(part for part in parts if part)
|
||
|
||
def _split(self, blocks: list[_Block]) -> list[ParsedChunk]:
|
||
"""把 (段落, 标题路径) 序列聚合成块。
|
||
|
||
段落优先成块;单段超长时硬切。硬切后把上一块的尾部若干个字符作为下一块的**前导片段**,
|
||
与正文之间用 `\\n\\n` 隔开,避免把两段不相邻的文本熔接成原文不存在的串。
|
||
前导片段长度取 `min(chunk_overlap, 上一块新内容长度)`,跨章节时清空。
|
||
|
||
正文由原文切片按**原文分隔符**(`_Block.separator`)拼接,只有块首那一段不带前置分隔符:
|
||
块的正文从段首开始,块边界处的分隔符不属于任何块。
|
||
"""
|
||
chunks: list[ParsedChunk] = []
|
||
buffer: list[_Block] = []
|
||
buffer_path: tuple[str, ...] = ()
|
||
prefix = ""
|
||
prefix_len = 0
|
||
|
||
def body() -> str:
|
||
"""按原文分隔符拼接正文;块首段落不参与拼接,因此不带前置分隔符。"""
|
||
if not buffer:
|
||
return ""
|
||
return buffer[0].text + "".join(block.separator + block.text for block in buffer[1:])
|
||
|
||
def window_of(last_body: str) -> str:
|
||
"""算下一块的前导片段(上一块正文尾部窗口)。
|
||
|
||
除了 `chunk_overlap` 与上一块正文长度,还受 `chunk_size` 约束:窗口 + 它前面的
|
||
`\\n\\n` 至少要留 1 个字符给正文,否则会产出「全是重复、零新内容」的纯重叠块
|
||
(`chunk_overlap` 接近 `chunk_size` 时必然发生)。`chunk_overlap=0` 时窗口为空。
|
||
"""
|
||
if not self.chunk_overlap:
|
||
return ""
|
||
length = min(
|
||
self.chunk_overlap, len(last_body), self.chunk_size - len(_CHUNK_SEPARATOR) - 1
|
||
)
|
||
return last_body[-length:] if length > 0 else ""
|
||
|
||
def flush() -> None:
|
||
nonlocal buffer, buffer_path, prefix, prefix_len
|
||
content = body()
|
||
chunks.append(
|
||
ParsedChunk(
|
||
text=self._join_parts(prefix, content),
|
||
heading_path=buffer_path,
|
||
index=len(chunks),
|
||
)
|
||
)
|
||
prefix = window_of(content)
|
||
prefix_len = len(prefix)
|
||
buffer, buffer_path = [], ()
|
||
|
||
for block in blocks:
|
||
if buffer and block.heading_path != buffer_path:
|
||
flush() # 标题变了就不再跨界聚合
|
||
if block.heading_path != buffer_path:
|
||
# C1:跨章节必须丢弃重叠窗口,否则上一章的字符会被标成新章的 heading_path
|
||
prefix, prefix_len, buffer, buffer_path = "", 0, [], block.heading_path
|
||
if buffer:
|
||
candidate = body() + block.separator + block.text
|
||
else:
|
||
candidate = block.text # 块首段落:它的前置分隔符被丢弃,不进入正文
|
||
if self._fits(prefix, candidate):
|
||
buffer.append(block)
|
||
continue
|
||
if buffer:
|
||
flush()
|
||
buffer_path = block.heading_path
|
||
if self._fits(prefix, block.text):
|
||
buffer.append(block)
|
||
continue
|
||
# 走到这里 buffer 必为空:单段连「前导片段 + 自己」都装不下,只能硬切
|
||
prefix, prefix_len = self._hard_split(block.text, prefix, chunks, buffer_path)
|
||
if buffer:
|
||
flush()
|
||
return chunks
|
||
|
||
def _fits(self, prefix: str, body: str) -> bool:
|
||
"""该块是否还能装下 body(前导片段 + 分隔符都要算进 chunk_size)。"""
|
||
overhead = len(prefix) + len(_CHUNK_SEPARATOR) if prefix else 0
|
||
return overhead + len(body) <= self.chunk_size
|
||
|
||
def _hard_split(
|
||
self,
|
||
block: str,
|
||
prefix: str,
|
||
chunks: list[ParsedChunk],
|
||
heading_path: tuple[str, ...],
|
||
) -> tuple[str, int]:
|
||
"""硬切超长段落;返回留给下一块的前导片段及其在原文中的长度。"""
|
||
remaining = block
|
||
prefix_len = len(prefix)
|
||
# 硬切前先把**传进来的**前导片段裁到「装得下 1 字正文」:它来自上一块的 `chunk_overlap`
|
||
# 或上一轮硬切,`chunk_overlap` 接近 `chunk_size` 时会长到挤掉整个正文预算,
|
||
# 让 `budget = max(1, ...)` 切出**超过 chunk_size** 的块(cs=512/co=511/源 1200 字)。
|
||
if prefix and prefix_len > self.chunk_size - len(_CHUNK_SEPARATOR) - 1:
|
||
prefix_len = max(0, self.chunk_size - len(_CHUNK_SEPARATOR) - 1)
|
||
prefix = prefix[-prefix_len:] if prefix_len else ""
|
||
while remaining:
|
||
# 前导片段 + 分隔符至少要留 1 个字符给新内容,保证正步进、必然终止。
|
||
# `max(1, ...)` 不可省:`chunk_overlap` 接近 `chunk_size`(如 cs=10/co=9)时
|
||
# 预算会变成非正,而 `remaining[:负值]` 是"去掉末尾若干字符"的语义,
|
||
# 会切出**超过 chunk_size** 的块。下界取 1 让步长恒为正。
|
||
budget = max(1, self.chunk_size - (prefix_len + len(_CHUNK_SEPARATOR) if prefix else 0))
|
||
head = remaining[:budget]
|
||
text = self._join_parts(prefix, head)
|
||
assert len(text) <= self.chunk_size, (
|
||
f"硬切产出的块长 {len(text)} 超出 chunk_size={self.chunk_size}"
|
||
)
|
||
remaining = remaining[len(head) :]
|
||
chunks.append(ParsedChunk(text=text, heading_path=heading_path, index=len(chunks)))
|
||
# 前导片段只取原文内容(不含分隔符),否则会把分隔符当内容再复制一份。
|
||
# 上界再受 `chunk_size` 约束:`chunk_overlap` 接近 `chunk_size` 时(如 cs=10/co=9),
|
||
# 窗口 + 分隔符会挤掉全部正文预算,使块长超过 `chunk_size`。
|
||
# 默认 512/64 下该上界为 509 > 64,故既有行为不受影响。
|
||
if self.chunk_overlap:
|
||
prefix_len = min(
|
||
self.chunk_overlap, len(head), self.chunk_size - len(_CHUNK_SEPARATOR) - 1
|
||
)
|
||
# 剩余内容多到能自成一整块时(`cs - co >= 1`),窗口按 `chunk_overlap` 取;
|
||
# 剩余很少(1 <= 剩余 < chunk_size)时若仍按 `chunk_overlap` 取窗口,
|
||
# 下一块的正文预算会被 `max(1, ...)` 压到 1 字,几乎全是重复内容
|
||
# (见 `_split` 的 flush 丢弃纯重叠块)。此时把窗口缩到「给剩余内容留够位」,
|
||
# 宁少重复不留空洞。剩余为 0 说明本段已切完,窗口要留给**下一段**,不能缩。
|
||
if 0 < len(remaining) < self.chunk_size:
|
||
limit = self.chunk_size - len(remaining) - len(_CHUNK_SEPARATOR)
|
||
prefix_len = max(0, min(prefix_len, limit))
|
||
else:
|
||
prefix_len = 0
|
||
prefix = head[-prefix_len:] if prefix_len else ""
|
||
return prefix, prefix_len
|