diff --git a/vector/README.md b/vector/README.md index 9a609a3..726bd26 100644 --- a/vector/README.md +++ b/vector/README.md @@ -566,96 +566,214 @@ curl -X POST "http://vector-api.20.249.191.180.nip.io/action-recommendation" \ "success": true, "recommendation": { "summary": { - "current_situation": "치킨 전문점으로 강남구 역삼동에 위치, 평균 별점 3.8점으로 동종업체 대비 개선 여지 존재", + "current_situation": "일식 업종으로 매출 감소를 겪고 있으며, 경쟁이 치열한 고품질 시장에서 운영 중입니다. 업계 평균 평점은 4.23점이고, 경쟁업체들은 3.9-4.7점 범위의 평점을 보이고 있습니다.", "key_insights": [ - "배달 서비스 만족도가 경쟁업체 대비 낮음", - "신메뉴 출시 주기가 3개월 이상으로 긴 편", - "온라인 리뷰 관리 시스템 부재" + "업계 공통 이슈로 고객 만족도 양극화 문제 존재", + "경쟁업체들은 가성비, 맛, 친절함에서 강점을 보임", + "고객 참여도와 일관성 있는 서비스 품질이 성공의 핵심 요소" ], - "priority_areas": ["배달 품질 개선", "메뉴 혁신"] + "priority_areas": [ + "서비스 일관성 개선", + "고객 만족도 안정화" + ] }, "action_plans": { "short_term": [ { - "title": "배달 포장재 개선", - "description": "보온성이 우수한 친환경 포장재로 교체하여 배달 만족도 향상", - "expected_impact": "배달 리뷰 평점 0.5점 상승 예상", - "timeline": "2주", - "cost": "월 50만원" + "title": "서비스 표준화 매뉴얼 작성 및 적용", + "description": "조리법, 서빙 방식, 고객 응대 등 모든 서비스 과정을 표준화하여 일관성 있는 품질 제공. 직원 교육 실시 및 체크리스트 활용", + "expected_impact": "고객 만족도 양극화 해소, 평점 0.3-0.5점 상승 예상", + "timeline": "2-4주", + "cost": "50-100만원 (교육비, 매뉴얼 제작비)" + }, + { + "title": "고객 피드백 수집 시스템 구축", + "description": "테이블 QR코드를 통한 실시간 피드백 수집, 불만사항 즉시 대응 체계 마련. 주간 피드백 분석 및 개선사항 도출", + "expected_impact": "고객 불만 30% 감소, 재방문율 15% 증가", + "timeline": "1-2주", + "cost": "30-50만원 (QR코드 제작, 시스템 구축)" } ], "mid_term": [ { - "title": "시즌 한정 메뉴 런칭", - "description": "여름 시즌 매운맛 신메뉴 2종 개발 및 SNS 마케팅", - "expected_impact": "신규 고객 유입 20% 증가", - "timeline": "6주", - "cost": "초기 투자 300만원" + "title": "메뉴 최적화 및 시그니처 메뉴 개발", + "description": "경쟁업체 대비 차별화된 시그니처 메뉴 3-5개 개발. 기존 메뉴 중 인기도 낮은 메뉴 정리하고 가성비 우수 메뉴 강화", + "expected_impact": "평균 주문 금액 20% 증가, 고객 재방문율 25% 향상", + "timeline": "2-3개월", + "cost": "200-300만원 (메뉴 개발, 재료비, 마케팅)" + }, + { + "title": "디지털 마케팅 강화", + "description": "네이버 플레이스, 구글 마이비즈니스 최적화. SNS 활용한 메뉴 홍보 및 고객 후기 관리. 온라인 주문 시스템 도입", + "expected_impact": "온라인 노출 50% 증가, 신규 고객 유입 30% 증가", + "timeline": "3-4개월", + "cost": "150-250만원 (마케팅비, 시스템 구축비)" } ], "long_term": [ { - "title": "브랜드 리뉴얼", - "description": "매장 인테리어 개선 및 브랜드 아이덴티티 강화", - "expected_impact": "브랜드 인지도 향상, 객단가 15% 상승", - "timeline": "4개월", - "cost": "1,500만원" + "title": "고객 충성도 프로그램 구축", + "description": "멤버십 시스템 도입, 단골 고객 특별 혜택 제공, 생일/기념일 이벤트 운영. 고객 데이터베이스 구축 및 맞춤형 서비스 제공", + "expected_impact": "고객 유지율 40% 향상, 월 매출 25-30% 증가", + "timeline": "6-8개월", + "cost": "300-500만원 (시스템 개발, 운영비)" + }, + { + "title": "브랜드 아이덴티티 강화 및 확장 전략", + "description": "독특한 브랜드 스토리 개발, 인테리어 리뉴얼, 패키징 디자인 개선. 향후 2호점 진출 또는 프랜차이즈 검토", + "expected_impact": "브랜드 인지도 향상, 프리미엄 가격 정책 가능, 사업 확장 기반 마련", + "timeline": "8-12개월", + "cost": "500-1000만원 (리뉴얼, 브랜딩 비용)" } ] }, "implementation_tips": [ - "배달앱 리뷰 모니터링 시스템 도입", - "경쟁업체 메뉴 트렌드 주기적 분석", - "고객 피드백 기반 개선사항 우선순위 설정" + "단기 계획부터 차근차근 실행하되, 서비스 일관성 개선을 최우선으로 진행하세요", + "고객 피드백을 적극 수집하고 빠르게 개선사항에 반영하여 고객과의 소통을 강화하세요", + "경쟁업체의 강점(가성비, 맛, 친절)을 벤치마킹하되, 차별화 포인트를 명확히 설정하세요" ] }, + "error_message": null, "input_data": { "request_context": { "store_id": "501745730", - "owner_request": "매출이 감소하고 있어서 메뉴 개선이 필요합니다.", - "analysis_timestamp": "2024-06-16T10:30:00" + "owner_request": "매출이 감소하고 있어서 개선 방안이 필요합니다.", + "analysis_timestamp": "2025-06-16T03:34:08.622763" }, "market_intelligence": { - "total_competitors": 7, + "total_competitors": 3, "industry_insights": [ - "업계 전반적으로 고객 만족도 개선 필요", - "고성과 업체 3개 벤치마킹 가능" + "경쟁이 치열한 고품질 시장", + "업계 공통 이슈: 고객 만족도 양극화 (일관성 부족)" ], "performance_benchmarks": { - "average_rating": 4.1, - "review_volume_trend": "증가" + "average_rating": 4.23, + "rating_range": { + "min": 3.9, + "max": 4.7 + }, + "industry_standard": "Above Average", + "review_activity": { + "average_reviews": 224, + "range": { + "min": 103, + "max": 412 + } + } } }, "competitive_insights": [ { "rank": 1, - "store_name": "○○치킨", - "similarity_score": 0.892, + "store_name": "", + "category": "일식", + "similarity_score": 0.389, "performance_analysis": { "performance": { - "rating": "4.3", - "review_count": "156" + "rating": "4.1", + "review_count": "156", + "status": "영업 중" }, "feedback": { - "positive_aspects": ["맛", "친절", "빠른배달"], - "negative_aspects": ["가격"] + "positive_aspects": [ + "가성비", + "맛", + "분위기" + ], + "negative_aspects": [], + "recent_trends": [ + "최근 만족도 상승" + ], + "rating_pattern": "안정적 고만족" }, - "business_insights": { - "key_finding": "신메뉴 런칭으로 리뷰 급증" + "insights": { + "competitive_advantage": [ + "높은 고객 만족도", + "활발한 고객 참여", + "맛 우수" + ], + "critical_issues": [], + "improvement_opportunities": [] + } + } + }, + { + "rank": 2, + "store_name": "", + "category": "일식", + "similarity_score": 0.347, + "performance_analysis": { + "performance": { + "rating": "4.7", + "review_count": "412", + "status": "영업 중" + }, + "feedback": { + "positive_aspects": [ + "가성비", + "맛", + "친절", + "분위기" + ], + "negative_aspects": [ + "고객 만족도 양극화 (일관성 부족)" + ], + "recent_trends": [ + "최근 만족도 상승" + ], + "rating_pattern": "안정적 고만족" + }, + "insights": { + "competitive_advantage": [ + "높은 고객 만족도", + "활발한 고객 참여", + "맛 우수", + "친절 우수" + ], + "critical_issues": [], + "improvement_opportunities": [] + } + } + }, + { + "rank": 3, + "store_name": "", + "category": "일식", + "similarity_score": 0.314, + "performance_analysis": { + "performance": { + "rating": "3.9", + "review_count": "103", + "status": "영업 중" + }, + "feedback": { + "positive_aspects": [ + "가성비", + "맛", + "친절" + ], + "negative_aspects": [], + "recent_trends": [], + "rating_pattern": "양극화 패턴" + }, + "insights": { + "competitive_advantage": [ + "활발한 고객 참여", + "맛 우수", + "친절 우수" + ], + "critical_issues": [], + "improvement_opportunities": [] } } } ], "actionable_recommendations": { "immediate_actions": [ - "배달 서비스 개선 (업계 5개 업체 공통 문제)" + "고객 만족도 양극화 (일관성 부족) 개선 (업계 1개 업체 공통 문제)" ], - "strategic_improvements": [ - "메뉴 다양성 확대" - ], - "benchmarking_targets": [ - "○○치킨: 신메뉴 마케팅 전략" - ] + "strategic_improvements": [], + "benchmarking_targets": [] } } } diff --git a/vector/app/main.py b/vector/app/main.py index d07f51d..3d50c71 100644 --- a/vector/app/main.py +++ b/vector/app/main.py @@ -52,6 +52,8 @@ from app.services.vector_service import VectorService from app.services.claude_service import ClaudeService from app.utils.category_utils import extract_food_category +import json + # 로깅 설정 logging.basicConfig( level=logging.INFO, @@ -434,7 +436,7 @@ async def find_reviews( # 동종 업체 리뷰 수집 (본인 가게 제외) similar_store_names = [] - max_similar_reviews = min(settings.MAX_REVIEWS_PER_RESTAURANT // 2, 20) # 절반 또는 최대 20개 + max_similar_reviews = settings.MAX_REVIEWS_PER_RESTAURANT for store in similar_stores[:settings.MAX_RESTAURANTS_PER_CATEGORY]: # 환경변수 활용 store_info, reviews = await review_service.collect_store_reviews( store.id, diff --git a/vector/app/services/vector_service.py b/vector/app/services/vector_service.py index 71b9155..613d6a4 100644 --- a/vector/app/services/vector_service.py +++ b/vector/app/services/vector_service.py @@ -147,74 +147,67 @@ class VectorService: food_category: str, region: str ) -> Dict[str, Any]: - """Vector Store를 구축합니다""" + """Vector Store를 구축합니다 (기존 데이터 업데이트 포함)""" if not self.is_ready(): raise Exception(f"VectorService가 준비되지 않음: {self.initialization_error}") try: - logger.info("🚀 Vector Store 구축 시작") + logger.info("🔧 Vector Store 구축 시작 (기존 데이터 업데이트 포함)...") + start_time = datetime.now() - processed_count = 0 - documents = [] - embeddings = [] - metadatas = [] - ids = [] + # 1단계: 가게 분류 (새 가게 vs 업데이트 vs 중복) + logger.info("🔍 가게 분류 중...") + categorization = self.categorize_stores(review_results, region, food_category) - for store_id, store_info, reviews in review_results: - try: - # 텍스트 추출 및 임베딩 생성 - text_for_embedding = extract_text_for_embedding(store_info, reviews) - embedding = self.embedding_model.encode(text_for_embedding) - - # 메타데이터 생성 - metadata = create_metadata(store_info, food_category, region) - - # 문서 ID 생성 - document_id = create_store_hash(store_id, store_info.get('name', ''), region) - - # 문서 데이터 생성 - document_text = combine_store_and_reviews(store_info, reviews) - - documents.append(document_text) - embeddings.append(embedding.tolist()) - metadatas.append(metadata) - ids.append(document_id) - - processed_count += 1 - - except Exception as e: - logger.warning(f"⚠️ 가게 {store_id} 처리 실패: {e}") - continue - - # Vector DB에 저장 - if documents: - self.collection.add( - documents=documents, - embeddings=embeddings, - metadatas=metadatas, - ids=ids + # 2단계: 기존 가게 업데이트 + update_result = {'updated_count': 0, 'skipped_count': 0} + if categorization['update_stores']: + logger.info(f"🔄 {len(categorization['update_stores'])}개 기존 가게 업데이트 중...") + update_result = self.update_existing_stores( + categorization['update_stores'], food_category, region ) - - logger.info(f"✅ Vector Store 구축 완료: {processed_count}개 문서 저장") - - return { - 'success': True, - 'processed_count': processed_count, - 'message': f"{processed_count}개 문서가 Vector DB에 저장되었습니다" - } - else: - return { - 'success': False, - 'error': '저장할 문서가 없습니다', - 'processed_count': 0 - } - + + # 3단계: 새 가게 추가 + add_result = {'added_count': 0} + if categorization['new_stores']: + logger.info(f"✨ {len(categorization['new_stores'])}개 새 가게 추가 중...") + add_result = self.add_new_stores( + categorization['new_stores'], food_category, region + ) + + # 4단계: 결과 정리 + total_processed = update_result['updated_count'] + add_result['added_count'] + execution_time = (datetime.now() - start_time).total_seconds() + + result = { + 'success': True, + 'processed_count': total_processed, # ← 기존 API 호환성 유지 + 'execution_time': execution_time, + 'summary': categorization['summary'], + 'operations': { + 'new_stores_added': add_result['added_count'], + 'existing_stores_updated': update_result['updated_count'], + 'update_skipped': update_result.get('skipped_count', 0), + 'duplicates_removed': categorization['summary']['duplicate_count'] + }, + 'message': f"✅ Vector DB 처리 완료: 새 가게 {add_result['added_count']}개 추가, " + f"기존 가게 {update_result['updated_count']}개 업데이트" + } + + logger.info(f"📊 Vector Store 구축 완료:") + logger.info(f" - 총 처리: {total_processed}개") + logger.info(f" - 새 가게: {add_result['added_count']}개") + logger.info(f" - 업데이트: {update_result['updated_count']}개") + logger.info(f" - 실행 시간: {execution_time:.2f}초") + + return result + except Exception as e: logger.error(f"❌ Vector Store 구축 실패: {e}") return { 'success': False, 'error': str(e), - 'processed_count': 0 + 'processed_count': 0 # ← 기존 API 호환성 유지 } def search_similar_cases_improved(self, store_id: str, context: str, limit: int = 5) -> Optional[Dict[str, Any]]: @@ -933,4 +926,256 @@ class VectorService: 'db_path': self.db_path, 'status': 'error', 'error': str(e) - } \ No newline at end of file + } + + def get_existing_store_data(self, region: str = None, food_category: str = None) -> Dict[str, Dict[str, Any]]: + """ + Vector DB에서 기존 store 데이터를 가져옵니다. + + Args: + region: 지역 필터 (선택사항) + food_category: 음식 카테고리 필터 (선택사항) + + Returns: + {store_id: {metadata, document_id}} 형태의 딕셔너리 + """ + try: + if not self.is_ready(): + logger.warning("⚠️ VectorService가 준비되지 않음") + return {} + + # 필터 조건 설정 + where_condition = {} + if region and food_category: + where_condition = { + "$and": [ + {"region": region}, + {"food_category": food_category} + ] + } + elif region: + where_condition = {"region": region} + elif food_category: + where_condition = {"food_category": food_category} + + # 기존 데이터 조회 + if where_condition: + results = self.collection.get( + where=where_condition, + include=['metadatas', 'documents'] + ) + else: + results = self.collection.get( + include=['metadatas', 'documents'] + ) + + if not results or not results.get('metadatas'): + logger.info("📊 Vector DB에 기존 데이터 없음") + return {} + + # store_id별로 기존 데이터 매핑 + existing_data = {} + metadatas = results.get('metadatas', []) + ids = results.get('ids', []) + documents = results.get('documents', []) + + for i, metadata in enumerate(metadatas): + if metadata and 'store_id' in metadata: + store_id = metadata['store_id'] + existing_data[store_id] = { + 'metadata': metadata, + 'document_id': ids[i] if i < len(ids) else None, + 'document': documents[i] if i < len(documents) else None, + 'last_updated': metadata.get('last_updated', 'unknown') + } + + logger.info(f"📊 기존 Vector DB에서 {len(existing_data)}개 store 데이터 발견") + return existing_data + + except Exception as e: + logger.error(f"❌ 기존 store 데이터 조회 실패: {e}") + return {} + + def categorize_stores(self, review_results: List[tuple], region: str, food_category: str) -> Dict[str, Any]: + """ + 가게들을 새 가게/업데이트 대상/현재 배치 중복으로 분류 + """ + # 기존 Vector DB 데이터 조회 + existing_data = self.get_existing_store_data(region, food_category) + existing_store_ids = set(existing_data.keys()) + + # 분류 작업 + new_stores = [] # 완전히 새로운 가게 + update_stores = [] # 기존 가게 업데이트 + current_duplicates = [] # 현재 배치 내 중복 + + seen_in_batch = set() + + for store_id, store_info, reviews in review_results: + # 현재 배치 내 중복 체크 + if store_id in seen_in_batch: + current_duplicates.append((store_id, store_info, reviews)) + logger.info(f"🔄 현재 배치 중복 발견: {store_id}") + continue + + seen_in_batch.add(store_id) + + # 기존 DB에 있는지 확인 + if store_id in existing_store_ids: + # 업데이트 대상 + update_stores.append({ + 'store_id': store_id, + 'new_data': (store_id, store_info, reviews), + 'existing_data': existing_data[store_id] + }) + logger.info(f"🔄 업데이트 대상: {store_id}") + else: + # 새 가게 + new_stores.append((store_id, store_info, reviews)) + logger.info(f"✨ 새 가게: {store_id}") + + result = { + 'new_stores': new_stores, + 'update_stores': update_stores, + 'current_duplicates': current_duplicates, + 'existing_data': existing_data, + 'summary': { + 'total_input': len(review_results), + 'new_count': len(new_stores), + 'update_count': len(update_stores), + 'duplicate_count': len(current_duplicates), + 'will_process': len(new_stores) + len(update_stores) + } + } + + logger.info(f"📊 가게 분류 완료:") + logger.info(f" - 새 가게: {len(new_stores)}개") + logger.info(f" - 업데이트: {len(update_stores)}개") + logger.info(f" - 현재 중복: {len(current_duplicates)}개") + + return result + + def update_existing_stores(self, update_stores: List[Dict], food_category: str, region: str) -> Dict[str, Any]: + """ + 기존 가게들을 업데이트 + """ + updated_count = 0 + skipped_count = 0 + update_details = [] + + for update_item in update_stores: + store_id = update_item['store_id'] + new_data = update_item['new_data'] + existing_data = update_item['existing_data'] + + try: + _, store_info, reviews = new_data + + # 새 임베딩 및 메타데이터 생성 + text_for_embedding = extract_text_for_embedding(store_info, reviews) + if not text_for_embedding or len(text_for_embedding.strip()) < 10: + logger.warning(f"⚠️ {store_id}: 임베딩 텍스트 부족, 업데이트 스킵") + skipped_count += 1 + continue + + embedding = self.embedding_model.encode(text_for_embedding) + new_metadata = create_metadata(store_info, food_category, region) + document_text = combine_store_and_reviews(store_info, reviews) + + # 기존 document_id 사용 (ID 일관성 유지) + document_id = existing_data['document_id'] + + # Vector DB 업데이트 (ChromaDB에서는 upsert 사용) + self.collection.upsert( + documents=[document_text], + embeddings=[embedding.tolist()], + metadatas=[new_metadata], + ids=[document_id] + ) + + updated_count += 1 + update_details.append({ + 'store_id': store_id, + 'document_id': document_id, + 'previous_updated': existing_data['metadata'].get('last_updated', 'unknown'), + 'new_updated': new_metadata['last_updated'] + }) + + logger.info(f"✅ {store_id} 업데이트 완료") + + except Exception as e: + logger.error(f"❌ {store_id} 업데이트 실패: {e}") + skipped_count += 1 + continue + + return { + 'updated_count': updated_count, + 'skipped_count': skipped_count, + 'update_details': update_details + } + + def add_new_stores(self, new_stores: List[tuple], food_category: str, region: str) -> Dict[str, Any]: + """ + 새 가게들을 Vector DB에 추가 + """ + if not new_stores: + return {'added_count': 0, 'add_details': []} + + documents = [] + embeddings = [] + metadatas = [] + ids = [] + added_count = 0 + add_details = [] + + for store_id, store_info, reviews in new_stores: + try: + # 임베딩용 텍스트 생성 + text_for_embedding = extract_text_for_embedding(store_info, reviews) + if not text_for_embedding or len(text_for_embedding.strip()) < 10: + logger.warning(f"⚠️ {store_id}: 임베딩 텍스트 부족, 추가 스킵") + continue + + # 임베딩 생성 + embedding = self.embedding_model.encode(text_for_embedding) + + # 메타데이터 생성 + metadata = create_metadata(store_info, food_category, region) + + # 문서 ID 생성 + document_id = create_store_hash(store_id, store_info.get('name', ''), region) + + # 문서 데이터 생성 + document_text = combine_store_and_reviews(store_info, reviews) + + documents.append(document_text) + embeddings.append(embedding.tolist()) + metadatas.append(metadata) + ids.append(document_id) + + add_details.append({ + 'store_id': store_id, + 'document_id': document_id, + 'added_at': metadata['last_updated'] + }) + + added_count += 1 + + except Exception as e: + logger.warning(f"⚠️ {store_id} 추가 실패: {e}") + continue + + # Vector DB에 새 가게들 추가 + if documents: + self.collection.add( + documents=documents, + embeddings=embeddings, + metadatas=metadatas, + ids=ids + ) + logger.info(f"✅ {added_count}개 새 가게 추가 완료") + + return { + 'added_count': added_count, + 'add_details': add_details + } \ No newline at end of file