核心修复: - _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
48 KiB
RAG 检索流程逻辑
本文档描述 RAG 知识库服务从文档解析入库到 LLM 回答的完整数据流,供开发排查和系统优化参考。
目录
- 整体架构
- 文档解析与入库流程
- ChromaDB 存储字段详解
- BM25 索引两种实现
- 检索管线完整数据流
- distances / scores 语义变化
- 路由层处理流程
- 三重救援机制详解
- 图片召回与选择
- 返回格式与溯源信息
- 配置项完整列表
- 单/多知识库路径说明
- 切片策略与检索策略兼容性分析
- MinerU 解析策略
一、整体架构
┌─────────────────────────────────────────────────────────────────┐
│ 文档入库流程 │
│ │
│ 文件上传 → MinerU 解析 → MinerUChunk → 语义增强 → ChromaDB 存储 │
│ ↓ │
│ BM25 索引构建 │
└─────────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────────┐
│ 检索问答流程 │
│ │
│ 用户提问 │
│ ↓ │
│ 意图分析(改写/子查询/意图分类) │
│ ↓ │
│ 语义缓存检查 ──命中──→ 直接返回缓存答案 │
│ ↓ 未命中 │
│ 混合检索(向量 + BM25 + 图片 + FAQ) │
│ ↓ │
│ RRF 融合 → 过滤链 → MMR → Rerank → 后处理 → 扩展 → 自适应 TopK │
│ ↓ │
│ search_hybrid(distances → scores 转换) │
│ ↓ │
│ 路由层:contexts 构建 → 三重救援 → 排序 → 预算截断 │
│ ↓ │
│ Prompt 构建 → LLM 流式生成 → 后处理 → 返回 │
└─────────────────────────────────────────────────────────────────┘
二、文档解析与入库流程
2.1 入口方法
# knowledge/manager.py
def add_file_to_kb(self, kb_name, filepath, embedding_model=None,
extra_metadata=None, enable_table_summary=True,
enable_image_description=False, file_content=None) -> int
完整流程:
1. 调用 parsers.parse_document(filepath) 进行 MinerU 解析
2. 合并跨页表格(_merge_cross_page_tables)
3. 清理同名旧切片(查询 source == filename 的旧切片并删除)
4. 逐切片处理:
a. 获取 chunk_type、page、section_path
b. 构建语义增强内容(semantic_content)
c. 构建 metadata 字典
d. 生成 embedding 向量
e. 可选:LLM 生成表格摘要 / VLM 生成图片描述
5. 批量写入 ChromaDB(collection.add)
6. 更新 BM25 索引(bm25.add_documents + save)
2.2 MinerU 解析流程
三个解析入口:
| 函数 | 用途 | 调用方式 |
|---|---|---|
parse_with_mineru() |
本地 GPU 解析 | 由 parse_with_mineru_persistent 在在线失败时调用 |
parse_with_mineru_online() |
在线 API 解析 | 由 parse_with_mineru_persistent 优先调用 |
parse_with_mineru_persistent() |
持久化解析(主入口) | 由 parsers.parse_document() 调用 |
实际主入口是 parse_with_mineru_persistent(),内部根据 config.MINERU_PREFER_ONLINE 自动选择在线或本地,在线失败自动回退本地。
解析核心流程(以在线为例):
- 文件校验(存在性、大小 ≤ 100MB、格式校验)
- 计算文件 MD5 hash(前12位),用于隔离输出目录
- 申请上传链接 → PUT 上传文件 → 轮询查询结果(5秒间隔)→ 下载 zip 包
- 解析 content_list(优先 v2 格式
_content_list_v2.json) - 逐项解析 content_list 构建
MinerUChunk
MinerUChunk 数据结构:
@dataclass
class MinerUChunk:
content: str # 文本内容
chunk_type: str # text / heading / table / image / chart / equation
page_start: int = 1 # 起始页码(1-based)
page_end: int = 1 # 结束页码
text_level: int = 0 # 标题级别 (0=正文, 1=h1, 2=h2, 3=h3)
title: str = "" # 标题文本
section_path: str = "" # 章节路径," > " 连接
bbox: Optional[List[float]] = None # 边界框 [x0, y0, x1, y1]
source_file: str = "" # 源文件名
table_html: Optional[str] = None # 表格 HTML(仅 table)
image_path: Optional[str] = None # 图片路径(独立图片)
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 生成逻辑
维护 section_stack: [(level, title), ...],遇到新标题时:
- 弹出栈中
level >= 当前 level的项 - 压入
(level, title) section_path = " > ".join([栈中所有项的 title])
2.4 后处理三阶段
- 过滤空切片:移除 content 为空的 chunk
- 合并碎片:标题+正文合并、短文本合并(阈值
MIN_CHUNK_SIZE//2),表格/图片/公式不参与 - 拆分超长:超过
MAX_CHUNK_SIZE的文本用split_text_with_limit拆分
2.5 图片处理
图片来源三类:
- 独立图片/图表(content_list 中 type=image/chart)→
chunk.image_path - 表格的图片形式(type=table 有 img_path)→
chunk.image_path - 嵌入表格 HTML 的图片(
<img src="...">)→chunk.images列表
图片路径重映射(parse_with_mineru_persistent 中):
- 从 MinerU 输出找到图片源文件
- 按文件内容 MD5 重命名,移动到
.data/images/目录 - 更新 chunk.image_path 和 chunk.images 中的路径
2.6 语义内容构建
文本类型 _build_semantic_content_for_text():
{标题}
主题:{章节路径(截断到3级)}
{正文内容}
表格类型 _build_semantic_content_for_table():
主题:{章节路径}
表格:{标题}(非"表格"时)
字段:{表头列表}
描述:该表包含N行数据,记录各字段信息
示例:字段1=值1, 字段2=值2
表格内容:
| ... | ... |
图片类型 generate_lightweight_image_description():
图片:图2.1,系统架构图,位于「第一章 > 1.2 概述」,第5页
前文:系统由三个模块组成...
后文:如图所示,各模块之间...
设计意图:语义增强内容存入 ChromaDB 的
documents字段,用于向量检索。增强后的内容比原始文本包含更多上下文信息,提升检索召回率。
三、ChromaDB 存储字段详解
3.1 存储结构
ChromaDB 使用 collection.add(ids, documents, metadatas, embeddings) 批量写入。
| 字段 | 类型 | 说明 |
|---|---|---|
ids |
List[str] |
切片唯一标识,格式 {filename}_{index},如 "1.docx_14" |
documents |
List[str] |
语义增强后的内容(非原始 content) |
metadatas |
List[dict] |
切片元数据(见下表) |
embeddings |
List[List[float]] |
768维向量,由 embedding_model.encode(semantic_content) 生成 |
3.2 metadatas 字段详解
| 字段 | 类型 | 来源 | 作用 | 示例 |
|---|---|---|---|---|
chunk_id |
str | {filename}_{index} |
切片唯一标识(与 ids 相同) | "1.docx_14" |
chunk_index |
int | 入库时赋值 i |
切片在文件中的序号,用于邻居扩展和救援 | 14 |
chunk_type |
str | MinerU 解析 | 切片类型,驱动检索和展示逻辑 | "text" |
source |
str | 文件名 | 源文件名,用于黑名单过滤和来源展示 | "1.docx" |
collection |
str | 入库参数 | 所属向量库名,确保跨库可追溯 | "public_kb" |
doc_type |
str | 文件扩展名映射 | 文档类型,驱动前端差异化溯源展示 | "word" |
page |
int | MinerU 解析 | 起始页码(仅 PDF 可靠) | 5 |
page_end |
int | MinerU 解析 | 结束页码 | 5 |
section |
str | MinerU section_path |
章节路径,用于章节过滤、聚类救援、prompt 标题注入 | "第一章 > 1.1 概述" |
status |
str | 默认 "active" |
切片状态,deprecated 时被引擎过滤 |
"active" |
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 查找。两者值相同但用途不同。
命名不一致:MinerU 中字段名
section_path,入库时映射为 metadata 的section。路由层代码中两种名称混用(meta.get('section')和meta.get('section_path')),通常用or兼容:meta.get('section', '') or meta.get('section_path', '')。
3.3 chunk_type 取值
| chunk_type | 说明 | 检索行为 | 展示行为 |
|---|---|---|---|
text |
普通文本 | 正常参与向量检索 | 正常展示 |
heading |
标题 | 正常参与向量检索 | 正常展示 |
table |
表格 | 走图片独立检索通道;有表格保护 | 受 max_chunks 豁免;可被表格救援找回 |
image |
图片 | 走图片独立检索通道 | 用 full_description 替换 doc |
chart |
图表 | 走图片独立检索通道 | 同 image |
equation |
公式 | 正常参与向量检索 | 正常展示 |
faq |
FAQ | FAQ 独立检索通道 | 享受分数加权 |
四、BM25 索引两种实现
项目中存在两个 BM25Index 类,返回格式有重要差异。
4.1 对比表
| 维度 | core/bm25_index.py |
knowledge/base.py |
|---|---|---|
| 使用场景 | 单知识库路径(当前不执行) | 多知识库路径(当前活跃) |
| 实例管理 | 全局单例 RAGEngine.bm25_index |
每个向量库独立实例 |
| 持久化 | 单一 bm25_index.pkl |
{kb_name}.pkl 每库独立 |
| search() 返回 | Dict(ChromaDB 兼容格式) |
Tuple[List, List, List, List] |
| 返回值解构 | result['ids'][0] |
ids, docs, metas, scores = bm25.search() |
| 列表嵌套 | [['id1', 'id2']](嵌套列表) |
['id1', 'id2'](扁平列表) |
| 分数字段名 | distances |
第4个返回值(匿名) |
| add_documents 模式 | 替换(覆盖) | 追加 + 去重 |
4.2 兼容处理
多知识库路径中,_search_multi_kb 方法对 knowledge/base.py 的 tuple 返回值做了兼容转换:
# core/engine.py _search_multi_kb 中
if isinstance(bm25_res, tuple):
_ids, _docs, _metas, _dists = bm25_res
bm25_res = {
'ids': [_ids], 'documents': [_docs],
'metadatas': [_metas], 'distances': [_dists]
}
设计建议:两个 BM25Index 实现有重叠功能,未来可考虑统一为 dict 返回格式,消除兼容转换代码。
五、检索管线完整数据流
5.1 入口方法
# api/chat_routes.py
search_result = search_hybrid(retrieval_query, top_k, candidates, allowed_collections, sub_queries)
# search_hybrid 内部调用
result = engine.search_knowledge(query, top_k, allowed_levels, collections, sub_queries)
# 然后添加 scores 字段
5.2 search_knowledge 完整流程
query (str)
│
├─ [1] 缓存检查 ──命中──→ 直接返回
│
├─ [2] 外部子查询(IntentAnalyzer 生成的 sub_queries)
│ → _search_with_sub_queries() → 合并 → 返回
│
├─ [3] 查询拆分(QueryDecomposer 判断)
│ → _search_with_decomposition() → 合并 → 返回
│
├─ [4] 查询扩展(当前仅构建 expanded_queries,未用于多查询检索)
│
├─ [5] 分支:USE_MULTI_KB=True → _search_multi_kb() → 返回
│ USE_MULTI_KB=False → 单知识库路径(当前不执行)
│
└─ 单知识库路径(以下步骤在 _search_multi_kb 中对称存在):
│
├─ [6] where 过滤构建(security_level / source)
│
├─ [7] 向量编码 → query_vector (768维)
│
├─ [8] 向量检索 → vector_results
│ {ids:[[]], documents:[[]], metadatas:[[]], distances:[[]]}
│
├─ [9] 图片独立检索 → image_results → 合并
│
├─ [10] FAQ 独立检索 → faq_results → 合并
│
├─ [11] BM25 检索 → bm25_results
│ + 捕获 BM25 原始 top-3 → _bm25_raw_top3
│
├─ [12] 动态 RRF 权重 → (vector_w, bm25_w)
│
├─ [13] RRF 融合 → fused_results
│ {distances:[[rrf_score]], '_score_source':'rrf'}
│ ids 带 collection 前缀:"public_kb/filename_3"
│
├─ [14] 废止过滤 → 移除 status != "active" 的切片
│
├─ [15] 枚举标记 → fused_results['_enum_query'] = bool
│
├─ [16] BM25 top3 传递 → fused_results['_bm25_top3']
│
├─ [17] 章节过滤 → 按查询中的章节关键词过滤
│
├─ [18] 上下文扩展 1(MMR前,min_score=0)→ 补齐邻居
│
├─ [19] MMR 去重 → 缩减到 MMR_TOP_K=30
│
├─ [20] Rerank 重排 → distances 变为 rerank 分数 [0,1]
│ '_reranked'=True, '_score_source' 仍为 'rrf'
│
├─ [21] FAQ 加权 → FAQ 切片 distances -= 0.1
│
├─ [22] 黑名单过滤 → 移除黑名单 source
│
├─ [23] 时间衰减 → 老 FAQ distances += decay
│
├─ [24] 章节聚类提升 → 低分但聚类的切片 distances = 0.65
│
├─ [25] 上下文扩展 2(Rerank后,min_score=0.3)→ 高分种子邻居
│
├─ [26] 自适应 TopK → 可能截断结果
│ ⚠️ 当前 bug: _score_source='rrf' 导致此步被跳过
│
└─ [27] 缓存写入 + 返回
5.3 特殊键在管线中的传递
以下键在过滤/截断方法中需要手动复制(否则会丢失):
for key in ('_debug', '_score_source', '_enum_query', '_expanded_context', '_bm25_top3'):
if key in results:
filtered[key] = results[key]
| 键 | 写入位置 | 读取位置 | 作用 |
|---|---|---|---|
_debug |
每步追加 step | 返回给前端(dev 模式) | 检索调试信息 |
_score_source |
RRF 融合写入 'rrf' |
自适应 TopK 判断 | 区分 distances 语义 |
_enum_query |
枚举检测后写入 | MMR lambda 选择、自适应 TopK | 枚举查询保留更多上下文 |
_expanded_context |
扩展时写入 | 返回给前端 | 扩展统计 |
_bm25_top3 |
BM25 检索后写入 | 路由层分歧检测救援 | BM25 原始 top-3 切片信息 |
_reranked |
Rerank 后写入 | search_hybrid 中判断 |
区分 distances 是距离还是分数 |
_cluster_boosted |
聚类提升写入 meta | 上下文扩展时作为种子资格 | 标记被引擎层提升的切片 |
5.4 多知识库并发检索
_search_multi_kb 使用 ThreadPoolExecutor 并行查询各向量库:
with ThreadPoolExecutor(max_workers=len(target_collections)) as executor:
futures = {executor.submit(_query_single_collection, name): name for name in target_collections}
for future in as_completed(futures):
coll_results, bm25_raw_items = future.result()
all_results.extend(coll_results)
_bm25_raw_top3.extend(bm25_raw_items)
每个向量库独立查询:向量检索 + BM25 检索,结果标记 _collection = coll_name。
5.5 子查询路径的 BM25 top3 传递
_search_with_sub_queries 和 _search_with_decomposition 合并结果时,会收集各子查询的 _bm25_top3,按 bm25_score 降序取全局 top-3,确保路由层分歧检测在子查询路径下也能工作。
六、distances / scores 语义变化
distances 字段在管线各阶段含义不同,是理解检索逻辑的关键。
6.1 语义变化表
| 阶段 | distances 含义 | 方向 | 范围 | 标记 |
|---|---|---|---|---|
| ChromaDB 向量检索 | cosine distance | 越小越好 | [0, 2] | - |
| BM25 检索 | BM25 原始分数 | 越大越好 | [0, ∞) | - |
| RRF 融合后 | RRF 分数 | 越大越好 | [0, ~0.05] | _score_source='rrf' |
| Rerank 后 | CrossEncoder 相关性分数 | 越大越好 | [0, 1] | _reranked=True |
| FAQ 加权后 | rerank 分数 - 0.1 | 越大越好 | [-0.1, 1] | - |
| 时间衰减后 | 距离 + decay | 越大越差 | - | - |
| 聚类提升后 | 被设为 1.0 - CLUSTER_SEED_FLOOR = 0.65 |
越大越好 | - | _cluster_boosted=True |
6.2 search_hybrid 中的 distances → scores 转换
# api/chat_routes.py search_hybrid()
if result.get('_reranked'):
# Rerank 后:distances 就是相关性分数,直接使用
scores = [float(d) for d in distances]
else:
# 未 Rerank:将向量距离转为相似度分数
scores = [1.0 - d if d <= 1.0 else 1.0 / (1.0 + d) for d in distances]
result['scores'] = [scores]
6.3 自适应 TopK 中的距离→相似度转换
# engine.py 第 790 行
top_score = 1.0 - fused_results['distances'][0][0] # 距离转相似度
⚠️ 已知问题:RRF+Rerank 后,
_score_source仍为'rrf',导致自适应 TopK 被跳过(第 788 行条件fused_results.get('_score_source') != 'rrf'不满足)。但此时 distances 已是有效的 rerank 分数,自适应 TopK 本应可以应用。此 bug 导致高置信度查询无法收缩结果,低置信度查询无法扩展。
七、路由层处理流程
7.1 generate_stream 完整流程
用户提问 (message)
│
├─ [0] 意图分析 → IntentAnalysis(rewritten_query, need_retrieval, use_context, sub_queries, intent)
│ need_retrieval=False + use_context=True → 直接用历史上下文回答 → return
│
├─ [0.5] retrieval_query = intent.rewritten_query
│
├─ [1] 语义缓存检查 ──命中──→ 流式返回缓存答案 → return
│
├─ [2] 混合检索 → search_hybrid() → search_result
│
├─ [3] 构建 contexts 列表
│ 从 search_result 的 documents[0] / metadatas[0] / scores[0] 三数组构建
│ contexts = [{'doc': display_doc, 'meta': meta, 'score': score}, ...]
│ 图片/图表切片:doc 替换为 meta.full_description(如果存在)
│
├─ [3.5] 补充检索:从文本切片提取图号/表号引用,补充检索缺失图片
│
├─ [4] 懒加载增强(当前禁用)
│
├─ [5] 图片选择 → select_images()
│
├─ [6] 三重救援
│ ├─ _rescue_bm25_divergence(BM25 分歧检测救援)
│ ├─ _rescue_lexical_match(词法匹配救援)
│ └─ _rescue_section_cluster(章节聚类救援)
│
├─ [7] 排序 + 预算截断
│ _order_text_contexts_for_prompt() → min_score 过滤 → max_chunks 截断
│
├─ [8] 表格救援 → _rescue_table_chunks()
│
├─ [9] Prompt 构建
│ ├─ 置信度分数(top-3 平均 rerank 分数)
│ ├─ 图片描述注入
│ ├─ 意图驱动指令注入(对比/推理/操作/枚举)
│ └─ 置信度指令注入
│
├─ [10] LLM 流式生成 → engine.generate_answer_stream()
│
└─ [11] 后处理
├─ 答案对齐过滤(提取图号/表号,反向筛选图片)
├─ 去引用标记(移除 [1][2])
├─ 附加引用标注([ref:chunk_id])
├─ 敏感信息过滤
├─ 保存会话
└─ 写入语义缓存
7.2 contexts 构建关键转换
# search_result 格式(来自 search_hybrid)
search_result = {
'documents': [[doc1, doc2, ...]],
'metadatas': [[meta1, meta2, ...]],
'scores': [[score1, score2, ...]], # search_hybrid 添加
'ids': [['public_kb/filename_3', ...]], # 多知识库模式带前缀
'distances': [[rerank_score, ...]], # 原始 distances 仍保留
'_bm25_top3': [{id, doc, meta, bm25_score, rank}, ...],
'_debug': {...},
}
# contexts 列表格式(路由层使用)
contexts = [
{'doc': display_doc, 'meta': meta, 'score': score},
...
]
注意:contexts 中没有
id字段。切片 ID 只能通过meta.chunk_id获取(格式如1.docx_14),而 engine 的ids可能带 collection 前缀(如public_kb/1.docx_14)。
7.3 min_score 过滤
RERANK_CONTEXT_MIN_SCORE = 0.05
在 _order_text_contexts_for_prompt 中:
score >= min_score的切片直接通过- 表格保护:同 section 有切片通过阈值时,同 section 的 table 切片保留下限为
min_score * 0.3= 0.015
八、三重救援机制详解
三重救援是双层保护架构:引擎层在 rerank 后提升低分但可信的切片(分数较高),路由层在 min_score 过滤前做最终安全网(分数较低)。
8.1 BM25 分歧检测救援 _rescue_bm25_divergence
触发条件:
BM25_DIVERGENCE_RESCUE_ENABLED = Truesearch_result._bm25_top3非空- BM25 项的
rank <= BM25_DIVERGENCE_MAX_RANK(= 3)
保底分数:CLUSTER_RESCUE_FLOOR = 0.06
两种情况:
| 情况 | 条件 | 处理 | 示例 |
|---|---|---|---|
| A — 分数压制 | 切片在 contexts 中但 score < min_score |
提升 score 至 CLUSTER_RESCUE_FLOOR |
q003: 0.0034 → 0.06 |
| B — 截断丢失 | 切片不在 contexts 中(被 rerank top_k 截断) | 从 _bm25_top3 备份注入新 context |
q014: 不在 → 注入 score=0.06 |
保护机制:
- 仅救援 BM25 rank ≤ 3 的切片(top-3 是精确关键词匹配的强信号)
- 救援分数固定为
CLUSTER_RESCUE_FLOOR(0.06),不会高于 rerank 正常通过的切片 - 匹配使用
meta.chunk_id(不带 collection 前缀),与 BM25 top3 的id字段匹配
数据来源:_bm25_top3 由 engine.search_knowledge 在 BM25 搜索后保存,格式为:
[{'id': '1.docx_14', 'doc': '...', 'meta': {...}, 'bm25_score': 12.4, 'rank': 1}, ...]
8.2 词法匹配救援 _rescue_lexical_match
触发条件:
- contexts 非空且 retrieval_query 非空
- 清理后查询长度 ≥ 2
- 能提取 bigram
保底分数:CLUSTER_RESCUE_FLOOR = 0.06
Phase 1 — 词法匹配救援:
- 清理查询(去除 markdown 格式和标点)
- 提取查询 bigram 集合(连续两字组)
- 对每个
score < min_score的切片,计算 bigram 命中率 - 命中率 > 0.35 → 提升 score 至
max(原score, CLUSTER_RESCUE_FLOOR) - 记录被救援的
(source, chunk_index)作为种子
Phase 2 — 邻居救援:
- 对每个词法匹配救援的种子,同时救援同 source 下
chunk_index后续 8 个相邻切片 - 目的:枚举类问题的 header 切片被救援后,其后续子条目也应被保留
8.3 章节聚类救援 _rescue_section_cluster
触发条件:
SECTION_CLUSTER_RESCUE_ENABLED = True- 存在"全灭 section":某 section 的成员数 ≥
CLUSTER_MIN_MEMBERS(= 3),类型多样性 ≥CLUSTER_MIN_TYPES(= 2),且所有成员 score < min_score
保底分数:CLUSTER_RESCUE_FLOOR = 0.06
处理逻辑:
- 按
(source, normalized_section)分组 - 检测"全灭 section":所有成员 score < min_score
- 计算聚类强度 = 成员数 × 类型多样性 × (1 + 查询匹配度)
- 按强度降序,救援 top
CLUSTER_MAX_SECTIONS(= 3)个 section - 每个 section 最多救援
CLUSTER_MAX_RESCUE_PER_SECTION(= 6)个切片
8.4 引擎层 vs 路由层的双层保护
| 维度 | 引擎层 _section_cluster_boost |
路由层 _rescue_section_cluster |
|---|---|---|
| 执行时机 | Rerank 后、扩展前 | min_score 过滤前 |
| 提升方式 | 修改 distances | 修改 contexts score |
| 保底分数 | CLUSTER_SEED_FLOOR = 0.35(dist=0.65) |
CLUSTER_RESCUE_FLOOR = 0.06 |
| 覆盖范围 | 聚类切片在 rerank 之前就能被后续步骤看到 | 最终安全网,确保不遗漏 |
设计意图:引擎层提升分数较高(0.35 对应 dist=0.65),使得这些切片能在后续的扩展步骤中被当作种子。路由层是最终安全网,分数较低(0.06)仅确保通过 min_score 过滤。
九、图片召回与选择
9.1 独立图片检索(P0 通道)
图片/图表切片走独立检索通道,不被文本切片挤占名额:
image_recall_k = max(5, top_k // 2) # 图片独立召回数量
image_results = _search_image_chunks(query_vector, image_recall_k, where_filter)
- 仅检索
chunk_type为image、chart、table的切片 - 多知识库模式下对每个 collection 分别调用
- 图片结果与文本结果通过
_merge_results()合并
9.2 图片相关性提升(Boost)
在路由层对图片/图表切片做相关性评估:
- 编号匹配:查询提到"图2.1"且图片 caption 匹配 → boost_factor = 2.0
- 语义重叠:caption 与查询有足够字符重叠 → boost_factor = 1.5
- Boost 以
_image_boost标记记录在 metadata 中,不改变排序
9.3 图片选择(select_images)
从召回结果中筛选最终展示给 LLM 的图片:
- 动态预算:精确查图 2 张,有图片数据 5 张,有引用 3 张,默认 2 张
- 对有
image_path的切片用score_image_relevance打分 - VLM 相关性筛选 + 章节关联检测 + 图号/表号匹配加分
- 按分数降序取 top
MAX_IMAGES
9.4 图片后置过滤
LLM 生成回答后,用回答内容反向过滤图片:
- 提取回答关键词
- 检查每张图片描述与回答关键词的重叠度
- 超过阈值的保留;兜底:若全部被过滤则保留最高分 1 张
十、返回格式与溯源信息
10.1 SSE 事件序列
| 事件类型 | 说明 |
|---|---|
intent_result |
意图分析结果(仅 dev) |
retrieval_debug |
检索管线调试信息(仅 dev) |
start |
开始生成 |
sources |
检索来源列表 |
chunks_retrieved |
召回切片详情(仅 dev) |
section_cluster_rescue |
章节聚类救援(仅 dev) |
chunk |
每个 token(流式) |
finish |
完成事件(含完整回答和元数据) |
10.2 来源信息(sources)
{
"source": "文件名.docx",
"page": 12,
"page_end": 14,
"page_range": "12-14",
"section": "第三章 > 第二节 > 小节名",
"chunk_type": "text",
"doc_type": "word",
"section_chunk_id": 5,
"score": 0.892
}
10.3 引用格式(citations)
_attach_citations() 自动插入 [ref:chunk_id] 标记:
根据相关规定,安全检查应包括以下几个方面[ref:3.docx_154]...
十一、配置项完整列表
检索管线配置
| 配置项 | 默认值 | 作用 | 状态 |
|---|---|---|---|
USE_MULTI_KB |
True |
多向量库模式 | ✅ 活跃 |
USE_HYBRID_SEARCH |
True |
向量+BM25混合检索 | ✅ 活跃 |
USE_RERANK |
True |
Rerank 重排 | ✅ 活跃 |
RERANK_CANDIDATES |
20 |
Rerank 候选数 | ✅ 活跃 |
RERANK_CONTEXT_MIN_SCORE |
0.05 |
路由层最低分数阈值 | ✅ 活跃 |
RERANK_BACKEND |
"local" |
local/cloud/fallback | ✅ 活跃 |
RERANK_USE_ONNX |
env("true") | ONNX 加速 | ✅ 活跃 |
DYNAMIC_RRF_ENABLED |
True |
动态 RRF 权重 | ✅ 活跃 |
RRF_K |
60 |
RRF 常数 | ✅ 活跃 |
MMR_ENABLED |
True |
MMR 去重 | ✅ 活跃 |
MMR_USE_EMBEDDING |
env("false") | 高精度/轻量版 | ✅ 活跃 |
MMR_TOP_K |
30 |
MMR 保留数量 | ✅ 活跃 |
MMR_LAMBDA |
0.5 |
相关性vs多样性 | ✅ 活跃 |
ENUM_QUERY_MMR_LAMBDA |
0.85 |
枚举查询 lambda | ✅ 活跃 |
QUERY_EXPANSION_ENABLED |
True |
查询扩展 | ⚠️ 构建但未用于多查询 |
SECTION_FILTER_ENABLED |
True |
章节过滤 | ✅ 活跃 |
ADAPTIVE_TOPK_ENABLED |
True |
自适应 TopK | ⚠️ RRF+Rerank 后被跳过(bug) |
CONTEXT_EXPANSION_ENABLED |
True |
上下文扩展 | ✅ 活跃 |
CONTEXT_EXPANSION_BEFORE |
1 |
向前扩展数 | ✅ 活跃 |
CONTEXT_EXPANSION_AFTER |
8 |
向后扩展数 | ✅ 活跃 |
CONTEXT_EXPANSION_MAX_CHUNKS |
50 |
最大扩展总数 | ✅ 活跃 |
SECTION_CLUSTER_BOOST_ENABLED |
True |
引擎层聚类提升 | ✅ 活跃 |
SECTION_CLUSTER_RESCUE_ENABLED |
True |
路由层聚类救援 | ✅ 活跃 |
BM25_DIVERGENCE_RESCUE_ENABLED |
True |
BM25 分歧救援 | ✅ 活跃 |
BM25_DIVERGENCE_MAX_RANK |
3 |
救援 BM25 排名阈值 | ✅ 活跃 |
RAG_SEARCH_TOP_K |
30 |
传给 search_hybrid 的 top_k | ✅ 活跃 |
RAG_SEARCH_CANDIDATES |
100 |
传给 search_hybrid 的 candidates | ❌ 不传递给 engine |
ENABLE_WEB_SEARCH |
False |
网络搜索 | ❌ 预留接口,未实现 |
VECTOR_WEIGHT / BM25_WEIGHT |
0.5 |
静态 RRF 权重 | ⚠️ 动态 RRF 启用时被覆盖 |
缓存配置
| 配置项 | 默认值 | 作用 | 状态 |
|---|---|---|---|
QUERY_CACHE_ENABLED |
True |
查询缓存 | ✅ 活跃 |
EMBEDDING_CACHE_ENABLED |
True |
Embedding 缓存 | ✅ 活跃 |
RERANK_CACHE_ENABLED |
True |
Rerank 分数缓存 | ✅ 活跃 |
SEMANTIC_CACHE_ENABLED |
True |
语义缓存 | ✅ 活跃 |
聚类/救援配置
| 配置项 | 默认值 | 作用 | 状态 |
|---|---|---|---|
CLUSTER_MIN_MEMBERS |
3 |
触发聚类最小成员数 | ✅ 活跃 |
CLUSTER_MIN_TYPES |
2 |
触发聚类最小类型数 | ✅ 活跃 |
CLUSTER_SEED_FLOOR |
0.35 |
引擎层聚类提升阈值 | ✅ 活跃 |
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 |
2 |
section 归一化层级(按章节前两级分组) | ✅ 活跃 |
LLM 预算配置
| 配置项 | 默认值 | 作用 | 状态 |
|---|---|---|---|
MAX_LLM_CALLS_PER_QUERY |
2 |
每查询 LLM 调用上限 | ⚠️ 仅 llm_budget.py 使用(模块未集成到主流程) |
MAX_QUERY_REWRITES |
1 |
查询改写上限 | ⚠️ 同上 |
十二、单/多知识库路径说明
12.1 当前配置
USE_MULTI_KB = True(硬编码在 config.py)
效果:search_knowledge 在第 612 行直接分支到 _search_multi_kb(),跳过第 628-811 行的单知识库路径。
12.2 两条路径对比
| 维度 | 单知识库路径 | 多知识库路径 |
|---|---|---|
| 向量库 | self.collection |
self.kb_manager.get_collection(coll_name) |
| BM25 | self.bm25_index(core/bm25_index.py) |
kb_manager.get_bm25_index(coll_name)(knowledge/base.py) |
| 并发 | 无 | ThreadPoolExecutor 并行查各库 |
| RRF 权重 | [VECTOR_WEIGHT, BM25_WEIGHT] |
[vector_w, bm25_w, ...] 交替 |
| ID 前缀 | 无 | 带 collection 前缀 |
| 安全过滤 | ChromaDB where 过滤 | source 过滤 |
12.3 单知识库路径保留原因
- 逻辑与多知识库路径对称,作为回退方案
- 小规模部署可能不需要多知识库
self.bm25_index(core/bm25_index.py)仅在此路径使用
12.4 独立检索路径
knowledge/search.py 的 SearchMixin 和 SearchResult 是独立于 engine 的检索路径:
- 被
knowledge/router.py使用(知识库路由推荐功能) - 不走 engine 主流程
- 返回
SearchResultdataclass(扁平列表格式),与 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 过滤时使用精确匹配:
where_filter = {"$and": [{"source": source}, {"section": section}]}
当种子是父级标题(如 section = "三、做好货源投放前的基础工作")时:
- 子标题下的正文切片
section = "三 > (一)货源投放要求 > ..."不匹配 - 回退到 source-only 模式,从整个文档中拉取相邻
chunk_index的切片 - 对大文档,回退模式可能拉取到不相关的切片
影响:父级标题被检索到时,无法通过 section 过滤拉取其子节内容,降低了上下文完整性。
失配 3:text_level 未传入检索管线 ✅ 已修复
text_level 未传入检索管线原问题:text_level(标题级别 0/1/2/3)在切片阶段被精确计算,但未存入 ChromaDB metadata,检索管线无法感知标题层级。
修复:knowledge/manager.py 的 metadata 构建处已新增 text_level 字段:
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:
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 中新增了两层防护:
_validate_level超长文本降级:H1 > 40字、H2 > 60字、H3 > 50字时自动降为正文(level=0)。覆盖所有返回路径(v1 常规匹配、v2 style 匹配、bold_short_text 兜底)。- 规则级
max_length+exclude_pattern:部分 H1 规则(如numeric_level1)设置max_length=50和排除句末标点(;;。,、::)的exclude_pattern,在匹配阶段即过滤段落文本。
当前状态:长段落不再被误判为标题,section_path 污染问题已消除。
13.4 切片-检索兼容性总结
| 失配点 | 严重度 | 影响范围 | 现状 |
|---|---|---|---|
| 标题语义内容冗余 | 🔴 严重 | 全部文档 | 670 个向量库切片中约 174 个是纯标题 |
| section 精确匹配 | 🔴 严重 | 父级标题检索 | 回退到 source-only,可能拉取不相关内容 |
| ✅ 已修复:text_level 已存入 ChromaDB metadata | |||
| ✅ 已修复:CLUSTER_SECTION_PREFIX_LEVELS 调整为 2 | |||
| 预算构建开销 | 🟢 轻微 | 上下文构建 | 标题单例组浪费字符预算 |
| MMR 过度消除 | 🟢 模式相关 | MMR 去重 | 当前 Jaccard 模式暂未触发 |
| ✅ 已修复: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 过滤从精确匹配改为前缀匹配:
# 当前:精确匹配
{"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_contenttable:提取table_caption(列表格式解析)、table_footnote、image_source.path、table_nest_levelimage/chart:提取image_source.path(回退img_path)、VLM 描述、chart_markdown、sub_type、caption(列表格式)equation:读取textlist:拼接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。