Mova ‘좀비 영화’ 미스터리 — 세 겹의 원인을 걷어내기까지
어제 백로그에 적어둔 실측 갭에서 출발했다: “좀비 영화 추천해줘” → 추천 0건. 카탈로그에 좀비 태그 영화가 다수 실재하는데도(부산행, #살아있다, 킹덤: 아신전…) 챗은 빈손으로 답했다. 오늘 이걸 파고들었는데, 고쳐도 고쳐도 안 풀리더니 결국 원인이 세 겹이었다.
1겹 — RAG 히트가 태그 검색을 가리는 구조
파이프라인은 “RAG 시맨틱 검색 → 0건이면 태그 폴백” 순서다. 문제는 RAG가 무관한 히트라도 물어오면 태그 검색을 아예 안 탄다는 것. 좀비 태그가 있어도 시맨틱이 딴 영화 8건을 반환하면 그걸로 후보가 확정됐다.
수정은 합집합: RAG 히트가 있어도 태그 검색을 함께 돌려, 실매칭 (popular_fallback 제외)이 있으면 후보에 합류시킨다(dedup, 캡 16). 설계 포인트는 “결정론 키워드 질의냐”를 미리 분류하지 않고 태그 검색 결과 자체로 판별한 것 — 순수 mood 질의는 원시 토큰이 태그에 안 걸려 자연히 현행(RAG 단독)이 유지된다. mood 확장을 합집합 경로에 넣지 않은 것도 같은 이유다(넣으면 mood 질의가 장르 인기작으로 희석된다).
2겹 — 2.4B는 목록 앞쪽에 끌린다
배포 후 재현하니 recs=0이 recs=1로 바뀌었는데, 픽이 ‘싸이보그지만 괜찮아’였다. 후보 11편에 좀비 영화가 들어 있는데도 앞쪽의 무관 시맨틱을 집은 것. 2.4B LoRA는 Gemini처럼 목록 전체를 공평하게 보지 않는다.
순서를 태그 실매칭 우선(상한 10) + 시맨틱 보충으로 바꿨다. 결정론 신호(키워드·배우·제목 매칭)가 코사인 0.69짜리 시맨틱 히트보다 정확하다는 원칙이기도 하다.
3겹 — 진짜 뿌리: “좀비”가 “비”가 되고 있었다
그래도 recs=0. 후보 생성 SQL을 손으로 재현하면 16편이 나와야 하는데 실제는 3편. 추출기를 직접 돌려보니 키워드가 ['영화', '비 영화'] — “좀비”가 없다.
범인은 문두 담화어 제거 정규식이었다:
_FILLER = re.compile(r"^(오늘|지금|좀|그냥|please|plz)\s*|...")
\s*가 공백 0개를 허용해서, 문두 “좀비 영화”의 “좀”을 담화어로 떼어 “비 영화”(rain)로 만들고 있었다. RAG는 사실 자기 일을 정확히 한 거였다 — “비 영화”의 top1이 ‘비와 당신의 이야기’였으니까. 어제 트레이스 에서 “시맨틱이 왜 이렇게 무관한 걸 물어오지?” 했던 미스터리가 이 한 글자로 풀렸다. \s+(공백 필수)로 고쳐 단독 담화어(“좀 “, “지금 “)만 제거되게 했다.
세 겹이 모두 맞물려야 완결이었다: 구조(합집합) + 순서(태그 우선) + 입력(필러). 최종 E2E — “좀비 영화 추천해줘” → 좀비딸 · 좀비스쿨 · #살아있다.
곁가지 1 — 예매 탐색형 질의
실사용 보고 하나 더: “지금 바로 예매할 수 있는 영화 뭐있어”에 챗이 “비슷한 제목이 여러 편이에요: 무서운 영화(2026)…”라고 되물었다. booking 트랙이 모든 질의에 제목이 있다고 가정하고, 일반어 “영화”를 제목으로 퍼지 매칭한 것. 제목 해석 전에 결정론 패턴(“뭐 있”, “상영작”…)으로 탐색형을 갈라 주간 박스오피스 상영작 나열(출처 명시)로 답하게 했다.
곁가지 2 — 배포가 두 번 막혔다
EC2 디스크: 재빌드가 No space left on device로 실패. auth가 태그를 뺏긴 구 이미지(9.31GB)로 떠 있던 드리프트를 정리해도, 빌드 캐시가 소실된 상태의 torch 전체 재설치(피크 ~15G+)는 30GB 디스크에 구조적으로 안 들어간다. requirements가 불변인 점을 이용해 기존 이미지 베이스 파생 빌드(FROM suvisdev-app:latest + COPY . .)로 우회 — 캐시 히트 빌드와 결과물이 같고, 코드 레이어만 추가된다.
lora-server 5배 감속: 배포 후 추천이 504(61초). 터널·서버 다 정상인데 생성만 78.6초(256토큰). 동일 구성을 오프라인에서 돌리니 17.8tok/s — 오래 뜬 서버 프로세스만 3.2tok/s로 열화돼 있었다(단편화→WDDM 스필 추정). serve.py에 어댑터 병합(merge_and_unload)과 SDPA를 반영하고 재기동하니 256토큰 8.5초. 프로덕션 왕복 6.4초.
오늘의 교훈
- zero-rec 같은 증상은 원인이 하나라고 가정하지 말 것. 한 겹 걷을 때마다 “고쳤는데 왜 그대로지?”를 새 데이터로 다시 물어야 했다.
- 한국어 전처리 정규식에서
\s*와\s+는 글자 하나 차이로 “좀비”를 “비”로 만든다. 문두 담화어는 반드시 공백을 요구할 것. - 작은 모델에게 후보 목록 순서는 프롬프트의 일부다.
테스트 신규 9건 포함 mova 287 passed. 커밋 5건, EC2 배포·실측까지 완료.