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