51dc8dc4f6
- FastAPI + Qdrant + Redis + Ollama 技术栈 - L1→L2→L3→chunk 四层分层检索(dense + sparse RRF 融合) - 文档三级总结与 2.5 级回退 - query 解析路由与分类 - /admin 管理页面
59 lines
1.8 KiB
Python
59 lines
1.8 KiB
Python
"""检索 API:POST /api/v1/search"""
|
||
|
||
from hashlib import sha256
|
||
from typing import Any
|
||
|
||
import structlog
|
||
from fastapi import APIRouter
|
||
|
||
from app.api.response import ApiError, ok
|
||
from app.core.retriever import Retriever
|
||
from app.models.search import SearchRequest
|
||
from app.services.redis import get_cache
|
||
|
||
logger = structlog.get_logger()
|
||
|
||
router = APIRouter(prefix="/api/v1", tags=["search"])
|
||
|
||
# 模块级懒加载单例,避免每请求重建 Qdrant/Embedding client
|
||
_retriever: Retriever | None = None
|
||
|
||
|
||
def _get_retriever() -> Retriever:
|
||
global _retriever
|
||
if _retriever is None:
|
||
_retriever = Retriever()
|
||
return _retriever
|
||
|
||
|
||
def _cache_key(request: SearchRequest) -> str:
|
||
"""检索缓存键:query + top_k 的短哈希(top_k 影响结果集,需参与键计算)"""
|
||
digest = sha256((request.query + "|" + str(request.top_k)).encode()).hexdigest()[:16]
|
||
return f"search:{digest}"
|
||
|
||
|
||
@router.post("/search")
|
||
async def search(request: SearchRequest) -> dict[str, Any]:
|
||
"""分层检索入口,返回统一包装的 SearchResponse
|
||
|
||
先查 Redis 缓存:命中直接返回缓存的响应;未命中走检索流程并回写缓存。
|
||
缓存读写失败均降级为无缓存行为,不影响检索。
|
||
"""
|
||
cache_key = _cache_key(request)
|
||
cached = await get_cache().get_json(cache_key)
|
||
if cached is not None:
|
||
logger.info("检索缓存命中", query=request.query, cache_key=cache_key)
|
||
return cached
|
||
|
||
try:
|
||
response = await _get_retriever().search(request)
|
||
except ApiError:
|
||
raise
|
||
except Exception as exc:
|
||
logger.exception("检索失败", query=request.query)
|
||
raise ApiError(2000, f"检索失败: {exc}") from exc
|
||
|
||
result = ok(response.model_dump())
|
||
await get_cache().set_json(cache_key, result)
|
||
return result
|