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 &lt;=&gt; :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의 코사인 거리 연산자입니다. 값이 작을수록 가깝습니다.

\[\text{cosine distance}(\mathbf{a}, \mathbf{b}) = 1 - \frac{\mathbf{a} \cdot \mathbf{b}}{\lVert \mathbf{a} \rVert \, \lVert \mathbf{b} \rVert}\]

벡터의 크기가 아니라 방향을 비교하기 때문에, 설명 텍스트가 길고 짧은 차이에 휘둘리지 않고 “어떤 감성 축에 놓여 있는가”만 봅니다. 국악 설명문의 길이 편차가 큰 이 데이터셋에 맞는 선택입니다.

항목 값
기본 반환 수 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가 없어도 서비스가 도는 구조라, 로컬 개발과 배포 환경에서 의존성 하나가 줄어듭니다.


SoundBridge — 당신의 음악 언어로 국악을 만나세요. 본 사이트는 개발 문서 자료입니다.

This site uses Just the Docs, a documentation theme for Jekyll.