검색 지표와 답변 지표는 파이프라인의 서로 다른 부분을 평가한다. Precision·Recall로 근거 회수 상태를 보고, BLEU와 LLM 평가로 생성 결과를 살펴보되 각 지표의 범위를 넘겨 해석하지 않는다. 이 구분이 있어야 낮은 점수를 보고 어느 단계를 수정할지 결정할 수 있다.
목차
- 사용 라이브러리 및 초기 설정
- 검색 평가 메트릭 (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
5
6
| 핵심 해석:
- 이 평가 결과에서는 K를 늘릴수록 평균 Precision이 감소함
- 일반적으로 Precision@K는 K를 늘릴 때 증가하거나 감소하거나 같을 수 있음
- 같은 순위 목록과 고정된 정답 집합에서는 Recall@K가 감소하지 않음 (같을 수도 있음)
- K=1일 때 P@K=1.0 → 이 평가셋의 모든 질문에서 1위 결과가 정답임
- K=5일 때 R@K=1.0 → 이 평가셋에서 정답으로 지정한 관련 문서를 상위 5개가 모두 포함함
|
반례: 검색 순서가 [오답, 정답]이면 P@1은 0이고 P@2는 0.5로 증가한다. 또한 평가셋의 점수가 1.0이라고 해서 새로운 질문에서도 같은 성능을 보장하는 것은 아니다.
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 평가
평가 모델의 점수도 입력한 근거와 채점 지시에 영향을 받는 출력이다. 평가 대상 모델을 비교할 때는 질문·근거·rubric을 고정하고, 점수 차이가 큰 사례에서 판단 이유와 원문이 일치하는지 검토한다.
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: 최종 품질 검증, 의미 판단 필요할 때
|
7. 한 숫자로 파이프라인을 판단하지 않기
검색이 정답 근거를 찾았는지와 생성기가 그 근거를 사용했는지를 나누어 기록한다. 검색 점수가 좋아도 답변이 누락될 수 있고, 근거를 놓친 상태에서 우연히 맞힌 답변도 있을 수 있기 때문이다.
이 글의 작은 평가셋은 지표의 동작을 확인하는 용도로 본다. 설정을 선택할 때는 질문 유형별 결과와 코퍼스 밖 질문에 대한 동작까지 추가해 특정 예제에 맞춘 개선인지 확인한다.