MINERVA A/B 실험 프레임워크

YAML 정의, sticky hash, JSONL 메트릭

MINERVA의 A/B 실험 프레임워크는 YAML dotted-key override로 실험 변형을 정의하고, sticky hash로 사용자를 일관되게 할당하며, JSONL로 메트릭을 기록한다. 실험 설계, 사용자 할당, 메트릭 수집, 분석 파이프라인을 정리한다.

Agent
저자

Kwangmin Kim

공개

2026년 05월 05일

1 왜 A/B 실험이 필요한가

RAG 파이프라인에는 조정할 수 있는 파라미터가 많다: 청크 크기, top-k, reranker 모델, LLM temperature 등. 이 파라미터를 변경하면 성능이 향상될 수도, 저하될 수도 있다. “더 나은 설정”을 찾으려면 두 설정을 동시에 실행하고 사용자 피드백과 메트릭으로 비교해야 한다.

MINERVA의 A/B 실험 프레임워크가 해결하는 문제:

  • 코드 변경 없이 파이프라인 파라미터를 변형한다 (YAML override)
  • 같은 사용자는 항상 같은 실험군에 할당된다 (sticky hash)
  • 실험 결과를 자동으로 기록한다 (JSONL 메트릭)
  • 실험군별 에이전트 인스턴스를 효율적으로 관리한다 (캐싱)

2 실험 정의: YAML Override

2.1 기본 설정 파일

# data/configs/qna_chatbot.yaml (기본 설정)
chunking:
  chunk_size: 1500
  child_chunk_size: 400
retriever:
  top_k: 20
  search_type: hybrid
reranker:
  model: flashrank
  top_n: 3
llm:
  model: gpt-4.1
  temperature: 0.7

2.2 실험 정의 파일

# data/experiments/reranker_topn_experiment.yaml
name: reranker_topn_experiment
description: "Reranker top_n을 3 vs 5로 비교"
status: active
start_date: 2026-05-01
end_date: 2026-05-31

arms:
  control:
    weight: 0.5
    overrides: {}    # 기본 설정 그대로

  treatment:
    weight: 0.5
    overrides:
      reranker.top_n: 5
      retriever.top_k: 30

overridesdotted-key 형식이다. reranker.top_n: 5는 기본 설정의 reranker.top_n을 3에서 5로 변경한다. 나머지 설정은 기본값을 유지한다.

2.3 Dotted-key Override 구현

from copy import deepcopy

def apply_overrides(base_config: dict, overrides: dict) -> dict:
    config = deepcopy(base_config)
    for dotted_key, value in overrides.items():
        keys = dotted_key.split(".")
        target = config
        for key in keys[:-1]:
            target = target[key]
        target[keys[-1]] = value
    return config
# 사용 예
base = {"reranker": {"model": "flashrank", "top_n": 3}, "retriever": {"top_k": 20}}
overrides = {"reranker.top_n": 5, "retriever.top_k": 30}

result = apply_overrides(base, overrides)
# {"reranker": {"model": "flashrank", "top_n": 5}, "retriever": {"top_k": 30}}

이 방식의 이점은 실험 정의가 간결하다는 것이다. 변경할 파라미터만 명시하면 되고, 나머지는 기본값을 상속한다.

3 사용자 할당: Sticky Hash

3.1 왜 Sticky인가

A/B 실험에서 같은 사용자가 요청할 때마다 다른 실험군에 배정되면 결과가 오염된다. Sticky hash는 사용자 ID와 실험 이름을 해시하여 항상 같은 실험군에 할당한다.

import hashlib

def sticky_hash(user_id: str, experiment_name: str, arms: dict) -> str:
    seed = f"{user_id}:{experiment_name}"
    hash_value = int(hashlib.sha256(seed.encode()).hexdigest(), 16)
    normalized = (hash_value % 10000) / 10000  # 0.0 ~ 1.0

    cumulative = 0.0
    for arm_id, arm_config in arms.items():
        cumulative += arm_config["weight"]
        if normalized < cumulative:
            return arm_id

    return list(arms.keys())[-1]
