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:
@@ -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 归一化保留的层级数(按章节前两级分组,提升聚类精确度)
|
||||
|
||||
# ==============================================================================
|
||||
# 十一、可选功能配置
|
||||
|
||||
303
docs/RAG引用跳转-优化计划.md
Normal file
303
docs/RAG引用跳转-优化计划.md
Normal 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 转 HTML,2-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/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:`<PdfViewer :highlight-bbox="citationBbox" ...>`
|
||||
|
||||
**风险点**:
|
||||
- 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
|
||||
<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 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 编辑器集成,超出引用溯源范畴。
|
||||
- **引用覆盖率统计**:回答中每个段落的引用覆盖情况可视化。需要前端渲染层深度改造。
|
||||
- **跨文档对比视图**:多个引用来自不同文档时,左右分屏对比。当前单文档预览已满足需求。
|
||||
@@ -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`。
|
||||
|
||||
196
docs/云端MinerU输出分析与优化方案.md
Normal file
196
docs/云端MinerU输出分析与优化方案.md
Normal 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 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 + 嵌入图片 |
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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,13 +455,94 @@ 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)
|
||||
|
||||
@@ -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")
|
||||
# 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")
|
||||
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:
|
||||
# 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)
|
||||
|
||||
# 刷新最后的缓冲
|
||||
|
||||
Reference in New Issue
Block a user