평가 세트 드리프트: 골든 세트가 오래됐는지 확인하는 방법

발행: (2026년 5월 24일 PM 06:37 GMT+9)
10 분 소요
원문: Dev.to

출처: 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. 도메인 이동
    새로운 제품 화면, 새로운 사용자 세그먼트, 새로운 통합 등. 1분기에 청구 및 로그인 질문을 중심으로 만든 지원봇 평가 세트는 3분기에 출시한 환불 자동화 워크플로에 대해 전혀 설명하지 못합니다. 평가 세트 자체가 나빠진 것이 아니라, 다루는 세계가 축소된 것입니다.

  2. 분포 이동
    같은 도메인이지만 구성 비율이 바뀐 경우. 예를 들어, 기업 고객 트래픽이 전체 쿼리의 10%에서 45%로 증가했을 수 있습니다(판매 실적이 좋았기 때문). 기업 쿼리는 길고, 다중 턴이며 내부 용어를 많이 사용합니다. 하지만 당신의 평가 세트는 여전히 90%가 짧은 단일 턴 질문이어서 3월과 같은 모습을 유지하고 있습니다.

  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가 더 나은 선택입니다.

실무에서 사용할 두 가지 기준

  1. 노이즈 플로어 측정
    골든 세트를 두 개의 무작위 파티션으로 나눠 탐지기를 실행합니다. 이때 얻는 MMD²가 같은 분포 내에서 기대되는 “잡음 수준”입니다. 드리프트가 의미 있게 보이려면 프로덕션‑대‑골든 비율이 이 잡음 수준의 3~5배 정도여야 합니다. 임의의 0.01 임계값을 넘는다고 판단하면 안 됩니다.

  2. 슬라이스별 분석
    전체 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이면,
        # 해당
0 조회
Back to Blog

관련 글

더 보기 »

내 스킬

프로젝트를 위한 AI 지시문을 만들고, 설치하고, 관리하세요 — 코딩이 필요 없습니다. CREATE 이름을 정하고, 카테고리를 선택하고, 원하는 것을 설명하세요 — 마법사가 자동으로 구성합니다.