HealthSync_Intelligence/app/utils/vector_client.py
hyerimmy 910bd902b1
Some checks failed
HealthSync Intelligence CI / build-and-push (push) Has been cancelled
feat : initial commit
2025-06-20 05:28:30 +00:00

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()