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

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

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

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

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

View File

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