목차
- 사용 라이브러리 및 초기 설정
- 검색 평가 메트릭 (Ranking Metrics)
- BLEU 스코어
- LLM-as-Judge 평가
- 자주 나오는 실수 / 주의사항
- [보충] 평가 메트릭 선택 가이드
1. 사용 라이브러리 및 초기 설정
1
2
3
4
5
6
7
8
9
10
11
12
| import os
import json
import numpy as np
import pandas as pd
import matplotlib.pyplot as plt
from typing import List, Dict, Tuple, Any
from dotenv import load_dotenv
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
from langchain_core.messages import HumanMessage, SystemMessage, AIMessage
|
모델 초기화
1
2
3
4
| LLM_MODEL = "gpt-4o-mini"
EMBEDDING_MODEL = "text-embedding-3-small"
llm = ChatOpenAI(model=LLM_MODEL)
embeddings_model = OpenAIEmbeddings(model=EMBEDDING_MODEL)
|
데이터 구조
- 10개 문서 DB: AI/LLM 관련 주제 (트랜스포머, RAG, 임베딩, 프롬프트 엔지니어링 등)
- 12개 QA 데이터셋: 질문 + 관련 문서 ID + ground truth 답변
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
| # 문서 구조
documents = [
{"doc_id": "doc_001", "title": "트랜스포머 아키텍처", "content": "..."},
... # doc_001 ~ doc_010
]
# QA 데이터셋 구조
qa_dataset = [
{
"query_id": "q01",
"question": "트랜스포머의 핵심 메커니즘은 무엇인가요?",
"relevant_doc_ids": ["doc_001"],
"ground_truth": "트랜스포머의 핵심 메커니즘은 셀프 어텐션으로..."
},
... # q01 ~ q12
]
|
FAISS 벡터 검색 구성
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
| langchain_docs = [
Document(page_content=doc["content"],
metadata={"doc_id": doc["doc_id"], "title": doc["title"]})
for doc in documents
]
vectorstore = FAISS.from_documents(langchain_docs, embeddings_model)
def search_documents(query: str, k: int = 5) -> List[Dict]:
results = vectorstore.similarity_search_with_score(query, k=k)
return [
{
"doc_id": doc.metadata["doc_id"],
"title": doc.metadata["title"],
"content": doc.page_content,
"score": float(score)
}
for doc, score in results
]
|
2. 검색 평가 메트릭 (Ranking Metrics)
RAG 시스템에서 “검색 단계”가 얼마나 잘 동작하는지 측정하는 지표들입니다.
Precision@K — 검색 결과의 정확도
“상위 K개 결과 중 실제로 관련 있는 문서 비율”
1
| Precision@K = (상위 K개 중 관련 문서 수) / K
|
1
2
3
4
5
6
7
8
| def precision_at_k(query_id, k):
qa = next(q for q in qa_dataset if q['query_id'] == query_id)
relevant_ids = set(qa['relevant_doc_ids'])
retrieved = search_results_cache[query_id][:k]
retrieved_ids = {r['doc_id'] for r in retrieved}
relevant_retrieved = relevant_ids & retrieved_ids # 교집합
return len(relevant_retrieved) / k
|
Recall@K — 검색의 완성도
“전체 관련 문서 중 상위 K개에서 찾아낸 비율”
1
| Recall@K = (상위 K개 중 관련 문서 수) / (전체 관련 문서 수)
|
1
2
3
4
5
6
7
8
| def recall_at_k(query_id, k):
qa = next(q for q in qa_dataset if q['query_id'] == query_id)
relevant_ids = set(qa['relevant_doc_ids'])
retrieved = search_results_cache[query_id][:k]
retrieved_ids = {r['doc_id'] for r in retrieved}
relevant_retrieved = relevant_ids & retrieved_ids
return len(relevant_retrieved) / len(relevant_ids) if relevant_ids else 0.0
|
F1@K — Precision과 Recall의 균형
“Precision과 Recall의 조화평균”
1
| F1 = 2 × (P × R) / (P + R)
|
1
2
3
4
5
6
| def f1_at_k(query_id, k):
p = precision_at_k(query_id, k)
r = recall_at_k(query_id, k)
if p + r == 0:
return 0.0
return 2 * p * r / (p + r)
|
R-Precision — 관련 문서 개수 기준 Precision
“관련 문서가 R개라면, 상위 R개 결과에서의 Precision”
1
2
3
4
5
6
7
| def r_precision(query_id):
qa = next(q for q in qa_dataset if q['query_id'] == query_id)
relevant_ids = set(qa['relevant_doc_ids'])
R = len(relevant_ids)
if R == 0:
return 0.0
return precision_at_k(query_id, R)
|
F-Beta@K — 가중치를 부여한 F1
“Precision과 Recall 중 어느 쪽을 더 중요하게 볼지 조절”
1
| F_beta = (1 + beta²) × (P × R) / (beta² × P + R)
|
1
2
3
4
5
6
7
| def f_beta_at_k(query_id, k, beta):
p = precision_at_k(query_id, k)
r = recall_at_k(query_id, k)
beta_sq = beta ** 2
if beta_sq * p + r == 0:
return 0.0
return (1 + beta_sq) * p * r / (beta_sq * p + r)
|
| beta 값 | 의미 |
|---|
| beta < 1 | Precision을 더 중요시 |
| beta = 1 | F1 (동일 가중치) |
| beta > 1 | Recall을 더 중요시 |
실험 결과
K 값에 따른 평균 메트릭:
| K | P@K | R@K | F1@K |
|---|
| 1 | 1.0 | 0.958 | 0.972 |
| 3 | 0.333 | 0.958 | 0.492 |
| 5 | 0.217 | 1.0 | 0.353 |
1
2
3
4
| 핵심 해석:
- K↑ → Precision↓, Recall↑ (항상 이 방향으로 움직임)
- K=1일 때 P@K=1.0 → 1위 결과가 항상 정확함
- K=5일 때 R@K=1.0 → 5개 검색하면 모든 관련 문서를 찾음
|
F-Beta 비교 (K=3):
| beta | F_beta@3 |
|---|
| 0.5 | 0.382 |
| 1.0 | 0.492 |
| 2.0 | 0.693 |
1
| → beta 증가 = Recall 중시 = 더 많이 찾는 것에 점수 보상
|
3. BLEU 스코어
생성된 텍스트의 품질을 참조 텍스트와 비교하여 측정하는 자동 평가 메트릭입니다.
동작 원리
1
2
3
4
5
6
7
| BLEU = BP × exp( Σ log(Precision_n) / N )
BP (Brevity Penalty): 짧은 출력에 패널티
- hypothesis 길이 >= reference 길이 → BP = 1.0
- hypothesis 길이 < reference 길이 → BP = exp(1 - |ref|/|hyp|)
Precision_n: N-gram 단위 겹침 비율 (Clipped)
|
구현 코드
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
28
29
30
31
32
33
34
35
| from collections import Counter
def get_ngrams(tokens, n):
return Counter(tuple(tokens[i:i+n]) for i in range(len(tokens) - n + 1))
def bleu_score(reference, hypothesis, max_n):
ref_tokens = reference.split()
hyp_tokens = hypothesis.split()
# Brevity Penalty
c, r = len(hyp_tokens), len(ref_tokens)
bp = 1.0 if c >= r else (np.exp(1 - r/c) if c > 0 else 0.0)
# N-gram Precision 계산
precision = {}
for n in range(1, max_n + 1):
ref_ngrams = get_ngrams(ref_tokens, n)
hyp_ngrams = get_ngrams(hyp_tokens, n)
if len(hyp_ngrams) == 0:
precision[n] = 0.0
continue
# Clipped: reference에 있는 만큼만 인정
clipped = sum(min(cnt, ref_ngrams.get(ng, 0))
for ng, cnt in hyp_ngrams.items())
precision[n] = clipped / sum(hyp_ngrams.values())
# 기하평균
log_avg, valid_n = 0.0, 0
for n in range(1, max_n + 1):
if precision[n] > 0:
log_avg += np.log(precision[n])
valid_n += 1
bleu = bp * np.exp(log_avg / valid_n) if valid_n > 0 else 0.0
return {'bleu': round(bleu, 4), 'bp': round(bp, 4)}
|
실행 예시
1
2
3
4
5
| ref = "트랜스포머의 핵심 메커니즘은 셀프 어텐션으로, 입력 시퀀스의 모든 위치 간 관계를 병렬로 처리합니다."
hyp = "트랜스포머는 셀프 어텐션 메커니즘을 사용하여 시퀀스 내 모든 위치 간 관계를 병렬로 처리합니다."
result = bleu_score(ref, hyp, 4)
# 출력: {'bleu': 0.3955, 'bp': 1.0}
|
1
2
3
4
| 해석:
- BLEU = 0.3955 → 의미는 비슷하지만 표현이 다르면 낮은 점수
- BP = 1.0 → hypothesis 길이가 reference보다 짧지 않음
- BLEU의 한계: 의미가 같아도 표현만 다르면 낮은 점수 → LLM 평가와 함께 사용
|
4. LLM-as-Judge 평가
LLM을 평가자로 사용하여 의미적 관련성을 판단하는 방법입니다.
왜 쓰나?
BLEU 같은 통계 기반 메트릭은 의미적 유사성을 못 잡음 → LLM에게 평가를 맡겨 의미적으로 판단
Context Relevance 평가
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
28
29
30
31
| def evaluate_context_relevance(question, contexts, model):
context_text = '\n\n'.join([
f"[컨텍스트 {i+1}] : {ctx}" for i, ctx in enumerate(contexts)
])
prompt = f"""질문과 검색된 컨텍스트의 관련성을 평가하세요.
## 질문
{question}
## 검색된 컨텍스트
{context_text}
## 평가 기준
각 컨텍스트에 대해 1-5점으로 평가하세요:
- 5 : 질문에 직접적으로 답할 수 있는 핵심 정보 포함
- 4 : 질문과 매우 관련 있는 정보 포함
- 3 : 부분적으로 관련 있는 정보 포함
- 2 : 간접적으로만 관련 있음
- 1 : 거의 관련 없음
## 응답 형식 (JSON)
{{"scores": [점수1, 점수2, ...], "average": 평균점수, "reasoning": "평가 근거"}}
반드시 유효한 JSON만 출력하세요."""
response = model.bind(response_format={'type': 'json_object'}).invoke(
[HumanMessage(content=prompt)]
)
result = json.loads(response.content)
return result
|
실행 결과 예시
1
2
3
4
5
6
7
8
9
| # 질문: "트랜스포머의 핵심 메커니즘은 무엇인가요?"
# 검색된 컨텍스트 3개: doc_001 (트랜스포머), doc_010 (멀티모달), doc_005 (파인튜닝)
cr_result = {
'scores': [5, 1, 1],
'average': 2.33,
'reasoning': '컨텍스트 1은 트랜스포머의 핵심 메커니즘에 대한 구체적인 정보를 제공하므로 5점,
컨텍스트 2와 3은 관련 정보가 전혀 없으므로 각각 1점.'
}
|
1
2
| 평가 흐름:
질문 + 검색된 컨텍스트 → LLM에게 전달 → 각 컨텍스트 점수(1~5) + 근거 반환
|
LLM-as-Judge의 장점
| 항목 | 통계 메트릭 (BLEU 등) | LLM-as-Judge |
|---|
| 의미 이해 | X | O |
| 유연한 기준 | X | O |
| 설명(reasoning) | X | O |
| 자동화 | O | O |
| 비용 | 무료 | API 비용 발생 |
5. 자주 나오는 실수 / 주의사항
embed_documents()는 list를 반환함 → .shape 바로 쓰면 오류. np.array(doc_embeddings).shape 로 변환 후 사용- Precision과 Recall은 반비례 → K 크게 하면 Recall↑, Precision↓. 목적에 맞게 K 설정
- BLEU는 의미 판단 불가 → 표현이 달라도 의미가 같으면 낮은 점수. LLM-as-Judge와 병행
- LLM-as-Judge에서 JSON 강제 안 하면 파싱 실패 →
response_format={'type': 'json_object'} 반드시 설정 - F-Beta에서 beta 의미 혼동 주의 → beta > 1이면 Recall 중시 (숫자가 크면 Recall 방향)
[보충] 평가 메트릭 선택 가이드
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| RAG 시스템 평가 구조:
[검색 단계 평가] [생성 단계 평가]
Precision@K BLEU (자동, 빠름)
Recall@K →→→ LLM-as-Judge (의미 기반, 비용)
F1@K / F-Beta@K
R-Precision
언제 어떤 메트릭을?
- Precision 중요: 사용자에게 노출되는 결과 수가 적을 때 (K=1, K=3)
- Recall 중요: 하나도 빠뜨리면 안 되는 도메인 (의료, 법률)
- F-Beta(beta>1): Recall 중시 시스템 (검색 엔진)
- BLEU: 빠른 1차 평가
- LLM-as-Judge: 최종 품질 검증, 의미 판단 필요할 때
|
끝