1057 lines
36 KiB
Markdown
1057 lines
36 KiB
Markdown
# AI 경품 추천 구현 방안
|
|
|
|
## 1. 개요
|
|
|
|
### 1.1 목적
|
|
매장의 업종, 지역, 시즌 등 컨텍스트를 분석하여 최적의 경품을 추천하고, 추천 이유를 명확히 설명하는 AI 기반 추천 시스템 구현
|
|
|
|
### 1.2 핵심 전략
|
|
**RAG(Retrieval-Augmented Generation) 기반 하이브리드 추천**
|
|
- 벡터 DB에서 유사 성공 사례 검색 → Claude API로 컨텍스트 기반 재해석 및 창의적 변형
|
|
- Redis Vector Search + Claude API 3.5 Sonnet 활용
|
|
- Prompt Caching으로 비용 절감 및 응답 속도 개선
|
|
|
|
---
|
|
|
|
## 2. 데이터 수집 및 처리
|
|
|
|
### 2.1 매장 데이터 수집
|
|
|
|
#### 2.1.1 입력 데이터 구조
|
|
```json
|
|
{
|
|
"store_id": "string",
|
|
"business_number": "string", // 사업자등록번호
|
|
"category": {
|
|
"main": "string", // 대분류: 음식점, 소매업, 서비스업
|
|
"sub": "string", // 중분류: 한식, 양식, 카페
|
|
"detail": "string" // 소분류: 고깃집, 베이커리
|
|
},
|
|
"location": {
|
|
"sido": "string", // 서울특별시
|
|
"sigungu": "string", // 강남구
|
|
"dong": "string", // 역삼동
|
|
"latitude": "float",
|
|
"longitude": "float"
|
|
},
|
|
"budget": {
|
|
"min": "integer",
|
|
"max": "integer"
|
|
},
|
|
"target_customer": {
|
|
"age_group": ["string"], // ["20대", "30대"]
|
|
"gender": "string", // "전체", "남성", "여성"
|
|
"income_level": "string" // "중상", "중", "중하"
|
|
},
|
|
"event_purpose": "string", // "신규 고객 유치", "단골 유지", "재방문 유도"
|
|
"season": "string" // "2025-Q1", auto-calculated
|
|
}
|
|
```
|
|
|
|
#### 2.1.2 데이터 자동 수집 프로세스
|
|
```mermaid
|
|
graph LR
|
|
A[사업자등록번호 입력] --> B[공공데이터 API 조회]
|
|
B --> C[업종/주소 자동 매핑]
|
|
C --> D[좌표 변환 Kakao Map API]
|
|
D --> E[상권 분석 데이터 결합]
|
|
E --> F[매장 프로필 생성]
|
|
```
|
|
|
|
**외부 API 연동**
|
|
1. **공공데이터포털 - 상권정보 조회**
|
|
- 엔드포인트: `https://api.odcloud.kr/api/StoreInfo/v1/getStoreInfo`
|
|
- 수집 항목: 업종, 주소, 개업일
|
|
|
|
2. **Kakao Local API - 좌표 변환**
|
|
- 엔드포인트: `https://dapi.kakao.com/v2/local/search/address.json`
|
|
- 수집 항목: 위경도, 행정구역 코드
|
|
|
|
3. **서울 열린데이터광장 - 상권 분석**
|
|
- 엔드포인트: `https://data.seoul.go.kr/dataList/OA-15572/S/1/datasetView.do`
|
|
- 수집 항목: 유동인구, 매출 추정, 경쟁 업체 수
|
|
|
|
### 2.2 경품 데이터 수집
|
|
|
|
#### 2.2.1 데이터 소스
|
|
|
|
**1) 내부 데이터 (과거 이벤트 성과)**
|
|
```json
|
|
{
|
|
"event_id": "string",
|
|
"store_profile": { /* 2.1.1 구조 동일 */ },
|
|
"prize": {
|
|
"name": "string",
|
|
"category": "string", // "식음료", "생활용품", "체험권", "할인쿠폰"
|
|
"unit_price": "integer",
|
|
"quantity": "integer",
|
|
"supplier": "string"
|
|
},
|
|
"performance": {
|
|
"participant_count": "integer",
|
|
"conversion_rate": "float", // 참여자 중 구매 전환율
|
|
"roi": "float", // 매출 증대액 / 경품 비용
|
|
"customer_satisfaction": "float" // 1~5점
|
|
}
|
|
}
|
|
```
|
|
|
|
**2) 외부 트렌드 데이터**
|
|
- **네이버 쇼핑 트렌드**: 주간/월간 인기 상품 키워드
|
|
- API: `https://developers.naver.com/docs/serviceapi/datalab/shopping/shopping.md`
|
|
- **SNS 트렌드 (Instagram, TikTok)**: 해시태그 순위
|
|
- 크롤링 또는 서드파티 API (예: Apify)
|
|
- **시즌 이벤트 캘린더**: 공휴일, 기념일, 페스티벌
|
|
- 수동 관리 또는 공공 캘린더 API
|
|
|
|
#### 2.2.2 경품 카탈로그 구축
|
|
```json
|
|
{
|
|
"prize_id": "string",
|
|
"name": "string",
|
|
"category": "string",
|
|
"subcategory": "string",
|
|
"price_range": {
|
|
"min": "integer",
|
|
"max": "integer"
|
|
},
|
|
"target_demographic": {
|
|
"age": ["string"],
|
|
"gender": "string"
|
|
},
|
|
"seasonality": ["string"], // ["봄", "여름", "크리스마스"]
|
|
"trend_score": "float", // 최근 1개월 트렌드 점수 (0~1)
|
|
"compatibility": {
|
|
"food": 0.9,
|
|
"retail": 0.7,
|
|
"service": 0.5
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
## 3. 데이터 정제 및 벡터화
|
|
|
|
### 3.1 정제 파이프라인
|
|
|
|
```mermaid
|
|
graph TD
|
|
A[원본 데이터] --> B[결측치 처리]
|
|
B --> C[이상치 제거]
|
|
C --> D[정규화]
|
|
D --> E[카테고리 인코딩]
|
|
E --> F[시계열 특성 추출]
|
|
F --> G[벡터화]
|
|
```
|
|
|
|
#### 3.1.1 매장 데이터 정제
|
|
|
|
**1) 결측치 처리**
|
|
- `budget`: 업종별 평균값으로 대체
|
|
- `target_customer`: "전체"로 기본값 설정
|
|
- `location.dong`: 시군구 단위로 통합
|
|
|
|
**2) 정규화**
|
|
- 예산: Min-Max Scaling (0~1)
|
|
- 좌표: Standard Scaling (평균 0, 분산 1)
|
|
|
|
**3) 카테고리 인코딩**
|
|
```python
|
|
# One-Hot Encoding for categorical features
|
|
업종_대분류 = OneHotEncoder(['음식점', '소매업', '서비스업'])
|
|
업종_중분류 = LabelEncoder() # 100+ categories → Integer
|
|
지역_시도 = OneHotEncoder(['서울', '경기', '인천', ...])
|
|
```
|
|
|
|
**4) 시계열 특성 (Cyclical Encoding)**
|
|
```python
|
|
# 계절성 표현: 봄(3~5월) = 0°, 여름 = 90°, 가을 = 180°, 겨울 = 270°
|
|
month = datetime.now().month
|
|
season_sin = np.sin(2 * np.pi * month / 12)
|
|
season_cos = np.cos(2 * np.pi * month / 12)
|
|
```
|
|
|
|
#### 3.1.2 경품 데이터 정제
|
|
|
|
**1) 트렌드 점수 계산**
|
|
```python
|
|
# 네이버 쇼핑 트렌드 API 응답 기반
|
|
trend_score = (
|
|
0.4 * 검색량_증가율 +
|
|
0.3 * 클릭률 +
|
|
0.3 * SNS_언급_빈도
|
|
)
|
|
```
|
|
|
|
**2) 호환성 매트릭스**
|
|
```python
|
|
# 과거 성과 데이터 기반 업종별 선호도
|
|
compatibility_matrix = {
|
|
('음식점', '식음료 쿠폰'): 0.95,
|
|
('음식점', '생활용품'): 0.6,
|
|
('소매업', '할인쿠폰'): 0.9,
|
|
...
|
|
}
|
|
```
|
|
|
|
### 3.2 벡터화 전략
|
|
|
|
#### 3.2.1 매장 임베딩 (Store Embedding)
|
|
|
|
**차원 구성 (총 128차원)**
|
|
```python
|
|
store_vector = np.concatenate([
|
|
업종_one_hot, # 20차원
|
|
지역_demographic, # 30차원 (인구 밀도, 소득 수준, 연령 분포 등)
|
|
예산_normalized, # 1차원
|
|
타겟고객_encoded, # 10차원
|
|
시즌_cyclical, # 2차원 (sin, cos)
|
|
이벤트_목적_one_hot, # 5차원
|
|
상권_특성 # 60차원 (유동인구, 경쟁 업체 수, 매출 추정치 등)
|
|
])
|
|
```
|
|
|
|
**임베딩 모델**
|
|
- **Option 1**: 수동 Feature Engineering (위 구조)
|
|
- **Option 2**: Pre-trained Model (추후 고도화)
|
|
- `sentence-transformers/all-MiniLM-L6-v2` 활용
|
|
- 매장 설명 텍스트를 자연어로 생성 후 임베딩
|
|
|
|
#### 3.2.2 경품 임베딩 (Prize Embedding)
|
|
|
|
**차원 구성 (총 128차원 - Store와 동일 공간)**
|
|
```python
|
|
prize_vector = np.concatenate([
|
|
카테고리_one_hot, # 20차원
|
|
가격_normalized, # 1차원
|
|
타겟_demographic, # 10차원
|
|
시즌_cyclical, # 2차원
|
|
트렌드_score, # 1차원
|
|
호환성_matrix, # 20차원
|
|
설명_embedding # 74차원 (sentence-transformers)
|
|
])
|
|
```
|
|
|
|
#### 3.2.3 성공 사례 임베딩 (Case Embedding)
|
|
|
|
**하이브리드 접근**
|
|
```python
|
|
case_vector = np.concatenate([
|
|
store_vector, # 128차원
|
|
prize_vector, # 128차원
|
|
performance_features # 16차원 (ROI, 전환율, 만족도 등)
|
|
])
|
|
# 총 272차원
|
|
```
|
|
|
|
### 3.3 Redis Vector Search 저장
|
|
|
|
#### 3.3.1 인덱스 생성
|
|
```python
|
|
from redis.commands.search.field import VectorField, TextField, NumericField
|
|
from redis.commands.search.indexDefinition import IndexDefinition, IndexType
|
|
|
|
schema = (
|
|
VectorField("case_vector", "FLAT", {
|
|
"TYPE": "FLOAT32",
|
|
"DIM": 272,
|
|
"DISTANCE_METRIC": "COSINE"
|
|
}),
|
|
TextField("store_category"),
|
|
TextField("prize_name"),
|
|
NumericField("roi"),
|
|
NumericField("conversion_rate"),
|
|
NumericField("timestamp")
|
|
)
|
|
|
|
redis_client.ft("idx:prize_cases").create_index(
|
|
schema,
|
|
definition=IndexDefinition(prefix=["case:"], index_type=IndexType.HASH)
|
|
)
|
|
```
|
|
|
|
#### 3.3.2 데이터 저장 예시
|
|
```python
|
|
import numpy as np
|
|
import pickle
|
|
|
|
case_id = "case:12345"
|
|
redis_client.hset(case_id, mapping={
|
|
"case_vector": pickle.dumps(case_vector.astype(np.float32)),
|
|
"store_category": "음식점 > 한식 > 고깃집",
|
|
"prize_name": "한우 선물세트",
|
|
"roi": 3.2,
|
|
"conversion_rate": 0.15,
|
|
"timestamp": "2025-01-15"
|
|
})
|
|
```
|
|
|
|
---
|
|
|
|
## 4. Claude API 호출 구조
|
|
|
|
### 4.1 프롬프트 설계
|
|
|
|
#### 4.1.1 시스템 프롬프트 (Prompt Caching 적용)
|
|
```json
|
|
{
|
|
"role": "system",
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": "당신은 소상공인 이벤트 기획 전문가입니다. 매장 정보와 유사 성공 사례를 바탕으로 최적의 경품을 추천하고, 그 이유를 명확히 설명해야 합니다.\n\n[역할]\n- 업종, 지역, 예산, 타겟 고객을 종합 분석\n- 과거 성공 사례 패턴 학습\n- 창의적이면서도 실현 가능한 경품 제안\n- 추천 이유를 구체적 데이터로 뒷받침\n\n[제약사항]\n- 예산 범위 내 경품만 추천\n- 법적 문제 없는 경품 (사행성 금지)\n- 조달 가능한 경품 우선",
|
|
"cache_control": {"type": "ephemeral"}
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
#### 4.1.2 Few-Shot Examples (Prompt Caching 적용)
|
|
```json
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": "[사례 1]\n매장: 강남역 고깃집, 예산 50만원, 타겟 20~30대 직장인\n유사 성공 사례: 역삼동 삼겹살집이 '소주 1+1 쿠폰' 이벤트로 재방문율 40% 증가\n\n[사례 2]\n매장: 홍대 카페, 예산 30만원, 타겟 10~20대 학생\n유사 성공 사례: 신촌 디저트카페가 'AirPods 추첨' 이벤트로 SNS 공유 200% 증가\n\n[사례 3]\n매장: 이태원 양식당, 예산 100만원, 타겟 30~40대 고소득층\n유사 성공 사례: 청담동 스테이크 하우스가 '와인 시음권' 이벤트로 객단가 25% 상승",
|
|
"cache_control": {"type": "ephemeral"}
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
#### 4.1.3 User Request (실시간 입력)
|
|
```json
|
|
{
|
|
"role": "user",
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": "## 매장 정보\n- 업종: 음식점 > 한식 > 고깃집\n- 위치: 서울 강남구 역삼동\n- 예산: 50만원\n- 타겟 고객: 20~30대 직장인, 남녀 모두\n- 이벤트 목적: 신규 고객 유치\n- 시즌: 2025년 1분기 (겨울)\n\n## 유사 성공 사례 (벡터 검색 결과)\n1. [ROI 3.2] 역삼동 삼겹살집 - 소주 1+1 쿠폰 (예산 40만원, 전환율 15%)\n2. [ROI 2.8] 서초동 한우집 - 한우 선물세트 추첨 (예산 60만원, 전환율 12%)\n3. [ROI 2.5] 강남역 숯불구이 - 배달앱 할인쿠폰 (예산 30만원, 전환율 18%)\n\n## 요청사항\n위 정보를 바탕으로 다음을 제공하세요:\n1. 최적 경품 1개 (구체적 상품명, 수량, 예상 비용)\n2. 대안 경품 2개\n3. 각 경품의 추천 이유 (과거 사례 근거 포함)\n4. 예상 효과 (참여자 수, 전환율, ROI)\n\nJSON 형식으로 응답해주세요."
|
|
}
|
|
]
|
|
}
|
|
```
|
|
|
|
### 4.2 API 요청 구조
|
|
|
|
#### 4.2.1 Request 예시
|
|
```json
|
|
{
|
|
"model": "claude-3-5-sonnet-20241022",
|
|
"max_tokens": 4096,
|
|
"temperature": 0.7,
|
|
"system": [
|
|
{
|
|
"type": "text",
|
|
"text": "당신은 소상공인 이벤트 기획 전문가입니다...",
|
|
"cache_control": {"type": "ephemeral"}
|
|
}
|
|
],
|
|
"messages": [
|
|
{
|
|
"role": "user",
|
|
"content": "[사례 1]\n...",
|
|
"cache_control": {"type": "ephemeral"}
|
|
},
|
|
{
|
|
"role": "user",
|
|
"content": "## 매장 정보\n..."
|
|
}
|
|
],
|
|
"stream": true
|
|
}
|
|
```
|
|
|
|
**Prompt Caching 효과**
|
|
- System Prompt (약 500 tokens): 캐싱 후 재사용 시 90% 비용 절감
|
|
- Few-Shot Examples (약 800 tokens): 캐싱 후 재사용 시 90% 비용 절감
|
|
- 5분 내 동일 프롬프트 재사용 시 캐시 히트
|
|
|
|
#### 4.2.2 Response 구조 (Streaming)
|
|
```json
|
|
{
|
|
"id": "msg_01XYZ...",
|
|
"type": "message",
|
|
"role": "assistant",
|
|
"content": [
|
|
{
|
|
"type": "text",
|
|
"text": "{\n \"recommendations\": [\n {\n \"rank\": 1,\n \"prize\": {\n \"name\": \"1++등급 한우 등심 200g 교환권\",\n \"category\": \"식음료\",\n \"quantity\": 20,\n \"unit_price\": 25000,\n \"total_cost\": 500000,\n \"supplier\": \"한우자조금\"\n },\n \"reasoning\": {\n \"why_this_prize\": \"고깃집 업종 특성상 '고기 품질'에 대한 직접 경험이 재방문 유도에 가장 효과적입니다. 역삼동 삼겹살집 사례(ROI 3.2)에서 '소주 쿠폰'보다 '한우 선물세트'가 고급화 전략에 적합합니다.\",\n \"target_alignment\": \"20~30대 직장인은 '가성비'보다 '특별한 경험'을 선호하는 경향(네이버 쇼핑 트렌드 2025.01 기준, 프리미엄 식품 검색량 35% 증가)\",\n \"budget_efficiency\": \"건당 2.5만원으로 20명 당첨 시 예산 100% 활용. 서초동 한우집 사례 대비 20% 저렴한 비용으로 유사 효과 기대\",\n \"seasonal_relevance\": \"겨울철 보양식 수요 증가 (1분기 한우 판매량 전년 대비 18% 상승, 농림축산식품부)\"\n },\n \"expected_outcome\": {\n \"participant_count\": 300,\n \"conversion_rate\": 0.14,\n \"new_customers\": 42,\n \"roi\": 3.0,\n \"rationale\": \"유사 사례(역삼동 삼겹살집) 전환율 15% 대비 소폭 하락 예상. 단, 한우 브랜드 신뢰도로 고객 만족도 상승 효과\"\n }\n },\n {\n \"rank\": 2,\n \"prize\": {\n \"name\": \"배달앱 5천원 할인쿠폰 (100매)\",\n \"category\": \"할인쿠폰\",\n \"quantity\": 100,\n \"unit_price\": 5000,\n \"total_cost\": 500000\n },\n \"reasoning\": {\n \"why_this_prize\": \"강남역 숯불구이 사례(ROI 2.5, 전환율 18%)에서 입증된 효과. 즉시 사용 가능한 혜택으로 참여 장벽 낮춤\",\n \"target_alignment\": \"배달 이용률 높은 직장인 타겟 적합 (20~30대 배달앱 이용률 주 3회 이상 68%, 통계청)\",\n \"budget_efficiency\": \"100명 당첨 가능, 경품 수량 많아 '당첨 가능성' 마케팅 효과\",\n \"seasonal_relevance\": \"겨울철 배달 수요 증가 (기온 5도 하락 시 배달 주문 12% 증가, 배민 데이터)\"\n },\n \"expected_outcome\": {\n \"participant_count\": 500,\n \"conversion_rate\": 0.18,\n \"new_customers\": 90,\n \"roi\": 2.5\n }\n },\n {\n \"rank\": 3,\n \"prize\": {\n \"name\": \"프리미엄 소주/와인 세트 (10세트)\",\n \"category\": \"식음료\",\n \"quantity\": 10,\n \"unit_price\": 50000,\n \"total_cost\": 500000\n },\n \"reasoning\": {\n \"why_this_prize\": \"고급화 전략 + SNS 공유 유도. '언박싱' 콘텐츠로 바이럴 효과 기대\",\n \"target_alignment\": \"MZ세대 '프리미엄 주류' 소비 증가 (위스키 수입액 2024년 전년 대비 23% 증가, 관세청)\",\n \"budget_efficiency\": \"고가 경품으로 '프리미엄 이미지' 구축, 브랜드 가치 상승 효과\",\n \"seasonal_relevance\": \"연말연시 선물 수요 연계 (1~2월 선물 세트 검색량 연중 최고치)\"\n },\n \"expected_outcome\": {\n \"participant_count\": 200,\n \"conversion_rate\": 0.10,\n \"new_customers\": 20,\n \"roi\": 2.2,\n \"rationale\": \"당첨자 수 적어 전환율 낮지만, SNS 공유로 브랜드 인지도 상승 효과 (간접 효과 미포함 시)\"\n }\n }\n ],\n \"metadata\": {\n \"analysis_timestamp\": \"2025-01-20T10:30:00Z\",\n \"vector_search_cases\": 3,\n \"trend_data_sources\": [\"네이버 쇼핑 트렌드\", \"농림축산식품부\", \"통계청\"]\n }\n}\n"
|
|
}
|
|
],
|
|
"model": "claude-3-5-sonnet-20241022",
|
|
"usage": {
|
|
"input_tokens": 2150,
|
|
"cache_creation_input_tokens": 1300,
|
|
"cache_read_input_tokens": 0,
|
|
"output_tokens": 1024
|
|
}
|
|
}
|
|
```
|
|
|
|
### 4.3 응답 파싱 및 후처리
|
|
|
|
#### 4.3.1 JSON 검증
|
|
```python
|
|
import json
|
|
from jsonschema import validate, ValidationError
|
|
|
|
response_schema = {
|
|
"type": "object",
|
|
"required": ["recommendations"],
|
|
"properties": {
|
|
"recommendations": {
|
|
"type": "array",
|
|
"minItems": 3,
|
|
"maxItems": 3,
|
|
"items": {
|
|
"type": "object",
|
|
"required": ["rank", "prize", "reasoning", "expected_outcome"],
|
|
"properties": {
|
|
"rank": {"type": "integer", "minimum": 1, "maximum": 3},
|
|
"prize": {
|
|
"type": "object",
|
|
"required": ["name", "category", "quantity", "unit_price", "total_cost"]
|
|
},
|
|
"reasoning": {
|
|
"type": "object",
|
|
"required": ["why_this_prize", "target_alignment", "budget_efficiency"]
|
|
},
|
|
"expected_outcome": {
|
|
"type": "object",
|
|
"required": ["participant_count", "conversion_rate", "roi"]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
try:
|
|
parsed_response = json.loads(claude_response)
|
|
validate(instance=parsed_response, schema=response_schema)
|
|
except (json.JSONDecodeError, ValidationError) as e:
|
|
# Fallback: 룰 기반 추천 호출
|
|
logger.error(f"Claude response validation failed: {e}")
|
|
return fallback_recommendation(store_data)
|
|
```
|
|
|
|
#### 4.3.2 예산 초과 필터링
|
|
```python
|
|
def filter_budget_overrun(recommendations, max_budget):
|
|
filtered = []
|
|
for rec in recommendations:
|
|
if rec['prize']['total_cost'] <= max_budget:
|
|
filtered.append(rec)
|
|
else:
|
|
# 수량 조정으로 예산 내 맞추기
|
|
adjusted_quantity = max_budget // rec['prize']['unit_price']
|
|
if adjusted_quantity > 0:
|
|
rec['prize']['quantity'] = adjusted_quantity
|
|
rec['prize']['total_cost'] = adjusted_quantity * rec['prize']['unit_price']
|
|
rec['reasoning']['budget_efficiency'] += f" (수량 조정: {adjusted_quantity}개)"
|
|
filtered.append(rec)
|
|
return filtered[:3] # 최대 3개 유지
|
|
```
|
|
|
|
---
|
|
|
|
## 5. 전체 시스템 플로우
|
|
|
|
### 5.1 추천 요청 흐름도
|
|
|
|
```mermaid
|
|
sequenceDiagram
|
|
participant User as 사용자
|
|
participant API as API Gateway
|
|
participant Svc as Prize Recommendation Service
|
|
participant Cache as Redis Cache
|
|
participant Vector as Redis Vector Search
|
|
participant Claude as Claude API
|
|
participant Fallback as Fallback Engine
|
|
|
|
User->>API: POST /api/v1/prizes/recommend
|
|
API->>Svc: 매장 정보 전달
|
|
|
|
alt 캐시 히트
|
|
Svc->>Cache: 동일 매장 프로필 조회
|
|
Cache-->>Svc: 캐시된 추천 결과 (TTL 1시간)
|
|
Svc-->>API: 추천 결과 반환
|
|
else 캐시 미스
|
|
Svc->>Svc: 매장 데이터 정제 및 벡터화
|
|
Svc->>Vector: 유사 성공 사례 검색 (Top 3)
|
|
Vector-->>Svc: 사례 데이터 반환
|
|
|
|
Svc->>Claude: Prompt 생성 및 API 호출
|
|
Note over Svc,Claude: System Prompt (캐싱)<br/>Few-Shot Examples (캐싱)<br/>User Request (실시간)
|
|
|
|
alt Claude 성공
|
|
Claude-->>Svc: Streaming Response (JSON)
|
|
Svc->>Svc: JSON 파싱 및 검증
|
|
Svc->>Cache: 추천 결과 캐싱 (TTL 1시간)
|
|
Svc-->>API: 추천 결과 반환
|
|
else Claude 실패
|
|
Claude-->>Svc: Error (Timeout/Rate Limit)
|
|
Svc->>Fallback: 룰 기반 추천 요청
|
|
Fallback-->>Svc: 기본 추천 결과
|
|
Svc-->>API: Fallback 결과 반환
|
|
end
|
|
end
|
|
|
|
API-->>User: HTTP 200 OK + JSON
|
|
```
|
|
|
|
### 5.2 데이터 파이프라인 스케줄
|
|
|
|
```mermaid
|
|
gantt
|
|
title 데이터 수집 및 갱신 스케줄
|
|
dateFormat YYYY-MM-DD
|
|
section 외부 데이터
|
|
네이버 트렌드 수집 :active, trend, 2025-01-20, 7d
|
|
SNS 트렌드 크롤링 :active, sns, 2025-01-20, 7d
|
|
공공데이터 동기화 :active, public, 2025-01-20, 7d
|
|
section 벡터 재계산
|
|
성공 사례 임베딩 :active, embed, 2025-01-20, 1d
|
|
경품 카탈로그 갱신 :active, catalog, 2025-01-20, 1d
|
|
section 모델 업데이트
|
|
Claude Prompt 최적화 :milestone, prompt, 2025-01-27, 0d
|
|
```
|
|
|
|
**스케줄 상세**
|
|
- **외부 데이터 수집**: 매주 월요일 02:00 (Airflow DAG)
|
|
- **벡터 재계산**: 매일 03:00 (신규 사례 추가 시)
|
|
- **캐시 갱신**:
|
|
- L1 (업종별 시즌 추천): TTL 7일
|
|
- L2 (개인화 추천): TTL 1시간
|
|
- **Prompt 튜닝**: 월 1회 (성과 데이터 기반 Few-Shot 사례 갱신)
|
|
|
|
---
|
|
|
|
## 6. Fallback 전략
|
|
|
|
### 6.1 Claude API 장애 대응
|
|
|
|
#### 6.1.1 룰 기반 추천 엔진
|
|
```python
|
|
class FallbackRecommendation:
|
|
def __init__(self):
|
|
self.rules = self._load_rules()
|
|
|
|
def recommend(self, store_data):
|
|
# 1단계: 업종별 인기 경품 필터링
|
|
category_prizes = self.rules['category_mapping'][store_data['category']['main']]
|
|
|
|
# 2단계: 예산 필터링
|
|
budget_filtered = [p for p in category_prizes if p['price'] <= store_data['budget']['max']]
|
|
|
|
# 3단계: 시즌 가중치 적용
|
|
season = store_data['season']
|
|
for prize in budget_filtered:
|
|
prize['score'] = prize['base_score'] * self.rules['season_weight'][season].get(prize['id'], 1.0)
|
|
|
|
# 4단계: 상위 3개 선정
|
|
top_prizes = sorted(budget_filtered, key=lambda x: x['score'], reverse=True)[:3]
|
|
|
|
return self._format_response(top_prizes, store_data)
|
|
```
|
|
|
|
#### 6.1.2 과거 인기 경품 순위
|
|
```python
|
|
def get_popular_prizes(category, budget_range, limit=3):
|
|
"""최근 3개월 성과 데이터 기반 인기 경품 조회"""
|
|
query = """
|
|
SELECT prize_id, name, AVG(roi) as avg_roi, COUNT(*) as usage_count
|
|
FROM event_performance
|
|
WHERE category = %s
|
|
AND budget BETWEEN %s AND %s
|
|
AND created_at >= NOW() - INTERVAL 3 MONTH
|
|
GROUP BY prize_id
|
|
ORDER BY avg_roi DESC, usage_count DESC
|
|
LIMIT %s
|
|
"""
|
|
return db.execute(query, [category, budget_range[0], budget_range[1], limit])
|
|
```
|
|
|
|
### 6.2 에러 처리 우선순위
|
|
|
|
```mermaid
|
|
graph TD
|
|
A[Claude API 호출] --> B{성공?}
|
|
B -->|Yes| C[JSON 검증]
|
|
B -->|No| D{재시도 가능?}
|
|
|
|
D -->|Yes| E[Exponential Backoff 재시도]
|
|
E --> A
|
|
D -->|No| F[Fallback: 룰 기반 추천]
|
|
|
|
C --> G{검증 통과?}
|
|
G -->|Yes| H[응답 반환]
|
|
G -->|No| F
|
|
|
|
F --> I[과거 인기 경품 조회]
|
|
I --> J{데이터 있음?}
|
|
J -->|Yes| K[기본 추천 반환]
|
|
J -->|No| L[에러 메시지 + 수동 선택 유도]
|
|
```
|
|
|
|
---
|
|
|
|
## 7. 성능 최적화
|
|
|
|
### 7.1 캐싱 전략
|
|
|
|
#### 7.1.1 L1 캐시 (업종별 시즌 추천)
|
|
```python
|
|
# Redis Key: "prize:season:{category}:{season}"
|
|
# TTL: 7일
|
|
# 예시: "prize:season:음식점-한식:2025-Q1"
|
|
|
|
cache_key = f"prize:season:{store_category}:{current_season}"
|
|
cached = redis_client.get(cache_key)
|
|
|
|
if cached:
|
|
return json.loads(cached)
|
|
else:
|
|
result = claude_api.recommend(...)
|
|
redis_client.setex(cache_key, 604800, json.dumps(result)) # 7일 = 604800초
|
|
return result
|
|
```
|
|
|
|
#### 7.1.2 L2 캐시 (개인화 추천)
|
|
```python
|
|
# Redis Key: "prize:personalized:{store_id}:{hash(request)}"
|
|
# TTL: 1시간
|
|
|
|
import hashlib
|
|
request_hash = hashlib.md5(json.dumps(store_data, sort_keys=True).encode()).hexdigest()
|
|
cache_key = f"prize:personalized:{store_id}:{request_hash}"
|
|
|
|
cached = redis_client.get(cache_key)
|
|
if cached:
|
|
return json.loads(cached)
|
|
else:
|
|
result = claude_api.recommend(...)
|
|
redis_client.setex(cache_key, 3600, json.dumps(result)) # 1시간 = 3600초
|
|
return result
|
|
```
|
|
|
|
### 7.2 벡터 검색 최적화
|
|
|
|
#### 7.2.1 인덱스 파라미터 튜닝
|
|
```python
|
|
# FLAT vs HNSW 비교
|
|
# FLAT: 정확도 100%, 검색 속도 O(n) - 데이터 10만 건 이하
|
|
# HNSW: 정확도 95%+, 검색 속도 O(log n) - 대규모 데이터
|
|
|
|
# 초기 (성공 사례 < 10만 건): FLAT 사용
|
|
schema = VectorField("case_vector", "FLAT", {...})
|
|
|
|
# 확장 시 (성공 사례 > 10만 건): HNSW 전환
|
|
schema = VectorField("case_vector", "HNSW", {
|
|
"TYPE": "FLOAT32",
|
|
"DIM": 272,
|
|
"DISTANCE_METRIC": "COSINE",
|
|
"M": 16, # 연결 수 (높을수록 정확, 느림)
|
|
"EF_CONSTRUCTION": 200 # 구축 시간 vs 정확도 트레이드오프
|
|
})
|
|
```
|
|
|
|
#### 7.2.2 Pre-filtering
|
|
```python
|
|
# 벡터 검색 전 메타데이터 필터링으로 검색 공간 축소
|
|
query = (
|
|
Query("(@category:{음식점}) (@budget:[0 500000])=>[KNN 3 @case_vector $vec AS score]")
|
|
.sort_by("score")
|
|
.return_fields("store_category", "prize_name", "roi", "score")
|
|
.dialect(2)
|
|
)
|
|
```
|
|
|
|
### 7.3 Claude API 비용 최적화
|
|
|
|
#### 7.3.1 Prompt Caching 효과 분석
|
|
```python
|
|
# Prompt Caching 적용 전
|
|
base_cost_per_request = (
|
|
(500 + 800 + 850) * 0.003 / 1000 # System + Few-Shot + User (입력)
|
|
+ 1024 * 0.015 / 1000 # 출력
|
|
) = 0.00642 + 0.01536 = $0.02178
|
|
|
|
# Prompt Caching 적용 후 (캐시 히트 시)
|
|
optimized_cost_per_request = (
|
|
(500 + 800) * 0.0003 / 1000 # 캐싱된 토큰 (90% 할인)
|
|
+ 850 * 0.003 / 1000 # 신규 User 토큰
|
|
+ 1024 * 0.015 / 1000 # 출력
|
|
) = 0.00039 + 0.00255 + 0.01536 = $0.0183
|
|
|
|
# 비용 절감율: (0.02178 - 0.0183) / 0.02178 = 16%
|
|
```
|
|
|
|
**월간 비용 추정 (일 1,000 요청 기준)**
|
|
- 캐시 히트율 70% 가정
|
|
- 월 비용: (1000 * 30) * (0.0183 * 0.7 + 0.02178 * 0.3) = **$581**
|
|
- Caching 미적용 시: (1000 * 30) * 0.02178 = **$653**
|
|
- **절감액: $72/월 (11%)**
|
|
|
|
#### 7.3.2 배치 처리
|
|
```python
|
|
# 동일 시간대 다수 요청 발생 시 배치 처리
|
|
async def batch_recommend(store_list, batch_size=5):
|
|
"""
|
|
5개씩 묶어서 Claude API 호출
|
|
- 단일 프롬프트에 여러 매장 정보 포함
|
|
- 응답을 파싱하여 개별 결과로 분리
|
|
"""
|
|
batches = [store_list[i:i+batch_size] for i in range(0, len(store_list), batch_size)]
|
|
results = []
|
|
|
|
for batch in batches:
|
|
combined_prompt = "\n\n".join([f"[매장 {i+1}]\n{format_store(s)}" for i, s in enumerate(batch)])
|
|
response = await claude_api.recommend_batch(combined_prompt)
|
|
results.extend(parse_batch_response(response, len(batch)))
|
|
|
|
return results
|
|
```
|
|
|
|
---
|
|
|
|
## 8. 모니터링 및 개선
|
|
|
|
### 8.1 성능 지표
|
|
|
|
#### 8.1.1 추천 정확도 (Recommendation Accuracy)
|
|
```python
|
|
# 사용자 행동 추적
|
|
metrics = {
|
|
"ctr": "추천 경품 클릭률", # 추천 3개 중 클릭한 비율
|
|
"selection_rate": "추천 선택률", # 추천 경품을 실제 이벤트에 사용한 비율
|
|
"satisfaction_score": "만족도", # 이벤트 종료 후 설문 (1~5점)
|
|
"roi_accuracy": "ROI 예측 정확도" # 예측 ROI vs 실제 ROI 오차율
|
|
}
|
|
|
|
# 목표 KPI
|
|
targets = {
|
|
"ctr": 0.6, # 60% 이상
|
|
"selection_rate": 0.4, # 40% 이상
|
|
"satisfaction_score": 4.0, # 5점 만점 중 4점 이상
|
|
"roi_accuracy": 0.8 # 오차 20% 이내
|
|
}
|
|
```
|
|
|
|
#### 8.1.2 시스템 성능
|
|
```python
|
|
# Prometheus + Grafana 모니터링
|
|
system_metrics = {
|
|
"claude_api_latency": "Claude API 응답 시간 (p50, p95, p99)",
|
|
"vector_search_latency": "벡터 검색 응답 시간",
|
|
"cache_hit_rate": "캐시 히트율 (L1, L2 별도)",
|
|
"fallback_rate": "Fallback 발생률",
|
|
"daily_api_cost": "일일 Claude API 비용"
|
|
}
|
|
|
|
# 알림 임계값
|
|
alerts = {
|
|
"claude_api_latency_p95": 3000, # 3초 초과 시 알림
|
|
"cache_hit_rate": 0.5, # 50% 미만 시 알림
|
|
"fallback_rate": 0.1, # 10% 초과 시 알림
|
|
"daily_api_cost": 100 # $100 초과 시 알림
|
|
}
|
|
```
|
|
|
|
### 8.2 피드백 루프
|
|
|
|
#### 8.2.1 사용자 피드백 수집
|
|
```python
|
|
class FeedbackCollector:
|
|
def collect(self, event_id, feedback_type):
|
|
"""
|
|
feedback_type:
|
|
- "thumbs_up": 추천 만족
|
|
- "thumbs_down": 추천 불만족
|
|
- "custom_prize": 사용자가 직접 경품 변경
|
|
- "roi_feedback": 이벤트 종료 후 실제 ROI 입력
|
|
"""
|
|
feedback = {
|
|
"event_id": event_id,
|
|
"type": feedback_type,
|
|
"timestamp": datetime.now(),
|
|
"store_profile": get_store_profile(event_id),
|
|
"recommended_prizes": get_recommendations(event_id),
|
|
"selected_prize": get_selected_prize(event_id),
|
|
"actual_performance": get_performance(event_id) if feedback_type == "roi_feedback" else None
|
|
}
|
|
|
|
# Kafka로 전송 → 데이터 파이프라인에서 벡터 DB 갱신
|
|
kafka_producer.send("feedback.prize_recommendation", feedback)
|
|
```
|
|
|
|
#### 8.2.2 Few-Shot 사례 자동 갱신
|
|
```python
|
|
def update_few_shot_examples():
|
|
"""
|
|
월 1회 실행: 최근 3개월 성과 데이터 기반 Few-Shot 사례 갱신
|
|
"""
|
|
top_cases = db.execute("""
|
|
SELECT store_profile, prize, performance
|
|
FROM event_performance
|
|
WHERE created_at >= NOW() - INTERVAL 3 MONTH
|
|
AND roi >= 2.5
|
|
AND customer_satisfaction >= 4.0
|
|
ORDER BY roi DESC, conversion_rate DESC
|
|
LIMIT 5
|
|
""")
|
|
|
|
# Few-Shot 템플릿 재생성
|
|
new_examples = format_few_shot_examples(top_cases)
|
|
|
|
# S3 또는 DB에 버전 관리
|
|
save_prompt_template("few_shot_v2.json", new_examples)
|
|
|
|
# Claude API 호출 시 새 템플릿 적용
|
|
update_system_prompt(new_examples)
|
|
```
|
|
|
|
---
|
|
|
|
## 9. 구현 우선순위
|
|
|
|
### Phase 1: MVP (2주)
|
|
- [x] 매장 데이터 수집 API 연동 (공공데이터포털)
|
|
- [x] 경품 카탈로그 수동 구축 (100개 경품)
|
|
- [x] 수동 Feature Engineering 벡터화
|
|
- [x] Redis Vector Search 기본 구현
|
|
- [x] Claude API 기본 프롬프트 (Few-Shot 3개)
|
|
- [x] 룰 기반 Fallback
|
|
|
|
### Phase 2: 최적화 (1주)
|
|
- [ ] Prompt Caching 적용
|
|
- [ ] L1/L2 캐싱 전략 구현
|
|
- [ ] 외부 트렌드 데이터 수집 자동화 (네이버 트렌드)
|
|
- [ ] 성능 모니터링 대시보드 (Grafana)
|
|
|
|
### Phase 3: 고도화 (2주)
|
|
- [ ] 사용자 피드백 수집 시스템
|
|
- [ ] Few-Shot 자동 갱신 파이프라인
|
|
- [ ] Sentence Transformer 임베딩 전환 (Option 2)
|
|
- [ ] A/B 테스트 프레임워크
|
|
- [ ] SNS 트렌드 크롤링
|
|
|
|
---
|
|
|
|
## 10. 참조 자료
|
|
|
|
### 10.1 관련 문서
|
|
- 유저스토리: `design/userstory.md`
|
|
- UI/UX 프로토타입: `design/uiux/prototype/05-AI경품추천.html`
|
|
- API 설계서: `design/backend/api/prize-recommendation-api.yaml`
|
|
|
|
### 10.2 외부 리소스
|
|
- Claude API 문서: https://docs.anthropic.com/claude/docs/intro-to-claude
|
|
- Redis Vector Search: https://redis.io/docs/stack/search/reference/vectors/
|
|
- 공공데이터포털: https://www.data.go.kr/
|
|
- 네이버 쇼핑 트렌드: https://datalab.naver.com/shoppingInsight/sCategory.naver
|
|
|
|
---
|
|
|
|
## 부록: 코드 예시
|
|
|
|
### A. 전체 추천 API 엔드포인트
|
|
|
|
```python
|
|
from fastapi import APIRouter, HTTPException
|
|
from pydantic import BaseModel
|
|
import asyncio
|
|
|
|
router = APIRouter()
|
|
|
|
class StoreProfile(BaseModel):
|
|
store_id: str
|
|
business_number: str
|
|
category: dict
|
|
location: dict
|
|
budget: dict
|
|
target_customer: dict
|
|
event_purpose: str
|
|
|
|
class RecommendationRequest(BaseModel):
|
|
store_profile: StoreProfile
|
|
|
|
@router.post("/api/v1/prizes/recommend")
|
|
async def recommend_prizes(request: RecommendationRequest):
|
|
"""
|
|
경품 추천 메인 엔드포인트
|
|
|
|
Flow:
|
|
1. 캐시 조회 (L2: 개인화)
|
|
2. 벡터 검색 (유사 성공 사례 Top 3)
|
|
3. Claude API 호출 (Streaming)
|
|
4. 응답 검증 및 후처리
|
|
5. 캐시 저장
|
|
"""
|
|
try:
|
|
# 1. 캐시 조회
|
|
cache_key = generate_cache_key(request.store_profile)
|
|
cached_result = await redis_cache.get(cache_key)
|
|
if cached_result:
|
|
logger.info(f"Cache hit: {cache_key}")
|
|
return cached_result
|
|
|
|
# 2. 매장 데이터 정제 및 벡터화
|
|
store_vector = await vectorize_store(request.store_profile)
|
|
|
|
# 3. 벡터 검색 (유사 사례)
|
|
similar_cases = await vector_search.find_similar(store_vector, top_k=3)
|
|
|
|
# 4. Claude API 호출
|
|
prompt = build_prompt(
|
|
system=SYSTEM_PROMPT,
|
|
few_shot=FEW_SHOT_EXAMPLES,
|
|
store_profile=request.store_profile,
|
|
similar_cases=similar_cases
|
|
)
|
|
|
|
claude_response = await claude_client.recommend(prompt, stream=True)
|
|
|
|
# 5. 응답 파싱 및 검증
|
|
recommendations = parse_and_validate(claude_response)
|
|
|
|
# 6. 예산 필터링
|
|
filtered = filter_budget(recommendations, request.store_profile.budget.max)
|
|
|
|
# 7. 캐시 저장 (TTL 1시간)
|
|
await redis_cache.setex(cache_key, 3600, filtered)
|
|
|
|
return {
|
|
"status": "success",
|
|
"data": filtered,
|
|
"metadata": {
|
|
"cache_hit": False,
|
|
"vector_search_count": len(similar_cases),
|
|
"model": "claude-3-5-sonnet-20241022"
|
|
}
|
|
}
|
|
|
|
except ClaudeAPIError as e:
|
|
logger.error(f"Claude API failed: {e}")
|
|
# Fallback
|
|
fallback_result = fallback_engine.recommend(request.store_profile)
|
|
return {
|
|
"status": "fallback",
|
|
"data": fallback_result,
|
|
"metadata": {"error": str(e)}
|
|
}
|
|
|
|
except Exception as e:
|
|
logger.exception("Unexpected error in prize recommendation")
|
|
raise HTTPException(status_code=500, detail=str(e))
|
|
```
|
|
|
|
### B. 벡터 검색 구현
|
|
|
|
```python
|
|
import redis
|
|
from redis.commands.search.query import Query
|
|
import numpy as np
|
|
import pickle
|
|
|
|
class VectorSearchEngine:
|
|
def __init__(self, redis_client):
|
|
self.client = redis_client
|
|
self.index_name = "idx:prize_cases"
|
|
|
|
async def find_similar(self, query_vector: np.ndarray, top_k: int = 3):
|
|
"""
|
|
코사인 유사도 기반 벡터 검색
|
|
|
|
Args:
|
|
query_vector: 272차원 매장 임베딩
|
|
top_k: 반환할 유사 사례 개수
|
|
|
|
Returns:
|
|
List[dict]: 유사 사례 데이터
|
|
"""
|
|
# 벡터를 bytes로 직렬화
|
|
query_bytes = pickle.dumps(query_vector.astype(np.float32))
|
|
|
|
# Redis Vector Search 쿼리
|
|
query = (
|
|
Query(f"*=>[KNN {top_k} @case_vector $vec AS score]")
|
|
.sort_by("score")
|
|
.return_fields("store_category", "prize_name", "roi", "conversion_rate", "case_vector", "score")
|
|
.paging(0, top_k)
|
|
.dialect(2)
|
|
)
|
|
|
|
results = self.client.ft(self.index_name).search(
|
|
query,
|
|
query_params={"vec": query_bytes}
|
|
)
|
|
|
|
# 결과 파싱
|
|
similar_cases = []
|
|
for doc in results.docs:
|
|
similar_cases.append({
|
|
"store_category": doc.store_category,
|
|
"prize_name": doc.prize_name,
|
|
"roi": float(doc.roi),
|
|
"conversion_rate": float(doc.conversion_rate),
|
|
"similarity_score": 1 - float(doc.score) # 코사인 거리 → 유사도
|
|
})
|
|
|
|
return similar_cases
|
|
```
|
|
|
|
### C. Claude API 클라이언트
|
|
|
|
```python
|
|
import anthropic
|
|
import json
|
|
|
|
class ClaudeRecommendationClient:
|
|
def __init__(self, api_key: str):
|
|
self.client = anthropic.Anthropic(api_key=api_key)
|
|
self.model = "claude-3-5-sonnet-20241022"
|
|
|
|
async def recommend(self, prompt: dict, stream: bool = True):
|
|
"""
|
|
Claude API 호출 (Streaming)
|
|
|
|
Args:
|
|
prompt: {system, messages} 구조
|
|
stream: Streaming 여부
|
|
|
|
Returns:
|
|
dict: 파싱된 JSON 응답
|
|
"""
|
|
try:
|
|
response = self.client.messages.create(
|
|
model=self.model,
|
|
max_tokens=4096,
|
|
temperature=0.7,
|
|
system=prompt['system'],
|
|
messages=prompt['messages'],
|
|
stream=stream
|
|
)
|
|
|
|
if stream:
|
|
# Streaming 응답 수집
|
|
full_text = ""
|
|
async for chunk in response:
|
|
if chunk.type == "content_block_delta":
|
|
full_text += chunk.delta.text
|
|
|
|
# JSON 파싱
|
|
return json.loads(full_text)
|
|
else:
|
|
return json.loads(response.content[0].text)
|
|
|
|
except anthropic.APIError as e:
|
|
raise ClaudeAPIError(f"Claude API failed: {e.status_code} - {e.message}")
|
|
|
|
except json.JSONDecodeError as e:
|
|
raise ClaudeAPIError(f"Invalid JSON response: {e}")
|
|
```
|