# 동일 사용자는 항상 같은 결과
sticky_hash("user_42", "reranker_topn_experiment", arms)  # "control"
sticky_hash("user_42", "reranker_topn_experiment", arms)  # "control" (동일)

# 다른 사용자는 다를 수 있다
sticky_hash("user_99", "reranker_topn_experiment", arms)  # "treatment"

3.2 할당 비율 검증

해시 기반 할당이 실제로 의도한 비율에 근사하는지 확인한다.

from collections import Counter

results = Counter()
for i in range(10000):
    arm = sticky_hash(f"user_{i}", "test_experiment", arms)
    results[arm] += 1

# Counter({'control': 5023, 'treatment': 4977})
# 50:50에 근사

SHA-256 해시의 균일성 덕분에 사용자 수가 충분하면 의도한 비율에 수렴한다.

4 실험 플로우

전체 요청 처리 흐름에서 실험이 어떻게 작동하는지 정리한다.

1. 사용자 요청 도착 (user_id: "user_42")
2. 활성 실험 목록 확인
3. sticky_hash("user_42", "reranker_topn_experiment") → "treatment"
4. treatment arm의 overrides 적용 → 변형 RAGConfig 생성
5. AgentCache에서 ("reranker_topn_experiment", "treatment") 키로 에이전트 조회
6. 캐시 미스 → 변형 config로 에이전트 생성 + warmup + 캐시 저장
7. 에이전트 실행 → Response 생성
8. 메트릭 기록 (experiment, arm, latency, user_feedback)
9. Response + experiment_id + arm_id를 프론트엔드에 반환

프론트엔드는 experiment_idarm_id를 받아 피드백 전송 시 함께 전달한다. 이로써 어떤 실험군의 응답에 대한 피드백인지 추적할 수 있다.

5 메트릭 수집: JSONL

5.1 기록 형식

{"timestamp": "2026-05-05T14:30:00", "experiment": "reranker_topn_experiment", "arm": "control", "user_id": "user_42", "run_id": "abc-123", "latency_ms": 1500, "input_tokens": 500, "output_tokens": 200}
{"timestamp": "2026-05-05T14:30:05", "experiment": "reranker_topn_experiment", "arm": "treatment", "user_id": "user_99", "run_id": "def-456", "latency_ms": 1800, "input_tokens": 700, "output_tokens": 250}

JSONL(JSON Lines) 형식을 사용하는 이유:

  • append-only: 파일 끝에 추가만 하므로 동시 쓰기에 안전하다
  • 스트리밍 처리: 파일 전체를 메모리에 올리지 않고 한 줄씩 처리한다
  • pandas 호환: pd.read_json(path, lines=True)로 즉시 DataFrame이 된다

5.2 메트릭 기록 구현

import json
from datetime import datetime
from pathlib import Path

class MetricsLogger:
    def __init__(self, base_dir: str = "data/experiments"):
        self.base_dir = Path(base_dir)

    def log(self, experiment: str, arm: str, user_id: str,
            run_id: str, latency_ms: int, **kwargs):
        record = {
            "timestamp": datetime.now().isoformat(),
            "experiment": experiment,
            "arm": arm,
            "user_id": user_id,
            "run_id": run_id,
            "latency_ms": latency_ms,
            **kwargs,
        }

        log_file = self.base_dir / experiment / "metrics.jsonl"
        log_file.parent.mkdir(parents=True, exist_ok=True)

        with open(log_file, "a") as f:
            f.write(json.dumps(record, ensure_ascii=False) + "\n")

5.3 피드백 기록

class FeedbackLogger:
    def log(self, run_id: str, feedback_type: str,
            experiment: str | None = None, arm: str | None = None):
        record = {
            "timestamp": datetime.now().isoformat(),
            "run_id": run_id,
            "feedback": feedback_type,  # "up" or "down"
            "experiment": experiment,
            "arm": arm,
        }

        log_file = self.base_dir / "feedback.jsonl"
        with open(log_file, "a") as f:
            f.write(json.dumps(record, ensure_ascii=False) + "\n")

6 분석

실험 결과를 분석하는 패턴이다.

