비전·음성·추천

DOMAIN / 32번째 글

대규모 추천 서빙: 투타워 후보 생성과 LLM 재랭킹

수십억 아이템에서 후보 수백 개를 뽑는 투타워·ANN 층과, 그 위에서 최종 순위와 추천 이유를 만드는 LLM 층을 한 편에 담았다. 타워 구조와 학습법, 인덱스 선택, 층마다의 지연·비용 예산까지 숫자로 따라간다.

PALDYN Team42 MIN READ

지난 글에서 NCF·Wide&Deep·DIN 같은 딥러닝 추천 모델이 사용자와 아이템의 비선형 상호작용을 어떻게 학습하는지 봤다. 이 글은 그 모델들을 실제로 서비스에 올릴 때 부딪히는 벽과, 지금 업계가 그 벽을 넘는 방식을 다룬다.

벽은 하나이고 형태가 단순하다. 앞 글의 모델들은 사용자와 아이템을 쌍으로 함께 넣어야 점수가 나온다. 아이템이 1억 개면 점수도 1억 번 계산해야 하고, 실시간 추천은 그 일을 수십 밀리초 안에 끝내야 한다. 답은 계산을 빠르게 만드는 것이 아니라 계산할 대상을 줄이는 것이었다. 투타워 모델과 ANN 검색이 후보를 수백 개로 좁히고, 그 위에서 랭킹 모델이, 요즘은 대형 언어 모델까지 최종 순위를 정한다. 이 두 층을 한자리에서 본다.

2단계 추천 구조

쌍 단위 점수

앞 글의 모델들은 입력이 (사용자, 아이템) 쌍이다. 사용자 벡터와 아이템 벡터를 이어 붙여 신경망에 넣고 점수 하나를 받는다. 이 구조에서는 아이템 하나마다 추론이 한 번이다.

숫자로 재 보면 규모가 보인다. 타워 하나가 입력 128차원을 받아 256 → 128 → 64로 줄이는 세 층이라면 곱셈이 128×256+256×128+128×64=73,728128 \times 256 + 256 \times 128 + 128 \times 64 = 73{,}728 회, 대략 7만 번이다. 아이템 1억 개에 이 계산을 각각 돌리면 곱셈만 7조 번이고, 그것도 사용자 한 명의 피드 한 화면을 만들기 위해서다. 동시 접속자가 수만 명이면 이 숫자에 다시 수만을 곱한다.

규모가 큰 쪽은 서비스마다 다르다. Netflix는 카탈로그가 수천 편 규모지만 구독자가 2억 명을 넘고, 그 사람마다 다른 줄을 그려야 한다. TikTok은 반대로 아이템 쪽이 커서, 사용자가 피드를 열 때마다 방대한 동영상 풀에서 수백 밀리초 안에 다음 영상을 고른다. 어느 쪽이 크든 전체 아이템에 모델을 한 번씩 돌리는 방식으로는 성립하지 않는다.

후보 생성과 랭킹의 분업

현대 대규모 추천 시스템은 이 문제를 2단계 파이프라인으로 푼다. 무거운 모델을 전체에 돌리는 대신, 싼 모델로 먼저 좁히고 비싼 모델을 좁혀진 곳에만 쓴다.

후보 생성(retrieval, 또는 recall)은 수십억 개 아이템 중에서 수백~수천 개를 빠르게 추리는 단계다. 랭킹(ranking)은 그 후보에 정교한 모델을 적용해 최종 순위를 정하는 단계다. 후보가 수백 개뿐이라 복잡한 계산을 감당할 수 있다.

전체 아이템 (수십억)
        ↓ 투타워 + ANN 검색
  후보 아이템 (수백~수천)
        ↓ 랭킹 모델
  최종 추천 (수십 개)

두 단계가 겨냥하는 지표도 다르다. 후보 생성은 재현율(recall), 곧 사용자가 좋아했을 아이템 중 몇 개를 건져 올렸는가를 본다. 랭킹은 정밀도를 본다. 이 분업이 성립하는 이유는 두 단계의 실패가 회복 가능성에서 갈리기 때문이다. 후보 생성에서 빠뜨린 아이템은 뒤에서 되살릴 방법이 없다 — 랭킹 모델은 받은 목록 안에서 순서만 바꾼다. 반대로 후보에 엉뚱한 것이 섞이는 실수는 랭킹이 아래로 밀어 내려 준다. 그래서 1단계는 넉넉히 건지는 쪽으로, 2단계는 깐깐하게 거르는 쪽으로 튜닝한다. 두 단계의 지표를 함께 재는 방법은 랭킹 지표에서 따로 다뤘다.

ID 표현의 한계

여기서 미리 짚어 둘 구멍이 하나 있다. 협업 필터링부터 앞 글의 딥러닝 모델까지, 지금까지의 추천 모델은 모두 ID 기반 표현에 기댄다. 사용자 ID 123과 아이템 ID 456을 임베딩 테이블에서 조회해 벡터를 얻고, 그 벡터로 모든 것을 계산한다.

이 방식에는 세 가지가 빠져 있다. 첫째, 콜드 스타트(cold start) — 상호작용 기록이 없는 새 사용자나 새 아이템은 임베딩 테이블에 학습된 자리가 없다. 둘째, 아이템이 들고 있는 풍부한 의미 정보가 버려진다. 줄거리, 리뷰, 맥락은 텍스트로 존재하는데 모델이 보는 것은 번호 하나뿐이다. 셋째, 「최근 등록된 공포 소설 중 심리 스릴러 요소가 강한 것」 같은 복잡한 의도를 표현할 자리가 없다. 사용자가 할 수 있는 일은 클릭뿐이다.

이 세 구멍이 뒤에서 언어 모델을 부르는 이유가 된다. 먼저 후보 생성 층부터 세운다.

투타워 구조

투타워 모델 아키텍처와 서빙 파이프라인

사용자 타워와 아이템 타워

투타워(Two-Tower) 모델은 사용자와 아이템을 완전히 독립된 두 개의 신경망으로 처리하고, 두 출력 벡터를 내적 한 번으로 결합해 점수를 만드는 구조다. 사용자 쪽 신경망을 사용자 타워, 아이템 쪽을 아이템 타워라고 부른다.

구조 자체는 단순하지만 결정적인 성질이 하나 딸려 온다. 아이템 타워가 계산할 때 사용자를 전혀 보지 않는다. 그러니 아이템 임베딩은 사용자가 접속하기 전에 미리 다 만들어 둘 수 있다. 앞 글의 DIN처럼 사용자 이력과 후보 아이템 사이에 어텐션을 계산하는 모델은 이것이 불가능하다 — 어느 사용자가 올지 알아야 값이 정해지기 때문이다. 투타워는 표현력을 조금 내주고 이 사전 계산 가능성을 산 셈이다.

미리 만들어 둔다는 것이 얼마나 큰 이득인지도 숫자로 확인된다. 아이템 1억 개를 64차원 float32 벡터로 저장하면 108×64×4=25.610^8 \times 64 \times 4 = 25.6 GB다. 서버 한 대의 메모리에 올리기에는 크지만, 뒤에서 볼 압축을 거치면 충분히 담긴다. 그리고 이 25.6GB를 만드는 계산은 하루에 한 번이면 된다.

import torch
import torch.nn as nn
import torch.nn.functional as F

class Tower(nn.Module):
    """사용자 타워와 아이템 타워가 같은 모양이다 — 먹는 특성만 다르다."""
    def __init__(self, feat_dim, emb_dim=64):
        super().__init__()
        self.net = nn.Sequential(
            nn.Linear(feat_dim, 256), nn.ReLU(),
            nn.Linear(256, 128), nn.ReLU(),
            nn.Linear(128, emb_dim),
        )

    def forward(self, features):
        return F.normalize(self.net(features), dim=-1)   # L2 정규화

타워의 입력 특성

두 타워는 모양이 같아도 입력이 전혀 다르다. 사용자 타워에는 사용자 ID 임베딩, 나이·지역 같은 인구통계 정보, 최근 클릭·구매 이력, 접속 시간대가 들어간다. 아이템 타워에는 아이템 ID 임베딩, 카테고리, 태그, 제목 텍스트를 인코딩한 벡터, 가격, 클릭률 통계가 들어간다. 출력은 양쪽 다 64~256차원 벡터다.

이 특성 구성에서 실무적인 비대칭이 하나 생긴다. 사용자 타워는 요청이 올 때마다 새로 돌리므로 지금 이 순간의 값을 넣을 수 있다 — 현재 시각, 방금 누른 상품, 이번 세션의 검색어. 아이템 타워는 배치로 미리 돌리므로 넣는 값이 마지막 배치 시점의 것이다. 아이템 특성에 클릭률 통계가 들어 있다면 그 값은 어제 값이다. 최신성이 중요한 서비스라면 이 낡음을 뒤쪽 랭킹 단계에서 보정해야 한다.

내적과 코사인 유사도

두 타워의 출력을 곱해 더하면, 즉 내적하면 점수가 나온다. 위 코드가 마지막에 하는 L2 정규화는 벡터의 길이를 1로 맞추는 연산이고, 길이가 1인 두 벡터의 내적은 정확히 코사인 유사도와 같다. 그래서 점수의 범위가 −1-1 부터 11 사이로 묶인다.

user_emb = user_tower(user_features)          # (B, D)
item_emb = item_tower(item_features)          # (B, D)
score = (user_emb * item_emb).sum(dim=-1)     # (B,) — 정규화했으니 코사인

정규화를 거는 이유는 두 가지다. 하나는 인기 편향이다. 벡터 길이가 자유로우면 모델이 인기 아이템의 벡터를 길게 만들어 버리는 지름길을 택하기 쉽다. 길이만 키우면 어떤 사용자와 내적해도 점수가 높게 나오니, 「많이 클릭된다」는 사실을 「누구에게나 잘 맞는다」로 잘못 배우는 셈이다. 길이를 1로 고정하면 그 손잡이가 사라지고 방향만 남는다. 다른 하나는 뒤에서 쓸 검색 인덱스 대부분이 내적이나 유클리드 거리를 기준으로 만들어져 있어서다. 코사인 유사도로 검색하고 싶으면 미리 정규화해 두고 내적 인덱스를 쓰는 것이 표준적인 방법이다.

투타워 학습

Pointwise의 불균형

가장 단순한 학습법은 (사용자, 아이템) 쌍마다 클릭했으면 1, 안 했으면 0이라는 절대 레이블을 붙여 이진 분류로 푸는 것이다. 손실 함수도 이진 교차 엔트로피 한 줄이면 된다.

구현은 쉽지만 두 군데가 어긋난다. 하나는 불균형이다. 실제 클릭률은 전체 노출의 1%를 밑도는 경우가 흔하고, 그러면 모델이 무조건 0을 내놓기만 해도 정확도 99%가 나온다. 손실이 잘 내려가는데 추천은 하나도 못 하는 상태에 쉽게 갇힌다.

다른 하나가 더 근본적이다. 레이블 0이 무슨 뜻인지 모른다. 사용자가 보고 싫어서 안 누른 것인지, 화면에 뜬 적조차 없어서 안 누른 것인지 로그만으로는 구별되지 않는다. 노출되지 않은 아이템까지 0으로 학습하면 모델은 「사용자가 아직 만나지 못한 것」을 「사용자가 싫어하는 것」으로 배운다. 게다가 후보 생성 단계에는 애초에 절대 점수가 필요 없다. 필요한 것은 순서뿐이다.

Sampled Softmax의 분모

그래서 실전에서는 Sampled Softmax를 쓴다. 사용자가 실제로 클릭한 정답 아이템 하나와 무작위로 뽑은 네거티브 후보 KK 개를 한자리에 놓고, 정답이 가장 높은 점수를 받도록 학습하는 방식이다. K+1K+1 지 선다형 문제를 푸는 셈이고, 정답은 언제나 0번 자리다.

def sampled_softmax_loss(user_emb, pos_item_emb, neg_item_embs):
    # user_emb: (B, D) / pos: (B, D) / neg: (B, K, D)
    pos = (user_emb * pos_item_emb).sum(-1, keepdim=True)            # (B, 1)
    neg = torch.bmm(neg_item_embs, user_emb.unsqueeze(-1)).squeeze(-1)  # (B, K)
    logits = torch.cat([pos, neg], dim=-1)                           # (B, K+1)
    labels = torch.zeros(logits.size(0), dtype=torch.long)           # 0번이 정답
    return nn.CrossEntropyLoss()(logits, labels)

이 손실의 정체는 전체 아이템에 대한 소프트맥스를 KK 개 샘플로 근사한 것이다. 원래대로라면 분모에 1억 개 아이템의 점수가 전부 들어가야 하는데 그것은 계산할 수 없으니, 무작위로 뽑은 몇 개로 분모를 대신한다. KK 를 키우면 근사가 정확해지지만 계산이 그만큼 는다. Google이 2019년 RecSys에 낸 YouTube 논문이 이 구성을 대규모 후보 생성에 쓰면서, 배치 안에서 네거티브를 뽑을 때 생기는 인기 편향을 보정하는 방법을 함께 붙였다. 그 편향이 다음 소절의 주제다.

네거티브 샘플링 전략

분모를 샘플로 대신하는 순간, 그 샘플을 어떻게 뽑느냐가 모델 품질을 좌우한다. 완전 무작위로 뽑으면 대부분이 아무도 안 보는 롱테일 아이템이라 문제가 너무 쉬워진다. 사용자가 클릭한 인기 영화와 조회수 세 자릿수짜리 무명 영상을 구별하는 일은 모델이 첫날에 배운다.

그래서 인기도 기반 샘플링을 쓴다. 인기 있는 아이템일수록 네거티브로 자주 등장시켜, 모델이 「인기 있으니 높은 점수」라는 지름길을 못 쓰게 만든다.

