From ee5295cc9759f98356063c73df21411b14d7f5a6 Mon Sep 17 00:00:00 2001 From: lacerate551 <128470311+lacerate551@users.noreply.github.com> Date: Fri, 19 Jun 2026 23:56:13 +0800 Subject: [PATCH] =?UTF-8?q?fix(parser):=20PDF/DOCX=20=E5=88=87=E7=89=87?= =?UTF-8?q?=E5=B1=82=E7=BA=A7=E7=BB=93=E6=9E=84=E4=BF=AE=E5=A4=8D=E4=B8=8E?= =?UTF-8?q?=20MinerU=20V2=20=E8=A7=A3=E6=9E=90=E5=A2=9E=E5=BC=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 核心修复: - _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 --- config.py | 4 +- docs/RAG引用跳转-优化计划.md | 303 ++++++++++++++++++++++ docs/RAG检索流程逻辑.md | 300 +++++++++++++++++++++- docs/云端MinerU输出分析与优化方案.md | 196 +++++++++++++++ knowledge/manager.py | 20 ++ parsers/heading_rules.py | 41 ++- parsers/mineru_parser.py | 362 +++++++++++++++++++++++++-- 7 files changed, 1193 insertions(+), 33 deletions(-) create mode 100644 docs/RAG引用跳转-优化计划.md create mode 100644 docs/云端MinerU输出分析与优化方案.md diff --git a/config.py b/config.py index 83ff452..2144879 100644 --- a/config.py +++ b/config.py @@ -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 归一化保留的层级数(按章节前两级分组,提升聚类精确度) # ============================================================================== # 十一、可选功能配置 diff --git a/docs/RAG引用跳转-优化计划.md b/docs/RAG引用跳转-优化计划.md new file mode 100644 index 0000000..0c76f4a --- /dev/null +++ b/docs/RAG引用跳转-优化计划.md @@ -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] → 渲染为 [N] + ↓ +用户点击引用项 → 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 转 HTML,2-gram Jaccard 段落匹配,`` 高亮 + 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/page,Word 含 section_chunk_id | bbox 已返回但前端未使用;content 截断 300 字 | + +#### 1.3 后端已提供但前端未消费的字段 + +| 字段 | 后端来源 | 当前状态 | +|------|---------|---------| +| `bbox` | ChromaDB metadata → `_build_citation` 解析 JSON → 返回数组 | PdfViewer 未接收,未渲染高亮 | +| `sub_type` | ChromaDB metadata(MinerU V2 解析) | `_build_citation` 未返回此字段 | +| `table_type` | ChromaDB metadata(MinerU V2 解析) | `_build_citation` 未返回此字段 | +| `vlm_description` | MinerUChunk(VLM 视觉描述) | 未进入 citation 数据 | +| `chart_markdown` | MinerUChunk(图表数据表) | 未进入 citation 数据 | + +--- + +### 二、优化项清单 + +#### Phase 1:PDF 页内 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:`` + +**风险点**: +- MinerU 的 bbox 坐标系需确认。PDF 标准坐标系原点左下角、y 向上;PDF.js viewport 默认原点左上角、y 向下。`viewport.convertToViewportPoint` 应能处理转换,但需实际测试坐标是否正确。 +- 部分切片可能没有 bbox(DOCX 来源、或 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 +
+ [{{ c.displayIndex }}] + + 表格 + + {{ c.sub_type || '图片' }} + + 第{{ c.page }}页 + {{ c.section }} +
+``` + +**效果**:用户可以一眼看出引用的是文本、表格还是图表,点击前有预期。 + +--- + +#### 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 +
+
+ + 表格 + {{ chunk.metadata.sub_type }} +
+ +
+ +
+ +
+ +
{{ chunk.document || chunk.content }}
+
+``` + +**注意**:preview API 返回的 chunk metadata 中是否包含 `table_html`、`image_path` 等字段,需确认后端 `/documents//preview` 接口是否透传了这些字段。如果未透传,需要后端配合补充。 + +--- + +#### Phase 5:DOCX 多段落高亮(可选) + +**目标**:当一个切片内容跨多个段落时,高亮所有匹配段落而非仅最佳匹配的一个。 + +**涉及文件**: +- `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 1:PDF 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 5:DOCX 多段落高亮 | 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 编辑器集成,超出引用溯源范畴。 +- **引用覆盖率统计**:回答中每个段落的引用覆盖情况可视化。需要前端渲染层深度改造。 +- **跨文档对比视图**:多个引用来自不同文档时,左右分屏对比。当前单文档预览已满足需求。 diff --git a/docs/RAG检索流程逻辑.md b/docs/RAG检索流程逻辑.md index a76be8f..5ffbe1f 100644 --- a/docs/RAG检索流程逻辑.md +++ b/docs/RAG检索流程逻辑.md @@ -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` 字段是同一文本的三次重复: + +``` +三、做好货源投放前的基础工作 ← title(text_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 个标题切片如果被拉入就会累积显著开销。 + +#### 失配 6:MMR 去重对标题切片的过度消除(模式相关) + +**问题**: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 切片有 bbox,DOCX 切片无此信息 + +**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 输出有显著差异: + +| 特征 | DOCX(office 后端) | PDF(hybrid/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`。 diff --git a/docs/云端MinerU输出分析与优化方案.md b/docs/云端MinerU输出分析与优化方案.md new file mode 100644 index 0000000..8a65ba2 --- /dev/null +++ b/docs/云端MinerU输出分析与优化方案.md @@ -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 DOCX(office 后端) + +| 字段 | 状态 | 说明 | +|------|------|------| +| `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 PDF(hybrid 后端,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 解析 Bug(P0,已完成 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 metadata(JSON 序列化,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 存入 ChromaDB(JSON 序列化) | +| `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+level,heading_rules 做补充校正 | +| 图片检索 | 仅靠 caption | VLM 描述 + caption 双通道 | +| 图表处理 | 无 | chart 类型含 markdown 数据表 | +| 列表处理 | 无独立 list 类型 | list 类型保留结构化列表 | +| 表格增强 | table_type/nest_level | table_type + footnote + 嵌入图片 | diff --git a/knowledge/manager.py b/knowledge/manager.py index 711779f..150d301 100644 --- a/knowledge/manager.py +++ b/knowledge/manager.py @@ -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() diff --git a/parsers/heading_rules.py b/parsers/heading_rules.py index 2bba751..23acfe1 100644 --- a/parsers/heading_rules.py +++ b/parsers/heading_rules.py @@ -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 diff --git a/parsers/mineru_parser.py b/parsers/mineru_parser.py index 99451a0..0f34a22 100644 --- a/parsers/mineru_parser.py +++ b/parsers/mineru_parser.py @@ -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_content,DOCX 理论上不应出现 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 转换后已丢失 标签) 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 转换后已丢失 标签) 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 - # 标题 chunk(text_level > 0),开始新的合并组 + # 标题 chunk(text_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) # 刷新最后的缓冲