2026-03-31 강의 정리
[프로젝트2] 안내 + RAG 성능평가 개요
목차
- RAG 검색 평가란?
- 데이터셋 구성 (문서 + 질답 쌍)
- 벡터 임베딩 & FAISS 검색
- Hit Rate@K
- MRR (Mean Reciprocal Rank)
- nDCG (Normalized Discounted Cumulative Gain)
- Precision & Recall
- 전체 메트릭 비교
- 자주 나오는 실수 / 주의사항
1. RAG 검색 평가란?
RAG(Retrieval-Augmented Generation)에서 검색(Retrieval) 단계의 품질이 최종 응답 품질을 결정합니다.
1
2
3
4
5
6
7
사용자 질문
↓
[검색 단계] 벡터 DB에서 관련 문서 K개 추출
↓
[생성 단계] LLM이 검색 문서 + 질문으로 답변 생성
↓
최종 응답
▶ 핵심: 검색이 잘못되면 아무리 좋은 LLM도 정확한 답변을 못 합니다. 검색 성능을 정량적으로 측정하는 것이 필수.
평가에 필요한 것
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
┌─────────────────────────────────────────────────────┐
│ 평가 구성 요소 │
│ │
│ 1. 문서 코퍼스 (Documents) │
│ : 검색 대상이 되는 문서들 │
│ │
│ 2. 질답 데이터셋 (QA Dataset) │
│ : 질문 + 정답 문서 ID(relevant_doc_ids) │
│ │
│ 3. 검색 시스템 (Retriever) │
│ : 질문에 대해 K개의 문서를 반환 │
│ │
│ 4. 평가 메트릭 │
│ : Hit Rate, MRR, nDCG, Precision, Recall 등 │
└─────────────────────────────────────────────────────┘
2. 데이터셋 구성
문서 코퍼스 (12개)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
documents = [
{"doc_id": "doc_001", "title": "트랜스포머 아키텍처", "content": "..."},
{"doc_id": "doc_002", "title": "RAG 시스템 개요", "content": "..."},
{"doc_id": "doc_003", "title": "벡터 임베딩과 유사도 검색","content": "..."},
{"doc_id": "doc_004", "title": "프롬프트 엔지니어링", "content": "..."},
{"doc_id": "doc_005", "title": "파인튜닝과 전이학습", "content": "..."},
{"doc_id": "doc_006", "title": "토큰화와 텍스트 전처리", "content": "..."},
{"doc_id": "doc_007", "title": "LLM 평가 메트릭", "content": "..."},
{"doc_id": "doc_008", "title": "청킹 전략", "content": "..."},
{"doc_id": "doc_009", "title": "하이브리드 검색", "content": "..."},
{"doc_id": "doc_010", "title": "멀티모달 AI", "content": "..."},
{"doc_id": "doc_011", "title": "어텐션 메커니즘의 변형", "content": "..."}, # 추가
{"doc_id": "doc_012", "title": "LLM 양자화 기법", "content": "..."}, # 추가
]
질답 데이터셋 구조
1
2
3
4
5
6
7
8
9
qa_dataset = [
{
"query_id": "q01",
"question": "트랜스포머의 핵심 메커니즘은 무엇인가요?",
"relevant_doc_ids": ["doc_001"], # ← 이 질문의 정답 문서
"ground_truth": "셀프 어텐션(Self-Attention)..."
},
...
]
▶ 포인트:
relevant_doc_ids가 평가의 기준(ground truth)입니다. 검색 결과에 이 ID가 포함되어 있으면 “정답을 찾은 것”으로 간주합니다.
데이터셋 검증
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
def validate_dataset(docs, qa_pairs):
doc_ids = {d['doc_id'] for d in docs}
errors = []
for qa in qa_pairs:
for rid in qa['relevant_doc_ids']:
if rid not in doc_ids:
errors.append(f"{qa['query_id']} : {rid} 문서 없음")
if errors:
print(f"오류")
else:
print(f"통과")
return len(errors)
validate_dataset(all_docs, all_qa)
# → 통과 | 0
▶ 주의: 질답 데이터셋의
relevant_doc_ids에 존재하지 않는 doc_id가 들어가면 평가가 전부 MISS로 나옵니다. 반드시 검증 후 사용.
3. 벡터 임베딩 & FAISS 검색
설정
1
2
3
4
5
6
7
8
9
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
LLM_MODEL = "gpt-4o-mini"
EMBEDDING_MODEL = "text-embedding-3-small"
llm = ChatOpenAI(model=LLM_MODEL)
embeddings = OpenAIEmbeddings(model=EMBEDDING_MODEL)
FAISS 벡터스토어 생성
1
2
3
4
5
6
7
8
9
10
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)
vectorstore.index.ntotal # → 12 (문서 12개 인덱싱)
검색 함수
1
2
3
4
5
6
7
8
9
10
11
def search_documents(query, k):
results = vectorstore.similarity_search_with_score(query, k=k)
retrieved = []
for doc, score in results:
retrieved.append({
'doc_id': doc.metadata['doc_id'],
'title': doc.metadata['title'],
'content': doc.page_content,
'score': float(score) # L2 거리 (낮을수록 유사)
})
return retrieved
검색 결과 캐싱
1
2
3
4
search_results_cache = {}
for qa in all_qa:
results = search_documents(qa['question'], k=5)
search_results_cache[qa['query_id']] = results
▶ 실무 팁: 평가 시 동일한 질문으로 API를 반복 호출하면 비용이 증가합니다. 캐싱으로 한 번만 호출하고 재사용하세요.
4. Hit Rate@K
개념
K개의 검색 결과 중에 정답 문서가 하나라도 포함되어 있으면 1, 없으면 0.
1
2
3
4
5
6
7
질문: "트랜스포머의 핵심 메커니즘은?"
정답 문서: doc_001
검색 결과 (k=3):
1위: doc_001 ← 정답 포함! → Hit Rate@3 = 1
2위: doc_005
3위: doc_002
구현
1
2
3
4
5
6
7
8
9
10
11
def hit_rate_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}
return 1 if relevant_ids & retrieved_ids else 0 # 교집합 존재 여부
def average_hit_rate_at_k(k):
hits = [hit_rate_at_k(qa['query_id'], k) for qa in qa_dataset]
return sum(hits) / len(hits)
평가 결과
1
2
3
4
5
6
7
8
9
for qa in qa_dataset:
hit = hit_rate_at_k(qa['query_id'], k=3)
retrieved_ids = [r['doc_id'] for r in search_results_cache[qa['query_id']][:3]]
status = 'HIT' if hit else 'MISS'
print(f"[{status}] {qa['query_id']}: 정답 = {qa['relevant_doc_ids']} | 검색 = {retrieved_ids}")
# [HIT] q01: 정답 = ['doc_001'] | 검색 = ['doc_001', 'doc_011', 'doc_005']
# [HIT] q02: 정답 = ['doc_002'] | 검색 = ['doc_002', 'doc_003', 'doc_009']
# ... (모든 질문 HIT)
특징
| K값 | 의미 |
|---|---|
| @1 | 1등 결과에 정답이 있는가 (가장 엄격) |
| @3 | 상위 3개 중 정답 있는가 |
| @5 | 상위 5개 중 정답 있는가 (가장 관대) |
▶ 한계: Hit Rate는 정답이 몇 위에 있는지는 구분하지 못합니다. 1위든 5위든 동일하게 “HIT”로 처리.
5. MRR (Mean Reciprocal Rank)
개념
정답 문서가 몇 위에 등장하는지를 역수로 점수화. 상위에 나올수록 높은 점수.
1
2
3
4
5
6
7
8
9
10
11
┌──────────────────────────────────────────────┐
│ Reciprocal Rank 계산 예시 │
│ │
│ 정답이 1위 → RR = 1/1 = 1.000 │
│ 정답이 2위 → RR = 1/2 = 0.500 │
│ 정답이 3위 → RR = 1/3 = 0.333 │
│ 정답이 5위 → RR = 1/5 = 0.200 │
│ 정답 없음 → RR = 0.000 │
└──────────────────────────────────────────────┘
MRR = 모든 질문의 RR 평균
구현
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
def reciprocal_rank(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]
for rank, result in enumerate(retrieved, 1): # rank는 1부터 시작
if result['doc_id'] in relevant_ids:
return 1.0 / rank # 처음 발견한 순위의 역수 반환
return 0.0 # K개 안에 정답 없음
def mean_reciprocal_rank(k):
rr_scores = [reciprocal_rank(qa['query_id'], k) for qa in qa_dataset]
return sum(rr_scores) / len(rr_scores)
mean_reciprocal_rank(k=5)
# → 1.0 (모든 질문에서 정답이 1위)
데이터프레임으로 확인
1
2
3
4
5
6
7
8
9
10
11
rows = []
for qa in qa_dataset:
rr = reciprocal_rank(qa['query_id'], 5)
rows.append({
'query_id': qa['query_id'],
'question': qa['question'],
'RR@5': round(rr, 4)
})
df_rr = pd.DataFrame(rows)
# 모든 질문의 RR@5 = 1.0 (정답이 항상 1위)
▶ Hit Rate vs MRR: Hit Rate는 “있냐 없냐”, MRR은 “몇 위에 있냐”를 측정합니다. MRR이 더 세밀한 평가지표입니다.
6. nDCG (Normalized Discounted Cumulative Gain)
개념 단계별 이해
Step 1: Relevance (관련성 점수)
각 검색 결과에 관련성 점수를 부여합니다.
1
2
3
4
검색 결과:
1위: doc_001 (정답) → relevance = 1
2위: doc_005 (무관) → relevance = 0
3위: doc_004 (정답) → relevance = 1
Step 2: DCG (Discounted Cumulative Gain)
순위가 낮을수록 할인(discount)을 적용하여 합산합니다.
1
2
3
4
5
6
7
8
DCG@K = Σ (relevance_i / log2(i+1)) (i = 1부터 시작)
예시:
1위 rel=1: 1 / log2(2) = 1 / 1.000 = 1.000
2위 rel=0: 0 / log2(3) = 0 / 1.585 = 0.000
3위 rel=1: 1 / log2(4) = 1 / 2.000 = 0.500
DCG@3 = 1.000 + 0.000 + 0.500 = 1.500
Step 3: IDCG (Ideal DCG)
정답을 가장 앞에 배치했을 때의 이상적인 DCG.
1
2
3
4
5
이상적 순서: 정답을 모두 앞에 → [1, 1, 0]
1위 rel=1: 1 / log2(2) = 1.000
2위 rel=1: 1 / log2(3) = 0.631
IDCG@3 = 1.000 + 0.631 = 1.631
Step 4: nDCG (Normalized DCG)
1
2
3
nDCG@K = DCG@K / IDCG@K
예시: nDCG@3 = 1.500 / 1.631 = 0.920
구현
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
def dcg_at_k(relevances, k):
relevances = relevances[:k]
dcg = 0.0
for i, rel in enumerate(relevances):
dcg += rel / np.log2(i + 2) # i+2 = i+1+1 (log2(1)=0 방지)
return dcg
def ndcg_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]
# 실제 검색 순서의 관련성 점수
relevances = [1 if r['doc_id'] in relevant_ids else 0 for r in retrieved]
dcg = dcg_at_k(relevances, k)
# 이상적 순서의 관련성 점수 (정답을 앞에 배치)
total_relevant = len(relevant_ids)
ideal_relevances = [1] * min(total_relevant, k) + [0] * max(0, k - total_relevant)
idcg = dcg_at_k(ideal_relevances, k)
if idcg == 0:
return 0.0
return dcg / idcg
def average_ndcg_at_k(k):
scores = [ndcg_at_k(qa['query_id'], k) for qa in qa_dataset]
return sum(scores) / len(scores)
평가 결과
1
2
3
4
5
6
7
for k in [1, 3, 5]:
score = average_ndcg_at_k(k)
print(f"NDCG@{k} : {score}")
# NDCG@1 : 1.0
# NDCG@3 : 0.9677622660637882
# NDCG@5 : 0.9897679429448374
질별 점수:
1
2
3
4
5
6
7
for qa in qa_dataset:
score = ndcg_at_k(qa['query_id'], 5)
print(f"{qa['query_id']} : NDCG@5 : {score} | 정답 수 = {len(qa['relevant_doc_ids'])}")
# q01 : NDCG@5 : 1.0 | 정답 수 = 1
# ...
# q10 : NDCG@5 : 0.877 | 정답 수 = 2 ← 복수 정답 중 하나가 낮은 순위
▶ 포인트: nDCG는 복수 정답이 있을 때 특히 효과적입니다. 정답이 2개인데 1개는 1위, 1개는 5위라면, 둘 다 1위인 이상적 경우 대비 점수가 깎입니다.
7. Precision & Recall
혼동행렬(Confusion Matrix) 기반 이해
1
2
3
예측: 관련 예측: 무관
실제: 관련 │ TP (True +) │ FN (False -) │
실제: 무관 │ FP (False +) │ TN (True -) │
코로나 검사 예시:
1
2
3
검사: 양성 검사: 음성
실제: 양성 │ TP = 진양성 │ FN = 위음성 │ ← 놓침(위험!)
실제: 음성 │ FP = 위양성 │ TN = 진음성 │
Precision (정밀도)
검색한 것 중 실제 정답이 얼마나 되는가?
1
2
3
4
5
Precision = TP / (TP + FP)
= 정답으로 검색된 수 / 전체 검색된 수
예시: K=5로 검색, 그 중 정답 3개
Precision@5 = 3/5 = 0.6
Recall (재현율)
실제 정답 중 검색에서 얼마나 찾았는가?
1
2
3
4
5
Recall = TP / (TP + FN)
= 정답으로 검색된 수 / 전체 정답 수
예시: 실제 정답 문서 5개, K=3으로 검색해서 2개 찾음
Recall@3 = 2/5 = 0.4
Precision-Recall 트레이드오프
1
2
3
4
5
6
7
8
9
10
11
┌─────────────────────────────────────────────────────┐
│ K를 늘리면 (더 많이 검색) │
│ │
│ Recall ↑ : 놓치는 정답이 줄어듦 │
│ Precision ↓ : 관련 없는 문서도 포함됨 │
│ │
│ K를 줄이면 (적게 검색) │
│ │
│ Precision ↑ : 검색 결과가 더 정확 │
│ Recall ↓ : 놓치는 정답이 많아짐 │
└─────────────────────────────────────────────────────┘
▶ RAG에서의 의미: K가 너무 작으면 관련 문서를 놓쳐 LLM이 답변을 못하고(Recall 낮음), K가 너무 크면 노이즈가 많아져 LLM이 혼란스러워짐(Precision 낮음).
8. 전체 메트릭 비교
| 메트릭 | 측정 대상 | 복수 정답 | 순위 반영 | 계산 복잡도 |
|---|---|---|---|---|
| Hit Rate@K | 정답 포함 여부 | O | X | 낮음 |
| MRR@K | 최초 정답 순위 | △(첫 번째만) | O | 낮음 |
| nDCG@K | 전체 순위 품질 | O | O | 중간 |
| Precision@K | 검색 정확도 | O | X | 낮음 |
| Recall@K | 정답 회수율 | O | X | 낮음 |
메트릭 선택 가이드
1
2
3
4
5
단일 정답, 빠른 평가 → Hit Rate
단일 정답, 순위 중요 → MRR
복수 정답, 순위 중요 → nDCG
노이즈 최소화 목표 → Precision
누락 최소화 목표 → Recall
9. 자주 나오는 실수 / 주의사항
❌ relevant_doc_ids에 없는 ID 사용
1
2
3
4
5
6
# 잘못된 예
qa_dataset = [{"relevant_doc_ids": ["doc_999"]}] # doc_999가 문서 코퍼스에 없음
# → 평가가 전부 MISS로 나옴
# 해결: validate_dataset()으로 사전 검증
validate_dataset(documents, qa_dataset)
❌ DCG 계산 시 log2(1) = 0 오류
1
2
3
4
5
# 잘못된 예 (i가 0부터 시작하면 log2(0+1) = log2(1) = 0 → 분모 0!)
dcg += rel / np.log2(i + 1) # i=0일 때 ZeroDivisionError
# 올바른 예
dcg += rel / np.log2(i + 2) # i=0일 때 log2(2)=1, 문제 없음
❌ similarity_search_with_score의 score가 거리값임을 망각
1
2
3
4
5
# FAISS의 score는 L2 거리 (낮을수록 유사)
# cosine similarity와 반대 방향!
# 정렬 시 오름차순(ascending)이 맞음
results = vectorstore.similarity_search_with_score(query, k=5)
# → results는 이미 score 오름차순으로 정렬되어 있음 (FAISS가 처리)
⚠️ 검색 캐시 재사용 시 문서 추가 후 업데이트 필요
1
2
3
4
5
6
7
8
# 문서를 추가했으면 캐시를 반드시 재구성
all_docs = documents + new_documents
vectorstore.add_documents(new_langchain_docs) # 벡터스토어 업데이트
# 캐시 재구성
search_results_cache = {}
for qa in all_qa:
search_results_cache[qa['query_id']] = search_documents(qa['question'], k=5)
⚠️ 평가 함수 내 qa_dataset 참조 범위
1
2
3
4
5
6
# 새로운 질답 쌍에 대한 평가 시, 함수 내 qa_dataset 범위 확인 필요
def hit_rate_at_k(query_id, k):
qa = next(q for q in qa_dataset if q['query_id'] == query_id)
# ^^^^^^^^^^^ 전역 변수 참조
# new_qa에 있는 query_id를 넘기면 StopIteration 오류 발생
# → 함수를 수정하거나 all_qa를 참조하도록 변경