拆解 RAG 系统中章节感知父子切分的文档处理流水线
拆解 RAG 系统中章节感知父子切分的文档处理流水线
本文以一套文档问答系统为对象,说明一份 PDF 如何经过解析、归一、质检与切分,最终成为可供检索命中的文本块;并重点讨论其中的章节感知切分与父子两级分块机制,及其设计依据。
本系统可部署在端侧设备rk3588上,所需内存为32G(qwen3:8B)
目录
一、问题与整体结构
RAG(Retrieval-Augmented Generation,检索增强生成)的流程可概括为「先检索、后生成」:将用户的问题在知识库中定位到相关文本,再连同问题一起交由大模型生成答案。检索得以成立的前提,是把非结构化的原始文档转化为可供索引与命中的文本块(chunk)。
本文所讨论的是一条文档处理流水线,其目标是产出便于检索的块。流水线可划分为以下阶段:
| 阶段 | 处理内容 | 产出 |
|---|---|---|
| 1. 接入 | 为 PDF 生成稳定标识 | document_id、file_hash |
| 2. 解析 | 将 PDF 转换为结构化信息 | 文本、表格、图片、层级、置信度 |
| 3. 归一 | 将不同解析器的结果统一为单一格式 | document.json 及配套文件 |
| 4. 质检 | 检测并隔离不合格页面 | quarantined_pages.json |
| 5. 切分 | 按章节层级拆分为语义单元 | 标题路径与语义单元 |
| 6. 分块 | 将语义单元聚合为父块与子块 | 两级 chunk |
| 7. 存储 | 将结果写入事实库 | SQLite 中的 documents / chunks |
| 8. 检索 | 为子块建立向量与关键词索引 | Chroma 与 BM25 索引 |
章节感知切分负责依据文档的层级结构划出语义边界,父子两级分块则在语义边界内进一步组织出用于检索与用于补充上下文的两级粒度。
二、PDF 的接入与解析
2.1 稳定标识
处理入口为 scripts/parse_pdfs.py。系统为每份 PDF 维护两个标识:
document_id:依据文件的相对路径生成,形式为「文件名 + 路径 SHA256 前 12 位」。由于标识取自路径而非内容,文件路径不变时document_id保持稳定。file_hash:对文件内容单独计算哈希,用于判断文件是否被修改、是否需要重新解析。
两者的职责彼此独立:document_id 用于身份定位与增量更新,file_hash 用于变更检测。
2.2 解析器
系统提供两套解析器,可互为备份或按需选用。
MinerU 通过 HTTP 上传 PDF,返回页面信息(pages / pdf_info)、内容列表(content_list)、Markdown 文本、表格、公式、图片、OCR 置信度以及原始响应。系统从中提取可用字段时遵循固定的标准化优先级:
pages / pdf_info 优先
↓ 无
content_list 次之
↓ 无
Markdown 兜底
LiteParse 在本机工作线程中解析 PDF,输出逐页 Markdown、纯文本、图片与 OCR 信息。项目对 Markdown 再做一次轻量解析,映射关系如下:
| Markdown 语法 | 转换结果 |
|---|---|
# 标题 |
heading,text_level = 1 |
## 二级标题 |
heading,text_level = 2 |
| 空行分隔的文本 | text |
| Markdown 表格 | table + rows |
 |
