fix(parser): PDF/DOCX 切片层级结构修复与 MinerU V2 解析增强

核心修复:
- _post_process_chunks: 用 _buffer_has_body 标志替代 buffer.text_level=0,
  标题+正文合并后保留 text_level 不置零,修复层级信息丢失
- heading_rules numeric_level2: 正则从 ^\d+\.\d+[\.、\s] 改为
  ^\d+\.\d+(?!\.\d),修复无空格标题如"2.1运行调度"无法匹配
- V2 title handler: heading_rules 优先(模式匹配可靠),VLM 仅兜底
  (VLM 常给所有标题 level=1)

MinerU V2 解析增强:
- 两轮 TOC 过滤:多行目录块检测 + 孤立标题/单字符残留清理
- 封面 logo 过滤、重复标题去重
- chart VLM 描述和 Markdown 数据表提取
- ChromaDB metadata 新增 text_level/bbox/table_type/sub_type 字段

配置调整:
- CLUSTER_SECTION_PREFIX_LEVELS 1→2(两级 section 聚类更精确)
- MINERU_LOCAL_BACKEND 默认改为 pipeline

文档:
- RAG检索流程逻辑.md 更新 MinerUChunk 字段、ChromaDB metadata、
  MinerU 解析策略等章节
- 新增 RAG引用跳转-优化计划.md、云端MinerU输出分析与优化方案.md
This commit is contained in:
lacerate551
2026-06-19 23:56:13 +08:00
parent e205eda6a8
commit ee5295cc97
7 changed files with 1193 additions and 33 deletions

View File

@@ -225,7 +225,7 @@ MINERU_PREFER_ONLINE = os.getenv("MINERU_PREFER_ONLINE", "true").lower() == "tru
# MinerU 解析模式(本地 + 云端统一配置)
MINERU_MODEL_VERSION = os.getenv("MINERU_MODEL_VERSION", "vlm") # 云端解析模型: pipeline(快速推荐) / vlm(高精度慢) / MinerU-HTML
MINERU_LOCAL_BACKEND = os.getenv("MINERU_LOCAL_BACKEND", "vlm-auto-engine") # 本地解析后端: pipeline(快速推荐) / vlm-auto-engine / hybrid-auto-engine
MINERU_LOCAL_BACKEND = os.getenv("MINERU_LOCAL_BACKEND", "pipeline") # 本地解析后端: pipeline(快速备选) / vlm-auto-engine / hybrid-auto-engine
MINERU_ONLINE_TIMEOUT = int(os.getenv("MINERU_ONLINE_TIMEOUT", "300")) # 云端解析轮询超时(秒),大文档/VLM 模式建议 600+
# 分块参数
@@ -269,7 +269,7 @@ CLUSTER_RESCUE_FLOOR = 0.06 # 路由层救援保底分数(略高于
CLUSTER_MAX_BOOST_PER_SECTION = 8 # 引擎层:每个 section 最大提升切片数
CLUSTER_MAX_SECTIONS = 3 # 全局最大提升/救援 section 数
CLUSTER_MAX_RESCUE_PER_SECTION = 6 # 路由层:每个 section 最大救援切片数
CLUSTER_SECTION_PREFIX_LEVELS = 1 # section_path 归一化保留的层级数(按章节顶层分组
CLUSTER_SECTION_PREFIX_LEVELS = 2 # section_path 归一化保留的层级数(按章节前两级分组,提升聚类精确度
# ==============================================================================
# 十一、可选功能配置

View File