import pandas as pd
from scipy import stats

# 메트릭 로드
metrics = pd.read_json("data/experiments/reranker_topn_experiment/metrics.jsonl", lines=True)
feedback = pd.read_json("data/experiments/feedback.jsonl", lines=True)

# 실험별 집계
summary = metrics.groupby("arm").agg(
    count=("run_id", "count"),
    avg_latency=("latency_ms", "mean"),
    p95_latency=("latency_ms", lambda x: x.quantile(0.95)),
).round(1)

# 피드백 join
merged = metrics.merge(feedback[["run_id", "feedback"]], on="run_id", how="left")
satisfaction = merged.groupby("arm")["feedback"].apply(
    lambda x: (x == "up").sum() / x.notna().sum()
).round(3)

# 통계 검정
control = metrics[metrics["arm"] == "control"]["latency_ms"]
treatment = metrics[metrics["arm"] == "treatment"]["latency_ms"]
t_stat, p_value = stats.ttest_ind(control, treatment)

6.1 메트릭 유형별 검정 선택

scipy.stats.ttest_ind만으로는 모든 실험 메트릭을 다룰 수 없다. MINERVA의 core/stats.py는 메트릭 유형별 검정을 분리한다.

메트릭 유형 예시 검정 함수
비율 (이진 결과) 인용률(citation_rate), 만족률, 포맷 준수율 two_proportion_z_test
시간 (연속) latency_ms, ttft_ms welch_t_test (등분산 가정 없이)
트래픽 분배 검증 50:50 의도 vs 실제 관측 비율 srm_chi_square
효과 크기 처치-대조 차이의 상대 변화 relative_lift
# core/stats.py 사용 예
from core.stats import two_proportion_z_test, welch_t_test, srm_chi_square, relative_lift

# 1. 인용률 비교 (비율 메트릭)
control_cited = (control_df["citation_count"] > 0).sum()
treatment_cited = (treatment_df["citation_count"] > 0).sum()
z, p = two_proportion_z_test(
    success_a=control_cited, n_a=len(control_df),
    success_b=treatment_cited, n_b=len(treatment_df),
)

# 2. 지연 비교 (연속 메트릭, 등분산 미보장)
t, p = welch_t_test(control_df["latency_ms"], treatment_df["latency_ms"])

# 3. 트래픽 분배 검증 (의도 50:50인데 관측 60:40이면 sticky 누수 의심)
chi2, p = srm_chi_square(observed=[len(control_df), len(treatment_df)], expected_ratio=[0.5, 0.5])

# 4. 효과 크기
lift = relative_lift(treatment_mean=t_mean, control_mean=c_mean)  # (t-c)/c

6.2 왜 SRM 검정이 필요한가

Sample Ratio Mismatch는 가장 흔히 놓치는 실험 무결성 문제다. 의도가 50:50인데 실제 관측이 52:48이면 그냥 우연이지만, 60:40이면 sticky_hash 구현 버그·캐시 누수·일부 트래픽 우회를 의심해야 한다. SRM이 깨진 실험은 효과 크기 추정 자체를 신뢰할 수 없으므로, 다른 검정을 보기 전에 먼저 실행한다. core/stats.pysrm_chi_squaredf=1은 erfc 정밀 계산, df>2는 Wilson-Hilferty 근사로 처리한다.

6.3 답변 품질 자동 신호화

A/B 실험에서 사람의 피드백(helpful/unhelpful 응답)만으로는 표본이 부족하다. MINERVA는 core/answer_features.py에서 정규식 기반 자동 신호를 추출해 인용률·표 포함률·원리근거 포함률·포맷 준수율을 매 응답마다 계산한다. 이 신호들이 runs.jsonl에 함께 기록되므로, A/B 분석 시 feedback.jsonl join 없이도 비율 메트릭으로 즉시 비교할 수 있다.

# answer_features.py 정규식 5패턴 (간략)
_PRINCIPLE_KEYWORD_RE = r"§|규칙|원칙|근거|결정\s*트리|구조\s*분해|..."
_PRINCIPLE_CITATION_RE = r"\[\s*\d+\s*,\s*§\s*[\d.]+\s*\]"

