maxiaohui

jira:NYJ-1447 desc:router -> service -> dao

1 from typing import List, Optional, Literal, Union, Dict, Any, Annotated 1 from typing import List, Optional, Literal, Union, Dict, Any, Annotated
2 -from pydantic import BaseModel, Field  
3 -from fastapi import APIRouter 2 +
  3 +from fastapi import APIRouter, Depends
  4 +import numpy
  5 +from pydantic import BaseModel, Field, TypeAdapter
  6 +
4 from api.resp_bean import RespBean, success 7 from api.resp_bean import RespBean, success
5 8
6 router = APIRouter() 9 router = APIRouter()
@@ -34,7 +37,7 @@ class PutDataRequest(BaseModel): @@ -34,7 +37,7 @@ class PutDataRequest(BaseModel):
34 embedding: List[float] = Field(..., description="向量") 37 embedding: List[float] = Field(..., description="向量")
35 embedding_version: str = Field(..., description="向量版本 小写数字下划线组成") 38 embedding_version: str = Field(..., description="向量版本 小写数字下划线组成")
36 kwargs: Optional[Dict[str, Any]] = Field(None, description="扩展字段,根据type不同字段不同") 39 kwargs: Optional[Dict[str, Any]] = Field(None, description="扩展字段,根据type不同字段不同")
37 - 40 +
38 def get_typed_kwargs(self, type: str) -> Optional[KwargsType]: 41 def get_typed_kwargs(self, type: str) -> Optional[KwargsType]:
39 """根据type自动转换kwargs为具体类型""" 42 """根据type自动转换kwargs为具体类型"""
40 if self.kwargs is None: 43 if self.kwargs is None:
@@ -52,6 +55,7 @@ class PutDataRequest(BaseModel): @@ -52,6 +55,7 @@ class PutDataRequest(BaseModel):
52 class DelByIdRequest(BaseModel): 55 class DelByIdRequest(BaseModel):
53 """删除数据请求体""" 56 """删除数据请求体"""
54 ids: List[str] = Field(..., description="id 集合") 57 ids: List[str] = Field(..., description="id 集合")
  58 + embedding_version: str = Field(..., description="向量版本 小写数字下划线组成")
55 59
56 60
57 class FilterItem(BaseModel): 61 class FilterItem(BaseModel):
@@ -85,35 +89,57 @@ class SearchResponseData(BaseModel): @@ -85,35 +89,57 @@ class SearchResponseData(BaseModel):
85 89
86 90
87 # ============== 接口定义 ============== 91 # ============== 接口定义 ==============
  92 +from service.data_service import DataService
  93 +from service import get_data_service
  94 +
88 95
89 @router.put("/{type}/put", response_model=RespBean, tags=["数据服务"], summary="写入数据") 96 @router.put("/{type}/put", response_model=RespBean, tags=["数据服务"], summary="写入数据")
90 @router.post("/{type}/put", response_model=RespBean, tags=["数据服务"], summary="写入数据") 97 @router.post("/{type}/put", response_model=RespBean, tags=["数据服务"], summary="写入数据")
91 -async def put_data(type: Literal["face", "sport_shot"], request: PutDataRequest): 98 +async def put_data(
  99 + type: Literal["face", "sport_shot"],
  100 + request: PutDataRequest,
  101 + data_service: DataService = Depends(get_data_service)
  102 +):
92 """ 103 """
93 写入数据 104 写入数据
94 """ 105 """
  106 + request.embedding = numpy.array(request.embedding)
95 107
96 - pass  
97 - return success() 108 + embedding_version = request.embedding_version
  109 + tb_name = f'{type}_{embedding_version}'
  110 + upserted_id = await data_service.upsert(tb_name, request.id, request.embedding,
  111 + request.get_typed_kwargs(type).model_dump())
  112 +
  113 + return success(data={'id': upserted_id})
98 114
99 115
100 @router.delete("/{type}/del_by_id", response_model=RespBean, tags=["数据服务"], summary="删除数据") 116 @router.delete("/{type}/del_by_id", response_model=RespBean, tags=["数据服务"], summary="删除数据")
101 @router.post("/{type}/del_by_id", response_model=RespBean, tags=["数据服务"], summary="删除数据") 117 @router.post("/{type}/del_by_id", response_model=RespBean, tags=["数据服务"], summary="删除数据")
102 -async def del_by_id(type: Literal["face", "sport_shot"], request: DelByIdRequest): 118 +async def del_by_id(
  119 + type: Literal["face", "sport_shot"],
  120 + request: DelByIdRequest,
  121 + data_service: DataService = Depends(get_data_service)
  122 +):
