文档切分思路(2026-10-30)
本文总结 tangdou-rag 当前已落地的文档切分方法:体裁分治(问答类 / 其他类)+ 统一 Markdown 中间表示 + 结构感知切块 + 父子块检索扩展。
1. 为什么不用「按固定字数硬切」
早期版本直接按字符数滑窗切块(chunk_size=300、chunk_overlap=50),问题集中在三点:
| 问题 | 具体表现 |
|---|---|
| 切断连续语义 | 论述被拦腰截断,句子/论证链首尾不完整 |
| 切断表格 | 表格跨块后,「某一列的值」无法与表头建立关系,检索命中不到 |
| 上下文不足 | 检索到的是碎片,送给 LLM 时缺少铺垫与结论 |
因此改造目标定为:让切块边界落在语义边界上(问答对边界、章节边界、表格边界),而不是落在字符边界上。
2. 总体思路
2.1 核心分治:文档在语义层面分为两大类
| 体裁 | 判定码 | 语义特征 | 切块策略 | 上下文处理 |
|---|---|---|---|---|
| 问答类 | qa | 文档主要内容是一个个独立的 Q&A(FAQ、访谈稿、QA 手册) | 按完整问答对切块 | 问答自含上下文,不设父块 |
| 其他类 | structure | 整个文件通过前后关联的章节阐述一个或一类问题(报告、方案、说明书) | 结构感知切块:按标题层级切子块 | 父子块:子块检索,父块供生成 |
判定原则(会话中确认):问答类比较好甄别,其余一律归为其他类——即「宁滥勿缺」,不确定时走其他类的保守路径。
2.2 全流程
上传 txt/pdf/docx/md
│
▼
① 解析为 Markdown 中间表示 ── 统一标题 / 管道表 / 空行分段
│
▼
② 体裁自动甄别(qa / structure) ── 可人工覆盖
│
▼
③ 用户确认环节:编辑 Markdown + 切换体裁 + 切块实时预览
│
▼
④ 按体裁切块(子块 / 父块) ── plan_chunks
│
▼
⑤ 向量化 → ChromaDB → 重建 BM25 内存索引
│
▼
⑥ 检索:BM25 + 向量 → RRF 融合 → CrossEncoder Rerank → 父块扩展 → LLM3. 解析层:统一 Markdown 中间表示
所有格式先转成 Markdown 文本(parse_file),因为后续的体裁甄别、切块、用户预览/编辑全部基于 md 结构(# 标题、管道表、--- 分割线、空行分段)。
| 输入 | 解析方式 | 标题识别 | 表格处理 |
|---|---|---|---|
.txt / .md | 按 utf-8 → gbk 顺序探测编码 | 原样保留已有 md 标题 | 原样保留管道表 |
.pdf | pdfplumber | 统计全文字号众数 body_size;行首字号 > body_size×1.15 且 ≤60 字判为标题,> ×1.45 用 #,否则 ## | find_tables() 提取,正文区域按 bbox 过滤去重 |
.docx | python-docx,按 body XML 原始顺序遍历 | 段落样式匹配 Heading N / 标题 N → #×N | w:tbl 转管道表 |
表格统一转为 Markdown 管道表(_format_table):
| 列 A | 列 B |
| --- | --- |
| 值 1 | 值 2 |单元格内的 | 做转义(\|),列数按最宽行对齐。
扫描版 PDF 解析不到文本时直接报错提示「请先转文字版」,不做 OCR。
4. 体裁甄别(detect_qa)
先把 md 按空行切成块,然后判断哪些块是「问题块」(_q_head):
- 首行(去掉
#标题前缀、1./一、序号后)匹配问 / 问题 / Q / q+ 分隔符(::.、)→ 判为问题; - 或首行长度 ≤60 字且以
?结尾 → 判为问题。
判定规则:问题块数量 ≥ 3,且占全部块比例 ≥ 20% → 问答类(qa),否则归为其他类(structure)。
| 配置项 | 值 | 说明 |
|---|---|---|
| 最小问题块数 | 3 | 少于 3 个不足以构成 QA 文档特征 |
| 问题块占比阈值 | 20% | 低于该比例视为偶发提问,归其他类 |
| 问句长度上限 | 60 字 | 超过则认为是叙述性句子而非提问 |
甄别结果只是默认值,前端确认弹窗中可一键切换体裁并实时预览切块效果(见 §8)。
5. 切块策略
入口:plan_chunks(md_text, doc_type, size, overlap) → 返回 (子块列表, 父块列表|None, 子→父索引映射|None)。
5.1 问答类(_plan_qa)
切分优先级:显式分割线 --- > 问题块自动识别。
md 文本
│
├─ 存在 ≥2 段被水平分割线切开 → 按分割线切单元(_split_dividers)
│ 每个单元内部再按空行切块(_split_blocks),同一单元的块合并为一个问答单元
│
└─ 否则 → 按问题块自动切
遇到问题块 → 开启新单元;其后的非问题块并入当前单元
(首个问题之前的内容自成单元)- 水平分割线识别
_DIVIDER_RE:^(?:[-*_]\s*){3,}$,即---/***/___/- - -均可; - 分割线不作为内容保留,在
_split_blocks中等价于空行(只作边界标记,不残留在块内); - 单元合并规则:问题块 + 其后所有非问题块 = 一块完整问答。
超长兜底:问答对完整成块的上限为 4000 字(_QA_UNIT_MAX,与 chunk_size 解耦)——按分割线完整切割是第一优先级,块长不受 chunk_size 约束。仅当单个问答对超过 4000 字时,答案部分才做滑窗兜底(片长 1000 字,步长 = 片长 − overlap),每一片都带上问题行前缀(f"{qline}\n{p}"),保证任意一片单独可读、问题不丢。
问答类不生成父块(parents=None),因为一个完整问答对本身就是自足上下文,再向上聚合反而稀释语义。
5.2 其他类(_plan_structure)
核心是「结构感知 + 父子块」。
第一步:按标题层级切 section(_sections)
按 md 标题行 ^(#{1,6})\s+(.+)$ 切分,并维护标题路径栈:
# 第一章 概述 → 路径:["第一章 概述"]
## 1.1 目标 → 路径:["第一章 概述", "1.1 目标"]
### 1.1.1 指标 → 路径:["第一章 概述", "1.1 目标", "1.1.1 指标"]每个 section 的子块都会带上标题路径前缀:
【第一章 概述 > 1.1 目标】
<正文内容…>这一步解决了「子块脱离章节后不知自己在讲什么」的问题,同时让检索时标题词(章节名)也能参与匹配。
第二步:section 内切子块(_pack_blocks)
先把 section 内容按空行切成原子块,再合并到接近 chunk_size:
- 表格原子:以
|开头的连续行视为一个不可拆分的原子单元; - 超长表格(
_split_table)按数据行分组,每组重复表头两行(表头 +|---|分隔行),保证「某列的值」永远与表头同块; - 普通块超长时按滑窗切分;
- 合并时
len(current) + len(unit) + 1 <= size才合并,避免块体膨胀; - 封口块带
overlap长度的尾巴(表格除外,表格不跨块拼接)。
第三步:父块分组
同一 section 内连续的子块按 parent_max = size × 4 上限聚合成父块,父块文本为组内子块拼接(带标题前缀)。记录子块 → 父块的索引映射(owner)。
第四步:兜底
全文没有任何标题时(chunks 为空),退化为「整篇按空行块合并」。
父块上限 4 × size 的取舍:父块太大则送入 LLM 的上下文噪声多、token 成本高;太小则失去「前后关联章节」的完整性意义。4 倍是经验值。
5.3 两种体裁对比
| 维度 | 问答类 | 其他类 |
|---|---|---|
| 切分依据 | --- 分割线 / 问题块 | md 标题层级(section) |
| 块内容 | 完整问答对 | 章节片段 + 【标题路径】 前缀 |
| 父块 | 无 | 有(≤ 4×size) |
| 检索后处理 | 原样送 LLM | 子块命中 → 替换/扩展为父块 |
| 超长处理 | >4000 字才滑窗兜底(1000 字片,带问题前缀) | 块级滑窗 / 表格按行分组 |
6. 数据结构
backend/app/db.py:
kb_document(doc_id, file_name, file_type, category, doc_type, file_size,
content_md5 UNIQUE, chunk_count, status, fail_reason, md_text, created_at)
kb_chunk (chunk_id, doc_id, chunk_index, text, parent_id) -- parent_id 可空
kb_parent (parent_id, doc_id, text)kb_document.md_text存用户确认后的 Markdown——入库的是用户调整过的版本,而非原始解析结果;kb_document.doc_type存体裁(qa/structure),决定重建时的切块路径;kb_chunk.parent_id→kb_parent.parent_id,为空表示该块无父块(问答类);content_md5唯一约束用于查重(解析阶段即拦截重复文件);- 迁移采用幂等方式:
PRAGMA table_info检测缺列后ALTER TABLE补齐(category/doc_type/md_text/parent_id)。
7. 检索侧:父子块扩展(_expand_parents)
切块只是第一步,检索链路必须把「小块精度」和「大块上下文」两件事都拿到:
BM25 召回 ┐
├→ RRF 融合(1/(K+rank) 累加)→ CrossEncoder Rerank → top-k
向量召回 ┘
│
▼
_expand_parents:按 parent_id 去重
子块文本替换为父块文本
│
▼
LLM- 同一父块被多个子块命中时只保留一条(按
parent_id去重,无父块则以chunk_id为键); - 父块文本比子块长才替换(防御性判断);
- 问答类块没有
parent_id,原样透传,不引入任何额外逻辑; - BM25 索引重建时用
LEFT JOIN kb_parent一并带出parent_text,内存索引_meta同时缓存子块文本与父块文本。
效果:小块负责「找得准」,父块负责「看得全」——检索精度由小粒度块保证,送入 LLM 的上下文由大粒度父块保证。
8. 入库流程与前端交互
8.1 两步入库接口
| 接口 | 作用 |
|---|---|
POST /api/kb/parse | 上传文件 → 解析为 md → 自动甄别体裁 → 返回 md_text + doc_type(不入库)。同时做类型校验、20MB 上限、MD5 查重 |
POST /api/kb/preview | 按当前 md + 体裁 + 切块参数返回块数、父块数、前 5 块摘要(不入库) |
POST /api/kb/confirm | 按用户确认后的 md + 体裁写入 kb_document,后台任务执行切块 → 向量化 → 建索引 |
当前只支持单文件上传(一次一个文件),多文件批量上传暂不支持。
8.2 前端确认弹窗(KbManage.vue)
- 体裁切换:
问答类/其他类单选,自动甄别结果作为默认值并显示「体裁已自动甄别为 X,可手动调整」; - Markdown 编辑:文本域直接编辑,
:rows="18",等宽字体; - Markdown 预览:切换到「预览」用
marked渲染(本地自建内容,不执行脚本),容器max-height: 480px内部滚动; - 切块实时预览:编辑内容或切换体裁后 400ms 防抖调用
/preview,展示块数; - 确认入库:块数为 0 时按钮禁用;入库后文档状态为「处理中」,前端每 3 秒轮询刷新。
8.3 后台处理与生命周期
process_document:读取md_text(为空时兼容旧数据回退解析原文件)→plan_chunks→ 写kb_parent/kb_chunk→ embedding → 写 Chroma → 置「就绪」→ 重建 BM25(先置就绪再重建 BM25,因为 BM25 只统计就绪文档的块);delete_document:级联删除 Chroma 条目 +kb_chunk+kb_parent+kb_document+ 上传文件,刷新 BM25;rebuild_all:清空 Chroma 集合 → 清空kb_chunk/kb_parent→ 按当前参数对所有文档重新切块入库(调整chunk_size等参数后一键生效);- 单文档失败隔离:异常只将该文档置为
failed并记录fail_reason,不影响其他文档。
9. 参数
sys_config(后台可热更新):
| 参数 | 默认值 | 范围 | 作用 |
|---|---|---|---|
chunk_size | 300 | 50–2000 | 子块目标字符数(作用于其他类;问答类按分割线完整成块,不受此参数约束) |
chunk_overlap | 50 | 0–500 | 相邻块重叠字符数 |
recall_size | 50 | 1–200 | 每路召回条数 |
final_top_k | 6 | 1–20 | 送入 LLM 的引用集大小 |
rrf_k | 60 | 0–1000 | RRF 平滑常数 |
bm25_k1 / bm25_b | 1.5 / 0.75 | 0–3 / 0–1 | BM25 参数 |
min_similarity | 0.3 | 0–1 | 命中阈值,低于则不调 LLM |
切块参数修改后需执行全量重建才会生效。
10. 关键设计取舍
- 体裁分治 vs 统一策略:统一策略(都按标题切或都按窗口切)无法同时照顾「问答自足」与「章节关联」两种语义,收益远低于复杂度成本,因此选择分治。
- 解析为 md 作为中间表示:一次解析,多处复用——甄别、切块、预览、编辑都基于同一份 md,用户看到的就是系统切分的依据,所见即所得。
- 用户确认环节:自动甄别不可能 100% 准确,允许人工改体裁 + 改 md,把「切分质量」的控制权交回用户,代价是多一次交互。
- 标题路径前缀:用少量 token 换取子块的自解释能力与标题词的可检索性,性价比高。
- 表格原子 + 重复表头:表格是「行级查询」的关键载体,绝不能跨块;重复表头让每一片都是独立可读的表。
- 分割线优先于问题识别:用户显式写
---是最强的结构信号,优先采纳;同时保留自动识别作为无分割线文档的兜底。 - 父块上限 4×size:过大增加噪声与 token 成本,过小丢失章节关联性。
11. 已知限制与后续可演进方向
当前限制
- 仅支持单文件上传,批量入库需逐个走确认流程;
- 扫描版 PDF 不做 OCR,需人工先转文字版;
- 表格不保留跨页合并信息,pdf 表格按页独立提取;
- 文档中的图片内容不入库(仅
doc/imgs中的附件,检索不到图片语义); chunk_size等切块参数改动需全量重建才生效;- 父块扩展是「替换」而非「子块 + 父块拼接」,命中子块的前置上下文由父块头部覆盖。
可演进方向
- 目录/标题层级识别增强(pdf 字号启发式对「标题字号与正文接近」的文档判别力有限);
- 表格跨页拼接与表头自动识别;
- 基于 LLM 的二次体裁判定(替代规则判定);
- 父块内按 token 预算而非字符数控制;
- 切块质量评估集与召回率回归测试。
附:关键代码位置
| 能力 | 位置 |
|---|---|
| 解析为 md | [ingestion.py](file:///d:/1sqyai/tangdou-rag/backend/app/services/ingestion.py) parse_file / _read_pdf / _read_docx / _format_table |
| 体裁甄别 | 同上 detect_qa / _q_head |
| 问答类切块 | 同上 _plan_qa / _split_dividers / _split_blocks |
| 其他类切块 | 同上 _plan_structure / _sections / _pack_blocks / _split_table |
| 表结构 | [db.py](file:///d:/1sqyai/tangdou-rag/backend/app/db.py) |
| 入库接口 | [kb.py](file:///d:/1sqyai/tangdou-rag/backend/app/routers/kb.py) /parse /preview /confirm |
| 父子块扩展 | [rag_engine.py](file:///d:/1sqyai/tangdou-rag/backend/app/services/rag_engine.py) _expand_parents / rebuild_bm25 |
| 前端确认弹窗 | [KbManage.vue](file:///d:/1sqyai/tangdou-rag/frontend/src/views/KbManage.vue) |