| """ |
| Structure-based Chunking + DFS-based Grouping |
| ============================================== |
| 对 MinerU 导出的 content_list.json 进行基于文档结构的智能分块。 |
| |
| 核心流程: |
| A. 预处理: 将 JSON 中的 blocks 拉平并按阅读顺序排序 |
| B. 标题识别: 从 blocks 中筛选候选标题 (text_level / 编号模式 / 启发式) |
| C. 构建标题层级树: 用栈维护层级, 编号推断 header_level |
| D. 将普通内容块挂到标题节点下 |
| E. DFS 遍历层级树, 生成 chunk (超过 max_len 则切分) |
| F. 输出 chunk 列表 (含 document_id, section_path 等元数据) |
| """ |
|
|
| import os |
| import re |
| import json |
| from typing import List, Dict, Optional, Tuple |
| from dataclasses import dataclass, field |
|
|
|
|
| |
|
|
| DEFAULT_MAX_TOKENS = 550 |
|
|
|
|
| |
|
|
| @dataclass |
| class Block: |
| """OCR 输出的一个内容块.""" |
| index: int |
| page_idx: int |
| bbox: List[int] |
| text: str |
| block_type: str |
| text_level: Optional[int] = None |
| image_caption: str = "" |
| image_footnote: str = "" |
| |
| is_header: bool = False |
| header_level: int = 0 |
|
|
|
|
| @dataclass |
| class TreeNode: |
| """层级树的节点.""" |
| header_text: str |
| header_level: int |
| block: Optional[Block] = None |
| children: List['TreeNode'] = field(default_factory=list) |
| content_blocks: List[Block] = field(default_factory=list) |
| parent: Optional['TreeNode'] = None |
|
|
|
|
| |
|
|
| def parse_content_list(json_path: str) -> Tuple[List[Block], str]: |
| """ |
| 加载 content_list.json, 解析为 Block 列表并按阅读顺序排序. |
| 返回 (all_segments, document_id). |
| """ |
| with open(json_path, 'r', encoding='utf-8') as f: |
| content_list = json.load(f) |
|
|
| |
| folder_name = os.path.basename(os.path.dirname(json_path)) |
| document_id = folder_name |
|
|
| all_segments = [] |
| for i, item in enumerate(content_list): |
| btype = item.get('type', 'text') |
| if btype == 'discarded': |
| continue |
|
|
| text = item.get('text', '').strip() |
| bbox = item.get('bbox', [0, 0, 0, 0]) |
| page_idx = item.get('page_idx', 0) |
| text_level = item.get('text_level', None) |
|
|
| |
| raw_caption = item.get('image_caption', '') or '' |
| if isinstance(raw_caption, list): |
| raw_caption = ' '.join(str(x) for x in raw_caption if x) |
| raw_footnote = item.get('image_footnote', '') or '' |
| if isinstance(raw_footnote, list): |
| raw_footnote = ' '.join(str(x) for x in raw_footnote if x) |
|
|
| block = Block( |
| index=i, |
| page_idx=page_idx, |
| bbox=bbox, |
| text=text, |
| block_type=btype, |
| text_level=text_level, |
| image_caption=str(raw_caption).strip(), |
| image_footnote=str(raw_footnote).strip(), |
| ) |
| all_segments.append(block) |
|
|
| |
| all_segments.sort(key=lambda b: (b.page_idx, b.bbox[1] if len(b.bbox) >= 2 else 0, |
| b.bbox[0] if len(b.bbox) >= 1 else 0)) |
|
|
| |
| for idx, seg in enumerate(all_segments): |
| seg.index = idx |
|
|
| return all_segments, document_id |
|
|
|
|
| |
|
|
| |
| _NUMBERED_HEADING_RE = re.compile( |
| r'^(?:' |
| r'(?P<num>\d+(?:\.\d+)*)\.?\s+' |
| r'|(?P<letter>[A-Z](?:\.\d+)*)\.\s+' |
| r')' |
| r'(?P<title>.+)', |
| re.DOTALL |
| ) |
|
|
| |
| _ROMAN_HEADING_RE = re.compile( |
| r'^(?P<roman>(?:X{0,3})(?:IX|IV|V?I{0,3}))[\.\)]\s+(?P<title>.+)', |
| re.IGNORECASE | re.DOTALL |
| ) |
|
|
|
|
| def infer_heading_level_from_numbering(text: str) -> Optional[int]: |
| """ |
| 从文本的编号模式推断标题层级. |
| "1 Title" -> level 1, "1.1 Title" -> level 2, "1.1.1 Title" -> level 3 |
| 返回 None 表示无法识别编号. |
| """ |
| text = text.strip() |
| m = _NUMBERED_HEADING_RE.match(text) |
| if m: |
| num = m.group('num') |
| letter = m.group('letter') |
| if num: |
| |
| parts = num.split('.') |
| return len(parts) |
| if letter: |
| parts = letter.split('.') |
| return len(parts) |
| |
| m2 = _ROMAN_HEADING_RE.match(text) |
| if m2: |
| return 1 |
| return None |
|
|
|
|
| def is_likely_page_header_footer(block: Block, all_segments: List[Block]) -> bool: |
| """启发式判断是否为页眉/页脚 (应排除).""" |
| text = block.text.strip() |
| if not text: |
| return True |
| |
| if re.match(r'^\d{1,4}\s*$', text): |
| return True |
| |
| bbox = block.bbox |
| if len(bbox) >= 4: |
| height = bbox[3] - bbox[1] |
| |
| if height < 20 and len(text) < 30: |
| if bbox[1] < 50 or bbox[1] > 750: |
| return True |
| return False |
|
|
|
|
| def is_all_caps_short(text: str, max_words: int = 12) -> bool: |
| """判断是否是全大写的短文本 (可能是标题).""" |
| cleaned = re.sub(r'[\d\.\s\-:,&/]', '', text) |
| if not cleaned: |
| return False |
| words = text.split() |
| return len(words) <= max_words and cleaned.isupper() and len(cleaned) >= 3 |
|
|
|
|
| def identify_headers(all_segments: List[Block]) -> List[Block]: |
| """ |
| 从 all_segments 中识别标题块, 并设置 is_header / header_level. |
| 返回 header_list (按原顺序). |
| """ |
| header_list = [] |
|
|
| for block in all_segments: |
| if block.block_type != 'text': |
| continue |
| text = block.text.strip() |
| if not text or len(text) < 2: |
| continue |
| if is_likely_page_header_footer(block, all_segments): |
| continue |
|
|
| |
| if block.text_level is not None and block.text_level >= 1: |
| block.is_header = True |
| |
| inferred = infer_heading_level_from_numbering(text) |
| if inferred is not None: |
| block.header_level = inferred |
| else: |
| |
| if is_all_caps_short(text): |
| block.header_level = 1 |
| else: |
| block.header_level = 2 |
| header_list.append(block) |
| continue |
|
|
| |
| inferred = infer_heading_level_from_numbering(text) |
| if inferred is not None: |
| words = text.split() |
| |
| if len(words) <= 20: |
| block.is_header = True |
| block.header_level = inferred |
| header_list.append(block) |
| continue |
|
|
| |
| if is_all_caps_short(text, max_words=8): |
| |
| words = text.split() |
| if len(words) <= 8: |
| block.is_header = True |
| block.header_level = 1 |
| header_list.append(block) |
|
|
| return header_list |
|
|
|
|
| |
|
|
| def build_header_tree(header_list: List[Block], document_id: str) -> TreeNode: |
| """ |
| 用栈从 header_list 构建层级树. |
| """ |
| |
| root = TreeNode(header_text="ROOT", header_level=0) |
|
|
| |
| if header_list: |
| first = header_list[0] |
| title_node = TreeNode( |
| header_text=first.text.strip(), |
| header_level=1, |
| block=first, |
| parent=root, |
| ) |
| else: |
| title_node = TreeNode( |
| header_text=document_id, |
| header_level=1, |
| parent=root, |
| ) |
| root.children.append(title_node) |
|
|
| |
| stack = [title_node] |
|
|
| for header_block in header_list[1:]: |
| level = header_block.header_level |
| |
| if level < 1: |
| level = 1 |
|
|
| |
| while len(stack) > 1 and stack[-1].header_level >= level: |
| stack.pop() |
|
|
| parent = stack[-1] |
| node = TreeNode( |
| header_text=header_block.text.strip(), |
| header_level=level, |
| block=header_block, |
| parent=parent, |
| ) |
| parent.children.append(node) |
| stack.append(node) |
|
|
| return root |
|
|
|
|
| |
|
|
| def attach_content_to_tree(root: TreeNode, all_segments: List[Block], header_list: List[Block]): |
| """ |
| 遍历 all_segments, 将非标题块挂到 current_header 的 content_blocks 中. |
| """ |
| |
| header_block_set = set(id(h) for h in header_list) |
| header_to_node = {} |
|
|
| def _map_nodes(node: TreeNode): |
| if node.block is not None: |
| header_to_node[id(node.block)] = node |
| for child in node.children: |
| _map_nodes(child) |
|
|
| _map_nodes(root) |
|
|
| |
| current_node = root.children[0] if root.children else root |
|
|
| for block in all_segments: |
| if id(block) in header_block_set and block.is_header: |
| |
| if id(block) in header_to_node: |
| current_node = header_to_node[id(block)] |
| continue |
|
|
| if block.block_type == 'discarded': |
| continue |
|
|
| |
| current_node.content_blocks.append(block) |
|
|
|
|
| |
|
|
| def _count_tokens(text: str) -> int: |
| """近似 token 计数 (按空白分词).""" |
| return len(text.split()) |
|
|
|
|
| def _block_to_text(block: Block) -> str: |
| """将一个内容 block 转化为文本.""" |
| if block.block_type == 'table': |
| table_text = block.text.strip() |
| if table_text: |
| return f"[TABLE]\n{table_text}\n[/TABLE]" |
| return "" |
| elif block.block_type == 'image': |
| parts = [] |
| if block.image_caption: |
| parts.append(f"[IMAGE CAPTION] {block.image_caption}") |
| if block.image_footnote: |
| parts.append(f"[IMAGE NOTE] {block.image_footnote}") |
| if block.text.strip(): |
| parts.append(block.text.strip()) |
| return "\n".join(parts) if parts else "" |
| elif block.block_type == 'equation': |
| eq_text = block.text.strip() |
| return f"[EQUATION] {eq_text}" if eq_text else "" |
| else: |
| return block.text.strip() |
|
|
|
|
| def _get_section_path(node: TreeNode) -> List[str]: |
| """获取从 Title 到当前节点的标题路径.""" |
| path = [] |
| current = node |
| while current is not None and current.header_level > 0: |
| path.append(current.header_text) |
| current = current.parent |
| path.reverse() |
| return path |
|
|
|
|
| def _make_md_heading(text: str, depth: int) -> str: |
| """生成 Markdown 标题行.""" |
| prefix = "#" * min(depth, 6) |
| return f"{prefix} {text}" |
|
|
|
|
| def _collect_node_text(node: TreeNode) -> List[str]: |
| """递归收集一个节点及其所有子孙的文本行 (不含标题上下文前缀).""" |
| lines = [] |
| |
| path = _get_section_path(node) |
| depth = len(path) |
| if depth > 0: |
| lines.append(_make_md_heading(node.header_text, depth)) |
| |
| for block in node.content_blocks: |
| piece = _block_to_text(block) |
| if piece: |
| lines.append(piece) |
| |
| for child in node.children: |
| lines.extend(_collect_node_text(child)) |
| return lines |
|
|
|
|
| def dfs_generate_chunks( |
| root: TreeNode, |
| document_id: str, |
| max_tokens: int = DEFAULT_MAX_TOKENS, |
| min_tokens: int = 50, |
| ) -> List[Dict]: |
| """ |
| DFS 遍历层级树, 生成 chunk 列表. |
| |
| 核心策略: |
| - 自底向上: 如果一个节点 (含子树) 的全部文本 <= max_tokens, 整体输出为一个 chunk |
| - 如果超出: 先输出该节点自身的内容, 再分别处理每个子节点 |
| - 小于 min_tokens 的 chunk 会与相邻 chunk 合并 |
| - 每个 chunk 开头带 Markdown 标题链 (section_path), 便于理解上下文 |
| |
| 每个 chunk: |
| { |
| "text": "<Markdown 格式文本>", |
| "metadata": { |
| "document_id": str, |
| "section_path": List[str] |
| } |
| } |
| """ |
| raw_chunks = [] |
|
|
| def _build_heading_context(node: TreeNode) -> List[str]: |
| """构建从 Title 到当前节点的 Markdown 标题链.""" |
| path = _get_section_path(node) |
| lines = [] |
| for i, title_text in enumerate(path): |
| lines.append(_make_md_heading(title_text, i + 1)) |
| return lines |
|
|
| def _emit_chunk(lines: List[str], section_path: List[str]): |
| """输出一个 chunk (行列表 -> 文本).""" |
| text = "\n".join(lines).strip() |
| if not text: |
| return |
| raw_chunks.append({ |
| "text": text, |
| "metadata": { |
| "document_id": document_id, |
| "section_path": list(section_path), |
| } |
| }) |
|
|
| def _dfs(node: TreeNode): |
| """ |
| DFS 处理节点. 策略: |
| 1. 尝试将整个子树作为一个 chunk |
| 2. 如果太大, 则节点自身内容 + 各子节点分别处理 |
| """ |
| section_path = _get_section_path(node) |
| heading_lines = _build_heading_context(node) |
|
|
| |
| all_lines = _collect_node_text(node) |
| total_tokens = _count_tokens("\n".join(all_lines)) |
|
|
| if total_tokens <= max_tokens: |
| |
| |
| if heading_lines and all_lines and heading_lines[-1] == all_lines[0]: |
| output_lines = heading_lines[:-1] + all_lines |
| else: |
| output_lines = heading_lines + all_lines |
| |
| output_lines = _dedup_heading_lines(output_lines) |
| _emit_chunk(output_lines, section_path) |
| return |
|
|
| |
| |
| own_content = [] |
| for block in node.content_blocks: |
| piece = _block_to_text(block) |
| if piece: |
| own_content.append(piece) |
|
|
| if own_content: |
| buffer = list(heading_lines) |
| for piece in own_content: |
| piece_tokens = _count_tokens(piece) |
| buffer_tokens = _count_tokens("\n".join(buffer)) |
| if buffer_tokens + piece_tokens > max_tokens and len(buffer) > len(heading_lines): |
| _emit_chunk(buffer, section_path) |
| buffer = list(heading_lines) |
| |
| if piece_tokens > max_tokens: |
| heading_overhead = _count_tokens("\n".join(heading_lines)) |
| available = max_tokens - heading_overhead |
| words = piece.split() |
| while len(words) > available: |
| sub = " ".join(words[:available]) |
| buffer.append(sub) |
| _emit_chunk(buffer, section_path) |
| buffer = list(heading_lines) |
| words = words[available:] |
| if words: |
| buffer.append(" ".join(words)) |
| else: |
| buffer.append(piece) |
| |
| if _count_tokens("\n".join(buffer)) > _count_tokens("\n".join(heading_lines)): |
| _emit_chunk(buffer, section_path) |
|
|
| |
| for child in node.children: |
| _dfs(child) |
|
|
| def _dedup_heading_lines(lines: List[str]) -> List[str]: |
| """去除连续重复的标题行.""" |
| result = [] |
| for line in lines: |
| if result and line == result[-1]: |
| continue |
| result.append(line) |
| return result |
|
|
| |
| if root.content_blocks: |
| buf = [] |
| for block in root.content_blocks: |
| piece = _block_to_text(block) |
| if piece: |
| buf.append(piece) |
| if buf: |
| _emit_chunk(buf, []) |
|
|
| |
| for child in root.children: |
| _dfs(child) |
|
|
| |
| if not raw_chunks: |
| return [] |
|
|
| def _strip_shared_heading(existing_text: str, new_text: str) -> str: |
| """ |
| 如果 new_text 的开头标题行与 existing_text 中已包含的标题行重复, |
| 则去掉 new_text 开头的重复标题行, 避免合并后出现冗余. |
| """ |
| existing_lines = existing_text.split("\n") |
| new_lines = new_text.split("\n") |
|
|
| |
| existing_headings = set() |
| for line in existing_lines: |
| stripped = line.strip() |
| if stripped.startswith("#"): |
| existing_headings.add(stripped) |
|
|
| |
| skip = 0 |
| for line in new_lines: |
| stripped = line.strip() |
| if stripped.startswith("#") and stripped in existing_headings: |
| skip += 1 |
| else: |
| break |
|
|
| if skip > 0: |
| return "\n".join(new_lines[skip:]) |
| return new_text |
|
|
| merged = [] |
| buffer_text = "" |
| buffer_section_path = raw_chunks[0]["metadata"]["section_path"] |
|
|
| for chunk in raw_chunks: |
| chunk_text = chunk["text"] |
| chunk_tokens = _count_tokens(chunk_text) |
| buffer_tokens = _count_tokens(buffer_text) |
|
|
| if buffer_tokens == 0: |
| |
| buffer_text = chunk_text |
| buffer_section_path = chunk["metadata"]["section_path"] |
| elif buffer_tokens < min_tokens: |
| |
| stripped = _strip_shared_heading(buffer_text, chunk_text) |
| buffer_text = buffer_text + "\n" + stripped |
| if len(chunk["metadata"]["section_path"]) > len(buffer_section_path): |
| buffer_section_path = chunk["metadata"]["section_path"] |
| elif buffer_tokens + chunk_tokens <= max_tokens: |
| |
| stripped = _strip_shared_heading(buffer_text, chunk_text) |
| buffer_text = buffer_text + "\n" + stripped |
| if len(chunk["metadata"]["section_path"]) > len(buffer_section_path): |
| buffer_section_path = chunk["metadata"]["section_path"] |
| else: |
| |
| final = buffer_text.strip() |
| if final: |
| merged.append({ |
| "text": final, |
| "metadata": { |
| "document_id": document_id, |
| "section_path": list(buffer_section_path), |
| } |
| }) |
| buffer_text = chunk_text |
| buffer_section_path = chunk["metadata"]["section_path"] |
|
|
| |
| final = buffer_text.strip() |
| if final: |
| merged.append({ |
| "text": final, |
| "metadata": { |
| "document_id": document_id, |
| "section_path": list(buffer_section_path), |
| } |
| }) |
|
|
| return merged |
|
|
|
|
| |
|
|
| def structure_chunk_document( |
| json_path: str, |
| max_tokens: int = DEFAULT_MAX_TOKENS, |
| ) -> List[Dict]: |
| """ |
| 对单个文档进行 Structure-based Chunking. |
| |
| Args: |
| json_path: content_list.json 路径 |
| max_tokens: chunk 最大 token 数 |
| |
| Returns: |
| chunk 列表, 每个 chunk: |
| { |
| "text": "<Markdown 标题 + 内容>", |
| "metadata": { |
| "document_id": str, |
| "section_path": List[str] |
| } |
| } |
| """ |
| |
| all_segments, document_id = parse_content_list(json_path) |
| if not all_segments: |
| return [] |
|
|
| |
| header_list = identify_headers(all_segments) |
|
|
| |
| root = build_header_tree(header_list, document_id) |
|
|
| |
| attach_content_to_tree(root, all_segments, header_list) |
|
|
| |
| chunks = dfs_generate_chunks(root, document_id, max_tokens=max_tokens) |
|
|
| return chunks |
|
|
|
|
| def structure_chunk_document_flat( |
| json_path: str, |
| max_tokens: int = DEFAULT_MAX_TOKENS, |
| ) -> Tuple[List[str], List[Dict]]: |
| """ |
| 便捷接口: 返回 (chunk_texts, chunk_metadatas). |
| chunk_texts[i] 对应 chunk_metadatas[i]. |
| """ |
| chunks = structure_chunk_document(json_path, max_tokens=max_tokens) |
| texts = [c["text"] for c in chunks] |
| metadatas = [c["metadata"] for c in chunks] |
| return texts, metadatas |
|
|
|
|
| |
|
|
| if __name__ == "__main__": |
| import sys |
|
|
| if len(sys.argv) < 2: |
| print("用法: python structure_chunker.py <content_list.json> [max_tokens]") |
| print("示例: python structure_chunker.py ../MinerU_Reports/AEO_2022_ESG_Report/AEO_2022_ESG_Report_content_list.json 550") |
| sys.exit(1) |
|
|
| json_path = sys.argv[1] |
| max_tokens = int(sys.argv[2]) if len(sys.argv) > 2 else DEFAULT_MAX_TOKENS |
|
|
| print(f"输入: {json_path}") |
| print(f"max_tokens: {max_tokens}") |
| print() |
|
|
| chunks = structure_chunk_document(json_path, max_tokens=max_tokens) |
|
|
| print(f"总 chunk 数: {len(chunks)}") |
| print() |
|
|
| for i, chunk in enumerate(chunks): |
| text = chunk["text"] |
| meta = chunk["metadata"] |
| word_count = len(text.split()) |
| section = " > ".join(meta["section_path"]) |
| print(f"--- Chunk {i} ({word_count} words) ---") |
| print(f" doc: {meta['document_id']}") |
| print(f" path: {section}") |
| |
| preview = text[:200].replace("\n", "\\n") |
| print(f" text: {preview}...") |
| print() |
|
|
| |
| output_path = json_path.replace(".json", "_chunks.json") |
| with open(output_path, 'w', encoding='utf-8') as f: |
| json.dump(chunks, f, ensure_ascii=False, indent=2) |
| print(f"输出已保存: {output_path}") |
|
|