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
+75
View File
@@ -0,0 +1,75 @@
"""知识分类 APIGET /api/v1/knowledge/categories、GET /api/v1/knowledge/stats"""
from functools import lru_cache
from typing import Any
import structlog
from fastapi import APIRouter
from app.api.response import ApiError, ok
from app.config import settings
from app.models.knowledge import UNCATEGORIZED, TaxonomyCategory, load_taxonomy
from app.services.qdrant import ALL_COLLECTIONS, COLLECTION_L1, QdrantService
logger = structlog.get_logger()
router = APIRouter(prefix="/api/v1", tags=["knowledge"])
# 类目分布统计的分页大小
_STATS_SCROLL_PAGE_SIZE = 100
# 模块级懒加载单例,避免每请求重建 QdrantService
_qdrant: QdrantService | None = None
def _get_qdrant() -> QdrantService:
global _qdrant
if _qdrant is None:
_qdrant = QdrantService()
return _qdrant
@lru_cache(maxsize=1)
def _get_taxonomy() -> list[TaxonomyCategory]:
"""加载并缓存 taxonomy 类目集(进程内只加载一次)"""
return load_taxonomy(settings.taxonomy_path)
@router.get("/knowledge/categories")
async def list_categories() -> dict[str, Any]:
"""返回完整知识分类类目集"""
categories = _get_taxonomy()
return ok({"categories": [c.model_dump() for c in categories], "count": len(categories)})
@router.get("/knowledge/stats")
async def knowledge_stats() -> dict[str, Any]:
"""返回四层集合规模与 L1 类目分布统计"""
service = _get_qdrant()
try:
collections = {collection: await service.count(collection) for collection in ALL_COLLECTIONS}
categories = await _aggregate_l1_categories(service)
except Exception as exc:
logger.error("获取知识库统计失败", error=str(exc))
raise ApiError(2000, f"获取知识库统计失败: {exc}") from exc
return ok(
{
"collections": collections,
"categories": categories,
"uncategorized_count": categories.get(UNCATEGORIZED, 0),
"documents_total": collections[COLLECTION_L1],
}
)
async def _aggregate_l1_categories(service: QdrantService) -> dict[str, int]:
"""分页遍历 L1 文档,按 category 聚合文档数(空类目归入 uncategorized"""
categories: dict[str, int] = {}
offset: str | None = None
while True:
items, offset = await service.scroll_l1(limit=_STATS_SCROLL_PAGE_SIZE, offset=offset)
for item in items:
category = item.get("category") or UNCATEGORIZED
categories[category] = categories.get(category, 0) + 1
if offset is None:
return categories