2) 감성 매칭 알고리즘
💡 쉽게 말하면: “비 오는 날 듣는 아이유”라는 문장을 AI가 읽고 감성 키워드로 바꿉니다. 그 키워드를 숫자 벡터(1,536개의 숫자)로 만들고, 미리 벡터로 바꿔 둔 국악 500곡 중에서 방향이 가장 비슷한 3곡을 고릅니다. 마지막으로 AI가 “왜 이 곡이 닮았는지”를 한국어와 영어로 씁니다.
4-1. 전체 흐름
flowchart LR
A["사용자 입력<br/>자연어 문장"] --> B{"Redis 캐시<br/>sb:discover:{md5}"}
B -- HIT --> Z["DiscoverResult"]
B -- MISS --> C["Gemini<br/>analyze_emotion()"]
C --> D["embed_text"]
D --> E["Gemini 임베딩<br/>1,536차원 벡터"]
E --> F["pgvector<br/>embedding <=> :vec"]
F --> G["top-3 track_ids"]
G --> H["TrackRepository<br/>find_by_ids() + joinedload"]
H --> I["Gemini<br/>explain_match() KO/EN"]
I --> J["match_logs 저장<br/>실패해도 무시"]
J --> K["preset_url 부착"]
K --> Z
Z --> Y["Redis SETEX 3600"]
4-2. 1단계 — 감성 해석 (analyze_emotion)
Gemini에게 넘기는 프롬프트는 JSON만 반환하라고 못 박습니다.
당신은 음악 감성 분석 전문가입니다.
사용자가 좋아하는 음악 정보를 분석해 국악 매칭에 필요한 감성 키워드를 추출하세요.
입력: {user_input}
다음 JSON 형식으로만 응답하세요 (마크다운 없이):
{
"emotions": ["감성1", "감성2"],
"mood": "전반적 분위기",
"instrument_hints": ["악기1"],
"embed_text": "임베딩 생성용 정제 텍스트 (한국어, 2-3문장)"
}
반환값은 EmotionAnalysisResult DTO에 담깁니다.
| 필드 | 용도 |
|---|---|
emotions | 결과 화면의 감성 칩 표시 |
mood | 입력 요약(input_summary) 구성 |
instrument_hints | (v1.2) 사전 필터링용 힌트 |
embed_text | 다음 단계 임베딩의 실제 입력 |
Gemini가 마크다운 코드펜스(
``json)를 씌워 응답하는 경우가 잦아,_parse_json()이 정규식으로 펜스를 벗겨낸 뒤json.loads()를 돌립니다. 파싱이 실패하면GeminiApiException`으로 감싸 Router에서 HTTP 503으로 변환합니다.
4-3. 2단계 — 임베딩
embed_text를 그대로 임베딩합니다. 원문이 아니라 정제 텍스트를 넣는 것이 핵심입니다.
임베딩 텍스트를 만드는 원칙은 준비 스크립트 embed_pipeline.py의 주석에 그대로 적혀 있습니다.
임베딩 품질의 핵심. 숫자(bpm)보다 감성 텍스트를 풍부하게 넣어야 “아이유 → 정가”, “Billie Eilish → 거문고 산조” 매칭이 잘 됨.
트랙 쪽 임베딩도 같은 원칙으로 만듭니다. 제목·연주자·악기·장단·감성 태그·설명을 이어 붙인 한국어 서술 텍스트를 임베딩해 gugak_tracks.embedding에 저장합니다.
| 항목 | 값 |
|---|---|
| DB 컬럼 | gugak_tracks.embedding Vector(1536) (Alembic 001_init) |
| 포트 계약 | EmbeddingPort.embed_text(text) -> list[float] — 1,536차원 |
| 배치 크기 | 20건 / 배치, SLEEP_SEC = 0.5 |
| 이어받기 | LEFT JOIN track_embeddings … WHERE track_id IS NULL |
⚠️ 준비 스크립트
embed_pipeline.py는VECTOR_DIM = 768(gemini-embedding-001 기본 출력)로 설정돼 있습니다. DB 스키마(1536)와 값이 다릅니다. 임베딩 모델의 출력 차원을 1536으로 지정하거나 스키마를 768로 낮춰 한쪽으로 통일해야 하며, 이 정합을 맞추기 전에는 적재가 실패합니다. MVP 잔여 과제로 표시합니다.
4-4. 3단계 — pgvector 코사인 유사도 검색
SELECT id FROM gugak_tracks
ORDER BY embedding <=> :vec
LIMIT :k
<=>는 pgvector의 코사인 거리 연산자입니다. 값이 작을수록 가깝습니다.
벡터의 크기가 아니라 방향을 비교하기 때문에, 설명 텍스트가 길고 짧은 차이에 휘둘리지 않고 “어떤 감성 축에 놓여 있는가”만 봅니다. 국악 설명문의 길이 편차가 큰 이 데이터셋에 맞는 선택입니다.
| 항목 | 값 |
|---|---|
| 기본 반환 수 | top_k = 3 |
| 인덱스 | MVP는 순차 스캔 (500건 규모라 체감 지연 없음) |
| v1.2 계획 | HNSW 인덱스 추가 — 데이터 확장 시 필수 |
4-5. 4단계 — 매칭 근거 생성 (explain_match)
이미 확정된 트랙에 대해서만 설명을 만듭니다. 어떤 트랙이 뽑힐지는 이 단계에서 바뀌지 않습니다.
사용자가 '{user_input}'을(를) 좋아한다고 했습니다.
아래 국악 트랙이 왜 감성적으로 비슷한지 한국어와 영어로 각각 1-2문장 설명하세요.
트랙: {track_title} ({instrument}, {jangdan})
감성: {emotion_tags}
JSON 형식으로만 응답 (마크다운 없이):
{"ko": "한국어 설명", "en": "English explanation"}
트랙 3건에 대해 순차 호출하며, 결과는 MatchExplanation DTO 리스트로 돌아옵니다. 한 건이 실패해도 나머지 결과는 살아남도록 트랙 단위로 예외를 잡습니다.
KO와 EN을 한 번의 호출로 동시에 받는 이유는, 두 번 호출하면 같은 근거를 서로 다르게 서술할 수 있기 때문입니다. 한 응답 안에서 만들면 두 언어의 논지가 일치합니다.
4-6. 5단계 — 매칭 로그 (match_logs)
for track, exp in zip(tracks, explanations):
try:
await self._track_repo.save_match_log(
command.input_text, command.lang, track.id, exp.score
)
except Exception:
pass # 로그 실패가 사용자 응답을 막지 않는다
| 컬럼 | 용도 |
|---|---|
input_text | 어떤 문장으로 검색했는가 |
lang | ko / en |
matched_track_id | 어떤 트랙이 매칭됐는가 (FK) |
similarity_score | Float — v4.0에서 String(20)에서 타입 수정 |
created_at | 시각 (UTC) |
이 로그가 쌓이면 “어떤 입력이 어떤 국악으로 이어지는가”를 집계할 수 있고, find_popular()를 조회수 기반 인기 순으로 바꾸는 근거가 됩니다. (현재 MVP는 단순 LIMIT)
4-7. 캐시 전략
def _make_cache_key(self, command) -> str:
h = hashlib.md5(f"{command.input_text}:{command.lang}".encode()).hexdigest()
return f"sb:discover:{h}"
| 항목 | 값 |
|---|---|
| 키 | sb:discover:{md5(input_text:lang)} |
| TTL | 3,600초 (1시간) |
| 저장 시점 | 결과 조립 완료 후 |
| 무효화 | TTL 만료만 (트랙 데이터 변경 빈도가 낮음) |
같은 문장이 반복 입력되면 Gemini 호출 4회(감성 분석 1 + 설명 3)를 통째로 아낍니다. 공모전 시연처럼 같은 예시를 반복하는 상황에서 응답이 즉시 돌아오는 이유입니다.
⚠️ 현재 캐시 직렬화는
json.dumps(result.__dict__)로 되어 있어, 중첩 dataclass(TrackResult)가 그대로 들어가면 직렬화에 실패합니다.dataclasses.asdict()로 바꾸는 정리가 필요합니다. MVP 잔여 과제로 표시합니다.
4-8. 실패 시 동작
| 실패 지점 | 처리 |
|---|---|
| Gemini 감성 분석 | GeminiApiException → Router에서 503 “AI 서비스 일시 오류” |
| Gemini 설명 생성 | 트랙 단위로 예외를 잡고 나머지 결과는 반환 |
| pgvector 검색 | 결과 0건이면 빈 리스트 → 프론트 EmptyState 표시 |
| 매칭 로그 저장 | 무시 (사용자 응답에 영향 없음) |
| Redis 미연결 | self._redis is None 분기로 캐시 없이 정상 동작 |
Redis가 없어도 서비스가 도는 구조라, 로컬 개발과 배포 환경에서 의존성 하나가 줄어듭니다.