파이프라인을 바꾼 뒤 점수가 달라졌다고 해서 바로 개선으로 결론 내리기는 어렵다. 같은 질문에 대한 변경 전후 결과, 표본 수, 평가 기준을 함께 살펴본다. 통계 검정과 LLM 평가도 각각 어떤 불확실성을 줄이려는 도구인지 구분해 사용한다.
목차
- A/B 테스트 개념 (t-test, Cohen’s d)
- A/B 테스트 구현 (ab_test)
- 리랭킹 효과 측정 실험
- BLEU / ROUGE 개요
- simple_rouge_f1 구현
- LLM-as-judge 개요
- basic_judge (비구조화 출력)
- basic_judge2 (JSON 구조화 출력)
- Pointwise Judge with RUBRIC
- Pairwise Judge + Position Bias 처리
- Reference-based Judge
- 자주 나오는 실수 / 주의사항
- [보충] 평가 기법 비교
- 평가 결과가 바뀐 이유까지 기록하기
1. A/B 테스트 개념 (t-test, Cohen’s d)
왜 필요한가?
리랭킹 기법을 바꿨을 때 “실제로 성능이 좋아진 건가?” 를 수치로 검증합니다.
1
2
3
4
5
6
7
8
| A (기존 방식): 벡터 검색만 사용
B (변경 방식): 벡터 검색 + BM25 리랭킹
예시:
A: AP = [0.87, 1.0, 0.58] → mAP ≈ 0.82
B: AP = [0.75, 1.0, 0.33] → mAP ≈ 0.69
→ "A가 더 좋아 보이지만, 이 차이가 통계적으로 의미 있는가?"
|
t-test와 p-value
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| [검정 구조]
귀무가설(H₀): A와 B의 성능 차이가 없다
대립가설(H₁): A와 B의 성능 차이가 있다
t-test → p-value 계산
p-value: H₀가 참일 때 지금의 결과(또는 더 극단적인 결과)가 나올 확률
p-value = 0.01 → "차이가 없는데도 이런 결과가 나올 확률이 1%" → H₀ 기각 가능
p-value < alpha(유의수준, 보통 0.05) → 통계적으로 유의미한 차이
[t-test 종류]
stats.ttest_rel(a, b) : 대응표본 (같은 쿼리셋에 A/B 적용) ← 주로 사용
welch's t-test : 두 집단의 분산이 다를 때
모집단이 같은 경우 : 같은 반의 수학점수 vs 영어점수
모집단이 다른 경우 : 우리 반 수학점수 vs 옆 반 수학점수
|
Cohen’s d — 효과 크기
1
2
3
4
5
6
7
8
9
10
| d = (mean_A - mean_B) / pooled_std
pooled_std: 두 집단을 합친 표준편차 (얼마나 퍼져있는지)
해석:
d < 0.2 : 효과 작음
d ≈ 0.5 : 효과 중간
d > 0.8 : 효과 큼
p-value가 유의해도 Cohen's d가 작으면 → 통계적으로는 차이 있지만 실용적으로는 미미
|
2. A/B 테스트 구현 (ab_test)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| from scipy import stats
def ab_test(scores_a, scores_b, alpha=0.05):
t_stat, p_val = stats.ttest_rel(scores_a, scores_b) # 대응표본 t-test
mean_a = np.mean(scores_a)
mean_b = np.mean(scores_b)
pooled_std = np.sqrt((np.std(scores_a)**2 + np.std(scores_b)**2) / 2)
cohens_d = (mean_a - mean_b) / pooled_std if pooled_std > 0 else 0.0
return {
'mean_A': mean_a,
'mean_B': mean_b,
'p_val': p_val,
'cohens_d': cohens_d,
'significant': p_val < alpha # True면 통계적으로 유의한 차이
}
|
3. 리랭킹 효과 측정 실험
같은 질문의 전후 점수를 비교할 때는 대응 관계가 유지되어야 한다. 질문별 변화량을 먼저 보고, 표본이 작은 실험에서는 유의성 여부만으로 효과의 존재나 부재를 단정하지 않는다. 아래 수치는 결과 해석을 위한 예시로 읽는다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
| test_queries = {
'트랜스포머 아키텍처': {'d1', 'd2', 'd3'},
'벡터 검색 방법': {'d5'},
'LLM 학습 방법': {'d6', 'd7'}
}
aps_before = [] # 리랭킹 전 AP (벡터 검색만)
aps_after = [] # 리랭킹 후 AP (BM25 리랭킹 적용)
bm25_reranker = BM25Reranker()
for q, relevant in test_queries.items():
orig = vector_search(q, vectorstore)
reranked = bm25_reranker.rerank(q, orig)
orig_ids = [doc.metadata['id'] for doc, _ in orig]
reranked_ids = [doc.metadata['id'] for doc, _ in reranked]
aps_before.append(average_precision(orig_ids, relevant))
aps_after.append(average_precision(reranked_ids, relevant))
result = ab_test(aps_before, aps_after, alpha=0.05)
|
1
2
3
4
5
6
| 결과 해석 예시:
mean_A = 0.82, mean_B = 0.69
p_val = 0.32 → 유의수준 0.05보다 크다 → 통계적으로 유의하지 않음
significant = False
→ 샘플이 3개뿐이라 통계 검정력 낮음 → 더 많은 쿼리로 실험 필요
|
4. BLEU / ROUGE 개요
텍스트 생성 품질을 n-gram 겹침으로 측정하는 전통적 지표입니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
| [BLEU]
- 기계 번역 평가에서 출발
- n-gram precision 기반: 생성문이 정답에 얼마나 정확한지
- 1-gram(unigram), 2-gram(bigram) 등 사용
[ROUGE]
- 요약 평가에 주로 사용
- n-gram recall 기반: 정답의 내용을 얼마나 커버하는지
- ROUGE-1 (unigram), ROUGE-2 (bigram), ROUGE-L (최장 공통 부분수열)
[F1 = ROUGE-F1]
Precision: 생성문 단어 중 정답에 있는 비율
Recall: 정답 단어 중 생성문에 있는 비율
F1 = 2 × P × R / (P + R)
|
한계
1
2
3
4
5
6
| BLEU/ROUGE의 한계:
- 단어 일치만 봄 → 의미적 유사성 측정 불가
- "딥러닝은 신경망 기반 AI 기술" vs "딥러닝은 심층 신경망을 사용하는 머신러닝"
→ 의미는 같지만 단어가 다르면 낮은 점수
- 언어, 전문용어, 패러프레이즈에 취약
→ 보완책으로 LLM-as-judge 사용
|
5. simple_rouge_f1 구현
1
2
3
4
5
6
7
8
9
10
11
12
13
| from collections import Counter
def simple_rouge_f1(reference, candidate):
ref_words = Counter(reference.split())
cand_words = Counter(candidate.split())
# 겹치는 단어 수 (최솟값 기준)
overlap = sum(min(ref_words[w], cand_words.get(w, 0)) for w in ref_words)
r = overlap / sum(ref_words.values()) if ref_words else 0 # Recall
p = overlap / sum(cand_words.values()) if cand_words else 0 # Precision
return 2*p*r / (p+r) if (p+r) > 0 else 0 # F1
|
1
2
3
4
5
6
7
| 예시:
reference : "딥러닝은 다층 신경망을 사용하여 데이터에서 복잡한 패턴을 학습하는 머신러닝 방법입니다"
candidate1: "딥러닝은 다층 신경망을 사용하여..." (정답 복사) → ROUGE ≈ 1.0
candidate2: "인공 신경망의 층을 깊게 쌓아..." (좋은 패러프레이즈) → ROUGE ↓ (단어 다름)
candidate3: "딥러닝 딥러닝 신경망 데이터 학습..." (키워드 나열) → ROUGE 중간 (단어 겹침)
→ ROUGE만으로는 candidate2 같은 좋은 패러프레이즈를 제대로 평가 못함
|
6. LLM-as-judge 개요
LLM을 평가자(Judge)로 활용하여 텍스트 품질을 평가합니다.
1
2
3
4
5
6
7
8
9
10
11
| [LLM-as-judge 유형]
1. Pointwise : 질문 + 답변 1개 → 점수(1~5)
용도: 단일 답변의 절대적 품질 평가
2. Pairwise : 질문 + 답변 A + 답변 B → A / B / Tie 중 선택
용도: 두 시스템의 상대적 비교
복잡도: N*(N-1)/2 쌍 (N=5 → 10쌍, N=500 → 125,000쌍)
3. Reference-based : 질문 + 정답 + 학생 답변 → 점수
용도: 정답이 있는 경우의 채점
|
LLM-as-judge 장단점
1
2
3
4
5
6
7
8
9
10
| [장점]
- 의미적 이해: 단어가 달라도 의미가 같으면 높은 점수
- 유연함: 다양한 평가 기준 적용 가능
- 복잡한 품질 평가: 창의성, 논리성 등 ROUGE로 못 하는 평가 가능
[단점]
- 비용: 평가마다 LLM API 호출
- 기준 모호: 같은 답변도 실행마다 다른 점수가 나올 수 있음
- 재현성: 동일 입력에 대해 결과가 달라질 수 있음
- Position bias: pairwise에서 A를 먼저 보여주면 A에게 유리한 경향
|
7. basic_judge (비구조화 출력)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| def basic_judge(question, answer, scale='1~5'):
"""기본 LLM 평가: 텍스트 형태로 점수 반환"""
prompt = f"""다음 답변을 {scale}스케일로 평가하세요.
질문 : {question}
답변 : {answer}
평가 기준
- 정확성 : 사실적으로 올바른가?
- 완전성 : 질문에 충분히 답했는가?
- 명확성 : 이해하기 쉽게 설명했는가?
반드시 아래 형식으로 답하세요:
점수 : [숫자]
이유 : [한 줄 설명]"""
return llm.invoke(prompt).content
|
1
2
3
4
5
6
7
8
9
10
11
| 사용 예시:
question = "파이썬의 장점은 무엇인가요?"
답변A: "파이썬은 간결한 문법으로 초보자도 쉽게 배울 수 있으며..."
→ 점수: 5 / 이유: 핵심을 명확하게 잘 설명했습니다.
답변B: "파이썬 좋아요."
→ 점수: 1 / 이유: 구체적인 내용이 전혀 없습니다.
답변C: "자바스크립트는 웹 개발에 주로 사용됩니다."
→ 점수: 1 / 이유: 질문과 무관한 답변입니다.
|
8. basic_judge2 (JSON 구조화 출력)
파싱 가능한 JSON 형태로 점수를 받습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
| def basic_judge2(question, answer, scale='1~5'):
prompt = f"""다음 답변을 {scale}스케일로 평가하세요. 답변은 반드시 JSON으로 반환하세요
질문 : {question}
답변 : {answer}
평가 기준
- 정확성 : 사실적으로 올바른가?
- 완전성 : 질문에 충분히 답했는가?
- 명확성 : 이해하기 쉽게 설명했는가?
반드시 아래 형식으로 답하세요:
{{"score": 1~5점수, "confidence": "high/mid/low"}}"""
result = llm.invoke(prompt).content
cleaned = result.strip()
if cleaned.startswith("```"):
cleaned = cleaned.split("```")[1]
if cleaned.startswith("json"):
cleaned = cleaned[4:]
return json.loads(cleaned)
|
1
2
3
4
5
| 비구조화 vs 구조화 비교:
basic_judge → 텍스트 파싱 필요, 형식 불안정
basic_judge2 → JSON 파싱으로 직접 .get('score') 접근 가능
→ 프로그램에서 점수를 활용할 때는 반드시 JSON 구조화 사용
|
9. Pointwise Judge with RUBRIC
루브릭(채점 기준표)을 제공하여 더 일관된 점수를 얻습니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
| RUBRIC = {
5: "정확하고 완전하며, 예시와 설명이 풍부하다",
4: "정확하고 핵심을 다루지만, 일부 세부사항이 부족하다",
3: "대체로 정확하지만, 중요한 내용 일부가 누락되었다",
2: "부분적으로만 정확하거나, 핵심을 빗나갔다",
1: "부정확하거나 질문과 무관하다",
}
def pointwise_judge(question, answer, rubric=RUBRIC):
rubric_text = '\n'.join(
f' {k} 점: {v}' for k, v in sorted(rubric.items(), reverse=True)
)
prompt = f"""다음 답변을 아래 루브릭에 따라 평가해주세요.
질문 : {question}
답변 : {answer}
루브릭 :
{rubric_text}
반드시 JSON으로 답하세요:
{{"score": 1-5, "reasoning": "이유"}}"""
result = llm.invoke(prompt).content
result = result.replace('```json', '').replace('```', '')
return json.loads(result)
|
1
2
3
4
5
6
7
8
9
10
| 루브릭 사용 효과:
루브릭 없음: LLM이 기준을 자의적으로 설정 → 일관성 낮음
루브릭 있음: 명확한 기준 제공 → 일관성 ↑, 재현성 ↑
사용 예시:
question = "REST API와 GraphQL의 차이점을 설명해주세요"
답변A (상세): REST는 리소스 단위 URL에 HTTP 메서드를 사용하고... → 점수: 5
답변B (짧음): REST는 URL을 사용하고 GraphQL은 쿼리를 사용합니다. → 점수: 3
답변C (부적절): 둘 다 API입니다. → 점수: 2
|
10. Pairwise Judge + Position Bias 처리
Pairwise 평가에서는 후보의 표시 순서를 바꾼 결과도 함께 확인한다. 순서에 따라 선택이 뒤집힌 사례를 단순 승패 합계에 묻어 두지 않고 별도로 검토하면 평가 지시와 판단 기준을 점검할 수 있다.
기본 Pairwise Judge
1
2
3
4
5
6
7
8
9
10
11
12
| def pairwise_judge(question, answer_a, answer_b):
prompt = f"""두 답변을 비교하여 더 나은 쪽을 선택하세요
질문 : {question}
답변 A : {answer_a}
답변 B : {answer_b}
반드시 JSON으로 답하세요:
{{"winner": "A 또는 B 또는 Tie", "reasoning": "선택 이유"}}"""
result = llm.invoke(prompt).content
return json.loads(result.replace('```json', '').replace('```', ''))
|
Position Bias 문제와 해결
1
2
3
4
5
6
7
| [Position Bias (위치 편향)]
LLM은 "Lost-in-the-middle" 현상이 있어 먼저 보여준 답변에 유리한 경향이 있음
(A, B) 순서: A 승리
(B, A) 순서: B 승리 ← 실제로는 같은데 순서가 바뀌어서 결과가 달라짐
→ 순서를 바꿔서 두 번 비교하고 결과가 일치하는 경우만 신뢰
|
1
2
3
4
5
6
7
8
9
10
11
12
| def pairwise_judge_with_swap(question, answer_a, answer_b):
"""A→B, B→A 순서로 두 번 비교하여 일관된 결과만 채택"""
result1 = pairwise_judge(question, answer_a, answer_b) # (A, B) 순서
result2 = pairwise_judge(question, answer_b, answer_a) # (B, A) 순서
# result2에서 A가 이겼다면 실제 winner는 B (순서가 뒤집혔으므로)
swapped_winner = {'A': 'B', 'B': 'A', 'Tie': 'Tie'}.get(result2['winner'])
if result1['winner'] == swapped_winner:
return {'final': result1['winner'], 'status': '일치'}
else:
return {'final': 'Tie', 'status': '불일치'} # 결과가 다르면 Tie 처리
|
Position Bias 측정
1
2
3
4
5
6
7
8
9
10
11
12
13
| def detect_position_bias(question, answer_a, answer_b, n_trials=3):
"""n번 반복 비교해서 불일치율(bias_rate) 계산"""
consistent = 0
inconsistent = 0
for _ in range(n_trials):
result = pairwise_judge_with_swap(question, answer_a, answer_b)
if result['status'] == '일치':
consistent += 1
else:
inconsistent += 1
return inconsistent / (consistent + inconsistent) # bias_rate
|
1
2
3
4
| bias_rate 해석:
0.0 → 항상 일관된 결과 (Position Bias 없음)
0.5 → 절반은 일치, 절반은 불일치
1.0 → 항상 순서에 따라 결과가 달라짐 (심각한 Bias)
|
11. Reference-based Judge
정답(reference)이 있을 때 학생 답변을 채점합니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
| def reference_based_judge(question, reference, answer):
prompt = f"""당신은 AI 교육 전문가입니다.
정답을 기준으로 학생의 답변을 평가하세요.
질문 : {question}
정답 : {reference}
학생 답변 : {answer}
평가 기준:
- 정답의 핵심 포인트를 얼마나 포함하는가
- 사실적으로 틀린 내용이 있는가
- 정답에 없는 올바른 추가 정보가 있는가
JSON으로 답하세요:
{{
"score": 1-5,
"covered_points": ["포함된 핵심 포인트들"],
"missing_points": ["누락된 포인트들"],
"errors": ["틀린 내용"]
}}"""
result = llm.invoke(prompt).content
cleaned = result.replace('```json', '').replace('```', '')
return json.loads(cleaned)
|
1
2
3
4
5
6
7
8
9
10
| 사용 예시:
question = "TCP와 UDP의 차이점은?"
reference = "TCP는 연결지향적이고 신뢰성 있는 전송을 보장하며, UDP는 비연결지향적이고 빠르지만 신뢰성을 보장하지 않습니다."
answer = "TCP는 연결을 먼저 수립하고 데이터 전송의 순서와 무결성을 보장합니다. 반면 UDP는 연결 없이 바로 전송하여 빠르지만 패킷 손실이 가능합니다."
결과:
score: 5
covered_points: ["연결지향 vs 비연결지향", "신뢰성", "속도"]
missing_points: []
errors: []
|
12. 자주 나오는 실수 / 주의사항
- 대응표본 t-test 조건:
ttest_rel은 두 배열의 길이가 같아야 함. 쿼리 수가 다르면 오류 - p-value 오해: p-value < 0.05 → “통계적으로 유의한 차이” 이지 “실용적으로 중요한 차이”가 아님 → Cohen’s d로 효과 크기 함께 확인
- 샘플 수 부족: 쿼리 3개로는 검정력(statistical power)이 너무 낮아 significant=False가 자주 나옴 → 최소 30개 이상 쿼리 권장
- Pairwise O(N²) 비용: 답변 5개 → 10쌍, 답변 500개 → 125,000쌍 → 실용적으로 비용 폭발. 토너먼트 방식 또는 샘플링 필요
- JSON 파싱 실패: LLM이
'''json 블록에 싱글쿼터를 써서 json.loads 실패 가능 → 파싱 전에 클리닝 로직 작성 필수 - Rubric 일관성: 루브릭이 있어도 경계 케이스(3점 vs 4점)에서 LLM이 흔들릴 수 있음 → 여러 번 실행하고 평균 사용 권장
[보충] 평가 기법 비교
| 기법 | 정답 필요 | 속도 | 의미 이해 | 특징 |
|---|
| ROUGE-F1 | 필요 | 빠름 | 없음 | n-gram 겹침, 요약 평가에 적합 |
| BLEU | 필요 | 빠름 | 없음 | n-gram precision, 번역 평가에 적합 |
| LLM Pointwise | 불필요 | 보통 | 있음 | 단일 답변 절대 평가 |
| LLM Pointwise + Rubric | 불필요 | 보통 | 있음 | 일관성 향상 |
| LLM Pairwise | 불필요 | 느림 | 있음 | 상대 비교, O(N²) 비용 |
| Reference-based | 필요 | 보통 | 있음 | 정답 있을 때 가장 정확 |
전체 평가 파이프라인
1
2
3
4
5
6
7
8
9
10
11
12
13
| 생성 시스템 평가
↓
[기초 평가]
ROUGE / BLEU → 빠른 참고용 (의미 한계 인지)
↓
[LLM 평가]
├─ 정답 있음 → Reference-based Judge
├─ 비교 필요 → Pairwise Judge (+ Swap으로 Bias 제거)
└─ 단일 평가 → Pointwise Judge with Rubric
↓
[통계 검증]
A/B test (t-test) + Cohen's d
→ p-value < 0.05 AND d > 0.5 → 신뢰할 수 있는 성능 향상
|
14. 평가 결과가 바뀐 이유까지 기록하기
점수 표에는 질문 집합과 평가 버전을 함께 남긴다. 생성 모델, 평가 모델, rubric을 동시에 바꾸면 어느 변화가 점수 차이를 만들었는지 해석하기 어렵다.
자동 평가에서 큰 차이가 난 질문과 판단이 일관되지 않은 질문을 우선 읽어 본다. 전체 평균과 함께 대표 실패 사례를 남기는 방식이 다음 구현 변경으로 연결하기 쉽다.