# 매 응답마다 계산되어 runs.jsonl에 기록
features = {
    "has_citation": bool(_PRINCIPLE_CITATION_RE.search(answer)),
    "has_principle": bool(_PRINCIPLE_KEYWORD_RE.search(answer)),
    "has_table": "|" in answer,  # 마크다운 표
    # ...
}

6.4 비용 메트릭

core/pricing.py는 모델별 토큰 단가표(gpt-4.1: (2.5, 10.0) 등 — input/output USD per 1M tokens)를 보유하며, runs.jsonlinput_tokens/output_tokens/model_name으로 호출별 비용을 사후 계산할 수 있다. A/B 실험에서 품질이 같다면 저렴한 arm을 채택해야 하므로, 비용은 검정 결과와 함께 보고되어야 한다.

7 실험 관리 API

A/B 라우터는 운영 대시보드(Tests.tsx)와 같은 화면에서 사용량 지표(Monitoring.tsx)와 함께 노출되도록 /monitoring/ab prefix 아래 둔다. 두 화면이 같은 prefix의 데이터를 소비하면 메트릭 변화와 실험 결과를 한 번에 대조할 수 있다.

# routers/ab.py
router = APIRouter(prefix="/monitoring/ab", tags=["A/B Experiments"])

@router.get("/experiments")
def list_experiments():
    """등록된 실험 목록 (active/paused/stopped 메타 포함)"""
    return load_all_experiments()

@router.get("/{experiment_name}")
def get_experiment(experiment_name: str, days: int = 7):
    """arm별 메트릭 + 통계 검정 결과"""
    metrics_df = load_metrics(experiment_name, days=days)
    feedback_df = load_feedback(experiment_name, days=days)

    arms = {}
    for arm_id, arm_df in metrics_df.groupby("arm"):
        arms[arm_id] = {
            "n": len(arm_df),
            "avg_latency_ms": arm_df["latency_ms"].mean(),
            "p95_latency_ms": arm_df["latency_ms"].quantile(0.95),
            "citation_rate": (arm_df["citation_count"] > 0).mean(),
            "input_tokens_avg": arm_df["input_tokens"].mean(),
            "output_tokens_avg": arm_df["output_tokens"].mean(),
            "estimated_cost_usd": estimate_cost(arm_df),
        }

    # SRM 검증: 의도 비율 vs 관측 비율
    expected_ratio = [a["weight"] for a in load_arms(experiment_name)]
    observed = [arms[k]["n"] for k in arms]
    srm_chi2, srm_p = srm_chi_square(observed, expected_ratio)

    # 핵심 메트릭 검정 (control vs treatment)
    if "control" in arms and "treatment" in arms:
        ctrl, trt = metrics_df[metrics_df["arm"] == "control"], metrics_df[metrics_df["arm"] == "treatment"]
        t_stat, t_p = welch_t_test(ctrl["latency_ms"], trt["latency_ms"])
        z_stat, z_p = two_proportion_z_test(
            (ctrl["citation_count"] > 0).sum(), len(ctrl),
            (trt["citation_count"] > 0).sum(), len(trt),
        )
    else:
        t_stat = t_p = z_stat = z_p = None

    return {
        "experiment": experiment_name,
        "days": days,
        "arms": arms,
        "tests": {
            "srm": {"chi2": srm_chi2, "p_value": srm_p, "ok": srm_p > 0.001},
            "latency_welch_t": {"t": t_stat, "p_value": t_p},
            "citation_rate_z": {"z": z_stat, "p_value": z_p},
        },
    }

이 엔드포인트의 핵심 설계: SRM 검정을 모든 실험 응답에 함께 포함시켜, 대시보드 사용자가 효과 크기 해석 전에 무결성 점검을 먼저 보게 한다. srm_p가 0.001 미만이면 실험 자체가 의심스럽다는 신호이므로 ok=false로 표시한다.

8 관련 주제

선행 지식

후속 주제

다른 카테고리 연결

Subscribe

Enjoy this blog? Get notified of new posts by email: