Hun-Bot

26년 반기를 돌아보면서

tag1 tag2

추천 엔진 제작기 02: 수학으로 보는 술 추천과 장소 추천

추천 시스템을 제품으로 만들 때 자주 생기는 오해가 있다.

“AI 모델을 넣으면 추천이 좋아질 것이다.”

실제로는 순서가 반대다. 먼저 추천 문제를 수학적으로 정의하고, 어떤 데이터를 ranking에 사용할 수 있는지 정리하고, 로그와 label을 쌓아야 한다. 그 다음에야 ML 모델이 의미가 있다.

이 글은 우리 recommendation-service의 scoring 구조를 수학적으로 설명한다.

문제 정의

우리는 크게 두 가지 추천을 한다.

  1. 사용자에게 맞는 음료 추천
  2. 선택한 음료를 마실 수 있는 장소 추천

음료 추천은 다음 문제다.

\operatorname{rank}(i \mid u, q)

여기서:

  • u: 사용자 taste profile
  • i: candidate beverage
  • q: 요청 조건, 예를 들어 category, budget mode, limit

장소 추천은 다음 문제다.

\operatorname{rank}(j \mid u, b, l, q)

여기서:

  • j: candidate venue
  • b: selected beverage
  • l: 사용자 위치
  • 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 similarity
  • C_i: category/style fit
  • E_i: experience fit, 예를 들어 beginner-friendly
  • P_i: popularity 또는 curated quality signal
  • Q_i: catalog quality/confidence
  • R_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 confidence
  • D_j: distance fit
  • B_j: budget/price fit
  • F_j: snapshot freshness
  • M_j: menu/inventory match confidence
  • O_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가 선호한 item
  • j: 관측되지 않았거나 negative인 item
  • s_ui: user u에 대한 item i score
  • Theta: 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 모델을 안전하게 올릴 수 있는 기반을 만드는 단계다.

series 이름 4 / 14

목차

댓글