Files
hkuds--lightrag/docs/ParagraphSemanticChunking-zh.md
T
2026-07-13 12:08:54 +08:00

524 lines
44 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Paragraph Semantic 分块策略
## 1. 适用场景与策略选择
### 1.1 P 策略要解决什么问题
Paragraph Semantic Chunking(下文简称 **P 策略**)面向 DOCX、PDF 等具有清晰章节结构的文档。其核心目标是:**让分块边界尽可能对齐文档原生的语义边界**(标题、段落、表格行),而不是仅由 token 长度计数决定切点。
P 策略主要解决以下四类问题:
1. **表格语境断裂**:大表被拆分后,首尾切片容易脱离前置说明、后置解释或中间桥接文字,召回时无法独立理解。
2. **层级信息利用不足**:仅看相邻段落的方法无法利用父标题路径、同级条款之间的关系。
3. **细碎章节尺寸失衡**:规章、标准、合同等文档常包含大量 100~300 token 的细碎条款,若不合并则块过短、语义稀薄;若仅按相邻长度合并又会跨主题污染。
4. **长块二次拆分破坏结构**:章节过长时,常规字符切分会忽略表格行边界和标题层级。
P 策略对**任何能生成 `.blocks.jsonl` sidecar 的 parser**`native` / `mineru` / `docling`)产出的结构化产物有效——三者经统一的 `write_sidecar()` 落盘相同结构的 `.blocks.jsonl`(含 `heading` / `level` / `parent_headings`)。仅 `legacy` 引擎不产出 sidecar;无 sidecar 的输入(legacy 路径或解析失败)会自动降级为 R 策略(见 §6)。
### 1.2 P / R / V 三种策略对比
| 维度 | R 策略(Recursive | V 策略(SemanticVector | P 策略(ParagraphSemantic |
|---|---|---|---|
| 切分依据 | 字符分隔符级联(段落 → 换行 → 中文标点 → 空格 → 字符)+ token 预算 | 句子级 embedding 距离阈值(百分位 / 标准差 / 四分位距 / 梯度)寻找语义断层 | 标题 outline level 与 `parent_headings` + 表格行边界 + 锚点 + 层级感知合并 |
| 块大小控制 | `chunk_token_size` 硬上限 | `chunk_token_size` 仅为 advisory ceiling,超限时通过 R 二次切分 | `target_max` 硬上限 + `target_ideal` 软目标 + 表格阈值 + 尾部吸收阈值多重协同 |
| 表格处理 | 不感知表格,可能在表格中间切断 | 不感知表格 | 表格小于 `table_max` 保持完整;大表按 JSON 行数组 / HTML `<tr>` 行边界切片,并重新包裹为合法 `<table>` |
| 表格上下文 | 依赖窗口偶然覆盖 | 依赖 embedding 距离 | 首切片粘连前置说明、末切片粘连后置解释、连续大表桥接文字双向重叠 |
| 块间重叠 | 全局 `chunk_overlap_token_size` | 不会出现重叠 | 章节边界不会重叠;同章节长正文 fallback 到 R 时按 `CHUNK_P_OVERLAP_SIZE` 重叠;连续大表桥接文字可同时进入前后两个表格块 |
| heading 元数据 | 通常无 | 通常无 | 继承或提升 heading;拆分后追加 `[part n]` 后缀;保留 `parent_headings``level` |
| 嵌入计算开销 | 无 | 高(需对每个句子计算 embedding) | 无 |
| 依赖输入 | 任意文本 | 任意文本 + Embedding 模型 | 必须有 `.blocks.jsonl` sidecar`native` / `mineru` / `docling` 任一引擎产出),否则降级为 R |
### 1.3 怎么选
| 场景 | 推荐 | 理由 |
|---|---|---|
| 章节层级清晰(内容解析引擎需要能够生成Sidecar文件) | **P** | 充分利用标题层级与表格行边界,块边界最贴合语义;避免跨主题污染 |
| 文档以散文 / 评论 / 长篇正文为主,没有明确章节结构 | **V** | 按语义相似度切分能在话题切换点形成自然边界,比字符切分更稳定 |
| 输入是纯文本、Markdown、代码、日志,或追求最低算力开销 | **R** | 无嵌入开销,分隔符级联对中英文混合文本足够稳定 |
| 通用配置(不确定文件类型) | **R** | P 在无 sidecar 时自动降级到 RV 在无 Embedding 模型时也降级到 R |
| 标题样式混乱、正文中大量伪标题的文档 | **R****V** | P 依赖 parser 正确识别标题,标题错乱会导致基础块边界偏移 |
| 单行超大表格或不可解析表格 | 任意 | 三种策略最终都会走字符级 fallback;P 仍保留表格上下文粘连优势 |
## 2. 设计目标与核心不变量
P 策略的全部规则都服务于一个目标:**让块边界对齐文档原生语义边界,并让每个块在召回时能被独立理解**。它把这个目标拆成针对三类场景的具体规则(表格、长块、细碎章节),逐条在 §3 展开。无论规则如何组合,以下四条**重叠不变量**始终成立——它们界定了「哪里允许文字复制、哪里绝不允许」:
1. **章节边界不会重叠**:不同 `.blocks.jsonl` 内容行之间的文本绝不会被复制到对方块里,避免“张冠李戴”。
2. **章节内长正文可重叠**:同一个内容行内拆分的多个片段允许按 `chunk_overlap_token_size` 保留 R 风格 overlap,减少长正文中途切断。
3. **表格之间桥接文字可双向重叠**:唯一的跨段落复制场景,专门服务连续大表的上下文保留。
4. **表格行不互相重叠**:行级切片本身是非重叠的,与 R 的 overlap 概念不同。
### 2.1 规则与效果速览
下表把 §3 的每条规则映射到它达到的效果,以及实现它的内部阶段(阶段名同时是代码注释、日志关键字与排查的交叉引用标识,见 §7.6):
| 分块规则 | 达到的效果 | 实现阶段 | 详见 |
|---|---|---|---|
| 标题级基础块 | 块边界对齐文档原生结构,而非 token 计数 | HeadingBlocks | §3.1 |
| 表格完整性 + 行边界切片 | 表格不被从中间截断,切片仍是合法 `<table>` | TableRowSplit | §3.2 |
| 表格上下文粘连(角色 + 桥接双向重叠) | 表格前置说明、后置解释、桥接文字不脱离表格 | TableRowSplit / TableBridge | §3.3 |
| 表头恢复(切分时补回中段/末段表头) | 被拆表的 `middle`/`last` 切片单独召回时不丢失列名,且不触发长度超限 | HeaderRecovery | §3.3.3 |
| 锚点驱动长块再切分 | 超长章节按语义点切分,保留标题层级 | AnchorSplit | §3.4 |
| 无正文标题粘连 | 父标题不与其子内容失散 | HeadingGlue | §3.5 |
| 层级感知合并 | 细碎条款聚到理想大小,又不跨主题污染 | LevelMerge | §3.6 |
| 重叠规则 | 召回上下文充分,但章节/表格边界不“张冠李戴” | 贯穿全程 | §3.7 |
| 尺寸阈值协同 | 大多数块落在 `[target_ideal, target_max]` | 贯穿全程 | §3.8 |
### 2.2 处理流水线总览
上述规则串成一条以 `.blocks.jsonl` 为输入的流水线(**每个 `type == "content"` 行被视为一个标题级基础块**):
```text
DOCX / PDF / PPTX / …
↓ native(docx) / mineru / docling parser —— 按标题输出基础块,不做 token 拆分
.blocks.jsonl + sidecar (.tables.json / .equations.json / .drawings.json / .blocks.assets/)
↓ TableRowSplit:超大表格按行边界切片并赋予 first/middle/last 角色 → §3.2
↓ HeaderRecovery:切分时把重复表头预扣预算后注入中段/末段切片 → §3.3.3
↓ TableBridge:连续大表之间桥接文字双向重叠 → §3.3
↓ AnchorSplit:锚点驱动的长文本块再切分 → §3.4
↓ PartLabeling[part n] 行级来源追溯编号(按原始内容行独立编号,故在跨行合并之前)
↓ HeadingGlue:无正文标题块向前并入严格更深的子块 → §3.5
↓ LevelMerge:层级感知的双相位合并 → §3.6
最终 chunk 列表
```
## 3. 分块规则与效果
### 3.1 标题级基础块——边界对齐文档原生语义 〔HeadingBlocks
**规则**:每个 `.blocks.jsonl``type == "content"` 行就是一个基础块,即「一条标题下的正文作为一个块」。标题识别完全由 **parser** 完成,**P chunker 自身不扫描文档 body、也不判断标题样式**,更不在解析阶段做 token 阈值拆分。
**达到的效果**:块的初始边界天然落在文档大纲结构上(标题切换处),而不是任意 token 位置;后续所有阶段都在这个语义对齐的基础上做加工。
三个能产出 sidecar 的引擎殊途同归地按标题切出基础块,各自得到 `heading` / `level` / `parent_headings`
- **nativedocx**:读取 `styles.xml`,按 `<w:basedOn>` 建立样式继承链回溯有效 `<w:outlineLvl>`;遍历 `document.xml` 段落沿继承链解析大纲级别,原始 outline level 0~8 映射为内部 `level` 1~9;维护 `current_heading_stack`,遇新标题清理不浅于当前 level 的旧标题并计算 `parent_headings`
- **mineru**:按条目的 `text_level > 0``label``title` / `section_header` 检测标题,用 heading_stack 维护父链。
- **docling**`label="title"` → level 1`label="section_header"``item.level + 1`(默认 level 2),同样维护父链。
三者最终都产出统一的 `IRBlock`(携带 `heading` / `level` / `parent_headings`),并由 `write_sidecar()` 落为相同结构的 `.blocks.jsonl`;表格、公式、图形被提取为单行标签(`<table id="..." format="json">...</table>` 等)写入对应 sidecar。所有可识别标题均触发基础块边界,**不**执行 token 阈值拆分。
P chunker 直接读取 `.blocks.jsonl`,每个 content 行作为后续 TableRowSplit/AnchorSplit 的独立处理单元——这也意味着 `[part n]` 编号按每个原始 content 行**独立重置**(见 §3.4 与 §4.4)。
### 3.2 表格完整性与行边界切片——不从中间截断表格 〔TableRowSplit
**规则**token 数不超过 `table_max` 的表格**保持完整**;只有超过 `table_max` 的表格才切片,且**优先按行边界切**,只有收敛到单行仍无法在上限内表达时,整张表才退化为字符级切分。
**达到的效果**:表格永远不会在「单元格中间」被截断;每个切片都重新包裹为合法的 `<table>` 标签,下游解析与 LLM 阅读都能把它当作表格理解,而非破碎的标记片段。
#### 3.2.1 行边界优先切片
- `format="json"`:按 JSON 顶层行数组切片。
- `format="html"`:按 `<tr>...</tr>` 行切片。
- 未显式标注但内容可嗅探为 JSON / HTML 的表格同样按上述规则处理。
切片前预扣 `<table {attrs}></table>` 外壳 token 开销,使重新包裹后的切片尽量不超过 `table_max`。每个切片重新包裹为合法的 `<table>` 标签,便于下游解析。
#### 3.2.2 行级递归二次切片
若某个行子集重新包裹后仍超过 `table_max`,则在该行子集内继续细分。**当切片收敛到单行、且该单行无法在 `target_max` 内与表头并存(单行内容本身超上限,或没超但加上表头后超上限)时,整张表退化为对原始 `<table>` 文本(其 body 天然含表头)的 R 递归字符切分,并打一条 `logger.warning` 警告**——表头内容随原表文本以纯文本形式保留,绝不被静默丢弃,也不产生「部分 `<table>` 切片 + 部分孤立字符片段」的混合输出。不需要注入表头、且单行本身装得进 `target_max` 的切片仍原样保留为合法 `<table>` 标记。该机制使可被行边界表达的表格内容尽量保留合法表格结构。
#### 3.2.3 末片回吞
若表格末片 token 数低于 `table_min_last`,且与前一切片合并后不超过 `table_max`,则将末片回吞至前一切片,减少无效短表格块。
### 3.3 表格上下文粘连——前后说明、桥接与表头不失散 〔TableRowSplit / TableBridge / HeaderRecovery
**规则**:被切片的表格按「首/中/末」角色与周围段落做差异化粘连;连续两张大表之间的短桥接文字按预算**双向**分配到两侧表格块;丢失表头的中段/末段切片在**切分时**就把该表的重复表头拼回自身的 `<table>`(表头 token 已在切分前预扣进每片上限)。
**达到的效果**:表格的**前置说明**进入首切片块、**后置解释**进入末切片块、**桥接文字**同时作为左表后文与右表前文——任一表格切片被召回时都带着足以独立理解的上下文,不会出现「表格在这、解释在另一块」的断裂。被拆表的中段/末段切片即使脱离了承载表头的首切片,也会把表头行重新拼回自身的 `<table>` 开头,使其单独召回时仍能理解每列含义。
#### 3.3.1 表格切片角色与物理粘连
每个表格切片被赋予内部字段 `table_chunk_role`,并按角色决定与周围段落的粘连方式:
| 角色 | 含义 | 粘连策略 |
|---|---|---|
| `first` | 原始表格的首切片 | 追加到当前累积块尾部,使表格**前置说明**与首切片进入同一块 |
| `middle` | 原始表格的中间切片 | 独立输出,避免与无关正文合并 |
| `last` | 原始表格的末切片 | 作为新累积块起点,使**后置解释**自动追加到末切片之后 |
| `none` | 非表格切片或未拆分的完整表格 | 按普通文本块处理 |
`table_chunk_role` 是内部字段,最终输出不会保留,**但在 LevelMerge 中继续作为合并约束使用**(见 §3.6.1)。
#### 3.3.2 连续大表桥接文字双向重叠 TableBridge
当同一原始内容行中出现「大表 A、短桥接文字、大表 B」的模式,且两张表均被拆分时,桥接文字按上下文预算进行双向分配:
1. 将桥接文字按 token 编码。
2. 计算左侧预算 `prev_budget = min(chunk_overlap_token_size, target_max - 左侧末切片当前 token 数)`
3. 计算右侧预算 `next_budget = min(chunk_overlap_token_size, target_max - 右侧首切片当前 token 数)`
4. **若桥接文字长度同时不超过两侧预算**:左右两个表格边界块都包含**完整桥接文字**。
5. **若桥接文字较长**:前缀进入左侧末切片块,后缀进入右侧首切片块;超出两侧预算的中间段独立成为普通文本块。该中间段块**与左右两侧各保留 `chunk_overlap_token_size` 的 R 风格 overlap**:向左回吞已进入左表块的前缀尾部、向右多含已进入右表块的后缀头部。由于每侧前缀/后缀长度本身就 ≤ overlap 预算,overlap 区间会覆盖整段前缀与后缀,**结果中间段块实际承载完整桥接文字**(桥接文字因此从不被切散,只是其首尾**额外**复制进相邻表格块)。overlap 索引始终夹在桥接文字 token 内,**绝不会把 `<table>` 内容拷进中间段块**。
单侧预算还会被限制到不超过 `chunk_token_size / 2`,避免桥接文字主导整个块。
这与普通相邻 chunk overlap 的差异:
- 普通 overlap 按前后顺序复制字符或 token,与边界类型无关。
- TableBridge 机制以表格切片角色为触发条件,把桥接文字同时作为左表后文上下文和右表前文上下文,避免桥接说明只归属一侧表格或被单独切散后难以召回。
#### 3.3.3 中段/末段切片表头恢复 HeaderRecovery
大表按行边界切片后,表头行只保留在**首切片**内;`middle` / `last` 切片因此丢失列名,单独召回时无法判断每列含义。为此在 **TableRowSplit 切分的同时**,把表头行直接拼回非首切片自身的 `<table>`,使每个切片重新成为带表头的完整表格。
1. **表头来源**:解析期已把每张表的「跨页重复表头」写入同目录的 `.tables.json`(条目字段 `table_header`;**只有真正带重复表头的表才有该字段**)。**该字段按表格自身格式原生存储,使合并单元格语义全程存活**:`format="json"` 表存为 JSON 二维数组字符串(如 `[["H1","H2"]]`),`format="html"` 表存为原始 `<thead>…</thead>` 片段(保留 `rowspan` / `colspan`)。P 按待拆 `<table>` 标签保留的 `id` 关联回对应表条目,取其 `table_header`
2. **预扣预算、切分时注入**:表头的 token 数在切分**之前**就从每片 body 上限中预扣(与 `<table {attrs}></table>` 包裹开销一并扣除)。`_split_table_text` 据此切分,再把表头拼回每个非首切片——`format="json"` 切片把表头行 prepend 到行数组,`format="html"` 切片把存储的原始 `<thead>` 片段 **verbatim 拼回 body 开头**(保留 `rowspan` / `colspan` 合并单元格语义,不再展开为无 span 的网格);若 HTML 切片已自带 `<thead>`(切点落在多行表头内部)则跳过,避免重复。切片原有 `attrs`(含首位的 `id`)保持不变。由于已预扣,**切片含表头后仍 ≤ `target_max`**,硬上限由所有下游阶段自然保证,不存在事后回填导致的超限。首切片自带真实表头行,不重复注入。若某切片收敛到单行后已无法在 `target_max` 内同时容纳行内容与表头(见 §3.2.2),则**整张表退化为 R 递归字符切分(含表头)并打 warning**,绝不保留无表头的孤立切片。
3. **绝不臆造表头**——以下情形均不注入:源表在 `.tables.json` 中没有 `table_header` 字段(无重复表头)、`.tables.json` 缺失/不可读、切片已退化为字符级非 `<table>` 片段(无 `id` 可关联),或表格未发生真正的多片切分。
4. **格式一致性硬校验(损坏即报错)**:注入前先判定 `table_header` 的格式(JSON 二维数组 vs `<thead>` 片段)并与待拆表格自身的 `format` 比对。两者明确冲突(如 HTML 表却拿到 JSON 数组表头,反之亦然)意味着 sidecar 已损坏或张冠李戴,此时 **`_split_table_text` 直接 `raise ValueError` 中断该文档分块**,而非用错位表头产出畸形切片。这是刻意的「损坏即硬报错」语义,与第 3 点「表头缺失则静默跳过」相区分——缺失是可容忍的常态,格式冲突是数据损坏信号。
> 因为表头在切分时即进入切片,被拆表的各切片在 LevelMerge 中被**完全冻结、互不重新合并**(见 §3.6.1)——否则把同一张表的两个切片重新合并会在表中重复一次表头。表头**进入 `content`**,计入该 chunk 的 token 数(表头通常很小);不写入 `heading`。
### 3.4 锚点驱动的长块再切分——按语义点切、保留标题 〔AnchorSplit
**规则**:对 TableRowSplit 后仍超过 `target_max` 的内容块,优先在「短段落锚点」处均衡切分,被选中的锚点晋升为子块新标题;无合格锚点时按「表格优先 → 贪心打包 → 字符切分」三级降级。
**达到的效果**:超长章节不是被硬切在任意 token 位置,而是切在短小的小标题/过渡句这类**自然语义点**上,子块继承可读的标题与父标题路径;同时保证算法**永不丢内容**,且尽量遵守用户配置的块大小上限。
#### 3.4.1 短段落锚点
把内容按段落恢复,选择满足以下条件的段落作为候选锚点:
- 段落不是表格(不以 `<table` 开头)。
- 段落文本长度不超过 `max_anchor_candidate_length`100 字符)。
- 段落不是该块的第一个段落(避免递归无法收敛)。
#### 3.4.2 均衡选锚
根据目标子块数量计算理想切分位置,从候选锚点中选择距离理想位置最近的锚点。被选中的锚点**晋升为后续子块的新 `heading`**,原 heading 写入该子块的 `parent_headings`
#### 3.4.3 无锚点降级
若不存在合格锚点:
1. **表格优先**:若块内仍存在超限表格,优先调用 TableRowSplit 的行边界切片。
2. **贪心打包**:其余文本按段落贪心打包到接近 `target_max`
3. **递归字符切分**:单一过长普通文本段落降级到 R 策略(`chunking_by_recursive_character`),使用 `chunk_overlap_token_size` 保持相邻文本片段的连续性。
无锚点 fallback 路径保证算法**不会丢弃内容**,并尽量遵守用户配置的块大小上限。
### 3.5 无正文标题粘连——父标题不与子内容失散 〔HeadingGlue
**规则**:当一个块是 heading-only(只有标题、没有自己的正文)且紧邻的下一块层级**严格更深**时,把它**向前并入**那个更深的子块,并保留较浅的**父标题**身份;其余情况原样留给 LevelMerge。
**达到的效果**:像 `## 2.4`(无正文)这样的父标题,绝不会被单独切成孤块、再被 LevelMerge 向后吞进上一个同级块 `## 2.3` 而与它真正的子内容 `### 2.4.1` 失散——标题始终随其子内容走,标题路径层级无损。
某些章节只有标题、没有自己的正文(heading-only),例如:
```
## 2.3 结构尺寸及重量 ..... (level 2,有正文)
## 2.4 环境适应性指标 level 2heading-only,无正文)
### 2.4.1 概述 (level 3,有正文)
```
若直接进入 LevelMerge`## 2.4` 会作为一个独立的同级小块,被 Phase A 同级合并或尾部整批吸收**向后吞进上一个同级块 `## 2.3` 的末端**,使这个父标题与它真正的子内容 `### 2.4.1` 失散。
因此在 LevelMerge 之前增加一个前置步骤(`_glue_heading_only_blocks`)。当前块为 heading-only`content` 仅由标题行构成,由 `^#{1,6} +` 判定)时,**仅向前粘连**
- **触发条件**:紧邻的下一块层级**严格更深**(`level` 更大),且其 `table_chunk_role``none``first``first` 即「被切大表的首切片」——子节正文若是超大表格,TableRowSplit 切片后其首个产出块的角色为 `first`;紧跟 heading-only 行的只可能是下一行的首个产出块,故角色必为 `none``first``middle`/`last` 只在同一行表格内部出现)。
- **并入 `first` 切片时保留其角色**:把 `## 2.4` 并入 `first` 切片后,合并块**仍标记 `first`**`## 2.4` 标题正是表格的前置上下文,本就该由 `first` 切片承载)。这样 LevelMerge 不会把它向后吸回 `## 2.3``first` 不可被向后吸收),表格边界保护得以维持;`none` 子块的行为与现状完全一致。
- **动作**:向前并入该子块,保留**父标题**身份(`heading` / `level` / `parent_headings` 取自较浅父块)。即 `## 2.4``### 2.4.1` 绑成一块,标题路径仍以 `2.4` 为主——子块 2.4.1 的 `parent_headings` 本就含 2.4,层级信息无损。链式标题(`# 2``## 2.4``### 2.4.1`)沿链折叠、保留**最浅**身份,直到遇到首个含正文的子块。
- **不做向后粘连**:当下一块**不更深**(更浅/同级标题,或已到末尾)时,该 heading-only 块原样留给 LevelMerge。**不会**把它向后并入更深的前块(如 `### 2.3.9`)——把更浅的 `## 2.4` 标题吞进更深的 L3 块会倒置层级(深吞浅)、压低标题层级。这类孤立标题直接交给 LevelMerge 正常处理。
- **保住硬上限**:子块来自 AnchorSplit、本在 `target_max` 内,但前缀拼入父标题行后可能超限。由于下游无人会重切超限块(LevelMerge 只阻止其继续变大),绑定后超限的块在此重切:**先剥离开头的标题行**,正文按**完整 `target_max`** 切分(使后续不含前缀的正文片保持完整预算),再把标题前缀拼回**第一个正文片**。仅当第一个正文片大到放不下前缀时,才单独对它用缩减后的上限再切——因此大前缀不会把整个子节切得过碎。这样标题始终随真实正文,绝不会被单独切成一个 heading-only 孤块(否则 LevelMerge 又会把它向后吸走),且每个产出片段仍 ≤ `target_max`。(退化情形:当前缀本身已吃满上限——极长标题或极小 `chunk_token_size`——无法保持完整,则整块直接切分、对超长标题行做字符级切分;此时 cap 优先于保持标题完整。)
- **不额外回填前块**:由于 `keep="left"` 保留父块的 `level`,绑定后的整体只是个普通小块(并非锁定独立)。是否并回前块 `2.3` 完全沿用 LevelMerge 既有规则——前块仍 < `target_ideal` 时走同级合并,或整体小于 `small_tail_threshold` 时走尾部吸收(即便前块已饱和也能被吸入),二者都以重测后的真实 token ≤ `target_max` 为界。本前置步骤只保证标题**不脱离其子内容**,并不把整体锁为独立块——因此在尺寸允许时让 `2.3 + 2.4 + 2.4.1` 同块正是期望的防过碎行为。
> 边界歧义:正文行若真以 `#␠` 开头会被误判为标题行——这是 `lightrag/parser/_markdown.py` 已记录并接受的同一启发式歧义,实际语料中概率极低。
### 3.6 层级感知合并——细碎条款聚到理想大小、不跨主题污染 〔LevelMerge
**规则**:**自深层级向浅层级处理**,先合并同级小块(Phase A),再尾部整批吸收,最后允许浅层块吸收深层块(Phase B);每次合并都要同时满足尺寸、表格角色、层级、父标题路径四类约束。
**达到的效果**:大量 100~300 token 的细碎条款被合并到接近 `target_ideal` 的尺寸(块不再过短、语义稀薄),同时**绝不把分属不同主题/不同父章节的相邻小块揉在一起**——既治「块过碎」,又防「跨主题污染」。
#### 3.6.1 合并约束(每次合并都要满足)
1. **尺寸约束**:合并后的真实文本 token 数不超过 `target_max`;已达到 `target_ideal` 的块原则上不继续参与普通同级合并。
2. **角色约束(切片冻结)**:被拆表的所有切片 `first` / `middle` / `last` 一律**锁定独立、不参与任何合并**(既不向后吸收、也不被前块吸收、也不进入尾部整批吸收)。原因:表头已在 TableRowSplit 切分时注入各切片,若把同一张表的两个切片重新合并会在表中重复一次表头(§3.3.3)。表格边界的前后说明已在切分阶段粘进首/末切片,冻结不影响上下文粘连,只放弃小的首/末切片块与无关邻块的事后整合。仅 `none`(普通块/未拆分的完整表)可参与合并。
3. **层级约束**:同级合并在相同 `level` 之间发生;跨级吸收只允许浅层吸收深层,**禁止深层反向吸收浅层**。
4. **父标题路径一致性约束**:避免跨主题污染的关键,按合并方向取严格语义——
- **同级合并(Phase A / 尾部吸收)**:两块 `parent_headings` 必须**完全相等**(真·兄弟)。仅 `level` 相同但父链不同(如 `2.4.1``2.5.1`)不允许合并。
- **跨级吸收(Phase B,浅吸深)**:深块必须是浅块的**后代**——浅块的完整标题路径(`parent_headings` + 自身 `heading`,已剥离 `[part n]`)是深块 `parent_headings` 的前缀。浅块吞并不同分支的深块被禁止。
- `parent_headings` 为空(preamble / 无层级输入)的块视为路径相容,放行(无层级可污染)。
#### 3.6.2 Phase A:同级合并
针对当前 level 的相邻块,当**当前块**低于 `target_ideal`、合并后真实 token ≤ `target_max`,且满足上述约束时,合并为一个块(被吸收的邻块不要求低于 `target_ideal`;反向合并时另要求前块也 < `target_ideal`)。
表格切片角色的方向规则(被拆表切片全部冻结,仅 `none` 可合并):
| 块角色 | 可向后吸收下一块 | 可被前一块吸收 |
|---|:-:|:-:|
| `none` | 是 | 是 |
| `first` | 否 | 否 |
| `middle` | 否 | 否 |
| `last` | 否 | 否 |
#### 3.6.3 尾部整批吸收
若一个**普通块(`none`**且已达到 `target_ideal` 的块后面紧跟一串同级小块,且该串小块总 token 数低于 `small_tail_threshold`、合并后真实 token 数不超过 `target_max`,则**一次性吸收**该串小块。遇到**任何被拆表切片**(`first` / `middle` / `last`),或父标题路径发生分叉时停止;被拆表切片自身也不会发起尾部吸收。
#### 3.6.4 Phase B:跨级吸收
对于 Phase A 后仍未饱和的小块,尝试跨级合并,但仅允许浅层吸收深层:
- 当前块比后一块更浅时,当前块可向后吸收后一块。
- 当前块比前一块更深时,前一浅层块可吸收当前块。
- 反方向合并被禁止。
- 被拆表切片(`first` / `middle` / `last`)在跨级阶段同样冻结,不参与合并;仅 `none` 块参与跨级吸收。
#### 3.6.5 合并后真实 token 复测
由于合并时会插入换行连接符,逐块 token 数相加可能低估合并结果。**每次提交合并前,都要对拼接后的真实文本重新计算 token 数**,确认不超过 `target_max` 后再提交。
合并后保留主块的 `heading`。如果多个 part 片段被合并,最终 heading 保留主块的 part 后缀,**不会**额外拼接多个 part 标签。
### 3.7 重叠规则汇总——哪里重叠、哪里绝不
**规则 + 效果**:P 策略对「文字复制(overlap)」有精确的边界划分,既保证召回上下文充分,又杜绝跨章节/跨表格的“张冠李戴”。把散落在各阶段的重叠行为集中如下:
| 场景 | 是否重叠 | 预算 / 机制 | 服务的效果 |
|---|---|---|---|
| 不同 `.blocks.jsonl` 内容行(章节边界) | **绝不重叠** | —— | 章节边界清晰,不张冠李戴 |
| 同一内容行内长正文 fallback 到 R | 可重叠 | `chunk_overlap_token_size` | 长正文中途切断处保持语义连续 |
| 连续大表之间的桥接文字 | 双向重叠 | 两侧各 `min(overlap, …, target_max/2)` | 桥接说明同时作为左右两表的上下文 |
| 桥接长文本的独立中间段块 | 与左右各重叠 | `chunk_overlap_token_size`(夹在桥接 token 内,绝不含 `<table>`) | 中间段与相邻表格块阅读连续 |
| 表格行级切片之间 | **绝不重叠** | —— | 行切片非重叠,避免重复行 |
### 3.8 尺寸阈值协同——大多数块落在 [ideal, max]
**规则**P 策略的阈值不是固定常量,而是按 `chunk_token_size`(记为 N)动态推导,多个阈值协同控制文本块与表格切片的大小。
**达到的效果**:理想分布下,大多数 chunk 落在 `[target_ideal, target_max]` 区间(N=2000 时约 1500~2000 token);明显偏小的块通常只是锁定独立的 `middle` 表格切片或章节边界尾块。
| 名称 | 计算式 | N = 2000 时取值 | 技术含义 |
|---|---|---:|---|
| `target_max` | N | 2000 | 文本块硬上限 |
| `target_ideal` | 0.75 × N | 1500 | 文本块理想目标,达到此值后停止参与普通同级合并 |
| `table_max` | 0.625 × N | 1250 | 表格触发切片阈值 |
| `table_ideal` | 0.375 × N | 750 | 表格切片理想大小 |
| `table_min_last` | 0.32 × `table_max` | 400 | 表格末片回吞阈值(小于此值且能合并则回吞至前一切片) |
| `small_tail_threshold` | 0.125 × N | 250 | 尾部碎块吸收阈值 |
| `max_anchor_candidate_length` | 固定 | 100 字符 | 长块拆分锚点候选段落长度上限 |
比例约束关系:`table_max < target_ideal < target_max``table_ideal < table_max`。这些比例源自审计模式经验值(`大块 8000、小表 5000、理想表 3000、表格尾块 1600`),现按 `chunk_token_size` 等比缩放。
## 4. 输入与输出
### 4.1 输入
`chunking_by_paragraph_semantic()` 接收以下输入:
| 参数 | 来源 | 说明 |
|---|---|---|
| `content` | `full_docs[doc_id].content` | 拼接后的合并文本,用于 sidecar 缺失时降级 |
| `blocks_path` | `full_docs[doc_id].lightrag_document_path` | `.blocks.jsonl` 路径,是 P 策略的主输入 |
| `.tables.json`(隐式) | 由 `blocks_path` 推导(`<base>.blocks.jsonl``<base>.tables.json` | HeaderRecovery(§3.3.3)的表头数据源;缺失时静默跳过表头注入 |
| `chunk_token_size` | `chunk_options.chunk_token_size` / `CHUNK_P_SIZE` | 目标硬上限 N,默认 `2000` |
| `chunk_overlap_token_size` | `CHUNK_P_OVERLAP_SIZE` / `chunk_overlap_token_size` | 同一内容行内长正文 fallback 与表格桥接预算的上限,默认 `100` |
| `drop_references` | hint `drop_references`(别名 `drop_rf`/ `CHUNK_P_DROP_REFERENCES` | 是否在分块前丢弃文末参考文献块,默认 `False`**入队冻结进 `chunk_options`,并记录到 `doc_status.metadata['chunk_opts']`**(开启时记为 `drop_rf=True` |
| `references_tail_n` | `CHUNK_P_REFERENCES_TAIL_N` | 参考文献块只在文末最后 N 个内容块内才被丢弃(安全窗口),默认 `2`;**运行时实时读 env,不快照、不进 metadata** |
| `references_headings` | `CHUNK_P_REFERENCES_HEADINGS`(竖线分隔) | 参考文献标题前缀,默认 `References\|Bibliography\|参考文献`;英文按单词边界、大小写不敏感匹配,`参考文献` 按前缀匹配;**运行时实时读 env,不快照、不进 metadata** |
| `tokenizer` | LightRAG 已解析好的 tokenizer | 所有 token 计数与文本 overlap 截取的基准 |
P 策略**不接收** `split_by_character` / `split_by_character_only`,因为正常路径由标题和段落结构驱动。
**丢弃参考文献(`drop_references`**:开启后,在 HeadingBlocks 之后、TableRowSplit/AnchorSplit/LevelMerge 之前,对从 `blocks.jsonl` 读出的有序内容块做过滤——**同时满足**「位于最后 `references_tail_n` 个块」且「`heading` 命中参考文献前缀」的块被丢弃。只有开关 `drop_references` 可经 per-file hint 设定并冻结进快照/metadata`references_tail_n` / `references_headings` 是纯 env 调参,由 chunker 每次运行时实时读取当前环境变量——改 env 即可即时影响已入队文档的重跑。若丢弃后没有任何含内容的块剩余,则放弃丢弃并告警,避免产出空文档。
### 4.2 `.blocks.jsonl` 约定
P 策略只处理 `type == "content"` 行。每个内容行通常包含:
- `content`:该标题下的正文文本,可能包含普通段落、`<table ... />` 标签、`<equation ... />` 公式、`<drawing ... />` 图形。
- `heading`:当前标题。
- `parent_headings`:父级标题链。
- `level`:标题级别(1~9,对应原始 outline level 0~8)。
- `positions`:原始段落定位(用于追溯)。
- `blockid`:该内容行的稳定标识(可选)。存在时会被带入最终 chunk 的 `sidecar` 字段,供多模态管线与文档删除按源 block 回溯;缺失时(raw / legacy 输入)输出不含 `sidecar`
parser 保证「一条标题下的正文作为一个基础块」(native 经按标题的结构化切分,mineru / docling 经各自 IR builder),不在解析阶段做 token 阈值拆分。表格保持完整插入到 `content` 中。
### 4.3 输出
最终输出为有序 chunk 列表,每个元素:
```python
{
"tokens": int, # 真实 token 数(合并后会复测)
"content": str, # 块文本(可能包含 <table> 标签)
"chunk_order_index": int, # 块顺序索引
"heading": { # 标题元数据(嵌套 dict,非扁平字段)
"level": int, # 标题层级
"heading": str, # 拆分后追加 [part n] 后缀
"parent_headings": list[str], # 父级标题链,不追加后缀
},
# 可选:仅当输入 .blocks.jsonl 行带 blockid 时出现,
# 供多模态管线与文档删除按源 block 回溯。
"sidecar": {
"type": "block",
"id": str, # 主块 blockidrefs[0]
"refs": [{"type": "block", "id": str}, ...], # 去重后的全部源 blockid
},
}
```
注意:`level``parent_headings` 现已收进 `heading` 嵌套 dict,顶层不再单独提供;`[part n]` 后缀落在 `heading["heading"]` 上。
实现内部还会临时使用 `paragraphs``content``table_chunk_role``blockids` 等字段辅助拆分和合并,但**不会**以这些名字进入最终输出(`blockids` 经转换后体现为 `sidecar`)。
### 4.4 `[part n]` 后缀规则
- 同一个原始 `.blocks.jsonl` 内容行被拆成多个片段时,所有片段的 `heading` 字段追加 `[part 1]``[part 2]`
- 未发生拆分的内容行保持原 heading 不变。
- `parent_headings` 不追加后缀。
- 编号在每个原始内容行内**独立重置**(因 PartLabeling 在跨行合并之前编号,见 §2.2)。
- 旧的 `[表格片段N]` 后缀已统一由 `[part n]` 替代。
## 5. 配置项
| 配置 | 默认 | 说明 |
|---|---|---|
| `CHUNK_P_SIZE` | `2000`(未设时使用 `DEFAULT_CHUNK_P_SIZE`**不**沿用 `CHUNK_SIZE` | P 专用 `chunk_token_size`;段落语义合并需要比全局默认更大的上限,因此独立默认而非回退到 `CHUNK_SIZE` |
| `CHUNK_P_OVERLAP_SIZE` | 未设(沿用 `CHUNK_OVERLAP_SIZE` | P 专用 overlap;只影响同一内容行内长正文 fallback 和表格桥接预算,**不**让表格行级切片互相重叠 |
| `CHUNK_OVERLAP_SIZE` / `LightRAG(chunk_overlap_token_size=…)` | `100` | 未设 P 专用 overlap 时的全局兜底 |
配置语法、优先级链、`addon_params["chunker"]` 运行时改值等详见 [FileProcessingConfiguration-zh.md](FileProcessingConfiguration-zh.md) §3。
`P` 是与引擎正交的 chunking 选项(`后缀:引擎-选项`),可与任何产出 sidecar 的引擎组合。启用 P 的典型 `LIGHTRAG_PARSER` 写法:
```bash
# docx 用 nativepdf 用 mineru,其余支持格式用 docling,都启用 P;不支持的格式回退 legacy-R
LIGHTRAG_PARSER=docx:native-teP,pdf:mineru-iteP,*:docling-iteP,*:legacy-R
CHUNK_P_SIZE=2000
CHUNK_P_OVERLAP_SIZE=100
```
(选项位 `i`/`t`/`e` 分别为图/表/公式分析,`P` 为 chunking 策略,可按需组合。)或在单文件覆盖:
```text
my-proposal.[native-P].docx
paper.[mineru-P].pdf
```
## 6. 降级保护——永不丢内容
**规则 + 效果**:P 策略有多层降级保护,任何结构化能力失效时都退到字符级切分,**保证文档仍产生检索块,不因结构化 sidecar 缺失而被静默丢弃**。
| 触发条件 | 降级行为 |
|---|---|
| `blocks_path` 缺失、不可读、无有效 content 行 | 整体降级到 `chunking_by_recursive_character()`,传入解析出的 `chunk_overlap_token_size` |
| TableRowSplit 中表格无法识别 JSON / HTML 结构 | 该表格调用 R 策略字符切分 |
| TableRowSplit 中单行无法在 `target_max` 内与表头并存(单行内容超上限,或加表头后超上限) | **整张表(含表头)退化为 R 策略字符切分,并打 `logger.warning`**;表头内容随原表文本以纯文本保留 |
| AnchorSplit 中长块没有合格短段落锚点 | 表格优先 → 贪心打包 → 单段落超长再降级 R 字符切分 |
| HeaderRecovery 时 `.tables.json` 缺失/不可读、源表无 `table_header` | 跳过表头注入(该表本就无重复表头,不影响其余分块) |
**重要**:整体 fallback 后不再具备标题层级、表格角色和桥接文字双向重叠能力;但能保证文档仍产生检索块。
## 7. 效果检验与调试
### 7.1 检查 sidecar 是否生成
确认 parser 是否成功产生 `.blocks.jsonl`
```bash
ls -l INPUT/__parsed__/<doc>.<ext>.parsed/<doc>.blocks.jsonl
```
若文件不存在或为空,P 策略会整体降级为 R,不会获得 P 的任何收益。常见原因:
- 未给该格式配置能产出 sidecar 的引擎(如 `LIGHTRAG_PARSER=docx:native-...` / `pdf:mineru-...` / `*:docling-...`),实际走了 `legacy` 路径。
- 解析失败(看 `pipeline_status` 错误条目)。
- 该格式不被所选引擎支持(如 native 仅支持 docx;换用 mineru / docling 覆盖更多格式)。
### 7.2 检查 blocks.jsonl 内容
每行一个 JSON,过滤 `type == "content"` 后查看 heading / level / parent_headings 是否符合预期:
```bash
jq -c 'select(.type=="content") | {level, heading, parent_headings}' \
INPUT/__parsed__/<doc>.<ext>.parsed/<doc>.blocks.jsonl | head
```
若 heading 大量为空或 level 异常,说明 parser 没正确识别标题 —— 此时 P 策略的层级合并和锚点提升都会失效。
### 7.3 检查最终 chunks 是否达到预期效果
查看 `text_chunks` 存储中的 chunk 元数据:
```bash
jq '.[] | {heading, level, tokens, parent_headings}' \
rag_storage/kv_store_text_chunks.json | head -30
```
应观察到以下「规则生效」的迹象:
- 大表前后块的 heading 通常对应 `[part 1]` / `[part n]`(§3.2 表格切片发生)。
- 细碎条款被合并到接近 `target_ideal` 的块(§3.6 层级合并生效)。
- `parent_headings` 在不同章节切换处发生跳变,同章节内保持稳定(§3.1 / §3.6 父路径约束)。
- 大多数 chunk 落在 `[target_ideal, target_max]` 区间(§3.8);明显偏小的块通常是 `middle` 表格切片(锁定独立)或紧靠章节边界的尾块。
若出现大量低于 `small_tail_threshold` 的尾块,可能是:
- 父标题路径一致性约束过严(不同 `parent_headings` 的相邻小块无法合并,§3.6.1)。
- 大量 `middle` 表格切片堆积(表格本身就很大)。
### 7.4 常见问题排查
#### 7.4.1 P 没生效,输出与 R 一致
按以下顺序排查:
1. `full_docs[doc_id].process_options` 是否包含 `P`
2. `full_docs[doc_id].parse_format` 是否为 `lightrag`?若为 `raw`,说明走的是 legacy 路径,P 会自动降级到 R。
3. `lightrag_document_path` 指向的 `.blocks.jsonl` 是否存在、是否非空?
4. 日志中是否有 `paragraph_semantic ... fallback to recursive_character` 字样?
#### 7.4.2 表格被切散、前后说明分离(§3.2 / §3.3 未生效)
- 检查表格是否真的被识别为 `<table format="json">``<table format="html">`(看 `.blocks.jsonl`)。未识别格式的表格只能走字符切分,无法启动 TableRowSplit 的角色机制。
- 检查表格 token 数是否真的超过 `table_max`。低于阈值的表格保持完整,不会触发首/中/末切片。
- 若是连续大表,确认两张表之间的桥接文字是否在**同一 content 行**内 —— 跨 content 行的桥接不参与 B.1 双向重叠。
#### 7.4.3 细碎条款没有被合并(§3.6 未生效)
- 检查相邻条款的 `parent_headings` 是否一致:父标题路径一致性约束会阻止跨主题合并。
- 检查 `level` 是否一致:同级合并要求相同 `level`,跨级吸收只允许浅吸深。
- 检查中间是否插入了 `middle` 表格切片:会阻断尾部整批吸收。
#### 7.4.4 出现单个超过 `target_max` 的块
正常情况下 LevelMerge 的真实 token 复测会拒绝超限合并,但以下场景仍可能出现超限块:
- 单行表格自身超过 `target_max`,无锚点可拆,最终走 R 字符切分但单 chunk 仍超限。
- `enforce_chunk_token_limit_before_embedding` 在 embedding 前会做最后的硬切分,下游不会真把超限 chunk 嵌入向量库。
#### 7.4.5 `[part n]` 后缀异常(§3.4 / §4.4
- 同一原始 content 行拆出多片但只看到一个 `[part 1]`:检查是否在 LevelMerge 中被合并 —— 合并后保留主块的 part 后缀,不拼接多个。
- 出现旧式 `[表格片段N]` 后缀:说明使用了旧版 chunker 输出的数据,新版统一为 `[part n]`,需要重新分块。
### 7.5 日志关键字
P 策略相关日志关键字(用于 `grep` 排查):
- `paragraph_semantic` — 模块入口
- `fallback to recursive_character` — 整体或单段落降级
- `table_chunk_role` — 表格角色相关(§3.3
- `bridge` — TableBridge 桥接文字处理(§3.3.2
- `table_header` / `tables.json` — HeaderRecovery 表头恢复(§3.3.3
- `anchor` — AnchorSplit 锚点选择(§3.4
### 7.6 阶段名 ↔ 规则对照
代码注释、docstring、日志与测试中使用下列**阶段名**作为交叉引用标识。「曾用名」列给出旧版字母编号(仍可能出现在历史 commit / issue / PR 讨论中):
| 阶段名 | 曾用名 | 对应规则 | 章节 |
|---|---|---|---|
| `HeadingBlocks` | Stage A | 标题级基础块 | §3.1 |
| `TableRowSplit` | Stage B | 表格完整性与行边界切片 | §3.2 |
| `HeaderRecovery` | Stage B.2 | 切分时为中段/末段切片补回表头 | §3.3.3 |
| `TableBridge` | Stage B.1 | 连续大表桥接文字双向重叠 | §3.3.2 |
| `AnchorSplit` | Stage C | 锚点驱动的长块再切分 | §3.4 |
| `PartLabeling` | Stage C.1 | `[part n]` 行级来源追溯编号 | §4.4 |
| `HeadingGlue` | Stage D 前置 | 无正文标题粘连 | §3.5 |
| `LevelMerge` | Stage D | 层级感知的双相位合并 | §3.6 |