평가 세트 드리프트: 골든 세트가 오래됐는지 확인하는 방법
출처: Dev.to
도서: LLM Observability Pocket Guide: Picking the Right Tracing & Evals Tools for Your Team
또 다른 저서: Thinking in Go (2권 시리즈) — Go 프로그래밍 완전 정복 + Go에서의 헥사고날 아키텍처
내 프로젝트: Hermes IDE | GitHub — Claude Code 및 기타 AI 코딩 도구와 함께 배포하는 개발자를 위한 IDE
개인 사이트: xgabriel.com | GitHub
당신의 골든 평가 세트는 3월에 괜찮았습니다. 이제 12월이 되었고, 실제 운영에서 보는 절반 이상의 쿼리는 평가 세트에 전혀 없는 형태입니다. 대시보드에는 여전히 98% 통과율이 표시되지만, 이는 거짓입니다. 왜냐하면 현재 측정하고 있는 테스트 세트가 몇 달 전부터 실제 워크로드와 닮지 않게 되었기 때문입니다.
이것이 평가 드리프트(eval drift)입니다. 모델 회귀(regression)의 조용한 버전이라고 할 수 있습니다: 아무것도 깨지지 않고, 초록 체크표시가 계속 뜨며, 우리는 눈을 가린 채 진행하고 있습니다.
아래 세 가지 현상이 보통 동시에 일어납니다.
-
도메인 이동
새로운 제품 화면, 새로운 사용자 세그먼트, 새로운 통합 등. 1분기에 청구 및 로그인 질문을 중심으로 만든 지원봇 평가 세트는 3분기에 출시한 환불 자동화 워크플로에 대해 전혀 설명하지 못합니다. 평가 세트 자체가 나빠진 것이 아니라, 다루는 세계가 축소된 것입니다. -
분포 이동
같은 도메인이지만 구성 비율이 바뀐 경우. 예를 들어, 기업 고객 트래픽이 전체 쿼리의 10%에서 45%로 증가했을 수 있습니다(판매 실적이 좋았기 때문). 기업 쿼리는 길고, 다중 턴이며 내부 용어를 많이 사용합니다. 하지만 당신의 평가 세트는 여전히 90%가 짧은 단일 턴 질문이어서 3월과 같은 모습을 유지하고 있습니다. -
특징 드리프트
임베딩에 사용한 특징이 바뀐 경우. 임베딩 모델을 업그레이드했거나, 검색 시 청크 전략을 바꾸었거나, 시스템 프롬프트를 수정해 응답이 다른 컨텍스트에 의존하게 되었습니다. 평가 입력 문자열은 동일하지만, 실제 검색·재정렬에 사용되는 벡터 표현이 변했습니다.
첫 번째 현상은 보통 티켓이 접수되면서 눈에 띕니다. 두 번째와 세 번째가 위험합니다. 통과율은 그대로지만 사용자 만족도는 급락합니다. 두 지표가 서로 다른 이야기를 하는 이유는 서로 다른 집단을 측정하고 있기 때문입니다.
연구 수준의 구현이 필요하지 않습니다. 생산 환경이 평가 세트와 달라질 때 상승하는 지표만 있으면 됩니다. 임베딩에 대한 Maximum Mean Discrepancy (MMD) 가 적절한 도구입니다. 두 벡터 분포를 가우시안이라고 가정하지 않고 비교할 수 있으며, 제곱 통계량은 “두 샘플이 같은 분포이면 0, 다르면 클수록 큰 값”이라는 직관적인 해석을 제공합니다.
아래는 작동하는 드리프트 탐지기 예시입니다. Sentence‑Transformers 로 임베딩을 만들고, RBF 커널을 사용합니다. 약 60줄 정도의 코드입니다.
# eval_drift.py
import numpy as np
from sentence_transformers import SentenceTransformer
from sklearn.metrics.pairwise import rbf_kernel
MODEL = SentenceTransformer("all-MiniLM-L6-v2")
def embed(texts: list[str]) -> np.ndarray:
# 배치 간 커널 대역폭이 잘 동작하도록 정규화
return MODEL.encode(texts, normalize_embeddings=True, batch_size=64)
def mmd_rbf(x: np.ndarray, y: np.ndarray, gamma: float = 1.0) -> float:
# RBF 커널을 이용한 편향 없는 MMD². 0이면 같은 분포.
kxx = rbf_kernel(x, x, gamma=gamma)
kyy = rbf_kernel(y, y, gamma=gamma)
kxy = rbf_kernel(x, y, gamma=gamma)
n, m = len(x), len(y)
# 편향 없는 추정치를 위해 대각선 항을 제거
np.fill_diagonal(kxx, 0); np.fill_diagonal(kyy, 0)
return (kxx.sum() / (n * (n - 1))
+ kyy.sum() / (m * (m - 1))
- 2 * kxy.mean())
def permutation_pvalue(x, y, gamma=1.0, n_perm=200) -> float:
# 무작위 분할이 현재 MMD보다 크게 나올 확률은?
pooled = np.vstack([x, y])
observed = mmd_rbf(x, y, gamma)
n = len(x)
count = 0
rng = np.random.default_rng(42)
for _ in range(n_perm):
rng.shuffle(pooled)
if mmd_rbf(pooled[:n], pooled[n:], gamma) >= observed:
count += 1
return (count + 1) / (n_perm + 1)
def detect_drift(eval_queries: list[str], prod_queries: list[str]):
x = embed(eval_queries)
y = embed(prod_queries)
# 정규화된 임베딩에 잘 맞는 커널 대역폭 – median heuristic
from scipy.spatial.distance import pdist
sigma = np.median(pdist(np.vstack([x, y]))) or 1.0
gamma = 1.0 / (2 * sigma ** 2)
mmd2 = mmd_rbf(x, y, gamma)
pval = permutation_pvalue(x, y, gamma, n_perm=200)
return {"mmd2": float(mmd2), "p_value": float(pval), "n_eval": len(x), "n_prod": len(y)}
if __name__ == "__main__":
import json, sys
eval_q = json.load(open(sys.argv[1])) # 골든 세트 쿼리
prod_q = json.load(open(sys.argv[2])) # 최근 7일간 샘플링한 프로덕션 쿼리
print(json.dumps(detect_drift(eval_q, prod_q), indent=2))
실제와 비슷한 데이터셋에 적용한 예시:
$ python eval_drift.py golden.json prod_week.json
{
"mmd2": 0.0382,
"p_value": 0.005,
"n_eval": 500,
"n_prod": 1200
}
p‑value가 0.05 이하이고 MMD²가 무시할 수 없을 정도라면, 두 분포는 통계적으로 구별됩니다. 이것이 바로 신호입니다: 골든 세트가 더 이상 프로덕션을 공정하게 대표하지 않는다는 뜻이죠. 커널을 쓰고 싶지 않다면, 임베딩 좌표를 구간화하고 히스토그램에 대해 KL‑다이버전스를 계산하는 방법도 있습니다. 비용은 적지만 노이즈가 많고, PM에게 설명하기는 쉽습니다. 기본값으로는 MMD가 더 나은 선택입니다.
실무에서 사용할 두 가지 기준
-
노이즈 플로어 측정
골든 세트를 두 개의 무작위 파티션으로 나눠 탐지기를 실행합니다. 이때 얻는 MMD²가 같은 분포 내에서 기대되는 “잡음 수준”입니다. 드리프트가 의미 있게 보이려면 프로덕션‑대‑골든 비율이 이 잡음 수준의 3~5배 정도여야 합니다. 임의의 0.01 임계값을 넘는다고 판단하면 안 됩니다. -
슬라이스별 분석
전체 MMD² 하나만 보면 무엇이 변했는지는 알 수 없습니다. 예를 들어, 어시스턴트가 청구, 온보딩, 환불, API 디버깅, 비밀번호 재설정 등 여러 영역을 담당한다면, 한 슬라이스만 변했을 때 전체 점수가 크게 올라갈 수 있습니다. 이 경우 전체 골든 세트를 교체하면 네 개의 슬라이스를 모두 수정한 셈이 됩니다.가장 저렴한 방법은 각 쿼리와 샘플에 의도(intent)를 태깅하고, 슬라이스별로 탐지기를 돌리는 것입니다:
slices = {} for slice_name in {q["intent"] for q in prod_queries}: eval_slice = [q["text"] for q in eval_queries if q["intent"] == slice_name] prod_slice = [q["text"] for q in prod_queries if q["intent"] == slice_name] # eval_slice와 prod_slice에 대해 detect_drift 실행 # MMD²가 잡음 플로어의 3배 이상이고 p < 0.05이면, # 해당