Mova 채팅 — ‘클래식 명작’ 요청에 엔드게임이 나온 이유
요약
| 영역 | 내용 |
|---|---|
| 증상 | “클래식 명작 처음 보는 사람용” 칩 → 주토피아(2016)·엔드게임(2019) 추천 |
| 원인 | 임베딩 쿼터 429 → RAG 생략 → 시대 어휘가 연도 조건이 안 됨 → 최근 15년 우선 인기작 폴백 |
| 수정 | 시대 어휘(“클래식·고전·옛날”) → year_max=1999 근사 + 프롬프트에 연도 조건 표기 |
| 검증 | 프로덕션 재호출 — 흥부와 놀부(1959)·증발(1994) 반환 확인 |
| 부산물 | mova 채팅 규칙 하네스(.claude/rules/mova-chat.md) 통합 신설 |
4단계로 겹친 원인
스크린샷 하나로 시작해 EC2 로그와 DB를 실측으로 따라가니, 단일 버그가 아니라 네 겹의 체인이었다.
- 쿼터 429 — Gemini 임베딩 일일 쿼터가 소진돼 RAG 시맨틱 검색이 통째로 생략됐다. 크론 백필(하루 950건)과 실시간 채팅이 같은 쿼터를 공유하는 구조가 배경.
- 폴백 매칭 실패 — 태그 8,584건 중 “클래식”·”명작” 라벨은 0건. 연도 추출기는 “90년대”·”2015년” 같은 명시 표현만 인식해 “클래식”을 연도로 해석하지 못했다. 결과: 하드 조건 0개.
- 선의의 정렬이 역효과 — 조건이 없으면 인기작 폴백이 “최근 15년 우선 + 평점순”으로 정렬한다. 사흘 전 콜드 스타트에서 1950~70년대 영화가 뜬금없이 뜨는 걸 막으려고 넣은 정렬이, 정작 클래식을 요청하자 옛 영화를 뒤로 밀었다. DB에 1990년 이전 작품이 238편 실재하는데 후보에 못 올라온 이유다.
- LLM의 포장 — 후보 16편이 전부 최신작인 상태에서 LLM은 “대중적으로 사랑받는 작품 중에서 엄선했습니다”라고 그럴듯하게 정당화했다.
수정은 두 줄기
_guess_year_range()에 시대 어휘 폴백 추가 — 명시 연대·연도가 없을 때만 “클래식·고전·옛날”을year_max=1999로 근사한다. “90년대 클래식”처럼 명시 연대가 있으면 그쪽이 우선. 연도 하드 필터가 잡히는 순간 3번의 recency 정렬은 자연히 무력화되므로, 정렬 자체는 건드리지 않았다(콜드 스타트 보호는 유지).- 프롬프트 의도 섹션에
연도=표기 추가 — RAG 경로는 SQL 하드 필터가 없어서, 쿼터가 살아나 시맨틱 검색이 복구된 뒤에도 LLM이 연도 제약을 볼 수 있게 했다.
배포 후 프로덕션 실호출로 확인: 같은 질의에 year_max: 1999가 잡히고 흥부와 놀부(1959)·증발(1994) 이 돌아왔다.
채팅 규칙을 하네스로
이 사고를 계기로, 두 달간 채팅 파이프라인에 누적된 결정들(카탈로그 밖 movie_id 금지, 폴백 유지, 히스토리 병합 인텐트, dedup 2단, 0건 정직 안내…)을 .claude/rules/mova-chat.md 한 파일로 통합했다. 채팅 관련 파일을 다룰 때만 자동 로드되는 경로 한정 규칙이라, 다음에 이 코드를 만지는 에이전트는 “recency 정렬과 연도 필터는 상호보완이라 한쪽만 살리면 이 사고가 재발한다” 같은 맥락을 처음부터 안고 시작한다.
남은 숙제는 근본 원인인 쿼터 공유 구조다. 백필과 실시간 채팅이 한 쿼터를 쓰는 한 RAG는 매일 오후 언젠가 죽는다 — 별도 키 분리·로컬 임베딩 폴백 등 방향은 백로그에 올려뒀다.