26년 반기를 돌아보면서
추천 엔진 제작기 02: 수학으로 보는 술 추천과 장소 추천
추천 시스템을 제품으로 만들 때 자주 생기는 오해가 있다.
“AI 모델을 넣으면 추천이 좋아질 것이다.”
실제로는 순서가 반대다. 먼저 추천 문제를 수학적으로 정의하고, 어떤 데이터를 ranking에 사용할 수 있는지 정리하고, 로그와 label을 쌓아야 한다. 그 다음에야 ML 모델이 의미가 있다.
이 글은 우리 recommendation-service의 scoring 구조를 수학적으로 설명한다.
문제 정의
우리는 크게 두 가지 추천을 한다.
- 사용자에게 맞는 음료 추천
- 선택한 음료를 마실 수 있는 장소 추천
음료 추천은 다음 문제다.
\operatorname{rank}(i \mid u, q)
여기서:
u: 사용자 taste profilei: candidate beverageq: 요청 조건, 예를 들어 category, budget mode, limit
장소 추천은 다음 문제다.
\operatorname{rank}(j \mid u, b, l, q)
여기서:
j: candidate venueb: selected beveragel: 사용자 위치q: radius, budget mode, limit
Taste Vector
사용자 취향은 taste_v1 vector로 표현한다.
u =
\left[
u_1, u_2, \dots, u_d
\right]
\in \mathbb{R}^{d}
음료 후보도 같은 schema의 vector를 가진다.
v_i =
\left[
v_{i,1}, v_{i,2}, \dots, v_{i,d}
\right]
\in \mathbb{R}^{d}
차원 예시는 다음과 같다.
- sweet
- acidity
- bitterness
- tannin
- smoky
- woody
- fruity
- floral
- herbal
- body
- carbonation
- alcohol intensity
정확한 vector schema는 docs/recommendation/vector-schema.md에 둔다.
Weighted Cosine Similarity
기본 taste score는 weighted cosine similarity로 설명할 수 있다.
T(u, v_i) =
\frac{
\sum_{k=1}^{d} w_k u_k v_{i,k}
}{
\sqrt{\sum_{k=1}^{d} w_k u_k^2}
\sqrt{\sum_{k=1}^{d} w_k v_{i,k}^2}
}
여기서 w_k는 dimension별 중요도다. 예를 들어 위스키 추천에서는 smoky, woody, body가 더 중요할 수 있고, 와인 추천에서는 acidity, tannin, fruity가 더 중요할 수 있다.
category-specific weight를 쓰는 이유는 vector 의미를 매번 바꾸지 않기 위해서다. vector schema는 안정적으로 유지하고, scoring config만 versioning해서 바꾼다.
음료 추천 Score
음료 추천의 단순화된 final score는 다음처럼 볼 수 있다.
S_i^{bev} =
\alpha T(u, v_i)
+ \beta C_i
+ \gamma E_i
+ \delta P_i
+ \epsilon Q_i
+ \zeta R_i
각 항:
T(u, v_i): taste similarityC_i: category/style fitE_i: experience fit, 예를 들어 beginner-friendlyP_i: popularity 또는 curated quality signalQ_i: catalog quality/confidenceR_i: reason-code coverage 또는 explanation readiness
현재 production ranker는 이 score를 deterministic하게 계산한다. 즉 같은 profile revision, 같은 catalog state, 같은 scoring config version이면 같은 결과가 나와야 한다.
재현성 조건은 다음 tuple로 표현할 수 있다.
R =
\left(
profile\_revision,
catalog\_revision,
vector\_schema\_version,
mapper\_version,
scoring\_config\_version,
request\_filters
\right)
같은 R이면 같은 ranking이 나와야 한다.
장소 추천 Score
장소 추천은 맛만 보면 안 된다. “얼마나 가까운가”와 “가격이 어떤가”가 들어가야 한다.
선택한 음료 b를 마실 수 있는 venue 후보 j에 대해 score를 다음처럼 둘 수 있다.
S_j^{venue} =
\alpha A_j
+ \beta D_j
+ \gamma B_j
+ \delta F_j
+ \eta M_j
+ \rho O_j
각 항:
A_j: selected beverage availability confidenceD_j: distance fitB_j: budget/price fitF_j: snapshot freshnessM_j: menu/inventory match confidenceO_j: option type adjustment, 예를 들어 nearest, best price, balanced
거리 Score
사용자 위치와 venue 위치 사이의 거리를 dist_j라고 하자.
가까울수록 score가 높아야 하지만, 100m와 200m 차이는 크고 4.9km와 5km 차이는 작게 느껴질 수 있다. 그래서 exponential decay를 사용할 수 있다.
D_j = \exp\left(-\frac{dist_j}{\tau_d}\right)
여기서 tau_d는 거리 민감도다.
tau_d가 작으면 가까운 장소를 강하게 선호한다.tau_d가 크면 거리 차이에 덜 민감해진다.
radius hard filter는 별도다.
dist_j \leq radius
이 조건을 통과한 후보만 soft ranking에 들어간다.
가격 Score
사용자의 선호 가격 중심을 b라고 하고, venue에서의 가격을 p_j라고 하자. 부드러운 가격 score는 Gaussian 형태로 볼 수 있다.
B_j =
\exp\left(
-\frac{(p_j - b)^2}{2\sigma_b^2}
\right)
여기서 sigma_b는 가격 허용 폭이다.
budget mode가 strict라면 hard filter가 먼저 적용된다.
p_j \in [b_{min}, b_{max}]
budget mode가 soft라면 가격이 조금 벗어나도 score에서만 감점한다.
가용성과 Freshness
재고나 메뉴 snapshot은 시간이 지나면 신뢰도가 떨어진다. snapshot age를 다음처럼 둔다.
age_j = now - snapshot\_created\_at
freshness score는 다음처럼 둘 수 있다.
F_j =
\begin{cases}
1.0 & \text{if fresh} \\
0.5 & \text{if stale} \\
0.0 & \text{if expired}
\end{cases}
가용성도 상태값을 numeric confidence로 매핑할 수 있다.
A_j =
\begin{cases}
1.0 & \text{available} \\
0.7 & \text{likely available} \\
0.4 & \text{unknown} \\
0.0 & \text{unavailable}
\end{cases}
하지만 중요한 점은 이것들이 canonical truth가 아니라 snapshot 기반 signal이라는 것이다. canonical owner는 map-service/place-service다.
최종 Ranking
후보 집합을 I라고 하면 top-k 추천은 다음이다.
TopK(u, I) =
\operatorname{arg\,top}_{i \in I, k}
S_i
hard filter를 통과한 후보 집합은 다음처럼 쓸 수 있다.
I' =
\left\{
i \in I
\mid active(i)
\land allowed(i, q)
\land notBlocked(i)
\right\}
최종 추천은 I'에서 score가 높은 순서다.
Explanation
추천 설명은 score에서 바로 만들어지지 않는다. 우리는 score breakdown과 reason code를 저장한다.
예:
MATCHES_SMOKY_PROFILE
MATCHES_ROUNDED_BODY
BEGINNER_FRIENDLY
WITHIN_BUDGET
NEARBY_VENUE
BEST_PRICE
BALANCED_BEST
score contribution을 reason code로 바꾸는 함수를 다음처럼 생각할 수 있다.
g: \mathbb{R}^{m} \rightarrow \mathcal{C}
여기서:
m: score component 개수C: reason code set
LLM이 문장을 자연스럽게 바꿀 수는 있지만, reason code와 score contribution을 바꾸면 안 된다.
Interaction Label
추천 결과에 대해 사용자 행동을 기록하면 label 후보가 생긴다.
현재 interaction event:
- impression
- click
- save
- dismiss
- detail_view
단순 label 정의:
y_i^{positive} =
click_i + save_i + detailView_i
y_i^{negative} =
dismiss_i
하지만 이것만으로는 부족하다. impression 없이 click만 있으면 CTR을 계산할 수 없다.
CTR =
\frac{\#clicks}{\#impressions}
그래서 Plan 011에서 impression coverage를 중요하게 본다.
Offline Evaluation
초기 deterministic ranker는 fixture 기반 평가를 한다.
대표 metric:
HitRate@K =
\frac{1}{N}
\sum_{n=1}^{N}
\mathbb{1}
\left[
relevant_n \cap TopK_n \neq \emptyset
\right]
NDCG는 rank 위치를 반영한다.
DCG@K =
\sum_{i=1}^{K}
\frac{2^{rel_i} - 1}{\log_2(i + 1)}
NDCG@K =
\frac{DCG@K}{IDCG@K}
negative violation은 싫어하는 후보가 높은 순위에 올라오는지 본다.
NV@K =
\sum_{i=1}^{K}
\mathbb{1}
\left[
item_i \in NegativeSet
\right]
현재 release gate에서는 fixture 20개 기준으로 top-k hit rate와 negative violation을 검증한다.
Candidate Model의 학습 목적 함수
충분한 label이 생기면 pairwise ranking loss를 쓸 수 있다. BPR 형태의 loss는 다음처럼 표현된다.
\mathcal{L}_{BPR}
=
- \sum_{(u, i, j) \in D}
\log \sigma
\left(
\hat{s}_{u,i} - \hat{s}_{u,j}
\right)
+ \lambda \lVert \Theta \rVert_2^2
여기서:
i: user가 선호한 itemj: 관측되지 않았거나 negative인 items_ui: useru에 대한 itemiscoreTheta: model parameter
클릭/저장/상세보기 같은 binary label이면 logistic loss도 가능하다.
\mathcal{L}_{log}
=
- \sum_{n=1}^{N}
\left[
y_n \log \hat{p}_n
+ (1-y_n)\log(1-\hat{p}_n)
\right]
하지만 이 loss를 쓴다고 바로 production model이 되는 것은 아니다. 먼저 label quality와 shadow evaluation이 필요하다.
Shadow Scoring
Plan 011의 핵심은 live ranking을 바꾸지 않고 candidate model을 비교하는 것이다.
baseline score:
S_i^{base}
candidate score:
S_i^{model}
rank delta:
\Delta rank_i =
rank_i^{model} - rank_i^{base}
우리가 보고 싶은 것은 평균 metric만이 아니다.
- beginner user에서 나빠졌는가?
- budget-sensitive user에서 나빠졌는가?
- venue distance가 과하게 멀어졌는가?
- best price 옵션이 사라졌는가?
- 특정 category가 과하게 노출되는가?
그래서 shadow report에는 slice metric이 들어가야 한다.
왜 아직 deterministic scoring인가
현재 deterministic scoring을 유지하는 이유는 보수적이어서가 아니라 production 관점에서 맞기 때문이다.
ML 모델이 production ranker가 되려면 최소한 다음이 필요하다.
- 충분한 impression
- 충분한 positive/negative label
- label quality audit pass
- feature leakage guard
- offline metric pass
- shadow comparison pass
- canary rollout plan
- rollback command
- model version logging
이게 없으면 “AI 모델”은 정확도를 올리는 도구가 아니라 장애 원인을 숨기는 도구가 된다.
정리
우리 추천 엔진의 현재 수학적 핵심은 다음이다.
structured\ profile
+ structured\ catalog
+ structured\ map\ snapshot
+ versioned\ scoring
+ logged\ interactions
그리고 다음 단계는:
deterministic\ ranker
\rightarrow offline\ candidate
\rightarrow shadow\ comparison
\rightarrow reviewed\ canary
이다.
즉 지금은 “AI 모델이 없는 추천 엔진”이 아니라, AI 모델을 안전하게 올릴 수 있는 기반을 만드는 단계다.
댓글