sampling_prob = item_popularity ** 0.75   # 제곱근 쪽으로 눌러 스무딩
sampling_prob /= sampling_prob.sum()
neg_items = np.random.choice(n_items, size=K, p=sampling_prob)

지수 0.75가 하는 일이 이 세 줄의 핵심이다. 인기도를 그대로 쓰면 상위 몇 개 아이템이 네거티브를 독점해 나머지는 학습에 등장하지 못하고, 균등하게 쓰면 다시 롱테일만 뽑힌다. 1보다 작은 지수는 큰 값을 더 많이 눌러 그 사이를 잡는다.

Sampled Softmax와 네거티브 샘플링

ANN 후보 검색

정확 탐색의 비용

학습이 끝나면 아이템 임베딩을 전부 오프라인으로 계산해 둘 수 있다. 실시간에 남는 일은 사용자 임베딩 하나를 만들고, 미리 쌓아 둔 아이템 벡터 중 가장 가까운 KK 개를 찾는 것뿐이다.

문제는 이 「가장 가까운 것 찾기」도 만만치 않다는 데 있다. 정확한 최근접 이웃을 구하려면 모든 아이템과 내적을 계산해 정렬해야 하고, 이것은 아이템 수 NN 과 차원 DD 에 대해 O(N⋅D)O(N \cdot D) 다. 1억 개 아이템에 64차원이면 요청 하나마다 곱셈 64억 번이다. 앞에서 신경망을 1억 번 돌리는 것보다는 훨씬 싸지만 여전히 수십 밀리초 예산에는 들어가지 않는다.

ANN(Approximate Nearest Neighbor, 근사 최근접 이웃)은 정확도를 조금 내주고 속도를 얻는 방법이다. O(log⁡N)O(\log N) 이나 O(D⋅N)O(D \cdot \sqrt{N}) 수준으로 떨어진다. 여기서 내주는 「정확도」란 진짜 상위 KK 개 중 몇 개를 실제로 건졌는가, 곧 재현율이다. 그리고 이 단계가 후보 생성이라는 점이 중요하다 — 완벽할 필요가 없다. 어차피 뒤의 랭킹이 다시 정렬하고, 후보 100개 중 두세 개를 놓치는 것과 지연이 열 배로 뛰는 것 중에는 앞쪽이 낫다.

IVF-PQ와 HNSW

Meta가 공개한 FAISS(Facebook AI Similarity Search)가 이 영역의 표준 라이브러리다. GPU 백엔드까지 갖췄고 여러 인덱스 방식을 한 인터페이스로 제공한다. 다만 인덱스의 기본 거리 척도가 유클리드라, 앞 절에서 정규화까지 해 놓고 내적으로 검색하려면 거리 척도를 생성자에 직접 넘겨야 한다. 빠뜨려도 오류가 나지 않고 순위만 조용히 달라지는 자리다.

import faiss

IP = faiss.METRIC_INNER_PRODUCT      # 정규화했으니 내적 = 코사인

# 소규모: 정확한 내적 검색
index_exact = faiss.IndexFlatIP(64)

# 대규모: IVF로 나누고 PQ로 압축
quantizer = faiss.IndexFlatIP(64)    # 변수로 붙들어 둔다 (인덱스가 소유하지 않는다)
index_ivfpq = faiss.IndexIVFPQ(quantizer, 64, 1000, 8, 8, IP)
index_ivfpq.train(item_embeddings)
index_ivfpq.add(item_embeddings)
index_ivfpq.nprobe = 50              # 1000개 클러스터 중 50개만 훑는다

# 그래프 기반: 정확도가 높고 삽입이 빠르다
index_hnsw = faiss.IndexHNSWFlat(64, 32, IP)   # 노드당 연결 32개
index_hnsw.add(item_embeddings)
scores, ids = index_hnsw.search(user_emb.detach().numpy(), k=100)

코드에 들어 있는 두 기법을 풀어 둔다. IVF(Inverted File)는 아이템을 미리 여러 클러스터로 나눠 두고 검색할 때 가까운 클러스터 몇 개만 보는 방식이다. 클러스터 수는 FAISS 문서가 데이터 100만 개 아래면 4N4\sqrt{N} 에서 16N16\sqrt{N} 사이를, 그보다 크면 수십만 개 규모를 권한다 — 위 코드의 1000은 손잡이의 동작을 보이려고 작게 잡은 값이다. PQ(Product Quantization, 곱 양자화)는 벡터를 압축한다 — 64차원을 8차원짜리 조각 여덟 개로 쪼개고, 각 조각을 미리 학습한 256개 대표값 중 하나의 번호로 바꾼다. 번호 하나가 1바이트이므로 벡터 하나가 8바이트가 되고, 앞에서 계산한 25.6GB가 800MB로 줄어든다. 32배 압축이고, 이 차이가 인덱스를 서버 메모리에 통째로 올릴 수 있느냐 없느냐를 가른다.

