Skip to content

文档切分思路(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 → 父块扩展 → LLM

3. 解析层:统一 Markdown 中间表示 ​

所有格式先转成 Markdown 文本(parse_file),因为后续的体裁甄别、切块、用户预览/编辑全部基于 md 结构(# 标题、管道表、--- 分割线、空行分段)。

输入解析方式标题识别表格处理
.txt / .md按 utf-8 → gbk 顺序探测编码原样保留已有 md 标题原样保留管道表
.pdfpdfplumber统计全文字号众数 body_size;行首字号 > body_size×1.15 且 ≤60 字判为标题,> ×1.45 用 #,否则 ##find_tables() 提取,正文区域按 bbox 过滤去重
.docxpython-docx,按 body XML 原始顺序遍历段落样式匹配 Heading N / 标题 N → #×Nw:tbl 转管道表

表格统一转为 Markdown 管道表(_format_table):

markdown
| 列 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:

sql
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_size30050–2000子块目标字符数(作用于其他类;问答类按分割线完整成块,不受此参数约束)
chunk_overlap500–500相邻块重叠字符数
recall_size501–200每路召回条数
final_top_k61–20送入 LLM 的引用集大小
rrf_k600–1000RRF 平滑常数
bm25_k1 / bm25_b1.5 / 0.750–3 / 0–1BM25 参数
min_similarity0.30–1命中阈值,低于则不调 LLM

切块参数修改后需执行全量重建才会生效。


10. 关键设计取舍 ​

  1. 体裁分治 vs 统一策略:统一策略(都按标题切或都按窗口切)无法同时照顾「问答自足」与「章节关联」两种语义,收益远低于复杂度成本,因此选择分治。
  2. 解析为 md 作为中间表示:一次解析,多处复用——甄别、切块、预览、编辑都基于同一份 md,用户看到的就是系统切分的依据,所见即所得。
  3. 用户确认环节:自动甄别不可能 100% 准确,允许人工改体裁 + 改 md,把「切分质量」的控制权交回用户,代价是多一次交互。
  4. 标题路径前缀:用少量 token 换取子块的自解释能力与标题词的可检索性,性价比高。
  5. 表格原子 + 重复表头:表格是「行级查询」的关键载体,绝不能跨块;重复表头让每一片都是独立可读的表。
  6. 分割线优先于问题识别:用户显式写 --- 是最强的结构信号,优先采纳;同时保留自动识别作为无分割线文档的兜底。
  7. 父块上限 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)

Released under the MIT License.