포스트

2026-03-31 강의 정리

[프로젝트2] 안내 + RAG 성능평가 개요

2026-03-31 강의 정리

목차

  1. RAG 검색 평가란?
  2. 데이터셋 구성 (문서 + 질답 쌍)
  3. 벡터 임베딩 & FAISS 검색
  4. Hit Rate@K
  5. MRR (Mean Reciprocal Rank)
  6. nDCG (Normalized Discounted Cumulative Gain)
  7. Precision & Recall
  8. 전체 메트릭 비교
  9. 자주 나오는 실수 / 주의사항

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값의미
@11등 결과에 정답이 있는가 (가장 엄격)
@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정답 포함 여부OX낮음
MRR@K최초 정답 순위△(첫 번째만)O낮음
nDCG@K전체 순위 품질OO중간
Precision@K검색 정확도OX낮음
Recall@K정답 회수율OX낮음

메트릭 선택 가이드

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를 참조하도록 변경

이 기사는 저작권자의 CC BY 4.0 라이센스를 따릅니다.

인기 태그