HNSW(Hierarchical Navigable Small World)는 접근이 다르다. 벡터들을 그래프로 잇고 가까운 이웃을 따라 걸어가며 답을 좁힌다. IVF-PQ보다 검색 정확도가 높고, 새 아이템 하나를 그래프에 끼워 넣는 온라인 삽입이 빠르다. 대신 압축을 하지 않으니 메모리를 그대로 먹는다. 고르는 기준은 어느 쪽이 벽이냐다 — 메모리가 벽이면 IVF-PQ, 정확도와 실시간 삽입이 벽이면 HNSW다.

FAISS 기반 투타워 추론

재현율과 지연의 교환

두 인덱스 모두 손잡이가 하나씩 있다. IVF의 nprobe는 몇 개 클러스터를 훑을지, HNSW의 efSearch는 그래프를 얼마나 넓게 탐색할지 정한다. 둘 다 올리면 재현율이 오르고 지연도 오른다.

IVF 쪽은 비율만 보면 감이 잡힌다. 전체를 클러스터 LL 개로 나누고 그중 nprobe 개를 훑으면 실제로 계산하는 양이 nprobe/L\text{nprobe}/L 이다. 위 코드처럼 클러스터가 1000개이고 nprobe=50이면 5%만 계산하니 정확 탐색보다 스무 배 빠르고, 훑지 않은 95% 안에 정답이 있으면 그대로 놓친다. nprobe를 200으로 올리면 20%를 훑어 재현율이 오르지만 지연도 네 배가 된다.

프로덕션에서는 이 손잡이를 P99 레이턴시 목표에 맞춰 튜닝한다. P99란 요청 100건을 빠른 순으로 줄 세웠을 때 99번째, 곧 가장 느린 1%가 시작되는 지점의 응답 시간이고, 흔히 20밀리초 같은 값을 목표로 잡는다. 평균이 아니라 P99를 보는 이유가 있다. 피드 한 화면은 보통 여러 요청의 결과를 모아 그리므로, 가장 느린 하나가 화면 전체가 뜨는 시각을 정한다. 평균이 5밀리초여도 P99가 300밀리초면 요청 백 건에 한 번은 300밀리초를 넘고, 화면 하나가 여러 요청으로 그려지니 사용자 눈에는 그보다 자주 나타난다.

LLM 재랭킹

LLM 기반 추천 아키텍처

컨텍스트 창과 후보 수

여기서 첫 절에 남겨 둔 세 구멍으로 돌아온다. 대형 언어 모델은 텍스트로 표현된 것이면 무엇이든 읽으므로, ID 임베딩이 놓치던 의미 정보와 자연어 의도를 그대로 받아들인다. 가장 단순한 활용은 사용자 이력과 후보 목록을 프롬프트에 담아 순서를 정하게 하는 것이다.

import anthropic

client = anthropic.Anthropic()

history_str = "\n".join(f"- {h}" for h in user_history[-10:])
items_str = "\n".join(
    f"{i+1}. [{c['id']}] {c['title']}: {c['description'][:80]}"
    for i, c in enumerate(candidates)
)

prompt = f"""사용자 최근 시청 이력:
{history_str}

후보 콘텐츠 목록:
{items_str}

위 이력을 바탕으로 사용자가 가장 좋아할 순서로 번호를 나열하고,
각 추천 이유를 한 줄로 설명하세요. JSON 형식으로 응답하세요."""

resp = client.messages.create(
    model="claude-haiku-4-5", max_tokens=1024,
    messages=[{"role": "user", "content": prompt}],
)

강력하지만 후보가 많아지면 곧바로 막힌다. 아이템 하나를 제목과 설명 80자로 줄여 적어도 100개면 프롬프트가 1만 자 근처이고, 후보를 1000개로 늘리면 열 배가 된다. 컨텍스트 창에 들어가더라도 그 길이만큼 입력 토큰 요금과 응답 지연이 그대로 따라 붙는다.

그래서 이 글의 앞뒤가 여기서 맞물린다. 투타워와 ANN으로 Top-100까지 좁힌 다음 LLM에게 Top-10을 고르게 하는 것이 실전 구성이다. 후보 생성 층이 없으면 LLM 층은 성립하지 않고, LLM 층이 없으면 후보 생성은 의미를 읽지 못한다.

텍스트 임베딩과 콜드 스타트

