735 lines
29 KiB
Python
735 lines
29 KiB
Python
# app/utils/vector_client.py
|
|
"""
|
|
HealthSync AI Pinecone 벡터DB 클라이언트 (인덱스 초기화 기능 추가)
|
|
"""
|
|
from pinecone import Pinecone, ServerlessSpec
|
|
import logging
|
|
import asyncio
|
|
import math
|
|
from typing import List, Dict, Any, Optional
|
|
from decimal import Decimal
|
|
from app.config.settings import settings
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class PineconeClient:
|
|
"""Pinecone 벡터DB 연동 클라이언트 (인덱스 초기화 기능 추가)"""
|
|
|
|
def __init__(self):
|
|
self.api_key = settings.pinecone_api_key
|
|
self.index_name = settings.pinecone_index_name
|
|
self.pc = None
|
|
self.index = None
|
|
self._initialized = False
|
|
self._connection_available = False
|
|
|
|
# 벡터 차원
|
|
self.vector_dimension = 1024
|
|
|
|
# 연결 설정
|
|
self.connection_timeout = 30
|
|
self.max_retries = 3
|
|
|
|
# API 키 검증
|
|
if not self.api_key or self.api_key == "" or self.api_key == "your_pinecone_api_key_here":
|
|
logger.warning("⚠️ Pinecone API 키가 설정되지 않음 - 벡터 기능 비활성화")
|
|
self._connection_available = False
|
|
else:
|
|
self._connection_available = True
|
|
|
|
async def is_available(self) -> bool:
|
|
"""Pinecone 서비스 사용 가능 여부 확인"""
|
|
if not self._connection_available:
|
|
return False
|
|
|
|
try:
|
|
# 간단한 연결 테스트
|
|
if not self.pc:
|
|
self.pc = Pinecone(api_key=self.api_key)
|
|
|
|
# 인덱스 목록 조회로 연결 테스트
|
|
def test_connection():
|
|
return self.pc.list_indexes()
|
|
|
|
result = await asyncio.wait_for(
|
|
asyncio.get_event_loop().run_in_executor(None, test_connection),
|
|
timeout=10
|
|
)
|
|
|
|
logger.info("✅ Pinecone 연결 테스트 성공")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.warning(f"⚠️ Pinecone 연결 테스트 실패: {str(e)}")
|
|
return False
|
|
|
|
async def initialize(self):
|
|
"""Pinecone 초기화 (공식 SDK v7.x 방식)"""
|
|
if self._initialized:
|
|
return True
|
|
|
|
if not self._connection_available:
|
|
logger.warning("⚠️ Pinecone API 키 없음 - 벡터 기능 건너뜀")
|
|
return False
|
|
|
|
if not await self.is_available():
|
|
logger.warning("⚠️ Pinecone 연결 불가 - 벡터 기능 건너뜀")
|
|
return False
|
|
|
|
for attempt in range(self.max_retries):
|
|
try:
|
|
logger.info(f"🔄 Pinecone 초기화 시도 ({attempt + 1}/{self.max_retries})")
|
|
|
|
def init_pinecone():
|
|
# Pinecone 클라이언트 생성
|
|
pc = Pinecone(api_key=self.api_key)
|
|
|
|
# 인덱스 존재 확인
|
|
existing_indexes = pc.list_indexes()
|
|
index_names = [idx.name for idx in existing_indexes.indexes]
|
|
|
|
if self.index_name not in index_names:
|
|
logger.info(f"📋 인덱스 '{self.index_name}' 생성 중...")
|
|
|
|
# 서버리스 인덱스 생성
|
|
pc.create_index(
|
|
name=self.index_name,
|
|
dimension=self.vector_dimension,
|
|
metric='cosine',
|
|
spec=ServerlessSpec(
|
|
cloud='aws',
|
|
region='us-east-1'
|
|
),
|
|
deletion_protection="disabled"
|
|
)
|
|
|
|
logger.info(f"✅ 인덱스 '{self.index_name}' 생성 완료")
|
|
|
|
# 인덱스 생성 후 잠시 대기
|
|
import time
|
|
time.sleep(5)
|
|
|
|
# 인덱스 연결
|
|
index = pc.Index(self.index_name)
|
|
return pc, index
|
|
|
|
# 타임아웃 내에서 초기화
|
|
self.pc, self.index = await asyncio.wait_for(
|
|
asyncio.get_event_loop().run_in_executor(None, init_pinecone),
|
|
timeout=self.connection_timeout
|
|
)
|
|
|
|
# 연결 테스트
|
|
await self._quick_connection_test()
|
|
|
|
self._initialized = True
|
|
logger.info(f"✅ Pinecone 클라이언트 초기화 완료 - Index: {self.index_name} (1024차원)")
|
|
return True
|
|
|
|
except asyncio.TimeoutError:
|
|
logger.warning(f"⏰ Pinecone 초기화 타임아웃 (시도 {attempt + 1}/{self.max_retries})")
|
|
if attempt < self.max_retries - 1:
|
|
await asyncio.sleep(5)
|
|
continue
|
|
else:
|
|
logger.error("❌ Pinecone 초기화 최종 실패 (타임아웃)")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ Pinecone 초기화 실패 (시도 {attempt + 1}/{self.max_retries}): {str(e)}")
|
|
if attempt < self.max_retries - 1:
|
|
await asyncio.sleep(5)
|
|
continue
|
|
else:
|
|
logger.error("❌ Pinecone 초기화 최종 실패")
|
|
return False
|
|
|
|
return False
|
|
|
|
async def _quick_connection_test(self):
|
|
"""빠른 연결 테스트"""
|
|
try:
|
|
def quick_test():
|
|
return self.index.describe_index_stats()
|
|
|
|
stats = await asyncio.wait_for(
|
|
asyncio.get_event_loop().run_in_executor(None, quick_test),
|
|
timeout=10
|
|
)
|
|
|
|
vector_count = stats.get('total_vector_count', 0)
|
|
logger.info(f"✅ Pinecone 인덱스 연결 성공 - 벡터 수: {vector_count}")
|
|
|
|
except Exception as e:
|
|
logger.warning(f"⚠️ Pinecone 인덱스 연결 테스트 실패: {str(e)}")
|
|
|
|
async def reset_index(self) -> bool:
|
|
"""인덱스 완전 초기화 (모든 벡터 삭제 후 재생성)"""
|
|
try:
|
|
if not self._connection_available:
|
|
logger.warning("⚠️ Pinecone API 키 없음 - 인덱스 초기화 불가")
|
|
return False
|
|
|
|
logger.info(f"🔄 인덱스 '{self.index_name}' 완전 초기화 시작...")
|
|
|
|
def reset_pinecone_index():
|
|
# Pinecone 클라이언트 생성
|
|
pc = Pinecone(api_key=self.api_key)
|
|
|
|
# 기존 인덱스 삭제
|
|
existing_indexes = pc.list_indexes()
|
|
index_names = [idx.name for idx in existing_indexes.indexes]
|
|
|
|
if self.index_name in index_names:
|
|
logger.info(f"🗑️ 기존 인덱스 '{self.index_name}' 삭제 중...")
|
|
pc.delete_index(self.index_name)
|
|
|
|
# 삭제 완료 대기
|
|
import time
|
|
time.sleep(10)
|
|
logger.info(f"✅ 기존 인덱스 '{self.index_name}' 삭제 완료")
|
|
|
|
# 새 인덱스 생성
|
|
logger.info(f"🆕 새 인덱스 '{self.index_name}' 생성 중...")
|
|
pc.create_index(
|
|
name=self.index_name,
|
|
dimension=self.vector_dimension,
|
|
metric='cosine',
|
|
spec=ServerlessSpec(
|
|
cloud='aws',
|
|
region='us-east-1'
|
|
),
|
|
deletion_protection="disabled"
|
|
)
|
|
|
|
# 인덱스 생성 완료 대기
|
|
time.sleep(15)
|
|
logger.info(f"✅ 새 인덱스 '{self.index_name}' 생성 완료")
|
|
|
|
# 새 인덱스 연결
|
|
index = pc.Index(self.index_name)
|
|
return pc, index
|
|
|
|
# 타임아웃 내에서 인덱스 초기화
|
|
self.pc, self.index = await asyncio.wait_for(
|
|
asyncio.get_event_loop().run_in_executor(None, reset_pinecone_index),
|
|
timeout=120 # 2분 타임아웃 (인덱스 삭제/생성 시간 고려)
|
|
)
|
|
|
|
# 연결 테스트
|
|
await self._quick_connection_test()
|
|
|
|
self._initialized = True
|
|
logger.info(f"🎉 인덱스 '{self.index_name}' 완전 초기화 성공!")
|
|
return True
|
|
|
|
except asyncio.TimeoutError:
|
|
logger.error(f"⏰ 인덱스 초기화 타임아웃 - index: {self.index_name}")
|
|
return False
|
|
except Exception as e:
|
|
logger.error(f"❌ 인덱스 초기화 실패 - index: {self.index_name}, error: {str(e)}")
|
|
return False
|
|
|
|
async def clear_all_vectors(self) -> bool:
|
|
"""모든 벡터 삭제 (인덱스는 유지)"""
|
|
try:
|
|
if not await self.initialize():
|
|
logger.warning("⚠️ Pinecone 초기화 실패 - 벡터 삭제 불가")
|
|
return False
|
|
|
|
logger.info("🧹 모든 벡터 삭제 시작...")
|
|
|
|
def delete_all_vectors():
|
|
# 모든 벡터 삭제 (네임스페이스 지정하지 않으면 전체 삭제)
|
|
return self.index.delete(delete_all=True)
|
|
|
|
await asyncio.wait_for(
|
|
asyncio.get_event_loop().run_in_executor(None, delete_all_vectors),
|
|
timeout=60
|
|
)
|
|
|
|
logger.info("✅ 모든 벡터 삭제 완료")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ 벡터 삭제 실패: {str(e)}")
|
|
return False
|
|
|
|
async def get_index_stats(self) -> Dict[str, Any]:
|
|
"""인덱스 통계 조회"""
|
|
try:
|
|
if not await self.initialize():
|
|
return {"status": "failed", "error": "초기화 실패"}
|
|
|
|
def get_stats():
|
|
return self.index.describe_index_stats()
|
|
|
|
stats = await asyncio.wait_for(
|
|
asyncio.get_event_loop().run_in_executor(None, get_stats),
|
|
timeout=15
|
|
)
|
|
|
|
result = {
|
|
"status": "success",
|
|
"total_vector_count": stats.get('total_vector_count', 0),
|
|
"dimension": stats.get('dimension', 0),
|
|
"index_fullness": stats.get('index_fullness', 0.0),
|
|
"namespaces": stats.get('namespaces', {})
|
|
}
|
|
|
|
logger.info(f"📊 인덱스 통계 조회 성공 - 벡터 수: {result['total_vector_count']}")
|
|
return result
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ 인덱스 통계 조회 실패: {str(e)}")
|
|
return {"status": "failed", "error": str(e)}
|
|
|
|
def _is_valid_number(self, value: float) -> bool:
|
|
"""숫자 유효성 검증"""
|
|
try:
|
|
return not (math.isnan(value) or math.isinf(value))
|
|
except (TypeError, ValueError):
|
|
return False
|
|
|
|
def _safe_float_conversion(self, value: Any, default: float = 0.0) -> float:
|
|
"""안전한 float 변환"""
|
|
try:
|
|
if value is None:
|
|
return default
|
|
elif isinstance(value, Decimal):
|
|
result = float(value)
|
|
elif isinstance(value, (int, float)):
|
|
result = float(value)
|
|
elif isinstance(value, str):
|
|
try:
|
|
result = float(value)
|
|
except (ValueError, TypeError):
|
|
return default
|
|
else:
|
|
return default
|
|
|
|
if not self._is_valid_number(result):
|
|
return default
|
|
|
|
return result
|
|
except Exception:
|
|
return default
|
|
|
|
def _safe_int_conversion(self, value: Any, default: int = 0) -> int:
|
|
"""안전한 int 변환"""
|
|
try:
|
|
if value is None:
|
|
return default
|
|
elif isinstance(value, Decimal):
|
|
return int(value)
|
|
elif isinstance(value, (int, float)):
|
|
return int(value)
|
|
elif isinstance(value, str):
|
|
try:
|
|
return int(float(value))
|
|
except (ValueError, TypeError):
|
|
return default
|
|
else:
|
|
return default
|
|
except Exception:
|
|
return default
|
|
|
|
def create_user_vector(self, user_data: Dict[str, Any]) -> List[float]:
|
|
"""사용자 데이터를 1024차원 벡터로 변환 (건강 데이터 중심 유사도 개선)"""
|
|
try:
|
|
vector = []
|
|
|
|
# 1. 나이 중심 특성 (50차원) - 나이 유사도를 높이기 위해 확장
|
|
age = self._safe_int_conversion(user_data.get("age", 30))
|
|
age_features = self._create_age_based_features(age)
|
|
vector.extend(age_features)
|
|
|
|
# 2. 건강 위험도 지표 (300차원) - 건강 상태 유사도 강화
|
|
health_risk_features = self._create_health_risk_features(user_data)
|
|
vector.extend(health_risk_features)
|
|
|
|
# 3. 주요 건강 지표별 상세 벡터 (500차원)
|
|
detailed_health_features = self._create_detailed_health_features(user_data)
|
|
vector.extend(detailed_health_features)
|
|
|
|
# 4. 직업 특성 (100차원) - 직업 유사도 강화
|
|
occupation_features = self._create_occupation_features(user_data.get("occupation", "OFF001"))
|
|
vector.extend(occupation_features)
|
|
|
|
# 5. 생활습관 패턴 (74차원)
|
|
lifestyle_features = self._create_lifestyle_features(user_data)
|
|
vector.extend(lifestyle_features)
|
|
|
|
# 6. 1024차원 맞추기
|
|
while len(vector) < self.vector_dimension:
|
|
vector.append(0.0)
|
|
vector = vector[:self.vector_dimension]
|
|
|
|
# 7. 유효성 검증
|
|
validated_vector = []
|
|
for v in vector:
|
|
float_val = self._safe_float_conversion(v, 0.0)
|
|
validated_vector.append(float_val)
|
|
|
|
logger.info(f"✅ 건강 중심 사용자 벡터 생성 완료 (1024차원) - "
|
|
f"user_id: {user_data.get('member_serial_number')}, "
|
|
f"age: {age}, occupation: {user_data.get('occupation')}, "
|
|
f"bmi: {self._safe_float_conversion(user_data.get('bmi'))}")
|
|
|
|
return validated_vector
|
|
|
|
except Exception as e:
|
|
logger.error(f"❌ 사용자 벡터 생성 실패: {str(e)}")
|
|
return [0.0] * self.vector_dimension
|
|
|
|
def _create_age_based_features(self, age: int) -> List[float]:
|
|
"""나이 기반 특성 생성 (50차원) - 연령대별 유사도 강화"""
|
|
features = []
|
|
|
|
# 연령대 구간별 특성
|
|
age_ranges = [
|
|
(20, 25), (25, 30), (30, 35), (35, 40), (40, 45),
|
|
(45, 50), (50, 55), (55, 60), (60, 65), (65, 70)
|
|
]
|
|
|
|
for start_age, end_age in age_ranges:
|
|
if start_age <= age < end_age:
|
|
# 해당 연령대에 높은 값
|
|
similarity = 1.0 - abs(age - (start_age + end_age) / 2) / 5
|
|
features.extend([max(0.0, similarity)] * 5)
|
|
else:
|
|
# 다른 연령대에는 거리 기반 유사도
|
|
mid_age = (start_age + end_age) / 2
|
|
distance = abs(age - mid_age)
|
|
similarity = max(0.0, 1.0 - distance / 20)
|
|
features.extend([similarity] * 5)
|
|
|
|
return features
|
|
|
|
def _create_health_risk_features(self, user_data: Dict[str, Any]) -> List[float]:
|
|
"""건강 위험도 특성 생성 (300차원) - 건강 상태 유사도 강화"""
|
|
features = []
|
|
|
|
# 주요 건강 위험 지표들
|
|
health_indicators = {
|
|
'bmi': {'normal': (18.5, 25), 'weight': 30},
|
|
'systolic_bp': {'normal': (90, 140), 'weight': 25},
|
|
'diastolic_bp': {'normal': (60, 90), 'weight': 25},
|
|
'fasting_glucose': {'normal': (70, 100), 'weight': 35},
|
|
'total_cholesterol': {'normal': (120, 200), 'weight': 30},
|
|
'hdl_cholesterol': {'normal': (40, 100), 'weight': 20},
|
|
'ldl_cholesterol': {'normal': (0, 100), 'weight': 25},
|
|
'triglyceride': {'normal': (50, 150), 'weight': 20},
|
|
'ast': {'normal': (10, 40), 'weight': 15},
|
|
'alt': {'normal': (10, 40), 'weight': 15},
|
|
'gamma_gtp': {'normal': (10, 60), 'weight': 15},
|
|
'hemoglobin': {'normal': (12, 16), 'weight': 10}
|
|
}
|
|
|
|
for indicator, config in health_indicators.items():
|
|
value = self._safe_float_conversion(user_data.get(indicator), 0.0)
|
|
normal_min, normal_max = config['normal']
|
|
weight = config['weight']
|
|
|
|
# 정상/위험 구간별 특성 생성
|
|
risk_features = self._calculate_health_risk_pattern(value, normal_min, normal_max, weight)
|
|
features.extend(risk_features)
|
|
|
|
return features
|
|
|
|
def _calculate_health_risk_pattern(self, value: float, normal_min: float, normal_max: float, dim_count: int) -> \
|
|
List[float]:
|
|
"""건강 지표별 위험도 패턴 계산"""
|
|
pattern = []
|
|
|
|
if value == 0.0: # 데이터 없음
|
|
pattern = [0.0] * dim_count
|
|
elif normal_min <= value <= normal_max: # 정상 범위
|
|
normal_score = 1.0 - abs(value - (normal_min + normal_max) / 2) / ((normal_max - normal_min) / 2)
|
|
pattern = [normal_score] * dim_count
|
|
else: # 위험 범위
|
|
if value < normal_min: # 낮음
|
|
risk_score = (normal_min - value) / normal_min
|
|
else: # 높음
|
|
risk_score = (value - normal_max) / normal_max
|
|
|
|
risk_score = min(1.0, max(0.0, risk_score))
|
|
pattern = [risk_score] * dim_count
|
|
|
|
return pattern
|
|
|
|
def _create_detailed_health_features(self, user_data: Dict[str, Any]) -> List[float]:
|
|
"""상세 건강 특성 생성 (500차원)"""
|
|
features = []
|
|
|
|
# 세부 건강 지표들을 더 정교하게 벡터화
|
|
detailed_metrics = [
|
|
'height', 'weight', 'waist_circumference',
|
|
'visual_acuity_left', 'visual_acuity_right',
|
|
'hearing_left', 'hearing_right',
|
|
'serum_creatinine', 'urine_protein'
|
|
]
|
|
|
|
# 각 지표당 약 55차원 할당
|
|
for metric in detailed_metrics:
|
|
value = self._safe_float_conversion(user_data.get(metric), 0.0)
|
|
|
|
# 값의 범위별 분포 특성 생성
|
|
metric_features = []
|
|
for i in range(55):
|
|
# 다양한 스케일로 특성 생성
|
|
scale_factor = (i + 1) / 10
|
|
normalized_value = min(1.0, value / (100 * scale_factor)) if value > 0 else 0.0
|
|
metric_features.append(normalized_value)
|
|
|
|
features.extend(metric_features)
|
|
|
|
# 나머지 차원 채우기
|
|
remaining_dims = 500 - len(features)
|
|
features.extend([0.0] * max(0, remaining_dims))
|
|
|
|
return features[:500]
|
|
|
|
def _create_occupation_features(self, occupation: str) -> List[float]:
|
|
"""직업 특성 생성 (100차원) - 직업 유사도 강화"""
|
|
features = []
|
|
|
|
# 직업별 건강 위험 프로필 (강화된 직업 특성)
|
|
occupation_health_profiles = {
|
|
"OFF001": { # 사무직
|
|
"sedentary_risk": 0.9, # 좌식 위험도 강화
|
|
"stress_level": 0.7, # 스트레스 수준
|
|
"exercise_need": 0.9, # 운동 필요도
|
|
"eye_strain": 0.9, # 눈 피로도
|
|
"metabolic_risk": 0.7 # 대사 위험도
|
|
},
|
|
"ENG001": { # IT직군/엔지니어
|
|
"sedentary_risk": 0.95, # 사무직보다 더 높은 좌식 위험
|
|
"stress_level": 0.8, # 높은 스트레스
|
|
"exercise_need": 0.95, # 높은 운동 필요도
|
|
"eye_strain": 0.95, # 높은 눈 피로도
|
|
"metabolic_risk": 0.8 # 높은 대사 위험도
|
|
},
|
|
"MED001": { # 의료진
|
|
"sedentary_risk": 0.3,
|
|
"stress_level": 0.9,
|
|
"exercise_need": 0.6,
|
|
"eye_strain": 0.4,
|
|
"metabolic_risk": 0.4
|
|
},
|
|
"EDU001": { # 교육직
|
|
"sedentary_risk": 0.6,
|
|
"stress_level": 0.6,
|
|
"exercise_need": 0.7,
|
|
"eye_strain": 0.7,
|
|
"metabolic_risk": 0.5
|
|
},
|
|
"SRV001": { # 서비스직
|
|
"sedentary_risk": 0.2,
|
|
"stress_level": 0.7,
|
|
"exercise_need": 0.4,
|
|
"eye_strain": 0.3,
|
|
"metabolic_risk": 0.4
|
|
}
|
|
}
|
|
|
|
profile = occupation_health_profiles.get(occupation, occupation_health_profiles["OFF001"])
|
|
|
|
# 각 위험 요소별로 20차원씩 할당
|
|
for risk_type, risk_value in profile.items():
|
|
risk_features = [risk_value + (i * 0.01) for i in range(20)]
|
|
features.extend([max(0.0, min(1.0, f)) for f in risk_features])
|
|
|
|
return features
|
|
|
|
def _create_lifestyle_features(self, user_data: Dict[str, Any]) -> List[float]:
|
|
"""생활습관 특성 생성 (74차원)"""
|
|
features = []
|
|
|
|
# 흡연 상태 (37차원)
|
|
smoking_status = self._safe_int_conversion(user_data.get("smoking_status"), 0)
|
|
smoking_features = []
|
|
for i in range(37):
|
|
if smoking_status == 0: # 비흡연
|
|
smoking_features.append(1.0 - (i * 0.02))
|
|
elif smoking_status == 1: # 과거 흡연
|
|
smoking_features.append(0.5 + (i * 0.01))
|
|
else: # 현재 흡연
|
|
smoking_features.append((i * 0.02))
|
|
|
|
features.extend([max(0.0, min(1.0, f)) for f in smoking_features])
|
|
|
|
# 음주 상태 (37차원)
|
|
drinking_status = self._safe_int_conversion(user_data.get("drinking_status"), 0)
|
|
drinking_features = []
|
|
for i in range(37):
|
|
if drinking_status == 0: # 비음주
|
|
drinking_features.append(1.0 - (i * 0.02))
|
|
else: # 음주
|
|
drinking_features.append(i * 0.03)
|
|
|
|
features.extend([max(0.0, min(1.0, f)) for f in drinking_features])
|
|
|
|
return features
|
|
|
|
async def upsert_user_vector(self, user_id: int, user_data: Dict[str, Any]) -> bool:
|
|
"""사용자 벡터를 Pinecone에 저장/업데이트 (공식 SDK v7.x)"""
|
|
try:
|
|
if not await self.initialize():
|
|
logger.warning(f"⚠️ Pinecone 초기화 실패 - 벡터 저장 건너뜀 (user_id: {user_id})")
|
|
return False
|
|
|
|
vector = self.create_user_vector(user_data)
|
|
|
|
if len(vector) != self.vector_dimension:
|
|
logger.error(f"❌ 벡터 차원 불일치 - 예상: {self.vector_dimension}, 실제: {len(vector)}")
|
|
return False
|
|
|
|
# 메타데이터 생성 (검색 및 디버깅용)
|
|
metadata = {
|
|
"user_id": user_id,
|
|
"occupation": str(user_data.get("occupation", "OFF001")),
|
|
"age": self._safe_int_conversion(user_data.get("age"), 30),
|
|
"bmi": round(self._safe_float_conversion(user_data.get("bmi"), 22.0), 2),
|
|
"systolic_bp": self._safe_int_conversion(user_data.get("systolic_bp"), 120),
|
|
"fasting_glucose": self._safe_int_conversion(user_data.get("fasting_glucose"), 90),
|
|
"total_cholesterol": self._safe_int_conversion(user_data.get("total_cholesterol"), 180),
|
|
"updated_at": str(user_data.get("updated_at", ""))
|
|
}
|
|
|
|
try:
|
|
logger.info(f"🔄 건강 중심 벡터 저장 시도 (1024차원) - user_id: {user_id}")
|
|
|
|
def upsert_vector():
|
|
# 공식 SDK v7.x 방식
|
|
return self.index.upsert(
|
|
vectors=[
|
|
{
|
|
"id": str(user_id),
|
|
"values": vector,
|
|
"metadata": metadata
|
|
}
|
|
]
|
|
)
|
|
|
|
result = await asyncio.wait_for(
|
|
asyncio.get_event_loop().run_in_executor(None, upsert_vector),
|
|
timeout=30
|
|
)
|
|
|
|
logger.info(f"✅ 건강 중심 사용자 벡터 저장 성공 (1024차원) - user_id: {user_id}, "
|
|
f"age: {metadata['age']}, bmi: {metadata['bmi']}, "
|
|
f"upserted_count: {result.get('upserted_count', 1)}")
|
|
return True
|
|
|
|
except asyncio.TimeoutError:
|
|
logger.warning(f"⏰ 벡터 저장 타임아웃 - user_id: {user_id}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.warning(f"⚠️ 벡터 저장 실패 - user_id: {user_id}, error: {str(e)}")
|
|
return False
|
|
|
|
except Exception as e:
|
|
logger.warning(f"⚠️ 사용자 벡터 저장 전체 실패 - user_id: {user_id}, error: {str(e)}")
|
|
return False
|
|
|
|
async def search_similar_users(self, user_id: int, top_k: int = 10) -> List[int]:
|
|
"""유사한 사용자 ID 목록 검색 (건강 데이터 중심 유사도)"""
|
|
try:
|
|
if not await self.initialize():
|
|
logger.warning(f"⚠️ Pinecone 초기화 실패 - 유사 사용자 검색 건너뜀 (user_id: {user_id})")
|
|
return []
|
|
|
|
try:
|
|
logger.info(f"🔄 건강 중심 유사 사용자 검색 시도 - user_id: {user_id}")
|
|
|
|
# 사용자 벡터 조회
|
|
def fetch_vector():
|
|
return self.index.fetch(ids=[str(user_id)])
|
|
|
|
user_vector_result = await asyncio.wait_for(
|
|
asyncio.get_event_loop().run_in_executor(None, fetch_vector),
|
|
timeout=15
|
|
)
|
|
|
|
if str(user_id) not in user_vector_result.vectors:
|
|
logger.warning(f"⚠️ 사용자 벡터를 찾을 수 없음 - user_id: {user_id}")
|
|
return []
|
|
|
|
user_vector = user_vector_result.vectors[str(user_id)].values
|
|
user_metadata = user_vector_result.vectors[str(user_id)].metadata
|
|
|
|
# 유사 벡터 검색
|
|
def query_similar():
|
|
return self.index.query(
|
|
vector=user_vector,
|
|
top_k=top_k + 1,
|
|
include_metadata=True
|
|
)
|
|
|
|
search_result = await asyncio.wait_for(
|
|
asyncio.get_event_loop().run_in_executor(None, query_similar),
|
|
timeout=15
|
|
)
|
|
|
|
similar_user_ids = []
|
|
for match in search_result.matches:
|
|
matched_user_id = int(match.id)
|
|
if matched_user_id != user_id:
|
|
similar_user_ids.append(matched_user_id)
|
|
|
|
# 유사도 디버깅 로그
|
|
if match.metadata:
|
|
logger.debug(f"🔍 유사 사용자 발견 - user_id: {matched_user_id}, "
|
|
f"유사도: {match.score:.3f}, "
|
|
f"나이: {match.metadata.get('age')}, "
|
|
f"직업: {match.metadata.get('occupation')}, "
|
|
f"BMI: {match.metadata.get('bmi')}")
|
|
|
|
similar_user_ids = similar_user_ids[:top_k]
|
|
|
|
logger.info(f"✅ 건강 중심 유사 사용자 검색 완료 - user_id: {user_id}, "
|
|
f"found: {len(similar_user_ids)}, "
|
|
f"기준 나이: {user_metadata.get('age')}, "
|
|
f"기준 직업: {user_metadata.get('occupation')}")
|
|
return similar_user_ids
|
|
|
|
except asyncio.TimeoutError:
|
|
logger.warning(f"⏰ 유사 사용자 검색 타임아웃 - user_id: {user_id}")
|
|
return []
|
|
|
|
except Exception as e:
|
|
logger.warning(f"⚠️ 유사 사용자 검색 실패 - user_id: {user_id}, error: {str(e)}")
|
|
return []
|
|
|
|
except Exception as e:
|
|
logger.warning(f"⚠️ 유사 사용자 검색 전체 실패 - user_id: {user_id}, error: {str(e)}")
|
|
return []
|
|
|
|
async def delete_user_vector(self, user_id: int) -> bool:
|
|
"""사용자 벡터 삭제 (공식 SDK v7.x)"""
|
|
try:
|
|
if not await self.initialize():
|
|
logger.warning(f"⚠️ Pinecone 초기화 실패 - 벡터 삭제 건너뜀 (user_id: {user_id})")
|
|
return False
|
|
|
|
def delete_vector():
|
|
return self.index.delete(ids=[str(user_id)])
|
|
|
|
await asyncio.wait_for(
|
|
asyncio.get_event_loop().run_in_executor(None, delete_vector),
|
|
timeout=15
|
|
)
|
|
|
|
logger.info(f"✅ 사용자 벡터 삭제 완료 - user_id: {user_id}")
|
|
return True
|
|
|
|
except Exception as e:
|
|
logger.warning(f"⚠️ 사용자 벡터 삭제 실패 - user_id: {user_id}, error: {str(e)}")
|
|
return False
|
|
|
|
|
|
# 전역 클라이언트 인스턴스
|
|
pinecone_client = PineconeClient() |