Files
QMDSearch/app/core/headings.py
T
kplam 51dc8dc4f6 Initial commit: QMDSearch 分层信息检索服务
- FastAPI + Qdrant + Redis + Ollama 技术栈
- L1→L2→L3→chunk 四层分层检索(dense + sparse RRF 融合)
- 文档三级总结与 2.5 级回退
- query 解析路由与分类
- /admin 管理页面
2026-07-29 21:24:40 +08:00

95 lines
3.1 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""标题树解析器
从纯文本中解析文档原生标题结构,支持两类模式:
- 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