LLM을 순위 매기기에만 쓰는 것은 아니다. 텍스트를 벡터로 바꾸는 임베딩 능력만 떼어 쓰면 사용자와 아이템을 의미 공간에 놓을 수 있다. 사용자 프로필은 최근 이력의 임베딩을 가중 평균해 만드는데, 오래된 이력일수록 가중치를 지수로 줄이는 것이 보통이다.

def build_user_profile(history: list[str]) -> np.ndarray:
    embeddings = [embed_text(h) for h in history]
    weights = np.exp(np.linspace(-1, 0, len(embeddings)))  # 최근일수록 큰 값
    return np.average(embeddings, axis=0, weights=weights / weights.sum())

이 방식의 진짜 값어치는 콜드 스타트 해결에 있다. 새로 등록된 아이템도 제목과 설명 텍스트만 있으면 즉시 임베딩이 나오고 바로 추천 풀에 들어간다. 상호작용 기록이 한 건도 없어도 된다.

투타워와 비교해 두면 경계가 분명해진다. 투타워도 아이템 특성으로 새 아이템의 임베딩을 만들 수 있지만, 학습 때 정해 둔 특성 스키마 안에서만 가능하다. 새 카테고리가 생기면 임베딩 테이블에 그 값의 자리가 없다. 텍스트 임베딩에는 스키마가 없어서 그 제약이 걸리지 않는다. 문장 단위 임베딩이 어떻게 만들어지는지는 문장 임베딩에서 따로 다뤘다.

한 걸음 더 나가면 RAG(Retrieval-Augmented Generation, 검색으로 가져온 자료를 프롬프트에 넣어 생성하게 하는 방식)를 추천에 그대로 적용할 수 있다. 사용자의 자연어 요청을 임베딩해 벡터 DB에서 후보를 뽑고, 그 후보를 LLM에게 넘겨 재랭킹과 설명을 함께 받는다.

SELECT id, title, description, 1 - (embedding <=> %s::vector) AS score
FROM items
ORDER BY embedding <=> %s::vector
LIMIT 30;

PostgreSQL의 벡터 확장인 pgvector를 쓰면 위처럼 SQL 한 줄로 ANN 검색이 된다. 구조는 앞 절의 FAISS와 같고 인덱스가 데이터베이스 안에 있을 뿐이다. 이 경로에서 비로소 「최근 등록된 공포 소설 중 심리 스릴러 요소가 강한 것」 같은 요청이 그대로 입력이 된다. 검색 쪽 설계는 RAG 기초에서 더 깊이 다뤘다.

LLM 기반 추천 구현 패턴

오프라인 속성 추출

지금까지는 LLM을 서빙 경로에 두는 방식이었다. 정반대 쪽에 훨씬 싼 활용이 하나 있다. LLM을 추론에 쓰지 않고 특성을 만드는 데만 쓰는 것이다.

아이템 설명 텍스트를 소형 모델에 넣어 장르, 감정 톤, 대상 연령대, 핵심 주제 같은 구조화된 속성을 뽑아낸다. 그렇게 만든 속성 테이블은 그대로 아이템 타워의 입력 특성이 된다.

prompt = f"""다음 콘텐츠 설명에서 추천에 쓸 속성을 JSON으로 추출하세요.
설명: {item_desc}
- genre: 장르 목록  - tone: 밝음/어두움/중립
- age_group: 대상 연령대  - themes: 핵심 주제 키워드 3개"""

resp = client.messages.create(
    model="claude-haiku-4-5", max_tokens=256,     # 저비용 모델로 배치 처리
    messages=[{"role": "user", "content": prompt}],
)

이 방식의 장점은 온라인 지연이 0이라는 것이다. 서빙 경로에는 투타워와 ANN만 남고, LLM이 만든 것은 이미 테이블에 들어 있는 숫자와 범주값이다. 비용도 아이템당 한 번뿐이라 소형 모델로 배치를 돌리면 수백만 건도 감당된다.

덤으로 앞 절의 콜드 스타트를 투타워 쪽으로 되돌려 놓는다. 새 아이템의 설명에서 속성을 뽑아 아이템 타워에 넣으면, 텍스트 임베딩 파이프라인을 따로 두지 않아도 그 아이템이 후보 풀에 들어간다. LLM을 쓰되 실시간 예산은 하나도 쓰지 않는 자리다.

추천 이유의 자연어 생성

LLM 층의 마지막 쓰임은 순위가 아니라 설명이다. 「이 영화를 추천하는 이유: 최근 즐겨 보신 감독 특유의 가족 드라마 스타일과 비슷하고, 선호하시는 블랙 코미디 요소가 강합니다.」 같은 문장을 사용자 프로필과 아이템 정보만으로 만들어 낸다. 숫자 점수만 있던 자리에 사람이 읽을 수 있는 근거가 생기는 것이고, 이것이 LLM 추천의 가장 뚜렷한 차별점이다.