@@ -0,0 +1,303 @@
## RAG 引用溯源跳转 — 优化计划
本文档基于 dev-ui 前端Vue3 + NaiveUI和 chat_routes.py 后端的现有实现,分析引用跳转功能的优化方向。供讨论后实施。
---
### 一、现有实现现状
#### 1.1 数据流
```
SSE finish 事件 → citations[] 数组_build_citation 构建)
Chat.vue extractCitations() → 解析 [ref:chunk_id] → 渲染为 <sup>[N]</sup>
用户点击引用项 → jumpToChunk(citation)
打开 1200px 分屏弹窗:
├─ 左侧PdfViewer / DocxViewer / TextViewer按 doc_type 选择)
└─ 右侧:切片上下文列表(/documents/preview API 返回)
弹窗内切换切片 → navigateToChunk(chunk) → 更新 viewer 响应式 prop
```
#### 1.2 各组件当前能力
| 组件 | 文件 | 当前能力 | 局限 |
|------|------|---------|------|
| **PdfViewer** | `viewers/PdfViewer.vue` | pdfjs-dist 渲染,`initialPage` prop 页码跳转watch 响应外部页码变化 | 仅页级跳转,无页内 bbox 高亮;单页渲染(非连续滚动) |
| **DocxViewer** | `viewers/DocxViewer.vue` | mammoth 转 HTML2-gram Jaccard 段落匹配,`<mark>` 高亮 + scrollIntoView | 无法精确定位到文本范围(仅段落级);大文档性能未测试 |
| **Chat.vue** | `views/Chat.vue` | jumpToChunk 调用 preview API 获取完整 chunk text 替代 300 字截断 | preview API 失败时回退到截断文本,匹配精度下降 |
| **_build_citation** | `api/chat_routes.py:1147` | 按 doc_type 构建定位信息PDF 含 bbox/pageWord 含 section_chunk_id | bbox 已返回但前端未使用content 截断 300 字 |
#### 1.3 后端已提供但前端未消费的字段
| 字段 | 后端来源 | 当前状态 |
|------|---------|---------|
| `bbox` | ChromaDB metadata → `_build_citation` 解析 JSON → 返回数组 | PdfViewer 未接收,未渲染高亮 |
| `sub_type` | ChromaDB metadataMinerU V2 解析) | `_build_citation` 未返回此字段 |
| `table_type` | ChromaDB metadataMinerU V2 解析) | `_build_citation` 未返回此字段 |
| `vlm_description` | MinerUChunkVLM 视觉描述) | 未进入 citation 数据 |
| `chart_markdown` | MinerUChunk图表数据表 | 未进入 citation 数据 |
---
### 二、优化项清单
#### Phase 1PDF 页内 bbox 高亮(核心优化)
**目标**:在 PDF 页码跳转的基础上,叠加半透明矩形标注引用区域。
**涉及文件**
- `dev-ui/src/components/viewers/PdfViewer.vue`(主要改动)
- `dev-ui/src/views/Chat.vue`(传递 bbox prop
- `api/chat_routes.py`无需改动bbox 已返回)
**PdfViewer.vue 改动方案**
1. 新增 `highlightBbox` prop
```js
const props = defineProps({
url: { type: String, required: true },
initialPage: { type: Number, default: 1 },
highlightBbox: { type: Array, default: null } // [x0, y0, x1, y1] 或 null
})
```
2.`renderPage()``page.render()` 完成后叠加绘制高亮矩形:
```js
async function renderPage() {
// ... 现有的 canvas 渲染逻辑 ...
await page.render({ canvasContext: ctx, viewport }).promise
// bbox 高亮叠加
if (props.highlightBbox && currentPage.value === props.initialPage) {
drawBboxHighlight(ctx, viewport, props.highlightBbox)
}
}
```
3. 新增 `drawBboxHighlight()` 函数:
```js
function drawBboxHighlight(ctx, viewport, bbox) {
// MinerU bbox 坐标系PDF 原始坐标,原点左下角
// PDF.js viewport原点左上角已通过 getViewport 转换
const [x0, y0, x1, y1] = bbox
// 方式1直接传 viewport 转换(如果 bbox 是 PDF 用户空间坐标)
const topLeft = viewport.convertToViewportPoint(x0, y1) // 注意 y 翻转
const bottomRight = viewport.convertToViewportPoint(x1, y0)
const w = bottomRight[0] - topLeft[0]
const h = bottomRight[1] - topLeft[1]
// 半透明填充
ctx.fillStyle = 'rgba(99, 102, 241, 0.2)'
ctx.fillRect(topLeft[0], topLeft[1], w, h)
// 边框
ctx.strokeStyle = 'rgba(99, 102, 241, 0.6)'
ctx.lineWidth = 2
ctx.strokeRect(topLeft[0], topLeft[1], w, h)
}
```
4. watch `highlightBbox` 变化时重新渲染(切换切片时):
```js
watch(() => props.highlightBbox, () => {
if (pdfDoc) renderPage()
})
```
**Chat.vue 改动方案**
1. 新增响应式变量 `citationBbox`
2. `jumpToChunk()` 中从 citation 取 bbox`citationBbox.value = citation.bbox || null`
3. `navigateToChunk()` 中从 chunk metadata 取 bbox`citationBbox.value = chunk.metadata?.bbox ? JSON.parse(chunk.metadata.bbox) : null`
4. 模板中传递给 PdfViewer`<PdfViewer :highlight-bbox="citationBbox" ...>`
**风险点**
- MinerU 的 bbox 坐标系需确认。PDF 标准坐标系原点左下角、y 向上PDF.js viewport 默认原点左上角、y 向下。`viewport.convertToViewportPoint` 应能处理转换,但需实际测试坐标是否正确。
- 部分切片可能没有 bboxDOCX 来源、或 MinerU 解析遗漏),需做空值保护。
- bbox 可能是页面坐标系0-1 归一化)或绝对像素坐标,需确认 MinerU 输出格式。
---
#### Phase 2后端 citation 数据增强
**目标**:让前端获得更多结构化信息,支持差异化渲染和更精准的定位。
**涉及文件**
- `api/chat_routes.py``_build_citation()` 函数
**改动方案**
`_build_citation()` 的通用字段部分新增:
```python
citation = {
# ... 现有字段 ...
# 新增MinerU 结构化元数据
"sub_type": meta.get('sub_type', ''), # 图片/图表子类型
"table_type": meta.get('table_type', ''), # 表格类型
"chunk_type": meta.get('chunk_type', 'text'), # 已有,但确保传递
}
```
在 PDF 分支中新增 bbox 的 page 关联信息(当前已有 bbox 和 page无需额外改动
在 Word 分支中新增更长的 content 截断:
```python
elif doc_type == 'word':
citation.update({
"section_chunk_id": meta.get('section_chunk_id'),
"content": (full_content or meta.get('preview', ''))[:500], # 300→500提升 DOCX 匹配精度
})
```
**收益**
- `sub_type` 可让前端在引用列表中区分图表类型bar/line/natural_image
- `table_type` 可让前端判断是否用表格样式展示
- Word content 截断从 300→500 字,减少 preview API 依赖,提升 fallback 场景下的匹配精度
---
#### Phase 3引用列表差异化展示
**目标**:在消息底部的引用列表中,根据 chunk_type 和 sub_type 显示类型图标/标签,提升可读性。
**涉及文件**
- `dev-ui/src/views/Chat.vue`(模板和 CSS
**改动方案**
在引用列表项(`.citation-item`)中增加类型标签:
```html
<div v-for="c in group.items" :key="c.displayIndex" class="citation-item clickable" @click="jumpToChunk(c)">
<span class="citation-num">[{{ c.displayIndex }}]</span>
<!-- 新增:类型标签 -->
<n-tag v-if="c.chunk_type === 'table'" size="tiny" :bordered="false" type="warning">表格</n-tag>
<n-tag v-else-if="c.chunk_type === 'image' || c.chunk_type === 'chart'" size="tiny" :bordered="false" type="info">
{{ c.sub_type || '图片' }}
</n-tag>
<span v-if="c.page" class="citation-location">第{{ c.page }}页</span>
<span v-if="c.section" class="citation-section">{{ c.section }}</span>
</div>
```
**效果**:用户可以一眼看出引用的是文本、表格还是图表,点击前有预期。
---
#### Phase 4弹窗内图表/表格的增强展示
**目标**:在引用预览弹窗的右侧切片面板中,对表格和图片/图表类型的切片提供差异化展示。
**涉及文件**
- `dev-ui/src/views/Chat.vue`(切片面板模板)
- 可选:`dev-ui/src/components/viewers/` 下新增或复用 viewer
**改动方案**
1. 表格切片(`chunk_type === 'table'`):如果切片 metadata 中有 `table_html`,渲染为 HTML 表格而非纯文本
2. 图片/图表切片(`chunk_type === 'image' || 'chart'`):显示 `image_path` 对应的图片缩略图
3. 图表数据预览:如果 `chart_markdown` 可用,在图表切片下方展示 Markdown 数据表
```html
<div class="citation-chunk" :class="{ target: chunk.is_target }">
<div class="chunk-header">
<!-- 现有 header 内容 -->
<n-tag v-if="chunk.metadata?.chunk_type === 'table'" size="tiny" type="warning">表格</n-tag>
<n-tag v-if="chunk.metadata?.sub_type" size="tiny" type="info">{{ chunk.metadata.sub_type }}</n-tag>
</div>
<!-- 表格:尝试渲染 HTML -->
<div v-if="chunk.metadata?.chunk_type === 'table' && chunk.metadata?.table_html"
class="chunk-table" v-html="chunk.metadata.table_html" />
<!-- 图片/图表:显示缩略图 -->
<div v-else-if="['image','chart'].includes(chunk.metadata?.chunk_type) && chunk.metadata?.image_path"
class="chunk-image">
<img :src="`/api/documents/${citationPreviewSource}/image/${chunk.metadata.image_path}`" />
</div>
<!-- 默认:纯文本 -->
<div v-else class="chunk-text">{{ chunk.document || chunk.content }}</div>
</div>
```
**注意**preview API 返回的 chunk metadata 中是否包含 `table_html``image_path` 等字段,需确认后端 `/documents/<path>/preview` 接口是否透传了这些字段。如果未透传,需要后端配合补充。
---
#### Phase 5DOCX 多段落高亮(可选)
**目标**:当一个切片内容跨多个段落时,高亮所有匹配段落而非仅最佳匹配的一个。
**涉及文件**
- `dev-ui/src/components/viewers/DocxViewer.vue`
**改动方案**
修改 `highlightAndScroll()` 中的选择逻辑,从"选最佳单个"改为"选所有超过阈值的"
```js
// 当前:只保留 bestEl
if (score > bestScore) { bestScore = score; bestEl = block }
// 优化:收集所有超过阈值的匹配
const SCORE_THRESHOLD = 30
const matches = []
// ... 遍历后 ...
for (const block of blocks) {
// ... 计算 score ...
if (score >= SCORE_THRESHOLD) {
matches.push({ el: block, score })
}
}
// 按 score 降序,高亮所有匹配
matches.sort((a, b) => b.score - a.score)
matches.forEach((m, i) => {
m.el.classList.add('citation-highlight-block')
if (i === 0) {
setTimeout(() => m.el.scrollIntoView({ behavior: 'smooth', block: 'center' }), 100)
}
})
```
**风险**:可能高亮过多不相关段落(特别是短文本切片匹配到多处相似内容时)。需配合阈值调优。
---
### 三、实施优先级建议
| 优先级 | 优化项 | 改动范围 | 预估工作量 | 收益 |
|--------|--------|---------|-----------|------|
| **P0** | Phase 1PDF bbox 高亮 | PdfViewer.vue + Chat.vue | 中2-3小时 | 核心功能补齐PDF 引用精确定位 |
| **P1** | Phase 2后端 citation 增强 | chat_routes.py | 小30分钟 | 为后续前端优化提供数据基础 |
| **P2** | Phase 3引用列表差异化展示 | Chat.vue 模板 | 小30分钟 | 提升可读性,改动低风险 |
| **P3** | Phase 4弹窗内图表/表格增强 | Chat.vue 模板 + 后端 preview | 中1-2小时 | 丰富引用预览信息 |
| **P4** | Phase 5DOCX 多段落高亮 | DocxViewer.vue | 小1小时 | 可选优化,当前单段落高亮已够用 |
---
### 四、需确认的技术细节
1. **MinerU bbox 坐标系**:需确认是 PDF 用户空间坐标72 DPI原点左下角还是归一化坐标0-1还是像素坐标。这决定了 `viewport.convertToViewportPoint` 的调用方式。建议用一个已知 PDF 的 bbox 值做实验。
2. **preview API 的 metadata 透传范围**`document_routes.py` 的 preview 接口返回 chunk metadata 时,是否包含 `table_html``image_path``bbox` 等字段?如果 ChromaDB metadata 中已存储,理论上会自动包含,但需验证。
3. **PdfViewer 连续滚动 vs 单页模式**:当前是单页渲染(一次只显示一页),切换页码时重新渲染。如果要做跨页 bbox 高亮(切片跨两页),需要支持连续滚动模式或双页渲染。当前单页模式已能覆盖大多数场景。
4. **mammoth 对复杂排版的还原度**DOCX 转 HTML 时mammoth 对表格嵌套、图片嵌入、特殊字体的还原度有限。如果文档包含复杂排版,高亮匹配可能偏离实际位置。这是 mammoth 库的固有限制。
5. **SSE citations 数据量控制**:当前 content 截断 300 字。如果增加到 500 字Phase 2每次 RAG 回答的 SSE 数据量会增加。按平均 5-8 个引用计算,增量约 1-2KB可接受。
---
### 五、不在本次范围
以下为更远期优化方向,不纳入本轮实施:
- **全文档连续滚动预览**PdfViewer 改为渲染所有页面(类似浏览器内置 PDF 查看器),支持滚动到任意位置。需要 pdfjs-dist 的多页渲染支持,性能开销大。
- **DOCX 原文编辑**:在预览弹窗中支持对 DOCX 内容的标注/批注。需要 DOCX 编辑器集成,超出引用溯源范畴。
- **引用覆盖率统计**:回答中每个段落的引用覆盖情况可视化。需要前端渲染层深度改造。
- **跨文档对比视图**:多个引用来自不同文档时,左右分屏对比。当前单文档预览已满足需求。

View File

@@ -18,6 +18,8 @@
10. [返回格式与溯源信息](#十返回格式与溯源信息)
11. [配置项完整列表](#十一配置项完整列表)
12. [单/多知识库路径说明](#十二单多知识库路径说明)
13. [切片策略与检索策略兼容性分析](#十三切片策略与检索策略兼容性分析)
14. [MinerU 解析策略](#十四mineru-解析策略)
---
@@ -121,6 +123,13 @@ class MinerUChunk:
images: Optional[List[Dict]] = None # 关联图片: [{"id":"abc.jpg","order":1}]
context_before: str = "" # 图片前文本上下文
context_after: str = "" # 图片后文本上下文
# VLM 增强信息(云端 MinerU V4 VLM 后端提供)
vlm_description: str = "" # VLM 视觉描述(图片/图表)
chart_markdown: str = "" # VLM 提取的图表数据表Markdown 格式)
# MinerU 结构化元数据
table_type: str = "" # 表格类型cflow/table/text来自 V2 _v2_table_type
table_nest_level: str = "" # 表格嵌套层级(来自 V2 _v2_table_nest_level
sub_type: str = "" # 图片/图表子类型natural_image/table_image/bar/line 等)
```
### 2.3 section_path 生成逻辑
@@ -210,6 +219,11 @@ ChromaDB 使用 `collection.add(ids, documents, metadatas, embeddings)` 批量
| `version` | str | 默认 `"v1"` | 版本号,用于缓存失效 | `"v1"` |
| `images_json` | str | `json.dumps(chunk.images)` | 关联图片列表的 JSON 序列化,图片选择时反序列化 | `'[{"id":"abc.jpg","order":1}]'` |
| `image_path` | str | MinerU 解析 | 图片文件名(不含目录),用于图片 URL 生成 | `"0569dd285537.jpg"` |
| `text_level` | int | MinerU 解析 | 标题级别0=正文, 1=H1, 2=H2, 3=H3用于层次感知检索 | `2` |
| `bbox` | str | MinerU 解析PDF | 边界框 JSON 序列化 `[x0,y0,x1,y1]`,用于前端页内精准定位。仅 PDF 有值DOCX 为 None 不存储 | `"[100,200,500,600]"` |
| `table_type` | str | MinerU V2 解析 | 表格类型cflow/table/text用于区分表格渲染方式 | `"table"` |
| `table_nest_level` | str | MinerU V2 解析 | 表格嵌套层级,标识嵌套表格的深度 | `"2"` |
| `sub_type` | str | MinerU V2 解析 | 图片/图表子类型natural_image/table_image/bar/line/bar_line 等),用于前端差异化展示 | `"bar"` |
> **注意**`chunk_id` 与 `ids` 重复存储。`ids` 是 ChromaDB 的主键,`chunk_id` 存在 metadata 中便于路由层按 metadata 查找。两者值相同但用途不同。
@@ -730,7 +744,7 @@ LLM 生成回答后,用回答内容反向过滤图片:
| `CLUSTER_MAX_BOOST_PER_SECTION` | `8` | 引擎层每 section 最大提升数 | ✅ 活跃 |
| `CLUSTER_MAX_SECTIONS` | `3` | 全局最大提升 section 数 | ✅ 活跃 |
| `CLUSTER_MAX_RESCUE_PER_SECTION` | `6` | 路由层每 section 最大救援数 | ✅ 活跃 |
| `CLUSTER_SECTION_PREFIX_LEVELS` | `1` | section 归一化层级 | ✅ 活跃 |
| `CLUSTER_SECTION_PREFIX_LEVELS` | `2` | section 归一化层级(按章节前两级分组) | ✅ 活跃 |
### LLM 预算配置
@@ -772,3 +786,287 @@ LLM 生成回答后,用回答内容反向过滤图片:
-`knowledge/router.py` 使用(知识库路由推荐功能)
- **不走 engine 主流程**
- 返回 `SearchResult` dataclass扁平列表格式与 engine 的 ChromaDB 格式不兼容
---
## 十三、切片策略与检索策略兼容性分析
本节基于 `public_kb` 三份文档1.docx / 2.docx / 3.docx的实际切片数据分析切片策略与检索管线各环节的搭配情况识别已确认的失配点并提出优化方向。
### 13.1 public_kb 切片统计
| 指标 | 1.docx | 2.docx | 3.docx |
|------|--------|--------|--------|
| 总切片数 | 65 | 193 | 411 |
| 文本切片 | 60 | 144 | 385 |
| 表格切片 | 3 | 49 | 21 |
| 图片切片 | 2 | 0 | 5 |
| 纯标题切片(<20字 | 16 (27%) | 61 (42%) | 97 (25%) |
| 正文碎片(<100字 | 2 | 4 | 5 |
| 文本中位长度 | 122字 | 24字 | 66字 |
| 文本最大长度 | 725字 | 798字 | 757字 |
| 连续标题链≥2个 | 3条 | 22条 | 34条 |
**关键发现**2.docx 的纯标题切片占比高达 42%3.docx 有 34 条连续标题链(其中一条包含 21 个连续标题——附件列表)。这些标题切片不包含实质内容,但在向量库中占据存储空间,并参与检索管线的所有阶段。
### 13.2 切片策略概述
当前切片策略以**标题层级**为核心拆分依据:
```
MinerU 解析 → content_list → 逐项构建 MinerUChunk
→ heading_rules 引擎识别标题级别text_level
→ 每个标题开始新的 section_path
→ _post_process_chunks 三阶段后处理:
Phase 1: 过滤空切片
Phase 2: 合并碎片(标题+正文、短文本 < min_merge_size=100
Phase 3: 拆分超长(> max_chunk_size=1000
```
**合并规则**
- 标题 chunk`text_level > 0`)刷新缓冲并开始新合并组
- 正文 chunk 若 `< min_merge_size`100字则并入缓冲
- 正文 chunk 若 `≥ min_merge_size` 且缓冲已满则直接输出
- 合并上限 `max_merged_size = 800`
**后果**每个标题级别H1/H2/H3的段落都会产生独立的 chunk。当标题下方没有正文或正文在子标题下产生"纯标题切片"。
### 13.3 已确认的失配点
#### 失配 1纯标题切片的语义内容冗余严重
**问题**:纯标题切片存入 ChromaDB 的 `documents` 字段是同一文本的三次重复:
```
三、做好货源投放前的基础工作 ← titletext_level > 0 时追加)
主题:三、做好货源投放前的基础工作 ← section_path最多3级
三、做好货源投放前的基础工作 ← content原始文本
```
生成代码见 `knowledge/base.py:231-254``_build_semantic_content_for_text`)。
**影响**
- Embedding 向量几乎无区分度——所有标题的向量高度相似
- 向量检索几乎不会命中这些切片(内容查询与重复标题的相似度极低)
- 浪费向量库存储空间2.docx 有 61 个此类切片)
#### 失配 2上下文扩展的 section 精确匹配(严重)
**问题**`_expand_contiguous_chunks``engine.py:1334`)在 section 过滤时使用**精确匹配**
```python
where_filter = {"$and": [{"source": source}, {"section": section}]}
```
当种子是父级标题(如 `section = "三、做好货源投放前的基础工作"`)时:
- 子标题下的正文切片 `section = "三 > (一)货源投放要求 > ..."` **不匹配**
- 回退到 source-only 模式,从整个文档中拉取相邻 `chunk_index` 的切片
- 对大文档,回退模式可能拉取到不相关的切片
**影响**:父级标题被检索到时,无法通过 section 过滤拉取其子节内容,降低了上下文完整性。
#### 失配 3~~`text_level` 未传入检索管线~~ ✅ 已修复
**原问题**`text_level`(标题级别 0/1/2/3在切片阶段被精确计算但未存入 ChromaDB metadata检索管线无法感知标题层级。
**修复**`knowledge/manager.py` 的 metadata 构建处已新增 `text_level` 字段:
```python
metadata = {
...
'text_level': getattr(chunk, 'text_level', 0), # 已新增
}
```
**当前状态**`text_level` 已存入 ChromaDB metadata为后续层次感知检索提供了数据基础。引擎层和路由层尚未利用此字段做特殊处理如标题切片降权、导航锚点扩展但数据层已就绪。
#### 失配 4~~章节聚类归一化粒度过粗~~ ✅ 已调整
**原问题**`CLUSTER_SECTION_PREFIX_LEVELS = 1` 将 section_path 归一化到第一级,不同二级章节的切片被归入同一聚类组,可能触发误提升。
**修复**`config.py` 中已将 `CLUSTER_SECTION_PREFIX_LEVELS``1` 调整为 `2`
```python
CLUSTER_SECTION_PREFIX_LEVELS = 2 # section_path 归一化保留的层级数(按章节前两级分组,提升聚类精确度)
```
**当前状态**:归一化到前两级(如 `(二)货源投放要求 > 7.关于主导品规投放`聚类信号精确度提升。对深层嵌套文档3 级及以上 section的影响已通过 `CLUSTER_MAX_SECTIONS=3``CLUSTER_MIN_TYPES=2` 约束。
#### 失配 5预算构建器中标题切片的开销轻微
**问题**`_build_context_with_budget``chat_routes.py:866`)按 `(source, section)` 分组后,每个组添加 `━ {section} ━` 分隔行。纯标题切片形成单例组:
```
━ 三、做好货源投放前的基础工作 ━ ← ~25字符开销
三、做好货源投放前的基础工作 ← ~14字符内容信息量≈0
```
**影响**:约 40 字符的预算被浪费在无信息量的组上。在 `CONTEXT_MAX_CHARS = 8000` 的预算下,少量标题切片影响可控,但 2.docx 的 61 个标题切片如果被拉入就会累积显著开销。
#### 失配 6MMR 去重对标题切片的过度消除(模式相关)
**问题**Embedding-based MMR`MMR_USE_EMBEDDING=True`)模式下,标题切片因 Embedding 高度相似而相互惩罚。Jaccard 模式(`MMR_USE_EMBEDDING=False`,当前设置)因词级分词有一定区分度,问题较轻。
**影响**:当标题切片恰好是某子章节的唯一入口时,被 MMR 消除后该子章节在检索结果中完全丢失。当前使用 Jaccard 模式,此问题暂未触发。
#### 失配 7~~3.docx 的 section_path 污染~~ ✅ 已修复
**原问题**3.docx 中存在一个完整的合同条款段落被误识别为 H1 标题,导致 section_path 极长且无语义意义,该 section 下 59 个切片分组失真。
**修复**`parsers/heading_rules.py` 中新增了两层防护:
1. **`_validate_level` 超长文本降级**H1 > 40字、H2 > 60字、H3 > 50字时自动降为正文level=0。覆盖所有返回路径v1 常规匹配、v2 style 匹配、bold_short_text 兜底)。
2. **规则级 `max_length` + `exclude_pattern`**:部分 H1 规则(如 `numeric_level1`)设置 `max_length=50` 和排除句末标点(`;。,、::`)的 `exclude_pattern`,在匹配阶段即过滤段落文本。
**当前状态**长段落不再被误判为标题section_path 污染问题已消除。
### 13.4 切片-检索兼容性总结
| 失配点 | 严重度 | 影响范围 | 现状 |
|--------|--------|---------|------|
| 标题语义内容冗余 | 🔴 严重 | 全部文档 | 670 个向量库切片中约 174 个是纯标题 |
| section 精确匹配 | 🔴 严重 | 父级标题检索 | 回退到 source-only可能拉取不相关内容 |
| ~~text_level 未传入~~ | ~~🟠 显著~~ | ~~全局~~ | ✅ 已修复text_level 已存入 ChromaDB metadata |
| ~~聚类归一化过粗~~ | ~~🟡 中等~~ | ~~聚类提升/救援~~ | ✅ 已修复CLUSTER_SECTION_PREFIX_LEVELS 调整为 2 |
| 预算构建开销 | 🟢 轻微 | 上下文构建 | 标题单例组浪费字符预算 |
| MMR 过度消除 | 🟢 模式相关 | MMR 去重 | 当前 Jaccard 模式暂未触发 |
| ~~section_path 污染~~ | ~~🟡 中等~~ | ~~3.docx~~ | ✅ 已修复heading_rules 超长文本降级防护 |
### 13.5 优化建议
#### 短期(改动小,收益明确)
**1. ~~后处理合并连续标题链~~ ✅ 已实施**
已在 `_post_process_chunks` Phase 2 中实现连续标题链合并:
- **H1 级标题是章节边界**,强制断开,不参与链合并
- **非 H1 连续标题**H2/H3合并为单个 chunk合并后取更高层级数值更小保留第一个标题的 section_path
- 合并上限仍为 `max_merged_size = 800`
**2. ~~存储 `text_level` 到 ChromaDB metadata~~ ✅ 已实施**
`knowledge/manager.py` 中已新增 `text_level` 字段存储,为后续层次感知检索提供数据基础。
**3. 后处理合并标题与首个子节正文**
当一个标题切片的下一个切片是子标题(更高 `text_level` 数值)下的正文时,将标题文本前置到子节切片中:
```
当前:#7 [H2] "四、相关的指标与分类"10字 → #8 [H2] "(一)货源属性分类\n1.紧俏品规..."349字
优化:#7 [H2] "四、相关的指标与分类\n货源属性分类\n1.紧俏品规..."359字
```
预期收益:消除父级标题的孤立切片,同时为子节切片提供上层上下文。
#### 中期(需要评测验证)
**4. 上下文扩展支持 section 前缀匹配**
`_expand_contiguous_chunks` 的 section 过滤从精确匹配改为前缀匹配:
```python
# 当前:精确匹配
{"section": section}
# 优化:前缀匹配(拉取所有子节切片)
{"section": {"$regex": f"^{re.escape(section)}"}}
```
需评估:前缀匹配可能拉取过多切片,需要配合 `CONTEXT_EXPANSION_MAX_CHUNKS` 上限控制。
**5. ~~调整 `CLUSTER_SECTION_PREFIX_LEVELS`~~ ✅ 已实施**
已从 `1` 调整为 `2`,聚类信号精确度提升。
#### 长期(架构级改进)
**6. 层次感知检索**
利用已存入 metadata 的 `text_level` 实现结构感知的检索策略:
- 标题切片作为"导航锚点",被检索到时自动拉取其 section_path 前缀下的所有子切片
- MMR 去重时对标题切片设置保护阈值,确保每个主要章节至少保留一个入口
- 预算构建器对标题单例组做特殊处理(合并到子节组或跳过)
**7. ~~修复 section_path 污染~~ ✅ 已实施**
`parsers/heading_rules.py``_validate_level` 方法已实现超长文本降级防护H1>40字、H2>60字、H3>50字降为正文
**8. bbox 空间感知检索**(新增)
利用已存入 metadata 的 `bbox` 坐标实现空间感知的检索策略:
- 同一页面相邻区域的切片在检索时可做空间聚类
- 图片/图表切片的 bbox 可用于判断其在文档中的物理位置关系
- 仅 PDF 切片有 bboxDOCX 切片无此信息
**9. VLM 描述增强图片检索**(新增)
利用已存入 MinerUChunk 的 `vlm_description``chart_markdown` 字段:
- 将 VLM 视觉描述注入图片切片的 semantic_content提升向量检索的语义匹配度
- chart_markdown图表数据表可增强图表切片的 BM25 关键词匹配
- 仅云端 MinerU V4 VLM 后端提供,本地 pipeline 后端无此信息
---
## 十四、MinerU 解析策略
### 14.1 云端优先 + 本地备选
当前解析策略为**云端 MinerU V4 API 优先,本地 MinerU 备选**
| 维度 | 云端 MinerU V4 | 本地 MinerU |
|------|---------------|------------|
| **入口** | `parse_with_mineru_online()` | `parse_with_mineru()` |
| **后端** | VLM视觉语言模型 | pipeline传统 OCR |
| **解析速度** | ~5 秒(含网络) | 取决于 GPU/CPU |
| **V2 数据丰富度** | 丰富bbox、title_content、VLM 描述、list_items、sub_type | 基础:无 bbox、无 title type、无 VLM 描述 |
| **配置项** | `MINERU_PREFER_ONLINE=True` | `MINERU_LOCAL_BACKEND="pipeline"` |
| **回退逻辑** | 失败自动回退本地 | 最终回退方案 |
**调度流程**
```
parse_with_mineru_persistent()
→ config.MINERU_PREFER_ONLINE=True
→ parse_with_mineru_online() ← 优先
→ 成功 → 返回
→ 失败 → parse_with_mineru() ← 本地回退pipeline 后端)
```
### 14.2 V2 content_list 解析
`_parse_v2_content_list()` 负责将 MinerU V2 格式的 content_list 转换为 MinerUChunk 列表。V2 是 MinerU 的结构化输出格式,比 V1 包含更丰富的元数据。
**V2 与 V1 的关键差异**
| 维度 | V2 格式 | V1 格式 |
|------|---------|---------|
| 标题文本 | `title_content` 字段 | `text` 字段 |
| 图片路径 | `image_source.path` | `img_path` |
| VLM 描述 | `content.content` | 无 |
| 列表 | `list_items` 数组 | 无(直接文本) |
| 表格标题 | `table_caption`(列表格式) | `table_caption`(字符串) |
| bbox | 所有元素都有 | 仅部分元素 |
**V2 解析已处理的类型**
- `title`:读取 `title_content`PDF回退 `paragraph_content`DOCX
- `paragraph`:读取 `paragraph_content`
- `table`:提取 `table_caption`(列表格式解析)、`table_footnote``image_source.path``table_nest_level`
- `image` / `chart`:提取 `image_source.path`(回退 `img_path`、VLM 描述、chart_markdown、`sub_type`、caption列表格式
- `equation`:读取 `text`
- `list`:拼接 `list_items` 为段落文本
### 14.3 DOCX vs PDF 后端差异
云端 MinerU 对 DOCX 和 PDF 使用不同的解析后端,导致 V2 输出有显著差异:
| 特征 | DOCXoffice 后端) | PDFhybrid/VLM 后端) |
|------|---------------------|----------------------|
| **bbox** | ❌ 无 | ✅ 所有元素都有 |
| **title type/level** | ❌ 无(仅 bold style | ✅ 有H1/H2/H3 |
| **VLM 描述** | ❌ 无 | ✅ 图片/图表有视觉描述 |
| **chart_markdown** | ❌ 无 | ✅ 图表有数据表 Markdown |
| **list_items** | ❌ 无 | ✅ 结构化列表 |
| **sub_type** | ❌ 无 | ✅ natural_image/bar/line 等 |
| **title 字段名** | `paragraph_content` | `title_content` |
| **图片路径** | `image_source.path` | `image_source.path` |
**策略**DOCX 的标题层级由本地 `heading_rules.py` 引擎基于文本模式补充推断PDF 的标题层级直接使用 MinerU 提供的 `text_level`

View File

@@ -0,0 +1,196 @@
## 云端 MinerU V4 输出分析 & 信息利用优化方案
### 一、测试环境
- 云端 API: MinerU V4 (`https://mineru.net/api/v4`)
- 模型: `vlm`(高精度视觉语言模型)
- DOCX 测试: `1.docx`472KB→ 5秒完成
- PDF 测试: `三峡公报_1-15页.pdf`2.7MB)→ 5秒完成
---
### 二、云端输出 Schema 对比
#### 2.1 DOCXoffice 后端)
| 字段 | 状态 | 说明 |
|------|------|------|
| `type` | paragraph/table/image | 无独立 title 类型 |
| `content.paragraph_content[].content` | 文本内容 | |
| `content.paragraph_content[].style` | 仅 `bold` | 无 italic/underline/font_size |
| `bbox` | **无** | DOCX 不做 OCR |
| `anchor` | 偶有 | Word 书签(如 `_Toc423623536` |
| `table_type`/`table_nest_level` | 有 | 表格类型和嵌套层级 |
| `image_source.path` | 有 | 图片路径 |
DOCX 的 V2 输出非常精简,与本地 `pipeline` 模式基本一致。
#### 2.2 PDFhybrid 后端VLM 模型)
| 字段 | 状态 | 说明 |
|------|------|------|
| `type` | paragraph/title/list/image/chart/table/page_header/footer/number | **丰富的类型区分** |
| `bbox` | **全部有** | 96/96 项都有坐标 |
| `title_content` (title 项) | 有 level 字段 | 标题层级 |
| `paragraph_content` (paragraph 项) | 文本内容 | 无 style/font_size |
| `sub_type` (image) | `natural_image` 等 | 图片子类型 |
| `content.content` (chart) | **Markdown 表格** | VLM 从图表中提取的数据表 |
| `content.content` (image) | **VLM 视觉描述** | 如 "Aerial view of a large dam..." |
| `list_items` (list) | 结构化列表 | 目录条目等 |
| `table_footnote` | 表格脚注 | |
| `chart_caption`/`chart_footnote` | 图表元数据 | |
| `image_source.path` | 图片路径 | 新格式(旧代码用 `img_path` |
#### 2.3 V1 vs V2 格式差异
| 特性 | V1 (`content_list.json`) | V2 (`content_list_v2.json`) |
|------|--------------------------|------------------------------|
| 结构 | 扁平列表 | 按页嵌套 `[[page0], [page1]...]` |
| title 文本 | `text` 字段 | `title_content` 字段 |
| title 级别 | `text_level` 字段 | `level` 字段 |
| bbox | 有 | 有 |
| bold style | Markdown `**text**` | `style: ["bold"]` |
| list 类型 | 有 `list_items` | 有 `list_items` |
| chart 数据 | 无 markdown 表格 | `content.content` 含 markdown |
| VLM 描述 | 无 | `content.content`image 项) |
---
### 三、发现的严重 Bug全部已修复 2026-06-19
#### Bug #1: V2 title 文本全部丢失(严重)✅ 已修复
当前 `_parse_v2_content_list()` 处理 title 类型时:
```python
elif v2_type == 'title':
level = content.get('level', 0)
text_parts = []
para_content = content.get('paragraph_content', []) # ← 错误!
for part in para_content:
...
```
但 PDF 的 title 项使用的是 `title_content` 字段,不是 `paragraph_content`
```json
{
"type": "title",
"content": {
"title_content": [{"type": "text", "content": "三峡工程公报"}],
"level": 1
},
"bbox": [203, 138, 794, 211]
}
```
**影响**: 所有 PDF 标题的文本丢失 → section_path 为空 → 章节层级结构完全破坏。
#### Bug #2: list 类型未处理 ✅ 已修复
V1/V2 都有 `list` 类型(如目录条目),但当前 `_parse_v2_content_list()` 没有处理分支,直接被静默丢弃。
#### Bug #3: chart markdown 数据未提取 ✅ 已修复
V2 的 chart 类型在 `content.content` 中包含 VLM 提取的 Markdown 表格,当前代码只提取 `img_path``caption`,丢掉了结构化数据。
#### Bug #4: VLM 图片描述未利用 ✅ 已修复
V2 的 image 项的 `content.content` 包含 VLM 生成的视觉描述文本(如 "Aerial view of a large hydroelectric dam..."),可用于增强图片检索,当前被丢弃。
#### Bug #5: image_source 路径格式不兼容 ✅ 已修复
V2 图片路径从 `img_path` 改为 `image_source.path`,当前转换代码未适配。
---
### 四、优化方案
#### Phase 1: 修复 V2 解析 BugP0已完成 2026-06-19
**目标**: 修复 PDF 标题丢失等严重问题。
| 改动 | 文件 | 状态 |
|------|------|------|
| title 项读 `title_content` | `mineru_parser.py` `_parse_v2_content_list()` | ✅ 已完成 |
| 添加 list 类型处理 | `mineru_parser.py` `_parse_v2_content_list()` | ✅ 已完成 |
| image_source 路径兼容 | `mineru_parser.py` `_parse_v2_content_list()` | ✅ 已完成 |
| chart markdown 提取 | `mineru_parser.py` `_parse_v2_content_list()` | ✅ 已完成 |
| VLM 图片描述提取 | `mineru_parser.py` `_parse_v2_content_list()` | ✅ 已完成 |
| MinerUChunk 新增 vlm_description/chart_markdown | `mineru_parser.py` | ✅ 已完成 |
| 在线/本地路径同步更新 | `mineru_parser.py` | ✅ 已完成 |
#### Phase 1.5: 审计修复(已完成 2026-06-19
**目标**: 修复审计发现的"提取但未用"字段和 bbox 存储断裂。
| 改动 | 文件 | 状态 |
|------|------|------|
| bbox 存入 ChromaDB metadataJSON 序列化DOCX None 保护) | `knowledge/manager.py` | ✅ 已完成 |
| table_type/table_nest_level → MinerUChunk → ChromaDB | `mineru_parser.py` + `manager.py` | ✅ 已完成 |
| sub_type → MinerUChunk → ChromaDB | `mineru_parser.py` + `manager.py` | ✅ 已完成 |
| MINERU_LOCAL_BACKEND 改为 pipeline | `config.py` | ✅ 已完成 |
#### Phase 2: 增强信息利用P1待实施
**目标**: 在检索管线中利用已存储的新元数据。
| 改动 | 文件 | 说明 |
|------|------|------|
| ~~VLM 图片描述存入 chunk~~ | ~~mineru_parser.py~~ | ✅ Phase 1 已完成 |
| ~~bbox 存入 ChromaDB~~ | ~~manager.py~~ | ✅ Phase 1.5 已完成 |
| ~~sub_type 存入 ChromaDB~~ | ~~manager.py~~ | ✅ Phase 1.5 已完成 |
| bbox 空间感知检索 | `core/engine.py` | 同页内容优先聚合 |
| VLM 描述增强图片检索 | `core/engine.py` | 图片 chunk 用描述文本做语义匹配 |
| table_type 检索路由 | `core/engine.py` | 按表格类型区别处理 |
#### Phase 3: 检索管线利用P2中期
**目标**: 在检索阶段利用新增的元数据。
| 改动 | 文件 | 说明 |
|------|------|------|
| bbox 空间感知检索 | `core/engine.py` | 同页内容优先聚合 |
| VLM 描述增强图片检索 | `core/engine.py` | 图片 chunk 用描述文本做语义匹配 |
| chart markdown 表格检索 | `core/engine.py` | 图表数据表按表格类型路由 |
| table_footnote 上下文补充 | `core/engine.py` | 表格命中时附带脚注 |
#### Phase 4: 架构调整
| 改动 | 说明 |
|------|------|
| 云端优先 + 本地 pipeline 备选 | `MINERU_PREFER_ONLINE=True`, 失败回退 `pipeline` |
| V2 默认启用 | `MINERU_PREFER_V2=True`(保持现状) |
| 本地 backend 改为 `pipeline` | `MINERU_LOCAL_BACKEND="pipeline"`(快速备选) |
---
### 五、字段利用状态汇总(更新于 2026-06-19
| 字段 | 来源 | 状态 | 说明 |
|------|------|------|------|
| `title_content` | PDF V2 | ✅ 已利用 | Phase 1 修复,标题文本正确提取 |
| `list_items` | PDF V1/V2 | ✅ 已利用 | Phase 1 拼接为段落文本 |
| `content.content` (chart) | PDF V2 | ✅ 已利用 | Phase 1 存入 chart_markdown → ChromaDB |
| `content.content` (image) | PDF V2 | ✅ 已利用 | Phase 1 存入 vlm_description → 嵌入 chunk content |
| `bbox` | PDF V1/V2 | ✅ 已利用 | Phase 1.5 存入 ChromaDBJSON 序列化) |
| `table_type` | PDF V2 | ✅ 已利用 | Phase 1.5 存入 ChromaDB metadata |
| `table_nest_level` | PDF V2 | ✅ 已利用 | Phase 1.5 存入 ChromaDB metadata |
| `sub_type` | PDF V1/V2 | ✅ 已利用 | Phase 1.5 存入 ChromaDB metadata |
| `table_footnote` | PDF V1/V2 | ✅ 已利用 | Phase 1 附加到表格 chunk content |
| `anchor` | DOCX V2 | 暂不利用 | Word 书签/交叉引用RAG 场景无需求 |
| `chart_caption`/`chart_footnote` | PDF V2 | ✅ 已利用 | chart caption 已提取 |
| `font_size`/`font_name` | 均无 | N/A | 云端 V4 不输出这些字段 |
| `layout.json` | 两者 | 暂不利用 | 精细布局分析,当前无需求 |
---
### 六、DOCX vs PDF 策略差异
| 策略 | DOCX (office) | PDF (hybrid/VLM) |
|------|---------------|-------------------|
| 标题识别 | 依赖 heading_rules正则+bold | VLM 已识别 title+levelheading_rules 做补充校正 |
| 图片检索 | 仅靠 caption | VLM 描述 + caption 双通道 |
| 图表处理 | 无 | chart 类型含 markdown 数据表 |
| 列表处理 | 无独立 list 类型 | list 类型保留结构化列表 |
| 表格增强 | table_type/nest_level | table_type + footnote + 嵌入图片 |

View File

@@ -335,6 +335,7 @@ class KnowledgeBaseManager(
"section": section_path,
"status": "active",
"version": "v1",
"text_level": getattr(chunk, 'text_level', 0), # 标题级别(0=正文,1=h1,2=h2,3=h3),供检索管线层次感知
}
if extra_metadata:
@@ -347,6 +348,25 @@ class KnowledgeBaseManager(
if hasattr(chunk, 'image_path') and chunk.image_path:
metadata['image_path'] = chunk.image_path
# bbox 坐标PDF 有DOCX 无,需 None 保护)
# _build_citation() 从 metadata 读取 bbox 做引用定位
chunk_bbox = getattr(chunk, 'bbox', None)
if chunk_bbox:
metadata['bbox'] = json.dumps(chunk_bbox)
# MinerU 结构化元数据(表格类型、嵌套层级、图片子类型)
chunk_table_type = getattr(chunk, 'table_type', '')
if chunk_table_type:
metadata['table_type'] = chunk_table_type
chunk_nest_level = getattr(chunk, 'table_nest_level', '')
if chunk_nest_level:
metadata['table_nest_level'] = str(chunk_nest_level)
chunk_sub_type = getattr(chunk, 'sub_type', '')
if chunk_sub_type:
metadata['sub_type'] = chunk_sub_type
# 生成向量
try:
embedding = embedding_model.encode(semantic_content).tolist()

View File

@@ -106,11 +106,12 @@ DEFAULT_HEADING_RULES: List[HeadingRule] = [
name="chinese_chapter",
),
# 2. 中文条款编号 -> h2
# 匹配:第一条、第三款 等
# 匹配:第一条、第三款 等(仅短标题,长正文段落不算标题)
HeadingRule(
pattern=re.compile(r'^第[一二三四五六七八九十百千万]+[条款]'),
level=2,
name="chinese_article",
max_length=30,
),
# 3. 数字三级标题 -> h3必须在二级之前匹配
# 匹配1.1.1 背景、2.3.4 方案 等
@@ -121,20 +122,23 @@ DEFAULT_HEADING_RULES: List[HeadingRule] = [
max_length=100,
),
# 4. 数字二级标题 -> h2必须在一级之前匹配
# 匹配1.1 背景、2.3 方案 等
# 匹配1.1 背景、2.3 方案、2.1运行调度(无空格)
# 使用负向前瞻排除三级标题(由 numeric_level3 处理)
HeadingRule(
pattern=re.compile(r'^\d+\.\d+[\.\s]'),
pattern=re.compile(r'^\d+\.\d+(?!\.\d)'),
level=2,
name="numeric_level2",
max_length=80,
),
# 5. 数字一级标题 -> h1
# 匹配1. 概述、2、背景 等
# 排除:以 ;。,、: 结尾的文本(这些是编号列表项/子条目,不是独立标题)
HeadingRule(
pattern=re.compile(r'^\d+[\.、\s]'),
level=1,
name="numeric_level1",
max_length=50,
exclude_pattern=re.compile(r'[;。,、::]$'),
),
# 6. 英文章节标题 -> h1
# 匹配Chapter 1、Section 2、Part 3 等
@@ -201,6 +205,22 @@ class HeadingRuleEngine:
import copy
self.rules = copy.deepcopy(DEFAULT_HEADING_RULES)
def _validate_level(self, level: int, text: str, rule_name=None):
"""各级别标题长度防护:超长文本不应作为标题,降为正文。
H1 > 40字, H2 > 60字, H3 > 50字 → 降为正文。
统一覆盖 v1 常规匹配、v2 style 匹配、bold_short_text 兜底所有返回路径。"""
text_len = len(text)
if level == 1 and text_len > 40:
logger.debug(f"标题识别: '{text[:30]}...' H1 但超长({text_len}字),降为正文")
return 0, None
if level == 2 and text_len > 60:
logger.debug(f"标题识别: '{text[:30]}...' H2 但超长({text_len}字),降为正文")
return 0, None
if level == 3 and text_len > 50:
logger.debug(f"标题识别: '{text[:30]}...' H3 但超长({text_len}字),降为正文")
return 0, None
return level, rule_name
def detect(self, text: str, style: Optional[List[str]] = None) -> Tuple[int, Optional[str]]:
"""
检测文本的标题级别
@@ -227,7 +247,7 @@ class HeadingRuleEngine:
level = rule.match(text)
if level > 0:
logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h{level} (规则: {rule.name})")
return level, rule.name
return self._validate_level(level, text, rule.name)
# 再检查是否匹配中文章节/条款等高优先级规则
for rule in self.rules:
@@ -236,9 +256,16 @@ class HeadingRuleEngine:
level = rule.match(text)
if level > 0:
logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h{level} (规则: {rule.name})")
return level, rule.name
return self._validate_level(level, text, rule.name)
# 最后兜底:加粗短文本 → h2
# 但需先检查所有规则的 exclude_pattern防止编号列表项被误判为标题
# 例如 "3.完全满足品规。指..." 虽有 bold 样式,但属于列表项而非标题
for rule in self.rules:
if rule.enabled and rule.exclude_pattern and rule.exclude_pattern.search(text):
logger.debug(f"标题识别(v2 style): '{text[:30]}'{rule.name} 的 exclude_pattern 排除")
return 0, None
# 否则作为加粗短文本 → h2与 bold_short_text 规则对齐,但不依赖 **...** 标记)
logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h2 (规则: bold_short_text_via_style)")
return 2, 'bold_short_text'
@@ -247,7 +274,7 @@ class HeadingRuleEngine:
level = rule.match(text)
if level > 0:
logger.debug(f"标题识别: '{text[:30]}' -> h{level} (规则: {rule.name})")
return level, rule.name
return self._validate_level(level, text, rule.name)
return 0, None

View File

@@ -131,6 +131,13 @@ class MinerUChunk:
# 图片上下文(用于语义检索)
context_before: str = "" # 图片前的文本上下文
context_after: str = "" # 图片后的文本上下文
# VLM 增强信息
vlm_description: str = "" # VLM 视觉描述(图片/图表)
chart_markdown: str = "" # VLM 提取的图表数据表Markdown 格式)
# MinerU 结构化元数据
table_type: str = "" # 表格类型cflow/table/text来自 _v2_table_type
table_nest_level: str = "" # 表格嵌套层级(来自 _v2_table_nest_level
sub_type: str = "" # 图片/图表子类型natural_image/table_image 等)
def parse_with_mineru_online(
@@ -308,15 +315,26 @@ def _parse_v2_content_list(v2_data: list) -> list:
转换规则:
- paragraph → text拼接 paragraph_content提取 style 信息)
- table → table保留 table_body/html
- title → text提取 level 信息
- title → text从 title_content 提取文本level 信息
- table → table保留 html提取 table_caption/table_footnote 列表格式
- image → image提取 image_source.path、VLM 视觉描述 content.content
- chart → chart提取 image_source.path、VLM 数据表 content.content Markdown
- list → text将 list_items 拼接为段落,保留 list_type
- equation → equation
- page_header / page_footer / page_number → 过滤掉
支持的额外字段_v2_ 前缀):
- _v2_styles: 样式列表(如 ['bold']
- _v2_table_type / _v2_table_nest_level / _v2_table_footnote: 表格元数据
- _v2_vlm_description: VLM 生成的图片视觉描述
- _v2_chart_markdown: VLM 从图表中提取的 Markdown 数据表
- _v2_list_type: 列表类型text_list 等)
Args:
v2_data: v2 格式的嵌套列表
Returns:
v1 兼容的扁平列表,额外包含 _v2_styles / _v2_table_type 字段
v1 兼容的扁平列表
"""
flat_list: List[Dict] = []
@@ -330,8 +348,8 @@ def _parse_v2_content_list(v2_data: list) -> list:
v2_type: str = item.get('type', '')
content = item.get('content', {})
# 过滤噪音类型(页眉、页脚、页码)
if v2_type in ('page_header', 'page_footer', 'page_number'):
# 过滤噪音类型(页眉、页脚、页码、目录索引
if v2_type in ('page_header', 'page_footer', 'page_number', 'index'):
continue
if v2_type == 'paragraph':
@@ -363,28 +381,68 @@ def _parse_v2_content_list(v2_data: list) -> list:
flat_list.append(flat_item)
elif v2_type == 'table':
# table_caption 在 V2 中是列表格式: [{"type": "text", "content": "..."}]
table_caption_list = content.get('table_caption', []) if isinstance(content, dict) else []
table_caption = ''.join(
p.get('content', '') for p in table_caption_list if isinstance(p, dict)
).strip() if isinstance(table_caption_list, list) else str(table_caption_list)
# 兜底: 如果 caption 列表为空,尝试旧的 caption 字符串字段
if not table_caption:
table_caption = content.get('caption', '') if isinstance(content, dict) else ''
# table_footnote
table_footnote_list = content.get('table_footnote', []) if isinstance(content, dict) else []
table_footnote = ''.join(
p.get('content', '') for p in table_footnote_list if isinstance(p, dict)
).strip() if isinstance(table_footnote_list, list) else ''
# image_source (V2 新格式) 兜底 img_path
img_src = content.get('image_source', {}) if isinstance(content, dict) else {}
img_path = img_src.get('path', '') if isinstance(img_src, dict) else ''
if not img_path:
img_path = content.get('img_path', '') if isinstance(content, dict) else ''
flat_item = {
'type': 'table',
'page_idx': page_idx,
'bbox': item.get('bbox', []),
'html': content.get('html', '') if isinstance(content, dict) else '',
'table_body': content.get('html', '') if isinstance(content, dict) else '',
'caption': content.get('caption', '') if isinstance(content, dict) else '',
'caption': table_caption,
'img_path': img_path,
'_v2_table_type': content.get('table_type', '') if isinstance(content, dict) else '',
'_v2_table_nest_level': content.get('table_nest_level', '') if isinstance(content, dict) else '',
'_v2_table_footnote': table_footnote,
}
flat_list.append(flat_item)
elif v2_type == 'title':
# v2 title 项 level 信息
level = content.get('level', 0) if isinstance(content, dict) else 0
# v2 title 项: level 在 content.level文本在 title_content非 paragraph_content
vlm_level = content.get('level', 0) if isinstance(content, dict) else 0
text_parts = []
para_content = content.get('paragraph_content', []) if isinstance(content, dict) else []
for part in para_content:
# PDF V2 使用 title_contentDOCX 理论上不应出现 title 类型
title_content = content.get('title_content', []) if isinstance(content, dict) else []
# 兜底: 如果 title_content 为空,尝试 paragraph_content
if not title_content:
title_content = content.get('paragraph_content', []) if isinstance(content, dict) else []
for part in title_content:
if isinstance(part, dict):
text_parts.append(part.get('content', ''))
full_text = ''.join(text_parts).strip()
if full_text:
# PDF V2: heading_rules 优先(模式匹配对编号标题可靠)
# VLM level 仅作兜底VLM 常给所有标题 level=1不可靠
level = 0
try:
from parsers.heading_rules import get_heading_engine
engine = get_heading_engine()
detected_level, rule_name = engine.detect(full_text, style=['bold'])
if detected_level > 0:
level = detected_level
except Exception:
pass
if level == 0 and vlm_level > 0:
level = vlm_level
flat_item = {
'type': 'text',
'text': full_text,
@@ -397,16 +455,97 @@ def _parse_v2_content_list(v2_data: list) -> list:
flat_list.append(flat_item)
elif v2_type in ('image', 'chart'):
# image_source (V2 新格式) 兜底 img_path
img_src = content.get('image_source', {}) if isinstance(content, dict) else {}
img_path = img_src.get('path', '') if isinstance(img_src, dict) else ''
if not img_path:
img_path = content.get('img_path', '') if isinstance(content, dict) else ''
# caption 在 V2 中可能是列表格式
caption_raw = content.get('image_caption', content.get('caption', '')) if isinstance(content, dict) else ''
if isinstance(caption_raw, list):
caption = ''.join(p.get('content', '') for p in caption_raw if isinstance(p, dict)).strip()
else:
caption = str(caption_raw)
# VLM 视觉描述 (image 和 chart 项的 content.content 字段)
vlm_description = ''
if isinstance(content, dict):
desc = content.get('content', '')
if isinstance(desc, str) and desc and len(desc) > 10:
# image 直接使用chart 需排除 markdown 表格(表格走 chart_markdown
if v2_type == 'image':
vlm_description = desc
elif v2_type == 'chart' and '|' not in desc:
vlm_description = desc
# chart 的 VLM 数据表 (content.content 字段Markdown 表格)
chart_markdown = ''
if v2_type == 'chart' and isinstance(content, dict):
md = content.get('content', '')
if isinstance(md, str) and md:
if '|' in md:
chart_markdown = md
# 即使没有 '|',如果有结构化数据特征也保留
elif len(md) > 50 and any(kw in md for kw in ('数据', '合计', '总计', '年份', '单位')):
chart_markdown = md
# chart caption
chart_caption_list = content.get('chart_caption', [])
if isinstance(chart_caption_list, list) and chart_caption_list:
caption = ''.join(
p.get('content', '') for p in chart_caption_list if isinstance(p, dict)
).strip() or caption
# sub_type (natural_image / table_image 等)
sub_type = item.get('sub_type', '')
# 封面 logo 过滤:第一页无 caption 无 VLM 描述的图片通常是封面装饰
if (v2_type == 'image' and page_idx == 0
and not caption and not vlm_description and not img_path):
continue
flat_item = {
'type': v2_type,
'page_idx': page_idx,
'bbox': item.get('bbox', []),
'img_path': content.get('img_path', '') if isinstance(content, dict) else '',
'image_path': content.get('img_path', '') if isinstance(content, dict) else '',
'caption': content.get('caption', '') if isinstance(content, dict) else '',
'img_path': img_path,
'image_path': img_path,
'caption': caption,
'sub_type': sub_type,
'_v2_vlm_description': vlm_description,
'_v2_chart_markdown': chart_markdown,
}
flat_list.append(flat_item)
elif v2_type == 'list':
# 结构化列表:将列表项拼接为段落文本
list_items = content.get('list_items', []) if isinstance(content, dict) else []
item_texts = []
for li in list_items:
if not isinstance(li, dict):
continue
item_content = li.get('item_content', [])
text = ''.join(
p.get('content', '') for p in item_content if isinstance(p, dict)
).strip()
if text:
item_texts.append(text)
if item_texts:
list_type = content.get('list_type', 'text_list') if isinstance(content, dict) else 'text_list'
full_text = '\n'.join(item_texts)
flat_item = {
'type': 'text',
'text': full_text,
'content': full_text,
'page_idx': page_idx,
'bbox': item.get('bbox', []),
'text_level': 0,
'_v2_styles': [],
'_v2_list_type': list_type,
}
flat_list.append(flat_item)
elif v2_type == 'equation':
flat_item = {
'type': 'equation',
@@ -419,6 +558,100 @@ def _parse_v2_content_list(v2_data: list) -> list:
}
flat_list.append(flat_item)
# ── TOC 残留过滤 ──
# 目录条目可能以 list / title / paragraph 类型混入,用行尾页码模式检测
# 模式1: 连续点号/省略号+页码(如 "1 综述..........1"、"2 三峡工程…4"
# 模式2: 短行+空格+页码数字(如 "2.2 防洪 8"、"6.2 水位 28"
import re
_toc_dots = re.compile(r'(\.{2,}|…+|⋯+)\s*\d+\s*$')
_toc_space_num = re.compile(r'\s{2,}\d{1,3}\s*$') # 2+空格+1-3位数字
_toc_filtered = 0
filtered_list = []
for fi in flat_list:
text = fi.get('text', '') or fi.get('content', '')
# 只检查 text 类型title/list/paragraph 产出table/image/chart 不动
if fi.get('type') == 'text' and text:
lines = [l.strip() for l in text.split('\n') if l.strip()]
if lines:
toc_hits = 0
for l in lines:
if _toc_dots.search(l):
toc_hits += 1
elif _toc_space_num.search(l) and len(l) < 40:
# 短行+空格+页码:典型的目录格式
toc_hits += 1
# TOC 判定逻辑:
# 1) 匹配率 > 50% → 确定是目录
# 2) 匹配率 > 25% 且平均行长 < 25字 → 目录(短行+页码是强信号)
avg_line_len = sum(len(l) for l in lines) / len(lines) if lines else 0
match_ratio = toc_hits / len(lines) if lines else 0
is_toc = (match_ratio > 0.5) or (match_ratio > 0.25 and avg_line_len < 25)
if is_toc:
_toc_filtered += 1
logger.debug(f"TOC 过滤命中({toc_hits}/{len(lines)}, avg={avg_line_len:.0f}): {text[:60]}...")
continue
elif toc_hits > 0:
logger.debug(f"TOC 部分匹配({toc_hits}/{len(lines)}): {text[:60]}...")
filtered_list.append(fi)
if _toc_filtered > 0:
logger.info(f"TOC 过滤第一轮: 移除 {_toc_filtered} 个目录块")
flat_list = filtered_list
# 第二轮:清理孤立的 TOC 标题(子条目被过滤后残留的父标题)
_toc_orphan = 0
final_list = []
for fi in flat_list:
text = (fi.get('text', '') or fi.get('content', '')).strip()
page = fi.get('page_idx', 0)
if fi.get('type') == 'text' and text and page <= 5:
# 孤立 "目录" 标题
if text == '目录':
_toc_orphan += 1
continue
# 短标题 + 尾部页码数字(如 "6 长江中下游河道状况 25"
if (len(text) < 40 and not text.endswith('') and not text.endswith('')
and re.search(r'\s+\d{1,3}\s*$', text)):
_toc_orphan += 1
continue
final_list.append(fi)
if _toc_orphan > 0:
logger.info(f"TOC 过滤第二轮: 移除 {_toc_orphan} 个孤立目录标题")
flat_list = final_list
# ── 单字符标题残留过滤 ──
# VLM 可能部分识别目录标题(如 "目录" → "录"),单字符标题几乎不会是有效章节
_single_char = 0
_sc_list = []
for fi in flat_list:
text = (fi.get('text', '') or fi.get('content', '')).strip()
if fi.get('type') == 'text' and text and len(text) <= 1 and fi.get('text_level', 0) > 0:
_single_char += 1
logger.debug(f"单字符标题过滤: '{text}' pg={fi.get('page_idx', 0)}")
continue
_sc_list.append(fi)
if _single_char > 0:
logger.info(f"单字符标题过滤: 移除 {_single_char} 个残留")
flat_list = _sc_list
# ── 封面重复标题去重 ──
# pg=0封面和 pg=1扉页常有完全相同的标题如 "三峡工程公报"、"2022"),保留较后的
_cover_seen = {} # text -> page_idx
_cover_dup = 0
dedup_list = []
for fi in flat_list:
text = (fi.get('text', '') or fi.get('content', '')).strip()
page = fi.get('page_idx', 0)
if fi.get('type') == 'text' and text and page <= 1 and fi.get('text_level', 0) > 0:
if text in _cover_seen:
_cover_dup += 1
logger.debug(f"封面重复标题过滤: '{text}' pg={page} (首次 pg={_cover_seen[text]})")
continue
_cover_seen[text] = page
dedup_list.append(fi)
if _cover_dup > 0:
logger.info(f"封面去重: 移除 {_cover_dup} 个重复封面标题")
flat_list = dedup_list
logger.info(f"v2 格式转换: {len(v2_data)} 页 → {len(flat_list)} 项(已过滤噪音类型)")
return flat_list
@@ -628,6 +861,7 @@ def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]
elif item_type == "table":
table_body = item.get("html", "") or item.get("table_body", "")
table_caption = item.get("caption", "") or item.get("table_caption", "")
table_footnote = item.get("_v2_table_footnote", "")
img_path = item.get("img_path", "") or item.get("image_path", "")
section_path = " > ".join([s[1] for s in section_stack])
@@ -642,8 +876,13 @@ def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]
# 从原始 HTML 提取嵌入图片md_table 经 get_text 转换后已丢失 <img> 标签)
table_images = extract_images_from_markdown(table_body) if table_body else []
# 表格内容增强caption + footnote
table_content = table_caption or "表格"
if table_footnote:
table_content = f"{table_content}\n[脚注] {table_footnote}"
chunk = MinerUChunk(
content=table_caption or "表格",
content=table_content,
chunk_type="table",
page_start=page_idx + 1,
page_end=page_idx + 1,
@@ -653,7 +892,9 @@ def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]
source_file=file_path.name,
table_html=table_body,
image_path=img_path,
images=table_images if table_images else None
images=table_images if table_images else None,
table_type=item.get("_v2_table_type", ""),
table_nest_level=item.get("_v2_table_nest_level", ""),
)
chunks.append(chunk)
if table_body:
@@ -664,16 +905,29 @@ def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]
elif item_type in ("image", "chart"):
img_path = item.get("img_path", "") or item.get("image_path", "")
caption = item.get("caption", "")
vlm_desc = item.get("_v2_vlm_description", "")
chart_md = item.get("_v2_chart_markdown", "")
sub_type = item.get("sub_type", "")
section_path = " > ".join([s[1] for s in section_stack])
markdown_parts.append(f"\n![{caption}]({img_path})")
# chart 的 VLM 数据表也写入 markdown 输出
if chart_md:
markdown_parts.append(chart_md)
chunk_type = "chart" if item_type == "chart" else "image"
context_before, context_after = get_context_for_image(idx, page_idx)
# 图片内容增强caption + VLM 描述
content_text = caption or ("图表" if item_type == "chart" else "图片")
if vlm_desc:
content_text = f"{content_text}\n[视觉描述] {vlm_desc}"
if chart_md:
content_text = f"{content_text}\n[数据表]\n{chart_md}"
chunk = MinerUChunk(
content=caption or ("图表" if item_type == "chart" else "图片"),
content=content_text,
chunk_type=chunk_type,
page_start=page_idx + 1,
page_end=page_idx + 1,
@@ -683,11 +937,18 @@ def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]
source_file=file_path.name,
image_path=img_path,
context_before=context_before,
context_after=context_after
context_after=context_after,
vlm_description=vlm_desc,
chart_markdown=chart_md,
table_html=chart_md if chart_md else None, # chart 数据表作为表格存储
sub_type=sub_type,
)
chunks.append(chunk)
if img_path:
images.append(img_path)
# chart 数据表也加入 tables 列表,便于表格检索
if chart_md:
tables.append(chart_md)
elif item_type == "equation":
# 处理公式类型
@@ -1148,6 +1409,7 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
elif item_type == "table":
table_body = item.get("table_body", "")
table_caption = item.get("table_caption", "")
table_footnote = item.get("_v2_table_footnote", "")
# 表格也可能有图片形式img_path
img_path = item.get("img_path", "")
@@ -1163,8 +1425,13 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
# 从原始 HTML 提取嵌入图片md_table 经 get_text 转换后已丢失 <img> 标签)
table_images = extract_images_from_markdown(table_body) if table_body else []
# 表格内容增强caption + footnote
table_content = table_caption or "表格"
if table_footnote:
table_content = f"{table_content}\n[脚注] {table_footnote}"
chunk = MinerUChunk(
content=table_caption or "表格",
content=table_content,
chunk_type="table",
page_start=page_idx + 1,
page_end=page_idx + 1,
@@ -1174,7 +1441,9 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
source_file=file_path.name,
table_html=table_body,
image_path=img_path, # 表格的独立图片形式
images=table_images if table_images else None # 嵌入图片列表
images=table_images if table_images else None, # 嵌入图片列表
table_type=item.get("_v2_table_type", ""),
table_nest_level=item.get("_v2_table_nest_level", ""),
)
chunks.append(chunk)
if table_body:
@@ -1187,10 +1456,15 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
# 处理图片和图表类型MinerU 将图表识别为 chart 类型)
img_path = item.get("img_path", "")
caption = item.get("caption", "")
vlm_desc = item.get("_v2_vlm_description", "")
chart_md = item.get("_v2_chart_markdown", "")
sub_type = item.get("sub_type", "")
section_path = " > ".join([s[1] for s in section_stack])
markdown_parts.append(f"\n![{caption}]({img_path})")
if chart_md:
markdown_parts.append(chart_md)
# 图表类型标记为 chart便于后续区分处理
chunk_type = "chart" if item_type == "chart" else "image"
@@ -1198,8 +1472,15 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
# 获取图片上下文
context_before, context_after = get_context_for_image(idx, page_idx)
# 图片内容增强caption + VLM 描述
content_text = caption or ("图表" if item_type == "chart" else "图片")
if vlm_desc:
content_text = f"{content_text}\n[视觉描述] {vlm_desc}"
if chart_md:
content_text = f"{content_text}\n[数据表]\n{chart_md}"
chunk = MinerUChunk(
content=caption or ("图表" if item_type == "chart" else "图片"),
content=content_text,
chunk_type=chunk_type,
page_start=page_idx + 1,
page_end=page_idx + 1,
@@ -1209,11 +1490,17 @@ def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
source_file=file_path.name,
image_path=img_path,
context_before=context_before,
context_after=context_after
context_after=context_after,
vlm_description=vlm_desc,
chart_markdown=chart_md,
table_html=chart_md if chart_md else None,
sub_type=sub_type,
)
chunks.append(chunk)
if img_path:
images.append(img_path)
if chart_md:
tables.append(chart_md)
elif item_type == "equation":
# 处理公式类型
@@ -1322,6 +1609,7 @@ def _post_process_chunks(
# Phase 2: 合并碎片
merged = []
buffer = None # 当前合并缓冲
_buffer_has_body = False # 缓冲是否已包含正文(防止标题继续合并)
for chunk in chunks:
# 表格、图片和图表不参与合并,直接输出
@@ -1329,13 +1617,34 @@ def _post_process_chunks(
if buffer:
merged.append(buffer)
buffer = None
_buffer_has_body = False
merged.append(chunk)
continue
# 标题 chunktext_level > 0,开始新的合并组
# 标题 chunktext_level > 0
if chunk.text_level > 0:
if buffer:
merged.append(buffer)
# H1 级标题是章节边界,强制断开,不参与连续标题链合并
if chunk.text_level == 1:
merged.append(buffer)
_buffer_has_body = False
# 连续标题链合并:缓冲也是纯标题(无正文)时,合并而非刷新(仅非 H1
elif buffer.text_level > 0 and not _buffer_has_body:
combined = buffer.content.rstrip() + '\n' + chunk.content
if len(combined) <= max_merged_size:
buffer.content = combined
buffer.page_end = chunk.page_end
# 取更高层级(数值更小)
buffer.text_level = min(buffer.text_level, chunk.text_level)
# section_path 保留第一个(更高级别)的
continue
# 合并后超限,刷新缓冲
merged.append(buffer)
_buffer_has_body = False
else:
# 缓冲是正文,正常刷新
merged.append(buffer)
_buffer_has_body = False
# 标题作为新缓冲的起点
buffer = MinerUChunk(
content=chunk.content,
@@ -1348,6 +1657,7 @@ def _post_process_chunks(
bbox=chunk.bbox,
source_file=chunk.source_file,
)
_buffer_has_body = False
continue
# 正文 chunk
@@ -1367,6 +1677,7 @@ def _post_process_chunks(
bbox=chunk.bbox,
source_file=chunk.source_file,
)
_buffer_has_body = True # 正文 chunk 创建的缓冲已含正文
else:
# 足够长,直接输出
merged.append(chunk)
@@ -1377,6 +1688,9 @@ def _post_process_chunks(
# 合并
buffer.content = buffer.content.rstrip() + '\n' + chunk.content
buffer.page_end = chunk.page_end
# 正文并入标题缓冲后,标记已含正文,防止后续标题继续合并
# 保留 text_level 不置零,使标题层级信息传递到向量库
_buffer_has_body = True
else:
# 超过上限,输出缓冲,当前 chunk 开始新缓冲或直接输出
merged.append(buffer)
@@ -1392,8 +1706,10 @@ def _post_process_chunks(
bbox=chunk.bbox,
source_file=chunk.source_file,
)
_buffer_has_body = True # 正文 chunk 创建的缓冲已含正文
else:
buffer = None
_buffer_has_body = False
merged.append(chunk)
# 刷新最后的缓冲