103 """ 123 """
104 删除数据 124 删除数据
105 125
106 - **type**: 数据类型 (face-人脸, sport_shot-体育镜头) 126 - **type**: 数据类型 (face-人脸, sport_shot-体育镜头)
107 - **ids**: id集合 127 - **ids**: id集合
108 """ 128 """
109 - # TODO: 实现数据删除逻辑  
110 - pass  
111 - return success() 129 + embedding_version = request.embedding_version
  130 + tb_name = f'{type}_{embedding_version}'
  131 + await data_service.delete_by_pks(tb_name, request.ids)
112 132
  133 + return success()
113 134
114 135
115 @router.post("/{type}/search", response_model=RespBean[SearchResponseData], tags=["数据服务"], summary="检索数据") 136 @router.post("/{type}/search", response_model=RespBean[SearchResponseData], tags=["数据服务"], summary="检索数据")
116 -async def search_data(type: Literal["face", "sport_shot"], request: SearchRequest): 137 +async def search_data(
  138 + type: Literal["face", "sport_shot"],
  139 + request: SearchRequest,
  140 + data_service: DataService = Depends(get_data_service),
  141 + adapter=TypeAdapter(List[SearchResultItem])
  142 +):
117 """ 143 """
118 检索数据 144 检索数据
119 145
@@ -123,6 +149,15 @@ async def search_data(type: Literal["face", "sport_shot"], request: SearchReques @@ -123,6 +149,15 @@ async def search_data(type: Literal["face", "sport_shot"], request: SearchReques
123 - **topk**: 返回top k条结果 149 - **topk**: 返回top k条结果
124 - **filters**: 过滤条件列表 150 - **filters**: 过滤条件列表
125 """ 151 """
126 - # TODO: 实现数据检索逻辑  
127 - pass  
128 - return success() 152 + #
  153 + embedding_version = request.embedding_version
  154 + tb_name = f'{type}_{embedding_version}'
  155 +
  156 + embedding = request.embedding
  157 + filters = request.filters
  158 + from_, size = 0, request.topk
  159 +
  160 + result_data = await data_service.search(tb_name, embedding, filters, from_, size)
  161 + search_results: List[SearchResultItem] = adapter.validate_python(result_data)
  162 +
  163 + return success(data=SearchResponseData(type=type, data_list=search_results))
  1 +from functools import lru_cache
  2 +
  3 +from elasticsearch import AsyncElasticsearch
  4 +
1 from config import settings 5 from config import settings
  6 +from .data_dao import DataDao
2 7
3 es_client = None 8 es_client = None
4 pg_client = None 9 pg_client = None
5 10
6 11
7 async def connect(): 12 async def connect():
8 - if settings.db_established:  
9 - from .es_client import get_es_client 13 + if settings.db_es_enable:
10 global es_client 14 global es_client
11 - es_client = get_es_client()  
12 - await es_client.connect() 15 + es_client = AsyncElasticsearch([settings.db_es_url])
  16 + await es_client.ping()
13 if settings.db_postgres_enable: 17 if settings.db_postgres_enable:
14 from .postgres_client import get_pg_client 18 from .postgres_client import get_pg_client
15 global pg_client 19 global pg_client
@@ -24,3 +28,8 @@ async def disconnect(): @@ -24,3 +28,8 @@ async def disconnect():
24 es_client = None 28 es_client = None
25 if pg_client is not None: 29 if pg_client is not None:
26 await pg_client.disconnect() 30 await pg_client.disconnect()
  31 +
  32 +
  33 +@lru_cache()
  34 +def get_data_dao():
  35 + return DataDao(es_client)
  1 +import json
  2 +import logging
  3 +import os
  4 +import threading
  5 +import typing
  6 +
  7 +from elasticsearch import AsyncElasticsearch
  8 +import numpy
  9 +
  10 +logger = logging.getLogger(__name__)
  11 +
  12 +
  13 +class DataDao:
  14 +
  15 + def __init__(self, es_client: AsyncElasticsearch):
  16 + self.es_client = es_client
  17 +
  18 + self._lock = threading.RLock()
  19 +
  20 + async def search(self, tb_name: str,
  21 + embedding=typing.Optional[typing.Union[numpy.ndarray, list]],
  22 + filters=None,
  23 + from_=typing.Optional[int],
  24 + size=typing.Optional[int],
  25 + ):
  26 + search_body = dict()
  27 +
  28 + from_ = from_ if isinstance(from_, int) and from_ >= 0 else 0
  29 + size = size if isinstance(size, int) and size > 0 else 10
  30 + search_body.update({
  31 + 'from': from_,
  32 + 'size': size,
  33 + })
  34 +
  35 + return_embedding = os.getenv('RETURN_EMBEDDING', 'false').lower().strip()
  36 + if return_embedding != 'true':
  37 + search_body.update({
  38 + "_source": {
  39 + "excludes": ["embedding"]
  40 + }
  41 + })
  42 +
  43 + condition_query = {
  44 + "match_all": {}
  45 + }
  46 + if isinstance(filters, typing.Collection) and len(filters) > 0:
  47 + tb_mapping = await self._get_mapping(tb_name)
  48 + exist_properties = tb_mapping.get('mappings', {}).get('properties', {})
  49 +
  50 + clauses = []
  51 + for filter in filters:
  52 + k = filter.name
  53 + v = filter.value
  54 + opt = filter.opt
  55 + if k not in exist_properties:
  56 + continue
  57 + clause = get_filter_clause(k, v, opt, exist_properties[k])
  58 + if clause:
  59 + clauses.append(clause)
  60 + condition_query = {
  61 + "bool": {
  62 + "filter": clauses
  63 + }
  64 + }
  65 + if embedding is not None:
  66 + use_knn = os.getenv('USE_KNN', 'false').lower().strip()
  67 + if use_knn != 'true':
  68 + query_body = {
  69 + "script_score": {
  70 + "query": condition_query,
  71 + "script": {
  72 + "source": """
  73 + // 1. 计算原始分数 (范围 [0, 2])
  74 + double rawScore = cosineSimilarity(params.query_vector, 'embedding') + 1.0;
  75 +
  76 + // 2. 归一化到 [0, 1]
  77 + double normalizedScore = rawScore / 2.0;
  78 +
  79 + // 3. 强制截断:确保最小值为 0.0,最大值为 1.0
  80 + // Math.max 防止出现负数,Math.min 防止出现 > 1.0 的数
  81 + double clampedScore = Math.max(0.0, Math.min(1.0, normalizedScore));
  82 +
  83 + // 4. 四舍五入到 4 位小数
  84 + // 原理:乘以 10000 -> 四舍五入取整 -> 除以 10000
  85 + // clampedScore = Math.round(clampedScore * 10000.0) / 10000.0;
  86 + return clampedScore;
  87 + """,
  88 + "params": {
  89 + "query_vector": embedding.tolist() if isinstance(embedding, numpy.ndarray) else list(embedding)
  90 + }
  91 + }
  92 + }
  93 + }
  94 + search_body.update({
  95 + "query": query_body
  96 + })
  97 + else:
  98 + search_body.update({
  99 + "knn": {
  100 + "field": "embedding",
  101 + "query_vector": embedding.tolist() if isinstance(embedding, numpy.ndarray) else list(embedding),
  102 + "k": from_ + size,
  103 + "num_candidates": (from_ + size) * 20,
  104 + "filter": condition_query,
  105 + }
  106 + })
  107 + else:
  108 + query_body = condition_query
  109 + search_body.update({
  110 + "query": query_body
  111 + })
  112 +
  113 + logger.info(f'index {tb_name} search body: {json.dumps(search_body, ensure_ascii=False, indent=4)}')
  114 + resp = await self.es_client.search(index=tb_name, body=search_body)
  115 + logger.info(f'index {tb_name} search response: {resp}')
  116 +
  117 + body = resp.body
  118 + hits = body.get('hits', {}).get('hits', [])
  119 + result_data = [
  120 + {
  121 + 'id': hit['_id'],
  122 + 'score': round(hit['_score'], 4),
  123 + 'kwargs': hit['_source'],
  124 + }
  125 + for hit in hits
  126 + ]
  127 + return result_data
  128 +
  129 + async def delete_by_pks(self, tb_name: str, pks: typing.List[str]):
  130 + query_body = {
  131 + "query": {
  132 + "terms": {
  133 + "_id": pks
  134 + }
  135 + }
  136 + }
  137 + resp = await self.es_client.delete_by_query(index=tb_name, body=query_body)
  138 + logger.info(f'index {tb_name} delete_by_query response: {resp}')
  139 +
  140 + body = resp.body
  141 + # return body.get('deleted')
  142 +
  143 + async def _get_mapping(self, tb_name: str):
  144 + resp = await self.es_client.indices.get_mapping(index=tb_name)
  145 + logger.info(f'index {tb_name} get_mapping response: {resp}')
  146 + return resp.body.get(tb_name, {})
  147 +
  148 + async def _tb_exist(self, tb_name, mapping=None):
  149 + resp = await self.es_client.indices.exists(index=tb_name)
  150 + result = resp.body
  151 + if result and mapping is not None:
  152 + tb_mapping = await self._get_mapping(tb_name)
  153 + exist_properties = tb_mapping.get('mappings', {}).get('properties', {})
  154 +
  155 + expect_properties = mapping.get('mappings', {}).get('properties', {})
  156 + if exist_properties and expect_properties:
  157 + shared_keys = set(exist_properties.keys()).intersection(set(expect_properties.keys()))
  158 + for k in shared_keys or []:
  159 + if exist_properties[k]['type'] != expect_properties[k]['type']:
  160 + raise Exception(
  161 + f'index {tb_name} `{k}` type not match, expect: {expect_properties[k]["type"]}, actual: {exist_properties[k]["type"]}')
  162 + else:
  163 + if exist_properties[k]['type'] == 'dense_vector':
  164 + if exist_properties[k].get('dims') != expect_properties[k].get('dims'):
  165 + raise Exception(
  166 + f'index {tb_name} dims not match, expect: {expect_properties[k].get("dims")}, actual: {exist_properties[k].get("dims")}')
  167 + return result
  168 +
  169 + async def _create_index(self, tb_name, mapping=typing.Optional[dict]) -> bool:
  170 + result = await self._tb_exist(tb_name=tb_name, mapping=mapping)
  171 + if result:
  172 + return True
  173 + with self._lock:
  174 + result = await self._tb_exist(tb_name=tb_name, mapping=mapping)
  175 + if result:
  176 + return True
  177 + resp = await self.es_client.indices.create(index=tb_name, body=mapping)
  178 + logger.info(f'index {tb_name} create response: {resp}')
  179 + return resp.body.get('acknowledged') is True
  180 +
  181 + async def upsert(self, tb_name, id, embedding, params: dict):
  182 + doc = {
  183 + 'embedding': embedding,
  184 + **params
  185 + }
  186 +
  187 + auto_create_index = os.getenv('AUTO_CREATE_INDEX', 'true').lower().strip()
  188 + if auto_create_index == 'true':
  189 + mapping = generate_mapping(doc)
  190 + # logger.info(f'Possible mapping: {json.dumps(mapping, indent=4)}')
  191 + result = await self._create_index(tb_name=tb_name, mapping=mapping)
  192 +
  193 + if isinstance(embedding, numpy.ndarray):
  194 + embedding = embedding.tolist()
  195 + # elif isinstance(embedding, typing.Iterable):
  196 + # if isinstance(embedding, typing.Mapping):
  197 + # pass
  198 + # else:
  199 + # embedding = list(embedding)
  200 + doc['embedding'] = embedding
  201 +
  202 + resp = await self.es_client.index(index=tb_name, id=id, body=doc)
  203 + logger.info(f'index {tb_name} index response: {resp}')
  204 +
  205 + body = resp.body
  206 + assert body.get('result') in ['created', 'updated'], f'数据插入失败, id: {id}'
  207 + return body.get('_id')
  208 +
  209 +
  210 +def get_filter_clause(k, v, opt, type_property):
  211 + property_type = type_property.get('type')
  212 + if property_type == 'text':
  213 + property_fields = type_property.get('fields') or {}
  214 + if 'keyword' in property_fields:
  215 + k = f'{k}.keyword'
  216 +
  217 + opt = opt.lower()
  218 + # "eq", "neq", "lt", "gt", "lte", "gte", "like", "in"
  219 + if opt in ['eq', 'in']:
  220 + v = list(v) if isinstance(v, typing.Collection) else [v]
  221 + should_list = []
  222 + if None in v or len(v) == 0:
  223 + should_list.append({
  224 + "bool": {
  225 + "must_not": [
  226 + {
  227 + "exists": {
  228 + "field": k
  229 + }
  230 + }
  231 + ]
  232 + }
  233 + })
  234 + no_null_v = [item for item in v if item is not None]
  235 + if len(no_null_v) > 0:
  236 + should_list.append({
  237 + "terms": {
  238 + k: no_null_v
  239 + }
  240 + })
  241 + return should_list[0] if len(should_list) == 1 else {
  242 + "bool": {
  243 + "should": should_list,
  244 + "minimum_should_match": 1
  245 + }
  246 + }
  247 + elif opt == 'neq':
  248 + must_list = []
  249 + v = list(v) if isinstance(v, typing.Collection) else [v]
  250 +
  251 + if None in v or len(v) == 0:
  252 + must_list.append({
  253 + "bool": {
  254 + "must": [
  255 + {
  256 + "exists": {
  257 + "field": k
  258 + }
  259 + }
  260 + ]
  261 + }
  262 + })
  263 + no_null_v = [item for item in v if item is not None]
  264 + if len(no_null_v) > 0:
  265 + must_list.append({
  266 + "bool": {
  267 + "must_not": {
  268 + "terms": {
  269 + k: no_null_v
  270 + }
  271 + }
  272 + }
  273 + })
  274 + return must_list[0] if len(must_list) == 1 else {
  275 + "bool": {
  276 + "must": must_list
  277 + }
  278 + }
  279 + elif opt in ['lt', 'lte', 'gt', 'gte']:
  280 + v = list(v) if isinstance(v, typing.Collection) else [v]
  281 + if len(v) > 0 and v[0] is not None:
  282 + return {
  283 + "range": {
  284 + k: {opt: list(v)[0]}
  285 + }
  286 + }
  287 + else:
  288 + return {
  289 + "range": {
  290 + k: {}
  291 + }
  292 + }
  293 + elif opt == 'like':
  294 + v = list(v) if isinstance(v, typing.Collection) else [v]
  295 + if len(v) > 0 and v[0] is not None:
  296 + return {
  297 + "wildcard": {
  298 + k: f"*{list(v)[0]}*"
  299 + }
  300 + }
  301 + else:
  302 + return {
  303 + "bool": {
  304 + "must_not": [
  305 + {
  306 + "exists": {
  307 + "field": k
  308 + }
  309 + }
  310 + ]
  311 + }
  312 + }
  313 + else:
  314 + raise Exception(f'opt {opt} not support')
  315 +
  316 +
  317 +def generate_mapping(doc):
  318 + def _inner(v):
  319 + if isinstance(v, numpy.ndarray):
  320 + return 'dense_vector'
  321 + elif isinstance(v, str):
  322 + return 'text'
  323 + elif isinstance(v, int):
  324 + return 'long'
  325 + elif isinstance(v, float):
  326 + return 'float'
  327 + elif isinstance(v, typing.Mapping):
  328 + return None
  329 + elif isinstance(v, typing.Collection):
  330 + if len(v) == 0:
  331 + return None
  332 + else:
  333 + return _inner(list(v)[0])
  334 + else:
  335 + return None
  336 +
  337 + properties = dict()
  338 + for key, value in (doc or {}).items():
  339 + key_type = _inner(value)
  340 + if key_type:
  341 + properties[key] = {
  342 + 'type': key_type,
  343 + }
  344 + if key_type == 'dense_vector':
  345 + properties[key]['dims'] = len(value)
  346 + properties[key].update({
  347 + 'dims': len(value),
  348 + 'index': True,
  349 + 'similarity': 'cosine'
  350 + })
  351 + elif key_type == 'text':
  352 + properties[key]['fields'] = {
  353 + "keyword": {
  354 + "type": "keyword",
  355 + # "ignore_above": 256
  356 + }
  357 + }
  358 + es_mapping = {
  359 + "settings": {
  360 + "number_of_replicas": int(os.getenv('ES_NUMBER_OF_REPLICAS', 0)),
  361 + },
  362 + "mappings": {
  363 + "properties": properties
  364 + }
  365 + }
  366 + return es_mapping
@@ -12,6 +12,7 @@ dependencies = [ @@ -12,6 +12,7 @@ dependencies = [
12 "omegaconf==2.3.0", 12 "omegaconf==2.3.0",
13 "elasticsearch>=9.3.0", 13 "elasticsearch>=9.3.0",
14 "packaging==26.0", 14 "packaging==26.0",
  15 + "aiohttp==3.13.5",
15 ] 16 ]
16 17
17 [project.optional-dependencies] 18 [project.optional-dependencies]
  1 +from functools import lru_cache
  2 +
  3 +from db import get_data_dao
  4 +from .data_service import DataService
  5 +
  6 +
  7 +@lru_cache()
  8 +def get_data_service():
  9 + data_dao = get_data_dao()
  10 + return DataService(data_dao)
  1 +import logging
  2 +
  3 +from db import DataDao
  4 +
  5 +logger = logging.getLogger(__name__)
  6 +
  7 +
  8 +class DataService:
  9 +
  10 + def __init__(self, data_dao: DataDao):
  11 + self.data_dao = data_dao
  12 +
  13 + async def upsert(self, tb_name, id, embedding, params):
  14 + return await self.data_dao.upsert(tb_name, id, embedding, params)
  15 +
  16 + async def delete_by_pks(self, tb_name, ids):
  17 + return await self.data_dao.delete_by_pks(tb_name, ids)
  18 +
  19 + async def search(self, tb_name, embedding, filters, from_, size):
  20 + return await self.data_dao.search(tb_name, embedding, filters, from_, size)