다만 여기에 조용한 함정이 있다. 순위를 정한 것과 설명을 만든 것이 서로 다른 모델이다. 순서는 투타워 점수와 랭킹 모델이 정했는데 설명은 LLM이 나중에 붙인다. LLM은 그 점수가 왜 높았는지 알 방법이 없으므로, 그럴듯하지만 실제 근거가 아닌 이유를 쓸 수 있다. 사후 합리화인 셈이다.

설명을 진짜 근거에 묶으려면 설명 생성에 넘기는 재료를 순위 근거와 맞춰야 한다. 앞 절의 속성 추출이 여기서 다시 쓰인다 — 랭킹에 실제로 들어간 특성값과 사용자 프로필의 대응하는 항목을 프롬프트에 함께 넣으면, LLM이 지어내는 대신 주어진 것을 문장으로 옮기는 일을 하게 된다.

서빙 파이프라인

층별 지연 예산

이제 두 층을 한 표에 놓을 수 있다. 실제 프로덕션은 비용과 지연을 기준으로 층을 나눈다.

단계 방법 후보 수 지연
Recall 투타워 + ANN 벡터 검색 10만 → 100 ~10ms
Pre-rank 경량 신경망 100 → 30 ~20ms
Re-rank LLM (소형 모델) 30 → 10 ~200ms
Explain LLM (중형 모델) Top-5 ~500ms

이 표를 읽는 방법이 하나 있다. 위에서 아래로 갈수록 아이템 하나당 단가가 몇 자릿수씩 오른다. ANN 검색은 아이템 하나를 보는 데 마이크로초 단위가 들지만 LLM은 밀리초 단위가 든다. 그래서 층마다 개수를 줄여 다음 층의 단가를 감당한다. 10만 → 100 → 30 → 10이라는 숫자는 임의로 정한 것이 아니라 각 층의 단가에 예산을 나눠 준 결과다.

합치면 730밀리초이고 그중 700밀리초가 LLM 몫이다. 사용자가 피드를 여는 순간 730밀리초를 기다리게 할 수는 없으므로 마지막 층은 보통 비동기로 뺀다. 목록을 먼저 그리고 설명은 뒤늦게 채워 넣거나, 상위 다섯 개에만 붙인다. 앞의 세 층까지 230밀리초는 사용자가 견디는 범위다.

오프라인과 온라인의 분리

투타워가 이 예산 안에 들어가는 것은 계산을 시간축으로 갈라 두었기 때문이다.

[오프라인 — 매일 또는 매주 배치]
item_tower(모든 아이템) → 임베딩 배열
→ ANN 인덱스 구축 → 인덱스 파일 저장

[온라인 — 수십 ms]
user_tower(사용자 특성) → 사용자 임베딩 (단 1회 추론)
→ index.search(user_emb, k=100) → Top-100 후보 ID
→ 랭킹 모델 → Top-10 최종 추천

오프라인 쪽은 전체 아이템의 임베딩을 다시 만들고 인덱스를 새로 세운다. 새 아이템이 중간에 들어오면 그 아이템의 임베딩만 계산해 인덱스에 밀어 넣는다 — HNSW의 빠른 삽입이 여기서 값을 한다. 온라인 쪽에 남는 신경망 추론은 딱 한 번, 사용자 타워뿐이다. 첫 절의 1억 번이 1번이 되었다.

실제 서비스에서는 인덱스를 추천 서버 안에 두는 대신 전용 벡터 DB에 올리고 gRPC로 노출하는 구성을 많이 쓴다. Milvus, Weaviate 같은 제품이 그 자리에 있고, 규모가 작으면 Redis에 얹기도 한다. 인덱스를 서비스로 분리해 두면 추천 서버를 배포할 때마다 25.6GB를 다시 올리지 않아도 된다.

랭킹과 재순위

후보가 좁혀진 다음 층은 여러 모델이 나눠 맡는다. Wide&Deep이나 DCN 같은 앞 글의 구조가 그대로 쓰이는데, 이번에는 전체가 아니라 후보 수백 개에만 적용하므로 복잡한 특성 교차와 맥락 정보를 아낌없이 넣을 수 있다. Meta가 공개한 DLRM(Deep Learning Recommendation Model)은 수천 개의 희소 특성과 밀집 특성을 함께 다루도록 만든 대규모 랭킹 모델이다.

그 위에 한 층이 더 있다. YouTube의 추천은 투타워로 수백 개 후보를 만들고 별도의 랭킹 DNN으로 최종 수십 개를 정한다. TikTok은 여기에 재순위(re-ranking) 단계를 더 두어 다양성과 신선도까지 고려한다. 같은 채널 영상이 연달아 나오지 않게 하거나, 방금 올라온 것을 위로 올리는 조정이 이 자리에서 일어난다.