image |
因此,LiteParse 的章节层级由 Markdown 中 # 的层级决定。两套解析器都会保留每个文本块所处的层级信息,这正是后续章节感知切分所依赖的输入。
2.3 统一元素结构
来自任一解析器的原始 block 均被转换为统一元素:
{
"kind": "text | heading | table | image",
"content": "文本内容",
"rows": null,
"confidence": 0.96,
"raw": {
"MinerU原始block": "..."
}
}
MinerU 的完整原始响应保存在 raw_parse_result.json 中,以备追溯。统一元素结构将两套解析器的输出差异收敛在解析层,使下游切分与存储无需感知具体后端,也便于后续替换或新增解析器。
三、统一中间产物
每份 PDF 解析完成后,在 data/parsed/<document_id>/ 目录下生成一组文件:
| 文件 | 作用 |
|---|---|
raw_parse_result.json |
原始解析器结果 |
parsed.md |
完整 Markdown,供人工检查 |
document.json |
切分阶段实际读取的数据 |
image_manifest.json |
图片、页码与语义说明 |
quality_report.json |
逐页质量检查报告 |
quarantined_pages.json |
被隔离的页面 |
images/ |
提取出的图片 |
document.json 的结构如下:
{
"document_id": "manual-xxxx",
"source_path": "data/pdfs/manual.pdf",
"file_hash": "...",
"parser_version": "...",
"markdown": "...",
"pages": [
{
"page_number": 10,
"elements": [
{"kind": "heading", "content": "13 主轴", "raw": {"text_level": 1}},
{"kind": "heading", "content": "13.4 齿轮档", "raw": {"text_level": 2}},
{"kind": "text", "content": "手动切换齿轮档时……"},
{"kind": "table", "content": "...", "rows": [["档位", "速度"]]}
]
}
]
}
四、质量检查与隔离
系统对每一页执行质量检查,检查项包括:空页、乱码、页码不一致、标题异常、表格列错位、OCR 置信度过低。命中任意一项的页面即被写入 quarantined_pages.json,在切分阶段整页跳过。
对于缺少解析器语义信息的图片,系统通过视觉模型补充 semantic_content;没有语义的纯图片不会成为检索文本。
隔离策略的着眼点在于索引质量。不合格页面若被切分,会向索引引入噪声;将其整页跳过,代价是少量召回损失,收益是避免错误结果。这一取舍在检索系统中较为常见。
五、章节感知切分
章节感知切分是整条流水线的中心环节。常规切分按长度窗口切分;此处则按文档的章节层级切分,使切分边界与语义边界尽量重合。
切分器按页面顺序扫描元素,并维护一个标题栈:
H1:主轴
H2:齿轮档切换
H3:手动方式
对应正文的标题路径为:
["主轴", "齿轮档切换", "手动方式"]
栈的维护规则如下:
- 遇到同级标题时,替换当前层级;
- 遇到更高级标题时,清除其下所有层级;
- 不同的标题路径不会归入同一章节。
正文、完整表格与图片语义分别构成一个语义单元(_Unit)。此处所称的「语义单元」指的是解析产物层面的元素划分,即由标题路径、表格边界与图片边界共同界定,而非由大模型进行的语义判断。相比较由模型理解,这一结构性划分更可控、成本更低,且结果可复现。
章节信息在此环节同时承担两项职能:一是确定切分的语义边界,二是为每个单元提供 title_path,后者作为元数据贯穿存储与检索阶段。
六、父子两级分块
在章节切分的基础上,系统于每个标题路径内部进一步组织两级块,分别对应检索精度与上下文完整性的不同需求。
6.1 父块
同一标题路径下的语义单元按顺序累加:
title_path + A + B + C
当加入下一个单元后总 token 数超过 1500 时,即在当前单元之前关闭父块。例如:
| 父块 | 组成 | token 量 |
|---|---|---|
| 父块 1 | A + B + C | 约 1400 |
| 父块 2 | D + E | 约 700 |
6.2 子块
每个父块内部重新组织子块,相关阈值如下:
| 指标 | 数值 |
|---|---|
| 软目标 | 350 token |
| 字符目标 | 400 字符 |
| 硬上限 | 480 token |
补充规则:
- 普通文本低于 250 token 时,尝试与相邻普通文本合并;表格与图片不参与此类合并。
- 超长文本依次按照
句号/分号/换行 → 逗号/冒号 → 字符的优先级切分。 - 表格优先按完整行切分,并为后续块重复表头,保证切分后的表格仍携带列名。
6.3 两级分块的目的
父块与子块在召回链路中承担不同职能。检索阶段以长度较短的子块为命中单位,以获得较高的匹配精度;命中后,系统经由 parent_chunk_id 取回对应的父块,以补充更完整的上下文。两级结构即用于在「检索精度」与「上下文完整性」之间取得平衡。
七、SQLite 存储与块关系
SQLite 是整条流水线的事实库(source of truth),保存文档与块的权威状态。
documents 表:每份 PDF 一行。
| 字段 | 含义 |
|---|---|
document_id |
文档稳定 ID |
source_path |
文件路径 |
file_hash |
内容哈希 |
parser_version |
解析器版本 |
chunking_version |
切分器版本 |
title |
标题 |
chunks 表:同时保存父块与子块。
| 字段 | 含义 |
|---|---|
chunk_id |
块 ID |
document_id |
所属文档 |
parent_chunk_id |
父块 ID(子块指向父块) |
chunk_order |
顺序 |
title_path |
标题路径(JSON 字符串) |
page_start / page_end |
起止页码 |
raw_text |
原始文本 |
cleaned_text |
经 NFKC 与空白规范化后的文本 |
content_type |
内容类型 |
char_count |
字符数 |
token_count |
token 数 |
tokenizer_version |
分词器版本 |
content_hash |
内容哈希 |
is_parent |
是否为父块 |
父块与子块通过约定式 ID 区分:
父块:chunk_id = manual:parent:0001 parent_chunk_id = NULL is_parent = 1
子块:chunk_id = manual:chunk:00001 parent_chunk_id = manual:parent:0001 is_parent = 0
chunk_links 表:保存块间关系。
child → parent
parent → child
child → next
child → previous
当同一文档被重新处理时,系统在一个事务内删除旧记录并插入新记录,不会保留同一文档的两套旧块,从而支持流水线的幂等重跑。
当前本地 SQLite 的规模如下:
| 指标 | 数量 |
|---|---|
| 文档块总数 | 325032 |
| 父块 | 97965 |
| 子块 | 227067 |
八、混合检索与最终召回
SQLite 不保存 embedding。向量与关键词索引由 Chroma 与 BM25 承担,两者仅读取 is_parent = 0 的子块,索引文本为:
title_path
cleaned_text
Chroma 保存 embedding 向量、chunk_id、索引文本,以及 document_id、页码、title_path 等 metadata。BM25 保存 chunk_id、索引文本、分词结果与 metadata。
在线查询时,系统先命中子块,再通过 parent_chunk_id 从 SQLite 取回父块交由回答模型。各组件在召回链路中的分工如下:
| 组件 | 职责 |
|---|---|
| 短子块 | 准确检索 |
| 长父块 | 补充上下文 |
| SQLite | 事实与父子关系 |
| Chroma / BM25 | 候选检索 |
这一结构即混合检索(Hybrid Retrieval):向量检索负责「语义相近」的匹配,BM25 负责「关键词精确」的匹配,二者结合可获得比单一方式更稳定的召回。需要强调的是,父子分块在此并非终端产物,其价值最终体现在子块命中、父块补齐的整体召回方式上。
九、小结
整条流水线可归纳如下:
PDF
│ ① 生成 document_id + file_hash
▼
解析(MinerU HTTP / LiteParse 本地)
│ ② 归一为统一元素(含层级、置信度)
▼
中间产物 document.json(权威数据 = pages[].elements)
│ ③ 逐页质检,坏页隔离
▼
章节感知切分(标题栈 → title_path → 语义单元)
│ ④ 聚合成父块(1500) 与 子块(480)
▼
SQLite 事实库(documents / chunks / chunk_links)
│ ⑤ 仅为子块建索引
▼
Chroma(向量)+ BM25(关键词)
│ ⑥ 命中子块 → 取回父块
▼
交由大模型生成答案
就设计意图而言,这条流水线是将非结构化的原始文件逐步转化为带层级、可追溯、可检索的知识单元。其中,章节感知切分负责在语义边界上划分单元,父子两级分块负责在同一语义单元内组织出检索与应答所需的两种粒度,二者共同决定了最终召回的质量。
浙公网安备 33010602011771号