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
+111
View File
@@ -0,0 +1,111 @@
"""文档 APIPOST /api/v1/documents 异步入库 + 任务查询 + 文档管理(列表/详情/删除)"""
from typing import Any
import structlog
from fastapi import APIRouter, Query
from fastapi.responses import JSONResponse
from app.api.response import ApiError, ok
from app.config import Settings
from app.core.ingest_tasks import IngestTaskManager
from app.core.ingestion import Ingester
from app.models.document import DocumentInput
from app.services.qdrant import QdrantService
from app.services.redis import RedisCache, get_cache
logger = structlog.get_logger()
router = APIRouter(prefix="/api/v1", tags=["document"])
# 模块级懒加载单例,避免每请求重建 Ingester 及其下游依赖
_ingester: Ingester | None = None
_qdrant: QdrantService | None = None
_task_manager: IngestTaskManager | None = None
def _get_ingester() -> Ingester:
global _ingester
if _ingester is None:
_ingester = Ingester()
return _ingester
def _get_qdrant() -> QdrantService:
global _qdrant
if _qdrant is None:
_qdrant = QdrantService()
return _qdrant
def _get_task_manager() -> IngestTaskManager:
"""入库任务管理器懒加载单例
Redis 沿用全局缓存单例(RedisCache 读写全容错,不可用时镜像写失败仅告警);
获取缓存实例异常时传 None,退化为纯内存模式。
"""
global _task_manager
if _task_manager is None:
try:
redis: RedisCache | None = get_cache()
except Exception:
logger.warning("Redis 缓存不可用,入库任务状态仅保留在内存", exc_info=True)
redis = None
_task_manager = IngestTaskManager(ingester=_get_ingester(), redis=redis, settings=Settings())
return _task_manager
@router.post("/documents")
async def ingest_document(doc: DocumentInput) -> JSONResponse:
"""文档入库入口:登记异步任务并返回 202 + task_id,入库结果经任务查询端点获取"""
if not doc.text.strip():
raise ApiError(1001, "文档内容不能为空")
task_id = await _get_task_manager().submit(doc)
return JSONResponse(status_code=202, content=ok({"task_id": task_id, "status": "pending"}))
@router.get("/documents/tasks/{task_id}")
async def get_ingest_task(task_id: str) -> dict[str, Any]:
"""查询入库任务状态:含 task_id/status/created_at/updated_atdone 附 resultfailed 附 error"""
task = await _get_task_manager().get(task_id)
if task is None:
raise ApiError(1004, "任务不存在")
return ok(task)
@router.get("/documents")
async def list_documents(
limit: int = Query(default=20, ge=1, le=100),
offset: str | None = None,
) -> dict[str, Any]:
"""分页列出文档(L1 摘要),返回 items 与下一页游标 next_offset"""
try:
items, next_offset = await _get_qdrant().scroll_l1(limit=limit, offset=offset)
except Exception as exc:
logger.error("文档列表查询失败", error=str(exc))
raise ApiError(2000, f"文档列表查询失败: {exc}") from exc
return ok({"items": items, "next_offset": next_offset})
@router.get("/documents/{doc_id}")
async def get_document(doc_id: str) -> dict[str, Any]:
"""获取文档详情:L1 记录 + L2/L3 节点 + chunks 数量"""
try:
detail = await _get_qdrant().get_doc_detail(doc_id)
except Exception as exc:
logger.error("文档详情查询失败", doc_id=doc_id, error=str(exc))
raise ApiError(2000, f"文档详情查询失败: {exc}") from exc
if detail is None:
raise ApiError(1004, "文档不存在")
return ok(detail)
@router.delete("/documents/{doc_id}")
async def delete_document(doc_id: str) -> dict[str, Any]:
"""删除文档:四层集合中该 doc_id 的所有点;幂等,不存在也返回成功(删除数全 0)"""
try:
deleted = await _get_qdrant().delete_by_doc_id(doc_id)
except Exception as exc:
logger.error("文档删除失败", doc_id=doc_id, error=str(exc))
raise ApiError(2000, f"文档删除失败: {exc}") from exc
return ok({"doc_id": doc_id, "deleted": deleted, "deleted_total": sum(deleted.values())})