포스트

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. 자주 나오는 실수 / 주의사항
  10. 점수와 실패 질문을 함께 남기기

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. 데이터셋 구성

평가셋의 관련 문서 ID가 잘못되면 검색기를 바꾸지 않아도 점수가 왜곡된다. 질문별 정답 기준을 먼저 검토하고, 표현이 비슷한 질문에만 치우치지 않도록 숫자·고유명사·복수 근거가 필요한 질문을 나누어 관리한다.

문서 코퍼스 (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개이고 K가 5라면, 두 정답이 1위와 2위에 있는 경우가 이상적인 순위입니다. 실제 순위가 1위와 5위이면 5위 문서에 더 큰 할인이 적용되어 nDCG가 낮아집니다. 정답마다 관련도 등급이 다를 때는 관련도가 높은 문서를 먼저 배치해 IDCG를 계산합니다.


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

10. 점수와 실패 질문을 함께 남기기

평균 점수는 변경 전후를 요약하는 데 유용하지만 어느 질문이 나빠졌는지는 숨긴다. 질문별 검색 ID와 순위를 함께 남겨 근거 누락과 순위 하락을 구분하면 다음 수정 대상을 정하기 쉽다.

새로운 검색 방식을 평가할 때도 같은 정답 집합과 K를 사용한다. 코퍼스를 바꾸었다면 정답 ID와 문서 경계부터 다시 확인해야 이전 점수와의 비교가 의미를 갖는다.

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

인기 태그