51dc8dc4f6
- FastAPI + Qdrant + Redis + Ollama 技术栈 - L1→L2→L3→chunk 四层分层检索(dense + sparse RRF 融合) - 文档三级总结与 2.5 级回退 - query 解析路由与分类 - /admin 管理页面
95 lines
3.1 KiB
Python
95 lines
3.1 KiB
Python
"""标题树解析器
|
||
|
||
从纯文本中解析文档原生标题结构,支持两类模式:
|
||
- Markdown ATX 标题(# ~ ######,# 数量即层级)
|
||
- 中文编号标题(第X章/节/篇、一、1.1 等编号,行长度不超过 60 字符)
|
||
|
||
解析结果用于 L2 大纲生成与 chunk 的 section 切分。
|
||
"""
|
||
|
||
import re
|
||
|
||
from pydantic import BaseModel, Field
|
||
|
||
|
||
class Heading(BaseModel):
|
||
"""文档标题节点"""
|
||
|
||
title: str = Field(description="标题文本")
|
||
level: int = Field(description="标题层级,1 为最顶层")
|
||
line_index: int = Field(description="标题所在行号(从 0 开始)")
|
||
|
||
|
||
# Markdown ATX 标题:1~6 个 # 后跟空白
|
||
_ATX_PATTERN = re.compile(r"^(#{1,6})\s+(.+?)\s*$")
|
||
|
||
# 中文篇章节编号:第X章/第X节/第X篇(章/篇=1 级,节=2 级)
|
||
_CN_CHAPTER_PATTERN = re.compile(r"^第[一二三四五六七八九十百\d]+([章节篇])")
|
||
|
||
# 中文序号:一、二、……,固定 1 级
|
||
_CN_ENUM_PATTERN = re.compile(r"^[一二三四五六七八九十]+、")
|
||
|
||
# 数字编号:1. / 1、/ 1.1 / 1.1.1 等,按点分段数定层级
|
||
_NUM_PATTERN = re.compile(r"^(\d+(?:\.\d+)*)[、.\s]")
|
||
|
||
# 编号类标题行的最大长度,超过则视为正文
|
||
_MAX_HEADING_LINE_LENGTH = 60
|
||
|
||
# 第X[章节篇] 后缀对应的层级
|
||
_CN_CHAPTER_LEVELS = {"章": 1, "节": 2, "篇": 1}
|
||
|
||
|
||
def parse_headings(text: str) -> list[Heading]:
|
||
"""解析文本中的标题,按行号升序返回
|
||
|
||
Args:
|
||
text: 文档纯文本内容
|
||
|
||
Returns:
|
||
list[Heading]: 标题列表,无标题时返回空列表
|
||
"""
|
||
headings: list[Heading] = []
|
||
for line_index, line in enumerate(text.splitlines()):
|
||
heading = _match_heading(line, line_index)
|
||
if heading is not None:
|
||
headings.append(heading)
|
||
return headings
|
||
|
||
|
||
def render_outline(headings: list[Heading]) -> str:
|
||
"""将标题树渲染为大纲文本,每行一个节点,按层级缩进"""
|
||
return "\n".join(f"{' ' * (h.level - 1)}- {h.title}" for h in headings)
|
||
|
||
|
||
def _match_heading(line: str, line_index: int) -> Heading | None:
|
||
"""匹配单行是否为标题,是则返回 Heading,否则返回 None"""
|
||
stripped = line.strip()
|
||
if not stripped:
|
||
return None
|
||
|
||
# Markdown ATX 标题(无长度限制)
|
||
match = _ATX_PATTERN.match(stripped)
|
||
if match:
|
||
return Heading(title=match.group(2), level=len(match.group(1)), line_index=line_index)
|
||
|
||
# 编号类标题有行长度限制,过长视为正文
|
||
if len(stripped) > _MAX_HEADING_LINE_LENGTH:
|
||
return None
|
||
|
||
# 第X章/节/篇
|
||
match = _CN_CHAPTER_PATTERN.match(stripped)
|
||
if match:
|
||
return Heading(title=stripped, level=_CN_CHAPTER_LEVELS[match.group(1)], line_index=line_index)
|
||
|
||
# 一、二、……
|
||
if _CN_ENUM_PATTERN.match(stripped):
|
||
return Heading(title=stripped, level=1, line_index=line_index)
|
||
|
||
# 数字编号,层级 = 点分段数(1.=1、1.1=2、1.1.1=3)
|
||
match = _NUM_PATTERN.match(stripped)
|
||
if match:
|
||
level = match.group(1).count(".") + 1
|
||
return Heading(title=stripped, level=level, line_index=line_index)
|
||
|
||
return None
|