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
View File
+51
View File
@@ -0,0 +1,51 @@
"""文档和总结相关的数据模型"""
from enum import StrEnum
from pydantic import BaseModel, Field
class SummaryLevel(StrEnum):
"""总结层级"""
L3 = "L3" # 完整三级:总结 → 大纲 → 内容大纲
L2_HALF = "L2.5" # 2.5 级回退:总结 → 内容大纲(跳过大纲)
class DocumentSummary(BaseModel):
"""文档三级总结结果"""
l1_summary: str = Field(description="L1 总结:一句话高度概括")
l2_outline: str | None = Field(default=None, description="L2 大纲:主要章节和关键主题")
l3_content_outline: str = Field(description="L3/L2.5 内容大纲:详细内容摘要")
level: SummaryLevel = Field(description="实际使用的总结层级")
class DocumentInput(BaseModel):
"""文档入库输入"""
text: str = Field(description="文档纯文本内容")
title: str = Field(default="", description="文档标题")
source: str = Field(default="", description="来源标识(文件路径/URL等)")
metadata: dict[str, str] = Field(default_factory=dict, description="附加元数据")
class ChunkModel(BaseModel):
"""文档分块结果"""
doc_id: str = Field(description="所属文档 ID")
chunk_index: int = Field(description="chunk 在文档内的序号")
text: str = Field(description="chunk 文本内容")
section_path: str = Field(default="", description="chunk 所在的章节路径")
class IngestionResult(BaseModel):
"""文档入库结果"""
document_id: str = Field(description="写入后的文档 ID")
summary: DocumentSummary = Field(description="三级总结结果")
category: str = Field(description="分类标签(主类目)")
collection: str = Field(description="写入的 Qdrant 集合名")
chunks_count: int = Field(default=0, description="写入的 chunk 数量")
tags: list[str] = Field(default_factory=list, description="附加分类标签")
category_confidence: float = Field(default=0.0, description="主类目分类置信度")
+68
View File
@@ -0,0 +1,68 @@
"""知识分类(taxonomy)相关的数据模型与加载逻辑"""
import json
from pathlib import Path
import structlog
from pydantic import BaseModel, Field
logger = structlog.get_logger()
# 未分类常量:分类置信度不足或无法归类时使用
UNCATEGORIZED = "uncategorized"
class TaxonomyCategory(BaseModel):
"""知识分类类目定义"""
name: str = Field(description="类目名称,全局唯一")
description: str = Field(default="", description="类目描述,用于辅助分类判断")
class CategoryResult(BaseModel):
"""文档/查询的分类结果"""
main_category: str = Field(description="主类目名称")
tags: list[str] = Field(default_factory=list, description="附加标签列表")
confidence: float = Field(ge=0, le=1, description="分类置信度,范围 [0, 1]")
def _default_taxonomy() -> list[TaxonomyCategory]:
"""内置默认类目集(通用企业知识库场景)"""
return [
TaxonomyCategory(name="技术文档", description="架构设计、API 文档、开发规范、运维手册等技术资料"),
TaxonomyCategory(name="产品手册", description="产品功能介绍、使用说明、版本发布说明"),
TaxonomyCategory(name="运营规范", description="运营流程、活动方案、内容规范、客服话术"),
TaxonomyCategory(name="财务行政", description="财务制度、报销流程、行政通知、办公管理"),
TaxonomyCategory(name="市场资料", description="市场分析、竞品调研、营销素材、品牌规范"),
TaxonomyCategory(name="人事制度", description="招聘、考勤、绩效、培训、员工手册等 HR 制度"),
TaxonomyCategory(name="法律法规", description="合同模板、合规要求、法律条文、知识产权"),
TaxonomyCategory(name=UNCATEGORIZED, description="无法归入其他类目的文档"),
]
def load_taxonomy(path: str = "") -> list[TaxonomyCategory]:
"""加载 taxonomy 类目集
path 为空时使用内置默认类目集;非空时从 JSON 文件加载,
文件格式为 [{"name": ..., "description": ...}]。
校验类目 name 唯一;若缺少 uncategorized 类目则自动追加。
"""
if not path:
return _default_taxonomy()
raw = json.loads(Path(path).read_text(encoding="utf-8"))
categories = [TaxonomyCategory.model_validate(item) for item in raw]
# 校验 name 唯一
names = [c.name for c in categories]
if len(names) != len(set(names)):
duplicates = sorted({n for n in names if names.count(n) > 1})
raise ValueError(f"taxonomy 类目 name 重复: {duplicates}")
# 必含 uncategorized,缺失则自动追加
if UNCATEGORIZED not in names:
logger.warning("taxonomy 缺少 uncategorized 类目,已自动追加", path=path)
categories.append(TaxonomyCategory(name=UNCATEGORIZED, description="无法归入其他类目的文档"))
return categories
+30
View File
@@ -0,0 +1,30 @@
"""检索请求与响应的数据模型"""
from pydantic import BaseModel, Field
class SearchRequest(BaseModel):
"""检索请求"""
query: str = Field(description="查询文本")
top_k: int | None = Field(default=None, description="返回结果数,为空时使用 settings.retrieval_final_k")
class SearchHit(BaseModel):
"""单条检索命中结果"""
text: str = Field(description="原文 chunk 内容")
doc_id: str = Field(description="所属文档 ID")
title: str = Field(default="", description="文档标题")
section_path: str = Field(default="", description="chunk 所在的章节路径")
score: float = Field(description="相关性得分")
doc_summary: str = Field(default="", description="L1 文档总结,仅用于上下文标注")
class SearchResponse(BaseModel):
"""检索响应"""
query: str = Field(description="原始查询文本")
hits: list[SearchHit] = Field(default_factory=list, description="命中结果列表")
routed_categories: list[str] = Field(default_factory=list, description="query 路由命中的类目")
fallback: bool = Field(default=False, description="是否走了全库兜底路径")