Initial commit: QMDSearch 分层信息检索服务

- FastAPI + Qdrant + Redis + Ollama 技术栈
- L1→L2→L3→chunk 四层分层检索(dense + sparse RRF 融合)
- 文档三级总结与 2.5 级回退
- query 解析路由与分类
- /admin 管理页面
This commit is contained in:
2026-07-29 21:24:40 +08:00
commit 51dc8dc4f6
83 changed files with 10794 additions and 0 deletions
+94
View File
@@ -0,0 +1,94 @@
"""标题树解析器
从纯文本中解析文档原生标题结构,支持两类模式:
- 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