From fda1b2f049ad330c55751f75769c5abc8f4bbf8f Mon Sep 17 00:00:00 2001
From: lacerate551 <128470311+lacerate551@users.noreply.github.com>
Date: Wed, 10 Jun 2026 12:10:22 +0800
Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BB=8E=20main=20=E5=88=86=E6=94=AF?=
=?UTF-8?q?=E8=BF=81=E7=A7=BB=E8=A7=A3=E6=9E=90=E5=99=A8=E4=B8=8E=E6=A3=80?=
=?UTF-8?q?=E7=B4=A2=E4=BC=98=E5=8C=96=EF=BC=88=E4=B8=8D=E5=BD=B1=E5=93=8D?=
=?UTF-8?q?=20API=20=E6=8E=A5=E5=8F=A3=EF=BC=89?=
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- 新增 heading_rules.py 标题规则引擎,替换硬编码正则链
- 重写 mineru_parser.py:v2 格式兼容、表单自动检测
- manager.py: 跨页表格合并规则收紧(防误合并)+ LLM token 上限提升
- engine.py: embedding 缓存 + 同 section 表格邻居扩展 + prompt 改进
- router.py: 路由分类 token 上限提升至 512
---
core/engine.py | 122 +-
knowledge/manager.py | 78 +-
knowledge/router.py | 2 +-
parsers/heading_rules.py | 347 ++++
parsers/mineru_parser.py | 3672 ++++++++++++++++++++------------------
5 files changed, 2498 insertions(+), 1723 deletions(-)
create mode 100644 parsers/heading_rules.py
diff --git a/core/engine.py b/core/engine.py
index b4468b6..f20f57d 100644
--- a/core/engine.py
+++ b/core/engine.py
@@ -625,7 +625,7 @@ class RAGEngine:
elif len(conditions) > 1:
where_filter = {"$and": conditions}
- query_vector = self.embedding_model.encode(query).tolist()
+ query_vector = self._encode_cached(query).tolist()
recall_k = RERANK_CANDIDATES if (USE_RERANK or USE_HYBRID_SEARCH) else top_k
recall_k = max(recall_k, top_k * RECALL_MULTIPLIER)
@@ -811,6 +811,76 @@ class RAGEngine:
logger.warning(f"FAQ 集合查询失败: {e}")
return get_empty_result()
+ def _encode_cached(self, text):
+ """
+ 带缓存的 embedding 编码
+
+ 优先从 Embedding Cache(LRU)读取,未命中再调用模型编码并写入缓存。
+ 支持单文本和批量文本输入。
+
+ Args:
+ text: 单个文本字符串 或 文本列表
+
+ Returns:
+ numpy 数组(单文本为一维,批量为二维)
+ """
+ import numpy as _np
+
+ # 检查 embedding 缓存是否启用(缓存配置查询结果,避免每次重复导入)
+ if not hasattr(self, '_emb_cache_enabled'):
+ self._emb_cache_enabled = True # 默认启用
+ if CACHE_AVAILABLE:
+ try:
+ from config import EMBEDDING_CACHE_ENABLED
+ self._emb_cache_enabled = EMBEDDING_CACHE_ENABLED
+ except ImportError:
+ pass
+
+ if not self._emb_cache_enabled:
+ return self.embedding_model.encode(text)
+
+ try:
+ _cache = get_cache_manager()
+ except Exception:
+ return self.embedding_model.encode(text)
+
+ # 批量输入
+ if isinstance(text, list):
+ try:
+ cached_embs, missed_indices = _cache.get_embeddings_batch(text)
+ if missed_indices:
+ missed_texts = [text[i] for i in missed_indices]
+ # encode(list) 始终返回 2D ndarray,直接按行索引即可
+ new_embs = self.embedding_model.encode(missed_texts)
+ if len(missed_indices) == 1:
+ # 单条时 encode 可能返回 1D,需统一处理
+ if new_embs.ndim == 1:
+ new_embs = new_embs.reshape(1, -1)
+ for idx, mi in enumerate(missed_indices):
+ emb_list = new_embs[idx].tolist()
+ cached_embs[mi] = emb_list
+ try:
+ _cache.set_embedding(text[mi], emb_list)
+ except Exception:
+ pass
+ return _np.array(cached_embs)
+ except Exception:
+ # 缓存故障时优雅降级为直接编码
+ return self.embedding_model.encode(text)
+
+ # 单文本输入
+ cached = _cache.get_embedding(text)
+ if cached is not None:
+ return _np.array(cached)
+
+ embedding = self.embedding_model.encode(text)
+ try:
+ emb_list = embedding.tolist() if hasattr(embedding, 'tolist') else list(embedding)
+ _cache.set_embedding(text, emb_list)
+ except Exception:
+ pass
+ return embedding
+
def _search_image_chunks(self, query_vector: list, top_k: int = 5, where_filter: dict = None) -> dict:
"""
独立检索图片切片(P0:图片独立召回通道)
@@ -1171,6 +1241,20 @@ class RAGEngine:
if not neighbors.get('ids') or len(neighbors.get('ids', [])) <= 1:
neighbors = _get_neighbors({"$and": [{"source": source}, {"chunk_type": "text"}]})
+ # 同时扩展同 section 的 table 邻居(table 切片的 rerank 分数往往偏低,
+ # 但与同 section 的 text 切片属于同一语义单元,不应割裂)
+ table_where = {"$and": [{"source": source}, {"chunk_type": "table"}]}
+ if section:
+ table_where["$and"].append({"section": section})
+ table_neighbors = _get_neighbors(table_where)
+
+ # 当 section 为空时,table 查询只有 source 条件,可能拉入大量无关表格,
+ # 缩小 chunk_index 窗口至 ±1 以降低噪音;有 section 时使用正常窗口
+ if section:
+ _t_before, _t_after = CONTEXT_EXPANSION_BEFORE, CONTEXT_EXPANSION_AFTER
+ else:
+ _t_before, _t_after = 1, 1
+
neighbor_rows = []
for n_id, n_doc, n_meta in zip(
neighbors.get('ids', []),
@@ -1183,6 +1267,18 @@ class RAGEngine:
if seed_index - CONTEXT_EXPANSION_BEFORE <= n_index <= seed_index + CONTEXT_EXPANSION_AFTER:
neighbor_rows.append((n_index, n_id, n_doc, n_meta))
+ # 同 section 的 table 邻居也加入扩展范围
+ for n_id, n_doc, n_meta in zip(
+ table_neighbors.get('ids', []),
+ table_neighbors.get('documents', []),
+ table_neighbors.get('metadatas', [])
+ ):
+ n_index = self._to_int(n_meta.get('chunk_index'))
+ if n_index is None:
+ continue
+ if seed_index - _t_before <= n_index <= seed_index + _t_after:
+ neighbor_rows.append((n_index, n_id, n_doc, n_meta))
+
seed_neighbors_added = 0
for n_index, n_id, n_doc, n_meta in sorted(neighbor_rows, key=lambda row: row[0]):
if len(items) >= max_chunks:
@@ -1387,7 +1483,7 @@ class RAGEngine:
if not target_collections:
return get_empty_result()
- query_vector = self.embedding_model.encode(query).tolist()
+ query_vector = self._encode_cached(query).tolist()
# 扩大召回数量,以便过滤废止切片后仍有足够结果
recall_k = RERANK_CANDIDATES if (USE_RERANK or USE_HYBRID_SEARCH) else top_k
recall_k = max(recall_k, top_k * RECALL_MULTIPLIER)
@@ -1739,12 +1835,12 @@ class RAGEngine:
# === 高精度版:基于语义向量 ===
from core.mmr import mmr_rerank
- # 获取查询向量
- query_emb = np.array(self.embedding_model.encode(query))
+ # 获取查询向量(使用 embedding 缓存)
+ query_emb = np.array(self._encode_cached(query))
- # 批量编码所有文档
+ # 批量编码所有文档(使用 embedding 缓存)
docs_list = results['documents'][0]
- all_embeddings = self.embedding_model.encode(docs_list)
+ all_embeddings = self._encode_cached(docs_list)
# 构建候选列表
candidates = []
@@ -2068,21 +2164,33 @@ class RAGEngine:
"content": (
"你是一个严谨的知识库问答助手。"
"你必须且只能根据用户提供的【参考资料】回答问题。"
+ "参考资料中每段内容前标有章节路径(━格式),请注意区分不同章节的内容,"
+ "特别当不同章节标题相似或包含相同关键词时,务必根据章节路径准确定位,不要混淆。"
"如果参考资料中有答案,必须引用对应内容回答,并在回答末尾标注引用编号(如[1]、[2])。"
"如果参考资料中确实没有相关信息,简短说明即可,不要编造或补充资料外的内容。"
"禁止使用参考资料以外的知识进行补充或推测。"
+ "【重要-表格处理规则】当用户询问表格、要求展示表格内容时,你必须将参考资料中的 Markdown 表格原样输出(保留 | 分隔符和表格结构),"
+ "不要仅用文字描述表格存在或仅列出章节名称。如果参考资料中多个章节都有表格,"
+ "优先展示与用户问题最相关的表格完整内容。"
)
})
# 添加当前问题(带上下文)- 强化指令
if context:
+ # 检测用户问题是否涉及表格,加入针对性指令
+ _table_hint = ""
+ # 检测上下文中是否包含 Markdown 表格(数据驱动,无需硬编码关键词)
+ _has_table_in_context = bool(re.search(r'\|.+\|', context)) if context else False
+ if _has_table_in_context:
+ _table_hint = "\n注意:参考资料中包含 Markdown 格式的表格数据,请务必将相关表格以原始 Markdown 表格格式完整展示在回答中,不要仅用文字描述。"
+
user_message = f"""【参考资料】
{context}
【用户问题】
{query}
-请仔细阅读以上全部参考资料后回答。如果参考资料中包含相关内容,必须引用回答并标注编号。如果资料中没有相关信息,请明确说明。"""
+请仔细阅读以上全部参考资料后回答。注意参考资料中标有章节路径,请根据章节路径准确定位相关内容。如果参考资料中包含相关内容,必须引用回答并标注编号。如果资料中没有相关信息,请明确说明。{_table_hint}"""
else:
user_message = query
diff --git a/knowledge/manager.py b/knowledge/manager.py
index 346698c..1c795bf 100644
--- a/knowledge/manager.py
+++ b/knowledge/manager.py
@@ -412,6 +412,11 @@ class KnowledgeBaseManager(
if len(chunks) < 2:
return chunks
+ # 检测页码是否可靠:若所有 chunk 的 page_start 相同(如 Word 文档 page_idx 全为 0),
+ # 则页码信息不可用,需要启用降级合并规则
+ page_values = set(getattr(c, 'page_start', 0) for c in chunks)
+ pages_unavailable = len(page_values) <= 1
+
merged_chunks = []
i = 0
merge_count = 0
@@ -424,6 +429,7 @@ class KnowledgeBaseManager(
# 查找下一个表格(跳过中间的"续表"文本)
next_table_idx = None
next_chunk = None
+ intermediate_texts = [] # 收集中间文本用于降级判断
for j in range(i + 1, min(i + 4, len(chunks))): # 最多向前看3个切片
candidate = chunks[j]
@@ -438,7 +444,11 @@ class KnowledgeBaseManager(
elif candidate_type == 'text' and ('续表' in candidate_title or '续表' in candidate_content):
# 遇到"续表"文本,继续查找下一个表格
continue
- elif candidate_type not in ('text',):
+ elif candidate_type == 'text':
+ # 非"续表"文本,收集后停止查找
+ intermediate_texts.append(candidate)
+ break
+ else:
# 遇到非文本类型,停止查找
break
@@ -458,24 +468,59 @@ class KnowledgeBaseManager(
# 获取内容(用于检测"续表")
next_content = getattr(next_chunk, 'content', '')
+ # 通用/无意义标题集合,这些标题不能用于"标题相似"判定
+ _GENERIC_TITLES = {'表格', 'table', '表格', ''}
+
# 判断是否为跨页表格
is_cross_page = False
# 规则1: 页码连续(如果页码有效)
page_valid = curr_page_end > 0 and next_page_start > 0
if page_valid and curr_page_end + 1 == next_page_start:
- is_cross_page = True
+ # 页码连续时,还需标题匹配或为通用标题才合并
+ # 避免把不同页面上不相关的表格错误合并
+ if curr_title == next_title or curr_title in _GENERIC_TITLES and next_title in _GENERIC_TITLES:
+ is_cross_page = True
+ elif curr_title and next_title:
+ clean_next_r1 = next_title.replace('续表', '').strip()
+ if curr_title in clean_next_r1 or clean_next_r1 in curr_title:
+ is_cross_page = True
# 规则2: 第二个表格标题或内容包含"续表"
elif '续表' in next_title or '续表' in next_content:
is_cross_page = True
# 规则3: 标题相似(去掉"续表"后比较)
- elif curr_title and next_title:
+ # 排除通用标题(如"表格"),防止把所有标题为"表格"的相邻表格都误合并
+ elif (curr_title and next_title
+ and curr_title not in _GENERIC_TITLES
+ and next_title not in _GENERIC_TITLES):
clean_next = next_title.replace('续表', '').strip()
- if curr_title in clean_next or clean_next in curr_title:
+ if clean_next and (curr_title in clean_next or clean_next in curr_title):
is_cross_page = True
+ # 规则4(降级): 页码不可用(如 Word 文档 page_idx 全为 0)
+ # 仅当页码信息缺失时才启用此规则,避免 PDF 正常页码时被误合并
+ if (not is_cross_page
+ and pages_unavailable
+ and curr_title in _GENERIC_TITLES
+ and next_title in _GENERIC_TITLES):
+ # 检查中间文本是否暗示跨页延续(空、短文本、续表标记等)
+ has_separating_content = False
+ for text_chunk in intermediate_texts:
+ tc = (getattr(text_chunk, 'content', '') or '').strip()
+ tt = (getattr(text_chunk, 'title', '') or '').strip()
+ if not tc:
+ continue # 空文本不算分隔
+ if '续表' in tc or '续表' in tt:
+ continue # 续表标记,说明是跨页
+ # 有实质性中间内容(如分类标题"A3类:xxx"),不合并
+ has_separating_content = True
+ break
+ if not has_separating_content:
+ is_cross_page = True
+ logger.debug(f"降级合并(页码不可用): '{curr_title}' + '{next_title}'")
+
if is_cross_page:
# 执行合并
merge_count += 1
@@ -488,14 +533,27 @@ class KnowledgeBaseManager(
# 合并两个表格的 HTML
current.table_html = curr_html + '\n' + next_html
- # 合并 image_path 到 images
+ # 合并 image_path 和嵌入图片到 images
curr_img = getattr(current, 'image_path', None)
next_img = getattr(next_chunk, 'image_path', None)
- merged_images = []
- if curr_img:
+ curr_images = getattr(current, 'images', None) or []
+ next_images = getattr(next_chunk, 'images', None) or []
+
+ # 合并两个表格的所有图片(image_path + 嵌入图片)
+ merged_images = list(curr_images) # 保留当前表格的嵌入图片
+ # 添加 image_path 图片(如果不在列表中)
+ existing_ids = {img.get('id', '') for img in merged_images if isinstance(img, dict)}
+ if curr_img and curr_img not in existing_ids:
merged_images.append({'id': curr_img, 'page': curr_page_end})
- if next_img:
+ existing_ids.add(curr_img)
+ for img in next_images: # 添加下一个表格的嵌入图片
+ img_id = img.get('id', '') if isinstance(img, dict) else ''
+ if img_id and img_id not in existing_ids:
+ merged_images.append(img)
+ existing_ids.add(img_id)
+ if next_img and next_img not in existing_ids:
merged_images.append({'id': next_img, 'page': next_page_start})
+
if merged_images:
current.images = merged_images
# 保留第一个图片作为主 image_path
@@ -544,7 +602,7 @@ class KnowledgeBaseManager(
try:
from config import get_llm_client, DASHSCOPE_MODEL
client = get_llm_client()
- summary = call_llm(client, prompt, DASHSCOPE_MODEL, max_tokens=100)
+ summary = call_llm(client, prompt, DASHSCOPE_MODEL, max_tokens=512)
return summary.strip() if summary else ""
except Exception as e:
logger.warning(f"生成表格摘要失败: {e}")
@@ -603,7 +661,7 @@ class KnowledgeBaseManager(
]
}
],
- max_tokens=200
+ max_tokens=512
)
description = response.choices[0].message.content
diff --git a/knowledge/router.py b/knowledge/router.py
index ad34123..5793dc6 100644
--- a/knowledge/router.py
+++ b/knowledge/router.py
@@ -283,7 +283,7 @@ class KnowledgeBaseRouter:
content = call_llm(
self.llm_client, prompt, MODEL,
temperature=0.1,
- max_tokens=100
+ max_tokens=512
)
if content is None:
diff --git a/parsers/heading_rules.py b/parsers/heading_rules.py
new file mode 100644
index 0000000..2bba751
--- /dev/null
+++ b/parsers/heading_rules.py
@@ -0,0 +1,347 @@
+# -*- coding: utf-8 -*-
+"""
+标题识别规则引擎
+
+将 _detect_heading_level 的硬编码正则提取为可配置的规则列表。
+规则按优先级从高到低排序,第一个匹配即返回。
+
+MinerU 解析 DOCX 等 Office 格式时通常不提供 text_level(全部为 0),
+此时需要启发式识别标题层级。本模块提供可配置的规则引擎替代原来的
+硬编码 if-elif 链。
+
+设计要点:
+- HeadingRule 数据类支持正向匹配(pattern)和反向排除(exclude_pattern)
+- 长度约束(min_length / max_length)可精确控制匹配范围
+- 规则可单独禁用(enabled=False),便于调试
+- 全局单例通过 config.py 覆盖默认值
+
+MinerU v2 格式备注:
+ content_list_v2.json 中的 paragraph_content 包含 style=["bold"] 信息,
+ layout.json 中的 spans 也有 style 信息。这些信息比正则匹配 **加粗** 更可靠,
+ 但当前代码使用 v1 格式(content_list.json),暂不利用 v2 的 style。
+ HeadingRuleEngine.detect() 签名预留了 style 参数,未来切换到 v2 格式后
+ 可直接利用 style 信息辅助判断。
+"""
+
+import re
+import logging
+from dataclasses import dataclass
+from typing import Optional, List, Tuple
+
+logger = logging.getLogger(__name__)
+
+
+@dataclass
+class HeadingRule:
+ """
+ 标题识别规则
+
+ 每条规则定义一个文本模式到标题级别的映射。
+ 规则引擎按列表顺序逐条匹配,第一个命中即返回。
+
+ Attributes:
+ pattern: 编译后的正则(match 语义,从文本开头匹配)
+ level: 匹配时返回的标题级别 (1=h1, 2=h2, 3=h3)
+ name: 规则名称(用于日志和配置覆盖)
+ max_length: 文本最大长度,0=不限
+ min_length: 文本最小长度,0=不限
+ enabled: 是否启用
+ exclude_pattern: 匹配此模式则排除(反向过滤)
+
+ Example:
+ >>> rule = HeadingRule(
+ ... pattern=re.compile(r'^第[一二三四五六七八九十百千万]+[章节篇部]'),
+ ... level=1,
+ ... name="chinese_chapter",
+ ... )
+ >>> rule.match("第一章 总则")
+ 1
+ >>> rule.match("这是正文")
+ 0
+ """
+
+ pattern: re.Pattern
+ level: int
+ name: str
+ max_length: int = 0
+ min_length: int = 0
+ enabled: bool = True
+ exclude_pattern: Optional[re.Pattern] = None
+
+ def match(self, text: str) -> int:
+ """
+ 检查文本是否匹配此规则
+
+ Args:
+ text: 待检测文本(调用前应已 strip)
+
+ Returns:
+ 标题级别,0 表示不匹配
+ """
+ if not self.enabled:
+ return 0
+ if self.min_length > 0 and len(text) < self.min_length:
+ return 0
+ if self.max_length > 0 and len(text) > self.max_length:
+ return 0
+ if self.exclude_pattern and self.exclude_pattern.search(text):
+ return 0
+ if self.pattern.match(text):
+ return self.level
+ return 0
+
+
+# 默认规则列表(按优先级从高到低)
+#
+# 注意事项:
+# - 数字三级标题 (1.1.1) 必须在二级 (1.1) 之前,因为 1.1.1 也匹配 ^\d+\.\d+
+# - short_chinese_heading 是最宽泛的规则,放在最后作为兜底
+# - 第 9 条规则相比原版增加了 exclude_pattern,排除以句末标点结尾的短文本
+DEFAULT_HEADING_RULES: List[HeadingRule] = [
+ # 1. 中文章节标题 -> h1
+ # 匹配:第一章、第二章、第十节、第三篇 等
+ HeadingRule(
+ pattern=re.compile(r'^第[一二三四五六七八九十百千万]+[章节篇部]'),
+ level=1,
+ name="chinese_chapter",
+ ),
+ # 2. 中文条款编号 -> h2
+ # 匹配:第一条、第三款 等
+ HeadingRule(
+ pattern=re.compile(r'^第[一二三四五六七八九十百千万]+[条款]'),
+ level=2,
+ name="chinese_article",
+ ),
+ # 3. 数字三级标题 -> h3(必须在二级之前匹配)
+ # 匹配:1.1.1 背景、2.3.4 方案 等
+ HeadingRule(
+ pattern=re.compile(r'^\d+\.\d+\.\d+[\.、\s]'),
+ level=3,
+ name="numeric_level3",
+ max_length=100,
+ ),
+ # 4. 数字二级标题 -> h2(必须在一级之前匹配)
+ # 匹配:1.1 背景、2.3 方案 等
+ HeadingRule(
+ pattern=re.compile(r'^\d+\.\d+[\.、\s]'),
+ 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,
+ ),
+ # 6. 英文章节标题 -> h1
+ # 匹配:Chapter 1、Section 2、Part 3 等
+ HeadingRule(
+ pattern=re.compile(r'^(Chapter|Section|Part|Chapter\s+\d+|Section\s+\d+)', re.IGNORECASE),
+ level=1,
+ name="english_chapter",
+ ),
+ # 7. 分类标题 -> h3(必须在 bold_short_text 之前,否则 **A2类:** 会被加粗规则抢先匹配)
+ # 匹配:A1类:公园、**A2类**:各类卫生医疗机构、**B1类:** 道路 等
+ HeadingRule(
+ pattern=re.compile(r'^\*{0,2}[A-Z]\d+[类類]\*{0,2}[::]'),
+ level=3,
+ name="category_heading",
+ ),
+ # 8. 加粗短文本 -> h2
+ # 匹配:**重要通知**、**概述** 等(Markdown 加粗标记)
+ # 注意:**A2类:** 已被分类标题规则优先匹配,不会误判为 h2
+ HeadingRule(
+ pattern=re.compile(r'^\*\*.+\*\*$'),
+ level=2,
+ name="bold_short_text",
+ max_length=50,
+ ),
+ # 9. 短中文文本 -> h2(替代原"任何 <20 字符含中文"规则)
+ # 关键改进:排除以句末标点结尾的文本
+ # 原规则将 "这是一段正文。" 也识别为 h2,导致大量误判
+ # 新规则:包含中文 + 长度 2-20 + 不以句末标点结尾 → h2
+ HeadingRule(
+ pattern=re.compile(r'[一-鿿]'),
+ level=2,
+ name="short_chinese_heading",
+ max_length=20,
+ min_length=2,
+ exclude_pattern=re.compile(r'[。!?;…]$'),
+ enabled=True,
+ ),
+]
+
+
+class HeadingRuleEngine:
+ """
+ 标题识别规则引擎
+
+ 按规则列表顺序逐条匹配,第一个命中即返回标题级别。
+ 支持从 config.py 加载自定义规则或覆盖默认规则参数。
+
+ Example:
+ >>> engine = HeadingRuleEngine()
+ >>> engine.detect("第一章 总则")
+ (1, 'chinese_chapter')
+ >>> engine.detect("这是普通正文。")
+ (0, None)
+ """
+
+ def __init__(self, rules: Optional[List[HeadingRule]] = None) -> None:
+ """
+ Args:
+ rules: 规则列表,None 则使用默认规则的深拷贝
+ """
+ if rules is not None:
+ self.rules: List[HeadingRule] = rules
+ else:
+ import copy
+ self.rules = copy.deepcopy(DEFAULT_HEADING_RULES)
+
+ def detect(self, text: str, style: Optional[List[str]] = None) -> Tuple[int, Optional[str]]:
+ """
+ 检测文本的标题级别
+
+ Args:
+ text: 待检测文本
+ style: MinerU v2 格式中的 style 信息(如 ["bold"]),
+ 当文本标记为 bold 且较短时,可直接判定为标题,
+ 无需依赖 Markdown **...** 标记。
+
+ Returns:
+ (level, rule_name): 标题级别和匹配的规则名
+ level=0 表示不是标题
+ """
+ text = text.strip()
+ if not text:
+ return 0, None
+
+ # v2 style 信息:如果文本标记为 bold 且较短,优先尝试加粗规则
+ if style and 'bold' in style and 2 <= len(text) <= 50:
+ # 先检查是否匹配更高优先级的分类标题规则
+ for rule in self.rules:
+ if rule.name == 'category_heading' and rule.enabled:
+ level = rule.match(text)
+ if level > 0:
+ logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h{level} (规则: {rule.name})")
+ return level, rule.name
+
+ # 再检查是否匹配中文章节/条款等高优先级规则
+ for rule in self.rules:
+ if rule.name in ('chinese_chapter', 'chinese_article', 'numeric_level3',
+ 'numeric_level2', 'numeric_level1', 'english_chapter') and rule.enabled:
+ level = rule.match(text)
+ if level > 0:
+ logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h{level} (规则: {rule.name})")
+ return level, rule.name
+
+ # 否则作为加粗短文本 → h2(与 bold_short_text 规则对齐,但不依赖 **...** 标记)
+ logger.debug(f"标题识别(v2 style): '{text[:30]}' -> h2 (规则: bold_short_text_via_style)")
+ return 2, 'bold_short_text'
+
+ # 常规规则匹配(v1 格式或无 style 信息时)
+ for rule in self.rules:
+ level = rule.match(text)
+ if level > 0:
+ logger.debug(f"标题识别: '{text[:30]}' -> h{level} (规则: {rule.name})")
+ return level, rule.name
+
+ return 0, None
+
+
+# ==================== 全局单例 ====================
+
+_engine: Optional[HeadingRuleEngine] = None
+
+
+def get_heading_engine() -> HeadingRuleEngine:
+ """获取全局标题识别引擎(延迟初始化,线程安全)"""
+ global _engine
+ if _engine is None:
+ _engine = _create_engine_from_config()
+ return _engine
+
+
+def _create_engine_from_config() -> HeadingRuleEngine:
+ """
+ 从 config 创建引擎(支持配置覆盖)
+
+ 优先级:
+ 1. config.HEADING_RULES_CONFIG 不为 None → 使用自定义规则
+ 2. config 细粒度参数覆盖默认规则(如 HEADING_SHORT_TEXT_ENABLED)
+ 3. 使用默认规则
+ """
+ # 尝试加载完整自定义规则
+ try:
+ from config import HEADING_RULES_CONFIG
+ if HEADING_RULES_CONFIG is not None:
+ rules = _build_rules_from_config(HEADING_RULES_CONFIG)
+ logger.info(f"使用自定义标题规则: {len(rules)} 条")
+ return HeadingRuleEngine(rules)
+ except ImportError:
+ pass
+
+ # 使用默认规则,应用细粒度配置覆盖
+ rules = list(DEFAULT_HEADING_RULES)
+ try:
+ from config import HEADING_SHORT_TEXT_ENABLED
+ for rule in rules:
+ if rule.name == "short_chinese_heading":
+ rule.enabled = HEADING_SHORT_TEXT_ENABLED
+ logger.debug(f"配置覆盖: short_chinese_heading.enabled={HEADING_SHORT_TEXT_ENABLED}")
+ except ImportError:
+ pass
+
+ try:
+ from config import HEADING_SHORT_TEXT_MAX_LENGTH
+ for rule in rules:
+ if rule.name == "short_chinese_heading":
+ rule.max_length = HEADING_SHORT_TEXT_MAX_LENGTH
+ logger.debug(f"配置覆盖: short_chinese_heading.max_length={HEADING_SHORT_TEXT_MAX_LENGTH}")
+ except ImportError:
+ pass
+
+ return HeadingRuleEngine(rules)
+
+
+def _build_rules_from_config(config: list) -> List[HeadingRule]:
+ """
+ 从配置字典列表构建规则列表
+
+ Args:
+ config: 规则配置列表,每项为 dict,包含:
+ - pattern (str): 正则表达式字符串
+ - level (int): 标题级别
+ - name (str): 规则名称
+ - max_length (int, 可选): 文本最大长度
+ - min_length (int, 可选): 文本最小长度
+ - enabled (bool, 可选): 是否启用
+ - exclude_pattern (str, 可选): 排除正则
+
+ Returns:
+ 规则列表
+ """
+ rules = []
+ for item in config:
+ exclude = None
+ if 'exclude_pattern' in item:
+ exclude = re.compile(item['exclude_pattern'])
+ rules.append(HeadingRule(
+ pattern=re.compile(item['pattern']),
+ level=item['level'],
+ name=item['name'],
+ max_length=item.get('max_length', 0),
+ min_length=item.get('min_length', 0),
+ enabled=item.get('enabled', True),
+ exclude_pattern=exclude,
+ ))
+ return rules
+
+
+def reset_heading_engine() -> None:
+ """重置引擎(用于测试)"""
+ global _engine
+ _engine = None
diff --git a/parsers/mineru_parser.py b/parsers/mineru_parser.py
index 62c3bfc..94cc517 100644
--- a/parsers/mineru_parser.py
+++ b/parsers/mineru_parser.py
@@ -1,1705 +1,1967 @@
-# -*- coding: utf-8 -*-
-"""
-MinerU 统一文档解析模块 (v3.0+)
-
-使用 MinerU v3.0+ 进行高质量多格式文档解析:
-- PDF: 表格识别率 95%+,支持 109 种语言 OCR
-- DOCX: 原生 Word 解析,速度提升数十倍,无幻觉
-- XLSX: Excel 表格解析
-- PPTX: PowerPoint 幻灯片解析
-- 图片: 直接 OCR 识别
-
-统一输出格式:
-- 保留标题层级(text_level)
-- 输出 Markdown + JSON 双格式
-- 表格输出 HTML 格式
-
-依赖:
- pip install "mineru[all]"
- mineru-models-download -s huggingface -m all
-"""
-
-import os
-
-# 配置 MinerU 设备模式(从 config.py 或环境变量读取)
-# 优先级:环境变量 > config.py 配置 > 默认值 cpu
-try:
- from config import MINERU_DEVICE_MODE
- if os.getenv('MINERU_DEVICE_MODE') is None:
- os.environ['MINERU_DEVICE_MODE'] = MINERU_DEVICE_MODE
-except ImportError:
- # config.py 不存在时回退到默认值
- if os.getenv('MINERU_DEVICE_MODE') is None:
- os.environ['MINERU_DEVICE_MODE'] = 'cpu'
-
-import json
-import tempfile
-import shutil
-import subprocess
-import hashlib
-from pathlib import Path
-from typing import List, Dict, Optional, Any
-from dataclasses import dataclass, field
-from urllib.parse import urlparse
-import re
-import logging
-
-logger = logging.getLogger(__name__)
-
-# 文件大小限制
-MAX_PDF_SIZE = 100 * 1024 * 1024 # 100MB
-
-# 支持的文件格式
-SUPPORTED_FORMATS = {
- '.pdf': 'PDF 文档',
- '.docx': 'Word 文档',
- '.xlsx': 'Excel 表格',
- '.pptx': 'PowerPoint 幻灯片',
- '.png': 'PNG 图片',
- '.jpg': 'JPEG 图片',
- '.jpeg': 'JPEG 图片',
- '.bmp': 'BMP 图片',
- '.tiff': 'TIFF 图片',
-}
-
-
-def normalize_image_path(path: str) -> str:
- """
- 规范化图片路径,处理各种边界情况
-
- Args:
- path: 原始图片路径(可能包含 query 参数、相对路径等)
-
- Returns:
- 规范化后的文件名
- """
- # 去掉 query 参数
- path = urlparse(path).path
- # 只保留文件名
- return os.path.basename(path)
-
-
-def extract_images_from_markdown(content: str) -> List[Dict]:
- """
- 从 Markdown/HTML 中提取图片引用
-
- Args:
- content: Markdown 或 HTML 内容
-
- Returns:
- [{"id": "abc.jpg", "order": 1}, ...]
- """
- seen = {} # 用于去重保序
- order = 0
-
- # 匹配 Markdown 格式: 
- md_pattern = r'!\[([^\]]*)\]\(([^)]+)\)'
- for match in re.finditer(md_pattern, content):
- img_path = match.group(2)
- img_id = normalize_image_path(img_path)
- if img_id and img_id not in seen:
- order += 1
- seen[img_id] = {"id": img_id, "order": order}
-
- # 匹配 HTML 格式:
- html_pattern = r'
]+src=["\']([^"\']+)["\']'
- for match in re.finditer(html_pattern, content):
- img_path = match.group(1)
- img_id = normalize_image_path(img_path)
- if img_id and img_id not in seen:
- order += 1
- seen[img_id] = {"id": img_id, "order": order}
-
- return list(seen.values())
-
-
-@dataclass
-class MinerUChunk:
- """MinerU 解析结果分块"""
- content: str # 文本内容
- chunk_type: str # 类型: text, table, image, equation
- page_start: int = 1 # 起始页码
- page_end: int = 1 # 结束页码
- text_level: int = 0 # 标题级别 (0=body, 1=h1, 2=h2...)
- title: str = "" # 标题文本
- section_path: str = "" # 章节路径
- bbox: Optional[List[float]] = None # 边界框 [x0, y0, x1, y1]
- source_file: str = "" # 源文件名
- table_html: Optional[str] = None # 表格 HTML(如果是表格)
- image_path: Optional[str] = None # 图片路径(独立图片)
- images: Optional[List[Dict]] = None # 关联图片列表: [{"id": "abc.jpg", "order": 1}]
- # 图片上下文(用于语义检索)
- context_before: str = "" # 图片前的文本上下文
- context_after: str = "" # 图片后的文本上下文
-
-
-def parse_with_mineru_online(
- file_path: str,
- api_token: str = None,
- api_url: str = None,
- model_version: str = "vlm",
- timeout: int = 300
-) -> Dict[str, Any]:
- """
- 使用 MinerU 在线 API 解析文档
-
- 当本地设备能力有限时,可使用在线 API 作为备选方案。
- 需要在 https://mineru.net/apiManage/token 申请 API Token。
-
- API 流程:
- 1. 申请上传链接 → /api/v4/file-urls/batch
- 2. PUT 上传文件
- 3. 系统自动提交解析任务
- 4. 轮询查询结果 → /api/v4/extract-results/batch/{batch_id}
- 5. 下载 zip 包并解析
-
- Args:
- file_path: 文档文件路径
- api_token: API Token(默认从 config 读取)
- api_url: API 地址
- model_version: 模型版本 (vlm / pipeline / MinerU-HTML)
- timeout: 请求超时(秒)
-
- Returns:
- 解析结果(与 parse_with_mineru 格式相同)
- """
- import requests
- import time
- import zipfile
- import io
- from config import MINERU_API_TOKEN, MINERU_API_URL
-
- token = api_token or MINERU_API_TOKEN
- url = api_url or MINERU_API_URL
-
- if not token:
- raise RuntimeError("MinerU 在线 API Token 未配置,请在 config.py 中设置 MINERU_API_TOKEN")
-
- file_path = Path(file_path)
- if not file_path.exists():
- raise FileNotFoundError(f"文件不存在: {file_path}")
-
- # 检查文件大小
- file_size = file_path.stat().st_size
- if file_size > MAX_PDF_SIZE:
- raise ValueError(f"文件过大: {file_size / 1024 / 1024:.1f}MB,最大允许 {MAX_PDF_SIZE / 1024 / 1024:.0f}MB")
-
- logger.info(f"使用 MinerU 在线 API 解析: {file_path.name}")
-
- # 读取文件内容
- with open(file_path, 'rb') as f:
- file_content = f.read()
-
- headers = {
- "Content-Type": "application/json",
- "Authorization": f"Bearer {token}"
- }
-
- try:
- # 1. 申请上传链接
- upload_url = url.replace("/extract/task", "/file-urls/batch")
-
- upload_data = {
- "files": [{"name": file_path.name, "data_id": "upload"}],
- "model_version": model_version
- }
-
- resp = requests.post(upload_url, json=upload_data, headers=headers, timeout=30)
- resp.raise_for_status()
- upload_result = resp.json()
-
- if upload_result.get("code") != 0:
- raise RuntimeError(f"获取上传凭证失败: {upload_result}")
-
- data = upload_result.get("data", {})
- batch_id = data.get("batch_id")
- file_urls = data.get("file_urls", [])
-
- if not file_urls or not batch_id:
- raise RuntimeError(f"未获取到上传 URL 或 batch_id: {upload_result}")
-
- logger.info(f"获取上传链接成功, batch_id: {batch_id}")
-
- # 2. PUT 上传文件
- put_url = file_urls[0]
- put_resp = requests.put(put_url, data=file_content, timeout=timeout)
- put_resp.raise_for_status()
- logger.info(f"文件上传成功: {file_path.name}")
-
- # 3. 轮询等待结果(系统自动提交任务)
- # 查询接口: /api/v4/extract-results/batch/{batch_id}
- result_url = url.replace("/extract/task", f"/extract-results/batch/{batch_id}")
- max_wait = timeout
- poll_interval = 5
- waited = 0
-
- while waited < max_wait:
- time.sleep(poll_interval)
- waited += poll_interval
-
- result_resp = requests.get(result_url, headers=headers, timeout=30)
- result_resp.raise_for_status()
- result = result_resp.json()
-
- extract_results = result.get("data", {}).get("extract_result", [])
- if not extract_results:
- logger.debug(f"等待解析结果... ({waited}s)")
- continue
-
- # 取第一个文件的结果
- file_result = extract_results[0]
- state = file_result.get("state", "")
-
- if state == "done":
- zip_url = file_result.get("full_zip_url")
- if not zip_url:
- raise RuntimeError(f"解析完成但未获取到结果 URL: {file_result}")
-
- logger.info(f"MinerU 在线解析完成,下载结果...")
-
- # 4. 下载 zip 包
- zip_resp = requests.get(zip_url, timeout=120)
- zip_resp.raise_for_status()
-
- # 5. 解析 zip 包内容
- result = _parse_mineru_online_zip(zip_resp.content, file_path)
- # 保存 zip 内容,供后续提取图片使用
- result['_zip_content'] = zip_resp.content
- return result
-
- elif state == "failed":
- error = file_result.get("err_msg", "未知错误")
- raise RuntimeError(f"MinerU 在线解析失败: {error}")
- else:
- # waiting-file / pending / running / converting
- progress = file_result.get("extract_progress", {})
- if progress:
- extracted = progress.get("extracted_pages", 0)
- total = progress.get("total_pages", 0)
- logger.info(f"解析进度: {extracted}/{total} 页 ({waited}s)")
- else:
- logger.debug(f"状态: {state}, 等待中... ({waited}s)")
-
- raise RuntimeError("MinerU 在线解析超时")
-
- except Exception as e:
- raise RuntimeError(f"MinerU 在线 API 调用失败: {e}")
-
-
-def _parse_mineru_online_zip(zip_content: bytes, file_path: Path) -> Dict[str, Any]:
- """
- 解析 MinerU 在线 API 返回的 zip 包
-
- zip 包结构:
- - full.md - Markdown 解析结果
- - *_content_list.json - 内容列表(扁平格式,优先使用)
- - *_content_list_v2.json - 内容列表(嵌套格式)
- - images/ - 图片目录
-
- Args:
- zip_content: zip 文件二进制内容
- file_path: 源文件路径
-
- Returns:
- 解析结果(与 _parse_mineru_output 格式相同)
- """
- import zipfile
- import io
-
- with zipfile.ZipFile(io.BytesIO(zip_content), 'r') as zf:
- # 列出所有文件
- file_list = zf.namelist()
- logger.debug(f"zip 包内容: {file_list}")
-
- # 查找 content_list.json(优先使用扁平格式)
- content_list_path = None
- for f in file_list:
- # 优先使用 content_list.json(扁平格式)
- if f.endswith('_content_list.json') and not f.endswith('_v2.json'):
- content_list_path = f
- break
- # 如果没有找到,再尝试 v2 格式
- if not content_list_path:
- for f in file_list:
- if f.endswith('_content_list_v2.json'):
- content_list_path = f
- break
-
- # 查找 markdown 文件
- md_path = None
- for f in file_list:
- if f.endswith('.md'):
- md_path = f
- break
-
- # 读取 content_list
- content_list = []
- if content_list_path:
- with zf.open(content_list_path) as f:
- content_list = json.load(f)
- logger.info(f"读取 content_list: {len(content_list)} 项, 来源: {content_list_path}")
-
- # 读取 markdown
- markdown_content = ""
- if md_path:
- with zf.open(md_path) as f:
- markdown_content = f.read().decode('utf-8')
-
- # 提取图片路径
- images = []
- for f in file_list:
- # 检查是否是 images 目录下的文件(支持 images/xxx 或 /images/xxx 格式)
- if ('images/' in f or f.startswith('images/')) and not f.endswith('/'):
- images.append(f)
-
- # 如果有 content_list,使用 _parse_mineru_online_result 解析
- if content_list:
- result = {
- "data": {
- "content_list": content_list,
- "markdown": markdown_content
- }
- }
- parsed_result = _parse_mineru_online_result(result, file_path)
- # 添加 zip 内图片列表,供后续提取使用
- parsed_result['_zip_images'] = images
- return parsed_result
-
- # 否则只返回 markdown
- return {
- 'markdown': markdown_content,
- 'chunks': [],
- 'tables': [],
- 'images': images,
- 'content_list': [],
- '_zip_images': images
- }
-
-
-def _parse_mineru_online_result(result: Dict, file_path: Path) -> Dict[str, Any]:
- """
- 解析 MinerU 在线 API 返回结果
-
- 保持与本地 _parse_mineru_output 完全一致的输出格式,
- 确保后续处理流程不受影响。
- """
- data = result.get("data", {})
-
- markdown_content = data.get("markdown", "") or data.get("full_markdown", "")
- content_list = data.get("content_list", [])
-
- logger.info(f"解析在线结果: content_list 项数={len(content_list)}, markdown 长度={len(markdown_content)}")
-
- chunks = []
- tables = []
- images = []
- section_stack = [] # 追踪章节层级
- markdown_parts = []
-
- # 第一遍扫描:收集文本内容(用于构建图片上下文)
- text_items = []
- for idx, item in enumerate(content_list):
- if item.get("type") == "text":
- text = item.get("content", "") or item.get("text", "")
- if text:
- text_items.append((idx, text.strip(), item.get("page_idx", 0)))
-
- def get_context_for_image(image_idx: int, page_idx: int, window: int = 3) -> tuple:
- """获取图片前后的文本上下文"""
- context_before = []
- context_after = []
- for item_idx, text, item_page in text_items:
- if item_idx < image_idx and item_page >= page_idx - 1:
- context_before.append(text)
- elif item_idx > image_idx and item_page <= page_idx + 1:
- context_after.append(text)
- return " ".join(context_before[-window:]), " ".join(context_after[:window])
-
- # 解析 content_list
- for idx, item in enumerate(content_list):
- item_type = item.get("type", "text")
- page_idx = item.get("page_idx", 0)
- bbox = item.get("bbox", [])
- text_level = item.get("text_level", 0)
-
- if item_type == "text":
- text = item.get("content", "") or item.get("text", "")
-
- # 启发式标题识别
- if text_level == 0:
- text_level = _detect_heading_level(text)
-
- title = ""
- if text_level > 0:
- title = text.strip()
- while section_stack and section_stack[-1][0] >= text_level:
- section_stack.pop()
- section_stack.append((text_level, title))
-
- section_path = " > ".join([s[1] for s in section_stack])
-
- if text_level > 0:
- md_line = f"{'#' * text_level} {text}"
- else:
- md_line = text
- markdown_parts.append(md_line)
-
- chunk = MinerUChunk(
- content=text,
- chunk_type="heading" if text_level > 0 else "text",
- page_start=page_idx + 1,
- page_end=page_idx + 1,
- text_level=text_level,
- title=title,
- section_path=section_path,
- bbox=bbox,
- source_file=file_path.name
- )
- chunks.append(chunk)
-
- elif item_type == "table":
- table_body = item.get("html", "") or item.get("table_body", "")
- table_caption = item.get("caption", "") or item.get("table_caption", "")
- img_path = item.get("img_path", "") or item.get("image_path", "")
-
- section_path = " > ".join([s[1] for s in section_stack])
-
- markdown_parts.append(f"\n| 表格 |")
- if table_body:
- md_table = html_table_to_markdown(table_body)
- markdown_parts.append(md_table)
- else:
- md_table = ""
-
- table_images = extract_images_from_markdown(md_table) if md_table else []
-
- chunk = MinerUChunk(
- content=table_caption or "表格",
- chunk_type="table",
- page_start=page_idx + 1,
- page_end=page_idx + 1,
- title=table_caption or "表格",
- section_path=section_path,
- bbox=bbox,
- source_file=file_path.name,
- table_html=table_body,
- image_path=img_path,
- images=table_images if table_images else None
- )
- chunks.append(chunk)
- if table_body:
- tables.append(table_body)
- if img_path:
- images.append(img_path)
-
- elif item_type in ("image", "chart"):
- img_path = item.get("img_path", "") or item.get("image_path", "")
- caption = item.get("caption", "")
-
- section_path = " > ".join([s[1] for s in section_stack])
-
- markdown_parts.append(f"\n")
-
- chunk_type = "chart" if item_type == "chart" else "image"
- context_before, context_after = get_context_for_image(idx, page_idx)
-
- chunk = MinerUChunk(
- content=caption or ("图表" if item_type == "chart" else "图片"),
- chunk_type=chunk_type,
- page_start=page_idx + 1,
- page_end=page_idx + 1,
- title=caption or ("图表" if item_type == "chart" else "图片"),
- section_path=section_path,
- bbox=bbox,
- source_file=file_path.name,
- image_path=img_path,
- context_before=context_before,
- context_after=context_after
- )
- chunks.append(chunk)
- if img_path:
- images.append(img_path)
-
- elif item_type == "equation":
- # 处理公式类型
- equation_content = item.get("content", "") or item.get("latex", "") or item.get("text", "")
- img_path = item.get("img_path", "") or item.get("image_path", "")
-
- section_path = " > ".join([s[1] for s in section_stack])
-
- if equation_content:
- markdown_parts.append(f"\n$$ {equation_content} $$")
-
- chunk = MinerUChunk(
- content=equation_content or "公式",
- chunk_type="equation",
- page_start=page_idx + 1,
- page_end=page_idx + 1,
- title="公式",
- section_path=section_path,
- bbox=bbox,
- source_file=file_path.name,
- image_path=img_path
- )
- chunks.append(chunk)
- if img_path:
- images.append(img_path)
-
- # 如果没有解析到内容,使用 Markdown
- if not chunks and markdown_content:
- markdown_parts = [markdown_content]
-
- # 后处理:与本地解析完全一致
- try:
- from config import MIN_CHUNK_SIZE, MAX_CHUNK_SIZE
- min_merge = MIN_CHUNK_SIZE // 2
- max_size = MAX_CHUNK_SIZE
- except ImportError:
- min_merge = 100
- max_size = 1200
-
- chunks = _post_process_chunks(chunks, min_merge_size=min_merge, max_chunk_size=max_size)
-
- return {
- 'markdown': "\n".join(markdown_parts) if markdown_parts else markdown_content,
- 'chunks': chunks,
- 'tables': tables,
- 'images': images,
- 'content_list': content_list
- }
-
-
-def parse_with_mineru(
- file_path: str,
- output_dir: Optional[str] = None,
- lang: str = "ch",
- enable_table: bool = True,
- enable_formula: bool = True,
- backend: str = "pipeline",
- start_page: int = 0,
- end_page: int = 99999
-) -> Dict[str, Any]:
- """
- 使用 MinerU 解析文档(支持 PDF、DOCX、XLSX、PPTX、图片)
-
- Args:
- file_path: 文档文件路径
- output_dir: 输出目录,默认使用临时目录
- lang: 语言代码 (ch, en, etc.)
- enable_table: 启用表格识别
- enable_formula: 启用公式识别
- backend: 解析后端
- - "pipeline": 通用模式(推荐)
- - "vlm-auto-engine": 高精度模式
- - "hybrid-auto-engine": 新一代高精度方案
- start_page: 起始页码(0-indexed,仅 PDF 有效)
- end_page: 结束页码(仅 PDF 有效)
-
- Returns:
- {
- 'markdown': str, # Markdown 内容
- 'chunks': List[MinerUChunk], # 结构化分块
- 'tables': List[str], # 表格列表
- 'images': List[str], # 图片列表
- 'content_list': List[Dict] # 原始 content_list
- }
- """
- file_path = Path(file_path)
- if not file_path.exists():
- raise FileNotFoundError(f"文件不存在: {file_path}")
-
- # 检查文件大小
- file_size = file_path.stat().st_size
- if file_size > MAX_PDF_SIZE:
- raise ValueError(f"文件过大: {file_size / 1024 / 1024:.1f}MB,最大允许 {MAX_PDF_SIZE / 1024 / 1024:.0f}MB")
-
- # 检查文件格式
- suffix = file_path.suffix.lower()
- if suffix not in SUPPORTED_FORMATS:
- raise ValueError(
- f"不支持的文件格式: {suffix}。"
- f"支持格式: {', '.join(SUPPORTED_FORMATS.keys())}"
- )
-
- logger.info(f"使用 MinerU 解析 {SUPPORTED_FORMATS.get(suffix, '文档')}: {file_path.name}")
-
- # 创建输出目录
- if output_dir is None:
- output_dir = tempfile.mkdtemp(prefix="mineru_")
- cleanup_output = True
- else:
- output_dir = Path(output_dir)
- output_dir.mkdir(parents=True, exist_ok=True)
- cleanup_output = False
-
- # 构建 mineru 命令
- # 使用虚拟环境中的 mineru
- import sys
- venv_dir = Path(sys.executable).parent
- mineru_exe = venv_dir / "mineru.exe"
- if not mineru_exe.exists():
- mineru_exe = venv_dir / "mineru"
- if not mineru_exe.exists():
- mineru_exe = "mineru" # 回退到系统 PATH
-
- # 参数白名单校验,防止注入非法参数
- ALLOWED_BACKENDS = {'auto', 'pipeline', 'vlm', 'vlm-sglang', 'vlm-auto-engine', 'hybrid-auto-engine', 'ocr'}
- ALLOWED_LANGS = {'ch', 'en', 'ch_lite', 'en_lite', 'formula', 'table'}
- if backend not in ALLOWED_BACKENDS:
- backend = 'pipeline'
- if lang not in ALLOWED_LANGS:
- lang = 'ch'
-
- cmd = [
- str(mineru_exe),
- "--",
- "-p", str(file_path),
- "-o", str(output_dir),
- "-m", "auto",
- "-b", backend,
- "-l", lang,
- "-s", str(start_page),
- "-e", str(end_page) if end_page < 99999 else str(99999),
- "-f", str(enable_formula).lower(),
- "-t", str(enable_table).lower()
- ]
-
- try:
- # 执行 MinerU 命令
- result = subprocess.run(
- cmd,
- capture_output=True,
- # 使用系统默认编码,避免 UTF-8 解码错误
- encoding=None,
- errors='replace',
- timeout=600 # 10分钟超时
- )
-
- if result.returncode != 0:
- # 安全解码 stderr
- if result.stderr:
- stderr_output = result.stderr.decode('utf-8', errors='replace') if isinstance(result.stderr, bytes) else str(result.stderr)
- else:
- stderr_output = ""
- logger.error(f"MinerU 解析失败: {stderr_output}")
- raise RuntimeError(f"MinerU 解析失败: {stderr_output}")
-
- # 解析输出结果
- return _parse_mineru_output(file_path, output_dir)
-
- except subprocess.TimeoutExpired:
- raise RuntimeError("MinerU 解析超时")
- except FileNotFoundError:
- raise RuntimeError(
- "MinerU 未安装或不在 PATH 中,请运行: pip install \"mineru[all]\""
- )
- except Exception as e:
- logger.error(f"MinerU 解析失败: {e}")
- raise
- finally:
- # 清理临时目录
- if cleanup_output and os.path.exists(output_dir):
- shutil.rmtree(output_dir, ignore_errors=True)
-
-
-def _detect_heading_level(text: str) -> int:
- """
- 启发式标题识别
-
- 用于 MinerU 解析 DOCX 等 Office 格式时不提供 text_level 的情况。
-
- Args:
- text: 文本内容
-
- Returns:
- 标题级别 (0=正文, 1=h1, 2=h2, 3=h3)
- """
- import re
-
- text = text.strip()
-
- # 空文本
- if not text:
- return 0
-
- # 中文章节标题模式
- # 第一章、第二章、... -> h1
- if re.match(r'^第[一二三四五六七八九十百千万]+[章节篇部]', text):
- return 1
-
- # 第一条、第二条、... -> h2 (条文编号)
- if re.match(r'^第[一二三四五六七八九十百千万]+[条款]', text):
- return 2
-
- # 数字章节: 1. 2. 3. 或 1、2、3、
- # 一级标题: 1. 2. 3. (单数字)
- if re.match(r'^\d+[\.、\s]', text):
- # 短文本可能是标题
- if len(text) < 50:
- return 1
-
- # 二级标题: 1.1 1.2 2.1 等
- if re.match(r'^\d+\.\d+[\.、\s]', text):
- if len(text) < 80:
- return 2
-
- # 三级标题: 1.1.1 1.1.2 等
- if re.match(r'^\d+\.\d+\.\d+[\.、\s]', text):
- if len(text) < 100:
- return 3
-
- # 英文章节标题
- # Chapter 1, Section 2, etc.
- if re.match(r'^(Chapter|Section|Part|Chapter\s+\d+|Section\s+\d+)', text, re.IGNORECASE):
- return 1
-
- # 短文本 + 加粗标记 (**xxx**) 可能是标题
- if re.match(r'^\*\*.+\*\*$', text) and len(text) < 50:
- return 2
-
- # 非常短的文本 (< 20 字符) 可能是标题
- # 但需要排除常见的非标题短文本
- if len(text) < 20 and not re.match(r'^[\d\s\.,;:!?,。;:!?、]+$', text):
- # 排除纯数字、纯标点
- if re.search(r'[\u4e00-\u9fff]', text): # 包含中文
- return 2
-
- return 0
-
-
-def _parse_mineru_output(file_path: Path, output_dir) -> Dict[str, Any]:
- """
- 解析 MinerU 输出结果
-
- Args:
- file_path: 源文件路径
- output_dir: 输出目录
-
- Returns:
- 解析结果字典
- """
- chunks = []
- tables = []
- images = []
- section_stack = [] # 追踪章节层级
- markdown_parts = []
-
- # 确保 output_dir 是 Path 对象
- output_dir = Path(output_dir)
-
- # 查找输出文件
- # 不同格式的输出目录不同:
- # - PDF: output/文件名/auto/
- # - DOCX/XLSX/PPTX: output/文件名/office/
- doc_name = file_path.stem
- auto_dir = output_dir / doc_name / "auto"
- office_dir = output_dir / doc_name / "office"
-
- # 选择正确的输出目录
- if auto_dir.exists():
- output_subdir = auto_dir
- elif office_dir.exists():
- output_subdir = office_dir
- else:
- raise RuntimeError(f"MinerU 输出目录不存在: {auto_dir} 或 {office_dir}")
-
- # 读取 content_list.json
- content_list_path = output_subdir / f"{doc_name}_content_list.json"
- if not content_list_path.exists():
- content_list_path = output_subdir / f"{doc_name}_content_list_v2.json"
-
- content_list = []
- if content_list_path.exists():
- with open(content_list_path, 'r', encoding='utf-8') as f:
- content_list = json.load(f)
-
- # 读取 Markdown
- md_path = output_subdir / f"{doc_name}.md"
- markdown_content = ""
- if md_path.exists():
- with open(md_path, 'r', encoding='utf-8') as f:
- markdown_content = f.read()
-
- # 第一遍扫描:收集所有文本内容(用于构建图片上下文)
- text_items = [] # [(index, text, page_idx), ...]
- for idx, item in enumerate(content_list):
- if item.get("type") == "text":
- text = item.get("text", "").strip()
- if text:
- text_items.append((idx, text, item.get("page_idx", 0)))
-
- def get_context_for_image(image_idx: int, page_idx: int, window: int = 3) -> tuple:
- """获取图片前后的文本上下文"""
- context_before = []
- context_after = []
-
- # 查找图片前后的文本项
- for item_idx, text, item_page in text_items:
- if item_idx < image_idx and item_page >= page_idx - 1:
- # 图片之前的文本(同页或上一页)
- context_before.append(text)
- elif item_idx > image_idx and item_page <= page_idx + 1:
- # 图片之后的文本(同页或下一页)
- context_after.append(text)
-
- # 只保留最近的 window 条
- context_before = context_before[-window:] if context_before else []
- context_after = context_after[:window] if context_after else []
-
- return " ".join(context_before), " ".join(context_after)
-
- # 解析 content_list
- for idx, item in enumerate(content_list):
- item_type = item.get("type", "text")
- page_idx = item.get("page_idx", 0)
- bbox = item.get("bbox", [])
- text_level = item.get("text_level", 0)
-
- if item_type == "text":
- text = item.get("text", "")
-
- # 启发式标题识别(当 text_level 为 0 时)
- if text_level == 0:
- text_level = _detect_heading_level(text)
-
- # 处理标题
- title = ""
- if text_level > 0:
- title = text.strip()
- # 更新章节栈
- while section_stack and section_stack[-1][0] >= text_level:
- section_stack.pop()
- section_stack.append((text_level, title))
-
- # 构建章节路径
- section_path = " > ".join([s[1] for s in section_stack])
-
- # 构建 Markdown
- if text_level > 0:
- md_line = f"{'#' * text_level} {text}"
- else:
- md_line = text
- markdown_parts.append(md_line)
-
- chunk = MinerUChunk(
- content=text,
- chunk_type="heading" if text_level > 0 else "text",
- page_start=page_idx + 1,
- page_end=page_idx + 1,
- text_level=text_level,
- title=title,
- section_path=section_path,
- bbox=bbox,
- source_file=file_path.name
- )
- chunks.append(chunk)
-
- elif item_type == "table":
- table_body = item.get("table_body", "")
- table_caption = item.get("table_caption", "")
- # 表格也可能有图片形式(img_path)
- img_path = item.get("img_path", "")
-
- section_path = " > ".join([s[1] for s in section_stack])
-
- markdown_parts.append(f"\n| 表格 |")
- if table_body:
- md_table = html_table_to_markdown(table_body)
- markdown_parts.append(md_table)
- else:
- md_table = ""
-
- # 提取表格中的嵌入图片
- table_images = extract_images_from_markdown(md_table) if md_table else []
-
- chunk = MinerUChunk(
- content=table_caption or "表格",
- chunk_type="table",
- page_start=page_idx + 1,
- page_end=page_idx + 1,
- title=table_caption or "表格",
- section_path=section_path,
- bbox=bbox,
- source_file=file_path.name,
- table_html=table_body,
- image_path=img_path, # 表格的独立图片形式
- images=table_images if table_images else None # 嵌入图片列表
- )
- chunks.append(chunk)
- if table_body:
- tables.append(table_body)
- # 表格图片也加入 images 列表
- if img_path:
- images.append(img_path)
-
- elif item_type in ("image", "chart"):
- # 处理图片和图表类型(MinerU 将图表识别为 chart 类型)
- img_path = item.get("img_path", "")
- caption = item.get("caption", "")
-
- section_path = " > ".join([s[1] for s in section_stack])
-
- markdown_parts.append(f"\n")
-
- # 图表类型标记为 chart,便于后续区分处理
- chunk_type = "chart" if item_type == "chart" else "image"
-
- # 获取图片上下文
- context_before, context_after = get_context_for_image(idx, page_idx)
-
- chunk = MinerUChunk(
- content=caption or ("图表" if item_type == "chart" else "图片"),
- chunk_type=chunk_type,
- page_start=page_idx + 1,
- page_end=page_idx + 1,
- title=caption or ("图表" if item_type == "chart" else "图片"),
- section_path=section_path,
- bbox=bbox,
- source_file=file_path.name,
- image_path=img_path,
- context_before=context_before,
- context_after=context_after
- )
- chunks.append(chunk)
- if img_path:
- images.append(img_path)
-
- elif item_type == "equation":
- # 处理公式类型
- equation_content = item.get("content", "") or item.get("latex", "") or item.get("text", "")
- img_path = item.get("img_path", "")
-
- section_path = " > ".join([s[1] for s in section_stack])
-
- if equation_content:
- markdown_parts.append(f"\n$$ {equation_content} $$")
-
- chunk = MinerUChunk(
- content=equation_content or "公式",
- chunk_type="equation",
- page_start=page_idx + 1,
- page_end=page_idx + 1,
- title="公式",
- section_path=section_path,
- bbox=bbox,
- source_file=file_path.name,
- image_path=img_path
- )
- chunks.append(chunk)
- if img_path:
- images.append(img_path)
-
- # 如果没有从 content_list 解析到内容,使用 Markdown
- if not chunks and markdown_content:
- markdown_parts = [markdown_content]
-
- # 后处理:过滤空切片 → 合并碎片 → 拆分超长
- # 使用配置中的切片约束
- try:
- from config import MIN_CHUNK_SIZE, MAX_CHUNK_SIZE
- min_merge = MIN_CHUNK_SIZE // 2 # 合并阈值为最小切片的一半
- max_size = MAX_CHUNK_SIZE
- except ImportError:
- min_merge = 100
- max_size = 1200
-
- chunks = _post_process_chunks(chunks, min_merge_size=min_merge, max_chunk_size=max_size)
-
- return {
- 'markdown': "\n".join(markdown_parts),
- 'chunks': chunks,
- 'tables': tables,
- 'images': images,
- 'content_list': content_list
- }
-
-
-def _post_process_chunks(
- chunks: List[MinerUChunk],
- min_merge_size: int = 100,
- max_merged_size: int = 800,
- max_chunk_size: int = 1000
-) -> List[MinerUChunk]:
- """
- 后处理:过滤空切片 → 合并碎片 → 拆分超长
-
- 解决三个问题:
- 1. 空切片(0 字符)入库
- 2. 标题/短文本独立成片导致碎片化
- 3. 超长切片超过 Embedding 模型的 token 限制
-
- 策略:
- - 标题 chunk 与下方第一个正文 chunk 合并
- - 连续短文本 chunk(< min_merge_size)合并
- - 合并后超过 max_merged_size 则停止合并
- - 表格、图片 chunk 保持独立不参与合并
- - 最终检查:超过 max_chunk_size 的 chunk 使用 split_text_with_limit 拆分
-
- Args:
- chunks: 原始 chunk 列表
- min_merge_size: 短于此长度的 chunk 触发合并
- max_merged_size: 合并后的最大字符数
- max_chunk_size: 单个 chunk 的硬性上限
-
- Returns:
- 处理后的 chunk 列表
- """
- if not chunks:
- return []
-
- # Phase 1: 过滤空切片(统一处理 list/string 类型)
- filtered = []
- for c in chunks:
- if not c.content:
- continue
- # 统一转字符串
- if isinstance(c.content, list):
- c.content = '\n'.join(str(item) for item in c.content)
- if c.content.strip():
- filtered.append(c)
- chunks = filtered
-
- if not chunks:
- return []
-
- # Phase 2: 合并碎片
- merged = []
- buffer = None # 当前合并缓冲
-
- for chunk in chunks:
- # 表格、图片和图表不参与合并,直接输出
- if chunk.chunk_type in ('table', 'image', 'chart', 'equation'):
- if buffer:
- merged.append(buffer)
- buffer = None
- merged.append(chunk)
- continue
-
- # 标题 chunk(text_level > 0),开始新的合并组
- if chunk.text_level > 0:
- if buffer:
- merged.append(buffer)
- # 标题作为新缓冲的起点
- buffer = MinerUChunk(
- content=chunk.content,
- chunk_type='text',
- page_start=chunk.page_start,
- page_end=chunk.page_end,
- text_level=chunk.text_level,
- title=chunk.title,
- section_path=chunk.section_path,
- bbox=chunk.bbox,
- source_file=chunk.source_file,
- )
- continue
-
- # 正文 chunk
- content_len = len(chunk.content.strip())
-
- if buffer is None:
- # 没有缓冲,开始新缓冲
- if content_len < min_merge_size:
- buffer = MinerUChunk(
- content=chunk.content,
- chunk_type='text',
- page_start=chunk.page_start,
- page_end=chunk.page_end,
- text_level=chunk.text_level,
- title=chunk.title,
- section_path=chunk.section_path,
- bbox=chunk.bbox,
- source_file=chunk.source_file,
- )
- else:
- # 足够长,直接输出
- merged.append(chunk)
- else:
- # 有缓冲,尝试合并
- combined_len = len(buffer.content) + 1 + content_len
- if combined_len <= max_merged_size:
- # 合并
- buffer.content = buffer.content.rstrip() + '\n' + chunk.content
- buffer.page_end = chunk.page_end
- else:
- # 超过上限,输出缓冲,当前 chunk 开始新缓冲或直接输出
- merged.append(buffer)
- if content_len < min_merge_size:
- buffer = MinerUChunk(
- content=chunk.content,
- chunk_type='text',
- page_start=chunk.page_start,
- page_end=chunk.page_end,
- text_level=chunk.text_level,
- title=chunk.title,
- section_path=chunk.section_path,
- bbox=chunk.bbox,
- source_file=chunk.source_file,
- )
- else:
- buffer = None
- merged.append(chunk)
-
- # 刷新最后的缓冲
- if buffer:
- merged.append(buffer)
-
- # Phase 3: 拆分超长切片
- result = []
- for chunk in merged:
- if chunk.chunk_type in ('table', 'image', 'chart', 'equation'):
- result.append(chunk)
- continue
-
- if len(chunk.content) > max_chunk_size:
- # 使用已有的 split_text_with_limit 函数拆分
- try:
- from core.chunker import split_text_with_limit
- sub_texts = split_text_with_limit(
- chunk.content,
- chunk_size=max_chunk_size,
- overlap=50,
- max_length=max_chunk_size
- )
- for i, sub_text in enumerate(sub_texts):
- sub_chunk = MinerUChunk(
- content=sub_text,
- chunk_type=chunk.chunk_type,
- page_start=chunk.page_start,
- page_end=chunk.page_end,
- text_level=chunk.text_level if i == 0 else 0,
- title=chunk.title if i == 0 else '',
- section_path=chunk.section_path,
- bbox=chunk.bbox,
- source_file=chunk.source_file,
- )
- result.append(sub_chunk)
- except ImportError:
- logger.warning("split_text_with_limit 不可用,保留原始超长切片")
- result.append(chunk)
- else:
- result.append(chunk)
-
- logger.info(
- f"切片后处理: {len(chunks)} → {len(result)} "
- f"(过滤空切片+合并碎片+拆分超长)"
- )
- return result
-
-
-def _split_oversized_text(text: str, max_size: int) -> List[str]:
- """
- 拆分超长文本,优先在句子边界切分
-
- 先尝试使用 core.chunker.split_text_with_limit(如果可用),
- 否则使用内置的句子边界拆分。
-
- Args:
- text: 待拆分文本
- max_size: 单片最大字符数
-
- Returns:
- 拆分后的文本列表
- """
- if len(text) <= max_size:
- return [text]
-
- # 尝试使用 LangChain 分块器
- try:
- from core.chunker import split_text_with_limit
- result = split_text_with_limit(text, chunk_size=max_size, overlap=50, max_length=max_size)
- if result:
- return result
- except (ImportError, Exception):
- pass
-
- # 内置回退:按句子边界拆分
- import re
- sentences = re.split(r'(?<=[。!?.!?\n])', text)
-
- chunks = []
- current = ""
-
- for sentence in sentences:
- if not sentence:
- continue
- if len(current) + len(sentence) <= max_size:
- current += sentence
- else:
- if current:
- chunks.append(current)
- # 单句超长则硬截断
- if len(sentence) > max_size:
- for i in range(0, len(sentence), max_size):
- chunks.append(sentence[i:i + max_size])
- current = ""
- else:
- current = sentence
-
- if current:
- chunks.append(current)
-
- return chunks if chunks else [text[:max_size]]
-
-
-def convert_to_rag_format(
- result: Dict[str, Any],
- source_file: str
-) -> List[Dict]:
- """
- 将 MinerU 结果转换为 RAG 入库格式
-
- Args:
- result: parse_with_mineru() 返回结果
- source_file: 源文件名
-
- Returns:
- [{'text': ..., 'page': ..., 'has_table': ..., ...}, ...]
- """
- pages_content = []
-
- for chunk in result['chunks']:
- # 跳过空内容
- content = chunk.content
- # content 可能是字符串或列表
- if isinstance(content, list):
- content = '\n'.join(str(item) for item in content)
- if not content or not content.strip():
- continue
-
- # 构建内容文本(基于已处理的 content)
- if chunk.chunk_type == "table" and chunk.table_html:
- # 表格:将 HTML 转为 Markdown 格式
- content = f"【表格】{chunk.title}\n\n{html_table_to_markdown(chunk.table_html)}"
- elif chunk.chunk_type == "image":
- content = f"【图片】{chunk.title}"
- elif chunk.chunk_type == "equation":
- content = f"【公式】{content}"
- elif chunk.text_level > 0:
- # 标题
- prefix = "#" * chunk.text_level
- content = f"{prefix} {content}"
-
- page_info = {
- 'text': content,
- 'page': chunk.page_start,
- 'page_end': chunk.page_end,
- 'has_table': chunk.chunk_type == "table",
- 'section': chunk.title,
- 'section_path': chunk.section_path,
- 'level': chunk.text_level,
- 'chunk_type': chunk.chunk_type,
- 'source_file': source_file,
- 'is_mineru_chunk': True # 标记为 MinerU 输出
- }
-
- if chunk.bbox:
- # 确保 bbox 是纯 Python 列表(转换 numpy 类型)
- page_info['bbox'] = [float(x) for x in chunk.bbox] if chunk.bbox else None
-
- pages_content.append(page_info)
-
- return pages_content
-
-
-def html_table_to_markdown(html_table: str) -> str:
- """
- 将 HTML 表格转换为 Markdown 格式
-
- 处理 rowspan 合并单元格
- """
- import re
- from bs4 import BeautifulSoup
-
- try:
- soup = BeautifulSoup(html_table, 'html.parser')
- table = soup.find('table')
- if not table:
- return html_table
-
- rows = table.find_all('tr')
- if not rows:
- return html_table
-
- # 计算最大列数
- max_cols = 0
- for row in rows:
- cols_in_row = 0
- for cell in row.find_all(['td', 'th']):
- colspan = int(cell.get('colspan', 1))
- cols_in_row += colspan
- max_cols = max(max_cols, cols_in_row)
-
- if max_cols == 0:
- return html_table
-
- # 处理表格,记录 rowspan 占用
- rowspan_tracker = {} # {col: remaining_rows}
- md_rows = []
-
- for row_idx, row in enumerate(rows):
- cells = []
- col_idx = 0
-
- for cell in row.find_all(['td', 'th']):
- # 跳过被 rowspan 占用的列
- while col_idx in rowspan_tracker:
- cells.append("") # 占位符
- rowspan_tracker[col_idx] -= 1
- if rowspan_tracker[col_idx] <= 0:
- del rowspan_tracker[col_idx]
- col_idx += 1
-
- # 提取单元格内容
- content = cell.get_text(strip=True)
-
- # 处理 rowspan
- rowspan = int(cell.get('rowspan', 1))
- colspan = int(cell.get('colspan', 1))
-
- # 当前单元格
- cells.append(content)
-
- # 处理 colspan
- for _ in range(colspan - 1):
- cells.append("")
- col_idx += 1
-
- # 记录 rowspan(当前列,不是+1后的列)
- if rowspan > 1:
- rowspan_tracker[col_idx] = rowspan - 1
-
- col_idx += 1
-
- # 补齐剩余列(被 rowspan 占用的列)
- while col_idx < max_cols:
- if col_idx in rowspan_tracker:
- cells.append("")
- rowspan_tracker[col_idx] -= 1
- if rowspan_tracker[col_idx] <= 0:
- del rowspan_tracker[col_idx]
- else:
- cells.append("")
- col_idx += 1
-
- # 构建 Markdown 行
- if cells:
- md_row = "| " + " | ".join(cells) + " |"
- md_rows.append(md_row)
-
- # 第一行后添加分隔线
- if row_idx == 0:
- separator = "| " + " | ".join(["---"] * len(cells)) + " |"
- md_rows.append(separator)
-
- return "\n".join(md_rows)
-
- except Exception:
- # 如果 BeautifulSoup 解析失败,回退到简单实现
- rows = re.findall(r'