후보 생성 (투타워 + ANN) → 100~1000개
        ↓
  1차 랭킹 (Wide&Deep, DLRM) → 50~100개
        ↓
  재순위 (다양성·신선도 고려) → 최종 10~20개
        ↓
  사용자 피드 노출

앞 절의 LLM 재랭킹이 정확히 이 재순위 자리에 들어간다. 다양성과 신선도를 지금까지는 규칙으로 적었다 — 같은 채널 최대 두 개, 30일 이내 콘텐츠 최소 세 개 같은 식이다. LLM 층에서는 그 조건을 지시문으로 적을 수 있고, 규칙으로 표현하기 어려운 조합도 문장으로 넘길 수 있다. 다만 규칙은 결과가 결정적인데 지시문은 그렇지 않으므로, 반드시 지켜야 하는 제약은 여전히 코드로 검사하는 편이 안전하다.

LLM 층의 제약

LLM 층을 어디까지 밀어 넣을지는 네 가지 제약이 정한다.

한계 내용
비용 사용자 요청마다 API 호출 비용이 붙는다
지연 밀리초 단위가 불가능하다 (수백ms~수s)
규모 전체 아이템을 실시간으로 훑을 수 없다
환각 존재하지 않는 아이템을 만들어 낼 수 있다

마지막 줄은 반드시 코드로 막아야 하는 항목이다. LLM에게 후보 목록을 주고 순서를 받으면 목록에 없던 ID가 섞여 나오거나, 존재하는 ID인데 제목이 다르게 적혀 나오는 일이 생긴다. 응답으로 받은 ID를 원래 후보 집합과 대조해 걸러 내는 검증을 넣는다. 통과하지 못한 항목은 버리고 앞 단계 점수 순으로 채우면 된다.

네 제약이 가리키는 결론은 앞 절의 지연 표가 이미 그려 놓은 것과 같다. LLM을 파이프라인 전체에 두르지 말고 Recall → Re-rank → Explain의 마지막 자리에만 투입하는 것이다.

그런데 이 파이프라인 전체가 조용히 깔고 있는 전제가 하나 있다. 과거 로그가 정답이라는 것이다. 사용자가 클릭한 것을 1로 두고 맞히도록 학습했고, 지금 보여 준 목록이 내일의 학습 데이터가 된다. 우리가 보여 주지 않은 아이템은 클릭될 기회조차 없었으므로 영영 0으로 남는다. 추천이 데이터를 만들고 그 데이터가 다시 추천을 만드는 이 되먹임을 정면으로 다루려면, 행동을 고르고 그 결과로 돌아온 신호에서 배우는 틀이 필요하다. 다음 글에서는 그 틀을 세운다 — 무엇이 행동이고 무엇이 그 행동을 평가하는 신호인지부터 정의하는 자리다.


읽어주셔서 감사합니다. 😊

LATEST

비전·음성·추천의 최신 글

비전·음성·추천2026.09.07

오프라인 강화학습 — 쌓인 로그만으로 정책을 배우기

실제 서비스에서 탐색은 곧 사용자에게 나쁜 행동을 해 보는 일입니다. 이미 쌓인 로그만으로 정책을 배우려 할 때 왜 Q값이 혼자 부풀어 오르는지, 그 부풀음을 누르는 세 갈래 대응, 행동 복제라는 기준선의 무게, 그리고 배포 전에 성능을 재는 일이 왜 가장 어려운지를 정리합니다.

18 MIN
비전·음성·추천2026.09.07

다국어 전이 — 라벨 없는 언어에서 모델이 동작하는 이유

영어 라벨만으로 학습한 분류기가 한국어 문장을 그대로 처리하는 일이 실제로 일어납니다. 여러 언어가 한 표현 공간에 겹쳐 놓이는 원리, 그 겹침이 무너지는 조건, 번역해서 학습할지 번역해서 추론할지 고르는 기준, 그리고 언어별로 나눠 재야 하는 이유를 정리합니다.

16 MIN
비전·음성·추천2026.09.07

정보 추출 — 글 한 덩이를 표 한 줄로 바꾸는 일

계약서와 이메일을 데이터베이스에 넣으려면 글에서 값을 뽑아 칸에 채워야 합니다. 개체명·관계·사건의 세 층위, 값을 정규화하는 일이 왜 절반인지, 근거 위치를 함께 남겨야 하는 이유, 규칙·전용 모델·언어 모델의 갈림길, 그리고 필드별로 재는 평가법을 정리합니다.

17 MIN