회사 상품 추천 모델 만들기 (1) - 왜 추천 시스템인가, 그리고 첫 모델은 왜 깨졌는가
B2B 패션 도매 사이트의 개인화 추천 모델을 만들면서 거쳤던 학습과 결정의 기록. 1편은 추천 패러다임 선택의 트레이드오프와, 첫 모델이 recall@20=0.0048로 깨졌던 진단.
회사 상품 추천 모델 만들기 (1) - 왜 추천 시스템인가, 그리고 첫 모델은 왜 깨졌는가
시리즈 안내
회사의 B2B 패션 도매 서비스의 홈 메인 “당신이 좋아할 상품” 영역에 들어갈 개인화 추천 모델을 만들었습니다. 추천 시스템을 직접 만드는 건 거의 처음이었고, 자료를 찾아보면서 새로 알게 된 개념과, 트레이드오프 사이에서 내렸던 결정을 7부작으로 정리합니다.
이 시리즈의 목표는 “내가 이런 걸 만들었다”가 아니라, “이 결정을 왜 이렇게 했고, 무엇과 비교했고, 무엇이 틀렸는가”를 적어두는 것입니다. 미래의 제가 다시 찾아볼 때를 가정하고 씁니다.
1. 무엇을 만드는가
이전에 해본 ML 실습은 입력과 출력이 명확하게 주어져 있었습니다. 타이타닉 생존자 추론은 18개 컬럼이 들어가면 0/1을 뱉어야 하고, 비트코인 가격 예측은 시계열을 받아 다음 값을 뱉으면 됩니다.
추천은 처음에 ‘무엇이 입력이고 무엇이 출력인지’부터 흔들렸습니다. 자료를 몇 개 읽고 나서야 정리가 됐습니다.
서비스 컨텍스트
- 우리 서비스: 패션 B2B 도매. 옷가게 사장님들이 도매 상품을 사는 사이트
- 추천이 들어갈 자리: 홈 메인의 “당신이 좋아할 상품” 영역
- 후보 상품: 활성 상품 약 142,000개
- 추천 대상 유저: 최근 활동이 있는 약 9,000명
- 사용 가능한 신호: 조회 / 위시 / 장바구니 / 견적요청 / 구매 로그
여기서 “한 유저에게 어떤 K개 상품을 보일 것인가”가 출력이라고 합니다. K는 보통 20~50개. 그리고 이 문제는 단순한 분류/회귀가 아니라는 걸 첫 주에 알게 됐습니다. 142,000개 후보 중 20개를 고르는 일을 분류 문제로 풀려면 출력 클래스가 142,000개가 되는데, 그렇게 만든 모델이 잘 학습된다는 얘기는 어디서도 본 적이 없습니다.
2. retrieval과 ranking이라는 두 단계
가장 먼저 부딪힌 개념이 이거였습니다. 추천 시스템 자료를 읽다 보면 retrieval (후보 추출) 과 ranking (정렬) 이라는 단어가 계속 나오는데, 둘이 뭐가 다른지 처음엔 헷갈렸습니다. 정리해보면 이렇다고 합니다.
| 단계 | 무엇을 하는가 | 후보 규모 | 보통 쓰는 지표 |
|---|---|---|---|
| retrieval (후보 추출) | 수십만~수억 개 중에서 “후보 K개” 뽑기 | 142K → 200 | Recall@K |
| ranking (정렬) | 후보 K개를 사용자가 좋아할 순서로 정렬 | 200 → 20 | NDCG, CTR 모델 |
검색 엔진으로 비유하면 이해가 됐습니다. retrieval은 ‘검색 결과 페이지에 들어갈 후보를 빠르게 추리는’ 일이고, ranking은 그 후보를 ‘어느 순서로 보일지 정밀하게 정하는’ 일이라고 합니다.
이번 프로젝트의 범위는 retrieval만으로 잡았습니다. 이유는 두 가지였습니다.
- 회사에 ranking을 위한 추가 학습 데이터(CTR, dwell time 등)가 정리되어 있지 않았습니다.
- retrieval만으로도 “메인에 무엇을 보일지”는 결정 가능합니다. ranking은 후속 과제로 미루기로 했습니다.
그래서 평가 지표는 Recall@20으로 잡혔습니다. “정답(= 실제로 유저가 클릭/구매한 상품) 중 모델이 추천한 상위 20개 안에 몇 %가 들어왔는가”입니다. 학교에서 자주 봤던 accuracy, RMSE와는 결이 다른 지표라 처음엔 어색했습니다.
추천 평가 지표 비교, 왜 하필 Recall이 표준인가
추천 평가 지표는 처음 보면 비슷비슷해 보이는 게 많습니다. 정리해두면 이렇게 된다고 합니다.
| 지표 | 의미 | 예시 (실제 정답이 10개, top-20 중 정답이 3개일 때) |
|---|---|---|
| Recall@K | 전체 정답 중 top-K에 들어온 비율 | 10개 중 3개가 top-20에 있음 → Recall@20 = 0.3 |
| Precision@K | top-K 중 정답인 비율 | top-20 중 3개가 정답 → Precision@20 = 3/20 = 0.15 |
| NDCG@K | 정답의 순위까지 고려한 점수 (위쪽 정답일수록 큰 점수) | 정답이 1·5·10등이면 1·15·20등일 때보다 높음 |
| Hit Rate@K | top-K에 정답이 하나라도 있나 (0 또는 1) | 3개 중 1개라도 있음 → 1 |
| MRR (Mean Reciprocal Rank) | 첫 번째 정답 순위의 역수 평균 | 첫 정답이 4등이면 1/4 = 0.25 |
이 중 retrieval 단계에서는 보통 Recall@K를 쓴다고 합니다. 이유는 분업 때문이라고 합니다.
retrieval은 “후보 200개를 잘 뽑는” 일이고, ranking은 “그 200개를 잘 정렬하는” 일이니까, retrieval 단계에서는 정답을 후보풀에 넣어두기만 하면 됩니다. 순서는 ranking이 정합니다. 그래서 retrieval의 성패는 “정답이 후보에 들어왔는가”(= Recall)에 달려 있다는 게 자료에서 본 설명이었습니다.
Precision은 retrieval에서 의미가 약하다고 합니다. 200개 후보 중 유저가 실제로 산 상품이 보통 13개라 Precision은 12% 수준에서 거의 움직이지 않고, 모델 간 차이가 잘 안 드러난다는 것입니다.
NDCG는 ranking 평가에 더 적합하다고 합니다. retrieval은 순서가 정해지지 않은 후보 풀을 만들 뿐이니까 굳이 순위 가중치를 줄 필요가 적다는 설명이었습니다.
K=20을 고른 이유는 서비스 노출 면적입니다. 우리 서비스 메인의 “당신이 좋아할 상품” 영역은 한 페이지에 약 20개 상품이 노출됩니다. 그 자리에 정답이 들어왔는지를 측정하면 평가 지표와 비즈니스 의미가 직접 연결됩니다.
3. 어떤 모델 패러다임을 쓸 것인가
다음 질문은 모델 선택이었습니다. 자료를 검색하면 지난 10여 년간 유명해진 추천 모델이 시간순으로 줄줄이 나옵니다. 아래 표는 제가 직접 분류한 게 아닙니다. 찾아본 서베이 글과 블로그들의 분류를 옮겨 적으며 겨우 갈래를 잡은 것에 가깝습니다. 그러니 “대략 이렇다고 한다” 정도로 읽어주시면 됩니다.
| 연도 | 모델 | 어떤 문제를 푸는가 |
|---|---|---|
| 2009 | MF / ALS | Netflix Prize 우승 흐름. 유저×아이템 평점 행렬 분해의 고전 |
| 2015 | LightFM | CF + 콘텐츠 피처를 함께 쓰는 hybrid retrieval |
| 2016 | YouTube DNN (Covington et al.) | retrieval-ranking 2단계 구조의 산업 표준화 |
| 2016 | GRU4Rec | 시퀀셜 추천의 초기 모델 (RNN으로 세션 내 행동 순서 학습) |
| 2017 | Neural CF, DeepFM | MF의 신경망 확장 / CTR 예측 (ranking) |
| 2018 | PinSage (Pinterest) | 그래프 + 콘텐츠 임베딩 |
| 2018 | SASRec | 시퀀셜 추천 (셀프 어텐션으로 행동 순서 학습) |
| 2019 | Two-Tower + logQ correction (Yi et al., Google) | retrieval 전용 학습의 정석이라고들 함 |
| 2019 | BERT4Rec | 트랜스포머 기반 시퀀셜 |
| 2019 | DLRM (Meta) | 광고 ranking, 대규모 임베딩 |
이 표에서 중요한 건 각 모델이 푸는 문제가 다르다는 점입니다. ranking에 특화된 모델(DeepFM, DLRM), 시퀀셜 패턴(유저의 행동 순서)을 쓰는 모델(GRU4Rec, SASRec, BERT4Rec), retrieval 전용 모델(YouTube DNN, Two-Tower)이 한 표 안에 섞여 있습니다.
이번 프로젝트가 retrieval 단계라는 게 정해진 시점에서 후보가 자연스럽게 좁혀졌습니다.
- 시퀀셜 모델(GRU4Rec, SASRec, BERT4Rec) 제외: 우리 서비스의 유저당 평균 인터랙션이 10~50개 수준으로 짧고, 도매 특성상 “오늘 본 상품 다음에 무엇을 볼지”의 순서 패턴이 일반 C2C e-commerce보다 약할 것 같았습니다.
- CTR/ranking 모델(DeepFM, DLRM) 제외: ranking은 후속 과제로 미뤘으니 retrieval에 집중하는 모델이 필요했습니다.
남은 후보를 카테고리로 묶으면 결국 세 가지로 정리된다고 합니다.
| 패러다임 | 핵심 아이디어 | 대표 구현 | 강점 | 약점 |
|---|---|---|---|---|
| CF (Collaborative Filtering, 협업 필터링) | “비슷한 유저는 비슷한 걸 좋아한다”. 유저×아이템 인터랙션 행렬을 분해(MF, ALS)하거나 implicit 모델로 학습 | implicit 라이브러리, ALS, LightFM(hybrid) | 단순, 빠름, baseline 강함 | 콜드 스타트 약함, 새 상품에 무력 |
| CBF (Content-Based Filtering, 콘텐츠 기반) | “유저가 좋아한 상품과 닮은 상품을 추천”. 상품 콘텐츠(카테고리/브랜드/텍스트)로 유사도 계산 | TF-IDF, item embedding 평균 | 콜드 스타트에 강함, 설명 가능 | 유저 표현이 빈약, 인기 편향 |
| Two-Tower (dual encoder) | 유저와 상품을 각각 신경망으로 임베딩 공간에 투영, 내적으로 매칭 점수 | YouTube DNN, Google Two-Tower (Yi et al. RecSys 2019) | retrieval에 최적화, 사전 계산 임베딩 + 내적 검색으로 서빙 효율 | 학습이 까다롭다는 얘기가 많았다 (왜인지는 당시엔 몰랐고 뒤에서 겪었다) |
각 패러다임을 구체적인 예시로
표만 보면 다 비슷해 보였습니다. 비유를 하나씩 붙여보면 좀 와닿았습니다.
CF: “취향이 비슷한 친구가 좋아한 걸 추천한다”
100명의 유저와 1,000개 상품이 있다고 합시다. 누가 어떤 상품을 봤는지를 100×1,000 표로 그리면 대부분 칸이 비어 있습니다 (모두가 모든 상품을 본 게 아니니까). MF/ALS라는 방식은 이 큰 표를 100×K와 K×1,000 두 작은 표의 곱으로 근사한다고 합니다. K는 보통 32~128 같은 작은 숫자라고 합니다.
[100×1000 인터랙션 표] ≈ [100×K 유저 벡터] × [K×1000 상품 벡터]이렇게 분해하면 각 유저가 K차원 벡터로 표현됩니다 (“이 사람의 잠재 취향”). 각 상품도 K차원 벡터로 표현됩니다 (“이 상품의 잠재 특성”). 빈 칸의 추천 점수는 두 벡터의 내적으로 추정합니다. 책 추천으로 비유하면, 베르나르 베르베르를 좋아한 유저들이 공통적으로 조정래도 좋아했다면, 베르베르를 좋아한 새 유저에게 조정래가 추천되는 식입니다.
내적(dot product), “취향이 맞는다”를 숫자 하나로
두 벡터를 같은 자리끼리 곱해서 전부 더한 값입니다. 유저 벡터가
[1, 2], 상품 벡터가[3, 4]라면 내적은1×3 + 2×4 = 11입니다.직관은 이렇습니다. 두 벡터가 같은 방향을 가리킬수록(유저의 취향과 상품의 특성이 같은 쪽을 향할수록) 내적이 커지고, 서로 반대 방향이면 음수가 됩니다. 그래서 “이 유저가 이 상품을 얼마나 좋아할까”라는 질문에 내적이라는 숫자 하나로 답할 수 있습니다. 위 책 비유에서 베르베르를 좋아한 유저의 벡터와 조정래 상품의 벡터가 같은 방향이면 내적이 크고, 그게 곧 높은 추천 점수입니다.
이 개념은 시리즈 내내 따라옵니다. 뒤에 나올 Two-Tower의 “유저 임베딩 × 상품 임베딩”도 결국 이 내적 하나이고, 점수를 매기는 모든 단계의 바닥에 깔려 있습니다.
문제는 표에 행이나 열이 없는 신규 유저·신규 상품은 추천이 불가능하다는 점입니다. 새 행을 학습할 데이터가 없으니까요. 자료를 보니 이걸 콜드 스타트(cold start) 문제라고 부른다고 합니다. “새로 들어온 유저나 상품에는 추천이 잘 안 통하는” 문제라는 뜻입니다.
CBF: “내가 좋아한 상품이랑 비슷한 걸 추천한다”
“검은색 린넨 셔츠”를 좋아한 유저에게 “베이지 린넨 셔츠”를 추천하는 식이라고 합니다. 상품의 카테고리·색·소재·브랜드·텍스트 같은 콘텐츠 속성을 벡터로 만들고, 유저가 좋아한 상품들의 벡터 평균을 그 유저의 취향 벡터로 본다는 것입니다.
유저 벡터 = average(좋아한 상품들의 콘텐츠 벡터)
추천 점수 = 유저 벡터 · 새 상품의 콘텐츠 벡터장점: 새 상품에도 카테고리·색 등 콘텐츠 속성만 있으면 즉시 추천에 들어갈 수 있다는 점. 콜드 스타트에 강하다고 합니다. 약점: 유저 표현이 “좋아한 상품들의 평균”이라 단조롭다는 것. 시그널의 강도(구매 vs 단순 조회)나 시간 차이를 반영하기 어렵다고 합니다.
Two-Tower: “검색 엔진처럼 유저-상품을 같은 공간에 임베딩”
검색 엔진을 떠올리면 가깝다고 합니다. 사용자의 쿼리를 벡터로 만들고, 문서를 벡터로 만들어, 두 벡터의 내적이 큰 문서를 위로 보내는 그 구조입니다.
추천에서는 쿼리 자리에 유저가 들어가고, 문서 자리에 상품이 들어갑니다. 두 개의 타워(유저 신경망, 상품 신경망)가 각자 입력을 받아 같은 차원의 임베딩(= 객체를 숫자 벡터로 표현한 것)을 만들고, 내적으로 매칭한다는 것입니다.
유저 피처 → [UserTower 신경망] → 128차원 유저 임베딩
↓
내적 = 매칭 점수
↑
상품 피처 → [ItemTower 신경망] → 128차원 상품 임베딩장점: 콘텐츠 피처와 행동 신호를 둘 다 임베딩에 녹일 수 있고, 학습이 끝나면 모든 상품 임베딩을 미리 계산해두고 실시간 추천은 내적 계산만 하면 된다는 것. 약점: 학습 동역학(뒤에서 다룰 in-batch sampled softmax, logQ 보정 같은 기법)이 까다롭다고 합니다.
우리 서비스의 도메인 특성
세 패러다임을 비교하기 전에, 우리 서비스가 어떤 환경인지를 먼저 정리해둘 필요가 있었습니다.
- 상품 회전율이 매우 높다 (churn 46~74%): 한 해에 후보풀 상품의 46~74%가 갈립니다. ‘churn’은 자료에서 본 용어인데, 상품(또는 유저)이 들어오고 빠지는 회전율을 뜻한다고 합니다. 도매 상품은 시즌·트렌드 회전이 빠르고, 외부 소스(1688, vvic 등)에서 들여오는 상품이 자주 갈리는 게 원인이었습니다.
- 유저 활성 비율은 낮은 편: 후보 유저 6.8만 중 활성 유저는 8,927명. 인터랙션이 1~2개뿐인 유저가 많습니다.
- 콜드 스타트가 일상: 매주 새 상품이 들어오고, 그 상품에는 어떤 유저도 아직 클릭하지 않은 상태입니다.
왜 CF가 아닌가
CF는 “유저 A가 본 상품을 유저 B도 봤다면, 유저 B에게 유저 A의 다른 상품을 추천”하는 방식이라고 했습니다. 학습은 유저×아이템 인터랙션 행렬을 저차원으로 분해(MF, ALS)하는 식이라고 정리했고요.
그런데 churn 46~74% 환경에서는 CF의 핵심 자산인 그 인터랙션 행렬이 매주 무너집니다. 작년에 학습한 상품 임베딩의 절반 이상이 지금 후보풀에 없습니다.
그리고 CF는 새 상품에 대해 추천 자체가 불가능하다고 합니다. 새 상품은 인터랙션이 0이라 행렬에 행/열이 존재하지 않으니까요. 이게 콜드 스타트 문제의 고전적 형태인데, 우리 서비스는 이게 가끔이 아니라 매주 일어납니다.
LightFM이라는 hybrid 모델은 콘텐츠 피처를 같이 쓸 수 있어 콜드 스타트를 일부 보완한다고 합니다. 후보로 진지하게 봤지만, Python 3.14 환경에서 wheel(설치 패키지)이 없다는 환경 제약과, 어차피 도메인 특성상 콘텐츠 신호가 지배적이라는 판단으로 메인 트랙에서는 제외했습니다.
왜 순수 CBF가 아닌가
CBF는 “유저가 좋아한 상품과 닮은 상품을 보여주는” 방식이라 콜드 스타트에 강하다고 합니다.
그러나 우리 서비스에서 단순 CBF는 두 가지가 약해 보였습니다.
- 유저 표현이 빈약: “유저가 본 상품들의 평균”으로 유저를 표현하면, 시그널 가중치(구매 vs 단순 조회)나 시간 차이를 반영하기 어렵습니다.
- 인기 편향: 콘텐츠 유사도 위에 인기 상품이 자연스럽게 위로 올라오는 경향이 있다고 합니다. retrieval 결과가 “이미 인기 있는 상품을 그대로 보여주는 수준”에서 못 벗어날 위험이 있습니다.
왜 Two-Tower인가
세 패러다임을 한 줄로 비교하면 이렇게 정리됐습니다.
- CF: 행동 신호 강함, 콘텐츠 신호 0
- CBF: 콘텐츠 신호 강함, 유저 표현 약함
- Two-Tower: 유저와 상품을 각자의 신경망으로 표현. 콘텐츠와 행동 신호를 둘 다 임베딩에 녹일 수 있음
또 하나의 큰 이점은 서빙(추천 제공) 효율이었습니다. Two-Tower는 학습이 끝나면 유저 임베딩과 상품 임베딩을 미리 계산해둘 수 있고, 추천 시점에는 유저 임베딩 × 상품 임베딩 행렬 곱으로 top-K를 뽑으면 된다고 합니다. 142K 규모면 NumPy의 argpartition 하나로 충분하다는 자료를 보고 안심했습니다. (FAISS 같은 ANN 인덱스를 쓰지 않은 이유는 #3에서 다룹니다.)
이 패러다임의 출발점으로 자주 언급되는 게 YouTube의 추천 논문(Covington et al., 2016)과 Google의 Two-Tower 논문(Yi et al., 2019)이라고 합니다. 솔직히 논문 제목이나 수식을 처음부터 다 이해한 건 아니고, 이름과 핵심 아이디어만 메모해 뒀습니다. 둘 다 retrieval 전용으로 설계되어 있어, “수억 개 후보에서 수십 개 뽑기”를 어떻게 학습할 것인가에 집중한 모델이라는 점만 붙잡았습니다.
최종 결정: 콘텐츠 기반 Two-Tower retrieval.
이 결정이 정답이라는 건 아닙니다. 우리 서비스 도메인 특성(높은 churn, 콜드 스타트 빈도, 사전 계산 임베딩으로 서빙 가능한 규모)에 맞춰 고른 결과입니다. 다른 환경(예: 음악 스트리밍처럼 churn이 낮고 카탈로그가 안정적)에서는 CF가 더 나은 선택일 가능성이 크다고 합니다.
4. 첫 모델의 처참한 진단
위 방향대로 첫 Two-Tower 모델을 만들었습니다. 결과는 Recall@20 = 0.0048.
수치 자체로는 좋고 나쁨이 와닿지 않아서, 비교 기준을 같이 적어둡니다.
- 후보풀 142K 중 무작위로 20개를 뽑으면 기대 Recall은 약 0.0001 수준
- 인기도 기준 baseline(“최근 인기 상품을 그대로 보이기”)의 Recall@20이 0.0166
- 첫 모델 0.0048은 인기도 기준선의 약 30% 수준. 무작위보다는 낫지만 “아무것도 안 하고 인기순으로 보이는 것”보다 못함
명확히 깨진 상태였습니다. 이 시점에서 책에서 봤던 “딥러닝 모델은 종종 안 됩니다”라는 한 줄이 새삼 와닿았습니다. 보통 모델이 안 되면 “내 코드가 잘못됐겠지”라고 생각하기 쉬운데, 이번엔 코드가 맞아도 모델 자체의 구조가 결과를 망친다는 걸 처음 체감했습니다.
증상을 진단해보니 근본 원인 세 가지가 보였습니다. 처방은 다음 편(#2, #3)에서 다루고, 이 편에서는 진단까지만 정리합니다.
결함 1. Item ID 임베딩 과적합
후보 상품 1.1M(당시는 후보풀 축소 전이라 모든 상품 대상)에 대해 32차원 임베딩(상품마다 32개 숫자로 표현되는 벡터)을 학습시켰습니다. 파라미터 수는 약 3,540만 개.
문제는 학습 데이터(약 80만 인터랙션)에서 대부분의 상품이 01회 등장한다는 점이었습니다. 임베딩은 그 상품이 등장할 때마다 조금씩 업데이트되는 식이라, 등장 횟수가 01회면 거의 학습이 안 됩니다. 그래서 1.1M 임베딩 중 대부분이 랜덤 노이즈(처음 초기화된 그대로의 의미 없는 숫자) 상태로 남았습니다.
문제는 이 노이즈가 콘텐츠 피처(브랜드, 카테고리, 텍스트)의 신호를 압도해버린다는 점입니다. 모델 입장에서는 “이 상품 고유의 표현(ID 임베딩)“이 “이 상품 종류의 일반 표현(콘텐츠)“보다 우선해서 보이게 되고, 그게 노이즈여도 일단 의존하려 합니다.
자료를 찾아보니 이런 분포를 긴 꼬리(long-tail) 분포라고 부른다고 합니다. 상위 1~2%의 인기 상품이 인터랙션 대부분을 차지하고, 나머지 98%는 한두 번씩만 등장하는 분포입니다. 실제 서비스 데이터에서는 이게 흔하고, 그래서 ID 임베딩이 학습 가능한 만큼만 쓰여야 한다는 게 이번 프로젝트에서 처음 알게 된 큰 교훈이었습니다.
결함 2. text_emb 90.7%가 0벡터
상품 텍스트(이름, 설명)를 Ko-SBERT라는 한국어 문장 임베딩 모델로 인코딩해서 임베딩으로 쓰려고 했습니다. 처음에는 “인터랙션이 있었던 상품”만 미리 인코딩해두는 식으로 구현했습니다. 약 103K개 상품에 대해서만 텍스트 임베딩을 가지고 있었습니다.
추론할 때는 1.1M 전체에 대해 점수를 계산해야 합니다. 그러면 90.7%의 상품에서 text_emb 자리가 0벡터(모든 값이 0인 의미 없는 벡터)가 됩니다. 모델이 “이 상품은 텍스트가 비어 있다”는 신호로 학습되거나, 그냥 무의미한 자리를 차지하게 된 셈입니다.
원인은 단순한 구현 결정이었습니다. “있는 것만 인코딩하자”라는 결정. 그 결정이 학습 전체를 망친다는 것까지는 처음에 미처 생각하지 못했습니다.
결함 3. logQ 보정 부재
Two-Tower 학습에서 자주 쓰이는 트릭이 in-batch sampled softmax라는 것이라고 합니다. 말이 어려운데, 의미는 단순합니다. 매 학습 스텝마다 “이 유저는 이 상품을 좋아하고, 다른 상품들은 안 좋아한다”는 신호를 줘야 하는데, 후보 142K개 전부를 매번 안 좋아한다고 두면 학습이 너무 느립니다. 그래서 같은 배치 안에 있는 다른 상품들을 임시로 “안 좋아한 상품(negative)” 자리에 빌려 쓴다는 트릭입니다. 배치 크기 512면 negative가 511개 생기는 효과라고 합니다.
문제는 인기 상품이 거의 모든 배치에 등장한다는 점이었습니다. 우리 서비스에서도 상위 인기 상품 몇 개가 인터랙션의 큰 비중을 차지합니다. 인기 상품이 negative로 자꾸 등장하면 모델이 “이 상품은 다들 안 좋아한다”고 잘못 학습합니다. 결과적으로 인기 상품의 임베딩이 다른 모든 유저에게서 멀어지는 방향으로 왜곡됩니다.
이걸 보정하는 방법을 Yi et al.이 RecSys 2019에서 logQ correction(로그Q 보정) 이라는 이름으로 정리했다고 합니다. 각 상품이 배치에 얼마나 자주 등장하는지를 추정해서, 그 값의 로그를 점수에서 빼주는 식입니다. 인기 상품일수록 더 큰 값을 빼서 편향을 상쇄합니다.
첫 모델에는 이 보정이 없었습니다. 자료를 찾으면서 이런 보정이 필요하다는 걸 처음 알게 됐고, 구체적인 수식과 구현은 #3에서 다루고, “이 보정이 실제로 효과가 있었는지”의 통계적 검증은 #5에서 이어집니다.
5. 이 편에서 결정된 것
- 무엇을 만드는가: 142K 상품 후보풀에서 활성 유저 약 9천 명에게 retrieval top-K 추천
- 평가 지표: Recall@20 (단계는 retrieval만, ranking은 후속)
- 모델 패러다임: 콘텐츠 기반 Two-Tower (CF·CBF 후보군 검토 후 도메인 특성으로 결정)
- 첫 모델 진단: Recall@20=0.0048, 인기도 기준선의 30% 수준. 세 가지 구조적 결함 식별
- 1.1M ID 임베딩 과적합 (35.4M 파라미터, 대부분 학습 불가)
- text_emb 90.7% 0벡터 (인코딩 범위 결정의 결과)
- logQ 보정 부재 (인기 상품 in-batch 편향)
이 편에서 새로 알게 된 것
- 추천은 retrieval / ranking 2단계 구조로 나눠 만든다고 한다
- Recall@K가 retrieval의 표준 지표라는 것, 그리고 그 이유(분업, Precision의 무력함)
- 추천 패러다임은 CF / CBF / Two-Tower 등 여러 갈래가 있고, 어느 게 옳다가 아니라 도메인 특성에 맞는 걸 골라야 한다는 것
- ‘churn’, ‘콜드 스타트’, ‘긴 꼬리(long-tail) 분포’ 같은 용어가 도메인 의사결정의 출발점이 된다는 것
- 모델이 안 되는 원인이 코드가 아니라 구조적 결함일 수 있다는 것. 학습이 불가능한 ID 임베딩, 부분만 채워진 텍스트 임베딩, in-batch sampling의 인기 편향이 대표적
- ‘in-batch sampled softmax’, ‘logQ correction’ 같은 학습 기법이 retrieval에서 표준이 되어 있다는 것
여전히 모르는 것
- LightFM의 Python 3.14 wheel이 나왔다면 결과가 어떻게 달랐을지는 결국 비교해보지 못했다.
- 첫 모델의 Recall@20=0.0048이 정확히 어느 결함이 몇 % 기여한 결과인지는 분리 측정하지 못했다. 세 결함이 동시에 작동했기 때문에 가능했던 “총 깨짐”이라고만 말할 수 있다.
다음 편 예고
구조적으로 깨진 모델의 진단이 끝났습니다. 다음 편에서는 치료의 첫 단계, 데이터 기반을 다시 까는 일부터 시작합니다. 1.1M 후보를 142K로 줄이는 결정과, 그 과정에서 함께 결정한 운영 안전 장치(prod DB SELECT-only 추출, 시간 기반 train/val split의 누수 가드)를 다룹니다. 어떻게 “처방”이 시작되는지는 #2에서 이어집니다.
이 시리즈를 쓰는 동안 Claude/ChatGPT 등의 AI 도구의 도움을 받았습니다. 의사결정과 트레이드오프 검토는 본인이 했고, 글의 구조 정리·코드 인용 검증·교차 참조 점검 등에 도구를 활용했습니다. 어느 단계에 어떻게 썼는지는 회고(7편)에서 더 자세히 다룹니다.