[RAG] RAG 검색 평가 지표 (1) Metrics

Hunie_07·2025년 4월 11일
0

Langchain

목록 보기
24/35

📌 Retrieval Metrics

1️⃣ 평가 지표 (Evaluation Metric)

1) 검색(Retrieval) 평가

  • Non-Rank Based Metrics: Accuracy, Precision, Recall@k 등을 통해 관련성의 이진적 평가를 수행

  • Rank-Based Metrics: MRR(Mean Reciprocal Rank), MAP(Mean Average Precision)를 통해 검색 결과의 순위를 고려한 평가를 수행

  • RAG 특화 지표: 기존 검색 평가 방식의 한계를 보완하는 LLM-as-judge 방식 도입

  • 포괄적 평가: 정확도, 관련성, 다양성, 강건성을 통합적으로 측정

2) 생성(Generation) 평가

  • 전통적 평가: ROUGE(요약), BLEU(번역), BertScore(의미 유사도) 지표 활용

  • LLM 기반 평가: 응집성, 관련성, 유창성을 종합적으로 판단하는 새로운 접근법 도입 (전통적인 참조 비교가 어려운 상황에서 유용)

  • 다차원 평가: 품질, 일관성, 사실성, 가독성, 사용자 만족도를 포괄적 측정

  • 상세 프롬프트사용자 선호도 기준으로 생성 텍스트 품질 평가


검색 성능 평가 데이터

  • 2개의 검색 쿼리에 대한 정답(실제 문서)와 검색 결과(예측 문서)를 준비

    1. 실제 문서(actual_docs):

      • 첫 번째 쿼리: 배송 지연(doc1), 결제 오류(doc2), 포인트 적립(doc5) 관련 문서
      • 두 번째 쿼리: 제품 교환(doc3)과 취소 환불(doc4) 관련 문서
    2. 예측 문서(predicted_docs):

      • 첫 번째 쿼리: doc1, doc2, doc5을 검색 결과로 반환
      • 두 번째 쿼리: doc6, doc4, doc5을 검색 결과로 반환
  • 랭체인 문서 객체로 구현된 각 문서는 다음 정보를 포함:

    • 문의 내용(page_content)
    • 메타데이터(id, category, priority)
from langchain_core.documents import Document
from textwrap import dedent

# 실제 문서 데이터 (정답)
actual_docs = [

    [
        Document(
        page_content=dedent("""
            고객 문의: 제품 배송 지연
            문의 일시: 2024-01-15 14:23
            고객명: 김지안
            문의 내용: 지난주 주문한 상품이 아직도 배송이 안 왔습니다. 
                      언제쯤 받을 수 있을까요?
            처리 상태: 답변 완료
            담당자: 이수진
            """).strip(),
        metadata={"id": "doc1", "category": "배송", "priority": "높음"}
        ),

        Document(
        page_content=dedent("""
            고객 문의: 결제 오류
            문의 일시: 2024-01-15 15:30
            고객명: 이동현
            문의 내용: 결제 시도 중 오류가 발생했습니다.
                      카드 결제가 되지 않아요.
            처리 상태: 답변 완료
            담당자: 김태호
            """).strip(),
        metadata={"id": "doc2", "category": "결제", "priority": "높음"}
        ),

        Document(
        page_content=dedent("""
            고객 문의: 포인트 적립 문의
            문의 일시: 2024-01-17 16:10
            고객명: 최서연
            문의 내용: 지난 구매 건에 대한 포인트가 적립이 안 되었습니다.
                      확인 부탁드립니다.
            처리 상태: 답변 대기
            담당자: 미배정
            """).strip(),
        metadata={"id": "doc5", "category": "포인트/적립", "priority": "낮음"}
        ),

    ],

    [
        Document(
        page_content=dedent("""
            고객 문의: 제품 교환 요청
            문의 일시: 2024-01-16 09:45
            고객명: 박현우
            문의 내용: 받은 제품의 사이즈가 맞지 않아 교환하고 싶습니다.
                      교환 절차를 알려주세요.
            처리 상태: 진행중
            담당자: 정미영
            """).strip(),
        metadata={"id": "doc3", "category": "교환/반품", "priority": "중간"}
        ), 
        
        Document(
        page_content=dedent("""
            고객 문의: 취소 환불 문의
            문의 일시: 2024-01-16 11:20
            고객명: 장민서
            문의 내용: 주문을 취소하고 싶습니다.
                      환불은 얼마나 걸리나요?
            처리 상태: 답변 완료
            담당자: 홍길동
            """).strip(),
        metadata={"id": "doc4", "category": "취소/환불", "priority": "중간"}
        ),
    ]

]

# 예측 문서 데이터 (검색 결과)
predicted_docs = [

    [
        Document(
        page_content=dedent("""
            고객 문의: 제품 배송 지연
            문의 일시: 2024-01-15 14:23
            고객명: 김지안
            문의 내용: 지난주 주문한 상품이 아직도 배송이 안 왔습니다. 
                      언제쯤 받을 수 있을까요?
            처리 상태: 답변 완료
            담당자: 이수진
            """).strip(),
        metadata={"id": "doc1", "category": "배송", "priority": "높음"}
    ),
        Document(
        page_content=dedent("""
            고객 문의: 결제 오류
            문의 일시: 2024-01-15 15:30
            고객명: 이동현
            문의 내용: 결제 시도 중 오류가 발생했습니다.
                      카드 결제가 되지 않아요.
            처리 상태: 답변 완료
            담당자: 김태호
            """).strip(),
        metadata={"id": "doc2", "category": "결제", "priority": "높음"}
    ),
        Document(
        page_content=dedent("""
            고객 문의: 포인트 적립 문의
            문의 일시: 2024-01-17 16:10
            고객명: 최서연
            문의 내용: 지난 구매 건에 대한 포인트가 적립이 안 되었습니다.
                      확인 부탁드립니다.
            처리 상태: 답변 대기
            담당자: 미배정
            """).strip(),
        metadata={"id": "doc5", "category": "포인트/적립", "priority": "낮음"}
        ),
    ],

    [

        Document(
        page_content=dedent("""
            고객 문의: 상품 재입고 문의
            문의 일시: 2024-01-17 17:45
            고객명: 한승우
            문의 내용: 품절된 상품의 재입고 일정이 궁금합니다.
                        알림 신청은 어떻게 하나요?
            처리 상태: 답변 대기
            담당자: 미배정
            """).strip(),
        metadata={"id": "doc6", "category": "재고", "priority": "중간"}
        ),

        Document(
        page_content=dedent("""
            고객 문의: 취소 환불 문의
            문의 일시: 2024-01-16 11:20
            고객명: 장민서
            문의 내용: 주문을 취소하고 싶습니다.
                      환불은 얼마나 걸리나요?
            처리 상태: 답변 완료
            담당자: 홍길동
            """).strip(),
        metadata={"id": "doc4", "category": "취소/환불", "priority": "중간"}
        ),

        Document(
        page_content=dedent("""
            고객 문의: 포인트 적립 문의
            문의 일시: 2024-01-17 16:10
            고객명: 최서연
            문의 내용: 지난 구매 건에 대한 포인트가 적립이 안 되었습니다.
                      확인 부탁드립니다.
            처리 상태: 답변 대기
            담당자: 미배정
            """).strip(),
        metadata={"id": "doc5", "category": "포인트/적립", "priority": "낮음"}
        ),
        
    ]
    
]


# 문서 ID 출력
actual_ids = [[doc.metadata["id"] for doc in docs] for docs in actual_docs]
predicted_ids = [[doc.metadata["id"] for doc in docs] for docs in predicted_docs]

print("실제 문서 ID:", actual_ids)
print("예측 문서 ID:", predicted_ids)

- 출력

실제 문서 ID: [['doc1', 'doc2', 'doc5'], ['doc3', 'doc4']]
예측 문서 ID: [['doc1', 'doc2', 'doc5'], ['doc6', 'doc4', 'doc5']]

2️⃣ 검색 성능 평가

  • 문서 검색 시스템의 성능은 정확도재현율을 통해 평가

  • 정밀도(Precision) 지표는 검색된 문서 중 관련 문서의 비율을 측정

  • 재현율(Recall) 지표는 전체 관련 문서 중 실제로 검색된 문서의 비율을 나타냄

  • F1 점수는 정밀도와 재현율의 조화 평균으로 종합적 성능을 평가

  • 효과적인 검색 시스템은 정확도와 재현율 간의 최적의 균형을 찾는 것이 중요


1. TP, FP, FN 계산

  1. True Positive (TP):

    • 실제로 관련 있는 문서를 정확하게 검색한 경우
    • 첫 번째 쿼리에서는 doc1, doc2를 정확히 검색
    • 두 번째 쿼리에서는 doc4를 정확히 검색
  2. False Positive (FP):

    • 실제로는 관련 없는 문서를 잘못 검색한 경우
    • 첫 번째 쿼리에서는 doc3를 불필요하게 검색
    • 두 번째 쿼리에서는 doc5, doc6을 불필요하게 검색
  3. False Negative (FN):

    • 실제로는 관련 있는 문서를 검색하지 못한 경우
    • 첫 번째 쿼리에서는 모든 관련 문서를 검색
    • 두 번째 쿼리에서는 doc3을 검색하지 못함
# True Positive - 예측 문서 중 실제 문서와 일치하는 문서
true_positives = [ 
    [doc.metadata["id"] for doc in actual if doc in predicted] 
    for actual, predicted in zip(actual_docs, predicted_docs)
]

# False Positive - 예측 문서 중 실제 문서와 일치하지 않는 문서
false_positives = [ 
    [doc.metadata["id"] for doc in predicted if doc not in actual] 
    for actual, predicted in zip(actual_docs, predicted_docs)
]

# False Negative - 실제 문서 중 예측 문서와 일치하지 않는 문서
false_negatives = [ 
    [doc.metadata["id"] for doc in actual if doc not in predicted] 
    for actual, predicted in zip(actual_docs, predicted_docs)
]

print("True Positive:", true_positives)
print("False Positive:", false_positives)
print("False Negative:", false_negatives)

- 출력

True Positive: [['doc1', 'doc2', 'doc5'], ['doc4']]
False Positive: [[], ['doc6', 'doc5']]
False Negative: [[], ['doc3']]

2. Precision, Recall, F1 Score 계산

  • 정밀도(Precision) = (검색된 문서 중 관련 문서 수) / (검색된 총 문서 수)

  • 재현율(Recall) = (검색된 관련 문서 수) / (전체 관련 문서 수)

  • F1 Score = 2 × (정밀도 × 재현율) / (정밀도 + 재현율)

  • 성능 평가는 문서셋과 검색 쿼리에 대해 각각 계산하여 평균값 사용

# 각 쿼리별 성능 분석
for i, (tp, fp, fn) in enumerate(zip(true_positives, false_positives, false_negatives)):  
    print(f"쿼리 {i+1} 분석:")
    print(f"정확하게 검색된 문서 (TP): {tp}")
    print(f"잘못 검색된 문서 (FP): {fp}")
    print(f"놓친 문서 (FN): {fn}")
    
    # 정밀도와 재현율 계산
    precision = len(tp) / (len(tp) + len(fp)) if len(tp) + len(fp) > 0 else 0
    recall = len(tp) / (len(tp) + len(fn)) if len(tp) + len(fn) > 0 else 0
    f1 = 2 * (precision * recall) / (precision + recall) if precision + recall > 0 else 0
    
    print(f"정밀도 (Precision): {precision:.2f}")
    print(f"재현율 (Recall): {recall:.2f}")
    print(f"F1 Score: {f1:.2f}")
    print('')

- 출력

쿼리 1 분석:
정확하게 검색된 문서 (TP): ['doc1', 'doc2', 'doc5']
잘못 검색된 문서 (FP): []
놓친 문서 (FN): []
정밀도 (Precision): 1.00
재현율 (Recall): 1.00
F1 Score: 1.00

쿼리 2 분석:
정확하게 검색된 문서 (TP): ['doc4']
잘못 검색된 문서 (FP): ['doc6', 'doc5']
놓친 문서 (FN): ['doc3']
정밀도 (Precision): 0.33
재현율 (Recall): 0.50
F1 Score: 0.40

3. Micro / Macro Average 계산

  • Micro Average는 전체 데이터셋의 개별 결과를 합산하여 계산

  • Macro Average는 각 클래스(쿼리)별 성능 지표를 먼저 계산한 후 평균을 구함

  • Micro는 데이터 불균형에 덜 민감하며 전체적 성능 평가에 적합

  • Macro는 각 클래스(쿼리)의 성능을 동등하게 고려하여 소수 클래스 성능도 중요하게 반영


(1) Macro Average (매크로 평균)

  • 매크로 평균은 각 클래스(쿼리)별 성능 지표(Precision, Recall, F1)을 독립적으로 계산 후 평균값 산출

  • 수식: Macro-Precision = 1ni=1nPi\frac{1}{n}\sum_{i=1}^{n} P_i

  • 수식: Macro-Recall = 1ni=1nRi\frac{1}{n}\sum_{i=1}^{n} R_i

  • 각 클래스(쿼리)는 동일한 가중치로 평가되어 소수 클래스의 성능도 중요하게 반영됨

from typing import List, Tuple
import numpy as np

def calculate_macro_metrics(
    true_positives: List[List], 
    false_positives: List[List], 
    false_negatives: List[List]
) -> Tuple[float, float, float]:
    """
    Macro-average 방식으로 Precision, Recall, F1 Score를 계산합니다.
    각 클래스별 메트릭을 먼저 계산한 후 평균을 냅니다.

    Args:
        true_positives (List[List]): 각 클래스별 true positive 케이스들의 리스트
        false_positives (List[List]): 각 클래스별 false positive 케이스들의 리스트
        false_negatives (List[List]): 각 클래스별 false negative 케이스들의 리스트

    Returns:
        Tuple[float, float, float]: (macro_precision, macro_recall, macro_f1) 값을 반환
    """
    n_classes = len(true_positives)
    precisions = []
    recalls = []
    f1_scores = []
    
    for i in range(n_classes):
        tp = len(true_positives[i])
        fp = len(false_positives[i])
        fn = len(false_negatives[i])
        
        # Precision 계산
        precision = tp / (tp + fp) if (tp + fp) > 0 else 0
        precisions.append(precision)
        
        # Recall 계산
        recall = tp / (tp + fn) if (tp + fn) > 0 else 0
        recalls.append(recall)
        
        # F1 Score 계산
        f1 = 2 * precision * recall / (precision + recall) if (precision + recall) > 0 else 0
        f1_scores.append(f1)

        print(f"Class {i+1}: Precision={precision:.3f}, Recall={recall:.3f}, F1 Score={f1:.3f}")
    
    # 각 메트릭의 평균을 계산
    macro_precision = np.mean(precisions)
    macro_recall = np.mean(recalls)
    macro_f1 = np.mean(f1_scores)
    
    return macro_precision, macro_recall, macro_f1   # type: ignore


# Macro-average 방식으로 Precision, Recall, F1 Score 계산    
macro_precision, macro_recall, macro_f1 = calculate_macro_metrics(true_positives, false_positives, false_negatives)

print("\nMacro-average Metrics:")
print(f"Macro-average Precision: {macro_precision:.2f}")
print(f"Macro-average Recall: {macro_recall:.2f}")
print(f"Macro-average F1 Score: {macro_f1:.2f}")

- 출력

Class 1: Precision=1.000, Recall=1.000, F1 Score=1.000
Class 2: Precision=0.333, Recall=0.500, F1 Score=0.400

Macro-average Metrics:
Macro-average Precision: 0.67
Macro-average Recall: 0.75
Macro-average F1 Score: 0.70

(2) Micro Average (마이크로 평균)

  • 마이크로 평균은 전체 데이터셋의 TP, FP, FN을 먼저 통합하여 계산

  • 수식: Micro-Precision = i=1nTPii=1n(TPi+FPi)\frac{\sum_{i=1}^{n} TP_i}{\sum_{i=1}^{n} (TP_i + FP_i)}

  • 수식: Micro-Recall = i=1nTPii=1n(TPi+FNi)\frac{\sum_{i=1}^{n} TP_i}{\sum_{i=1}^{n} (TP_i + FN_i)}

  • 데이터 수가 많은 클래스가 전체 성능에 더 큰 영향을 미침

from typing import List, Tuple

def calculate_micro_metrics(
    true_positives: List[List],
    false_positives: List[List], 
    false_negatives: List[List]
) -> Tuple[float, float, float]:
    """
    Micro-average 방식으로 Precision, Recall, F1 Score를 계산합니다.
    모든 클래스의 TP, FP, FN을 합산한 후 메트릭을 계산합니다.
    
    Args:
        true_positives (List[List]): 각 클래스별 true positive 케이스들의 리스트
        false_positives (List[List]): 각 클래스별 false positive 케이스들의 리스트  
        false_negatives (List[List]): 각 클래스별 false negative 케이스들의 리스트

    Returns:
        Tuple[float, float, float]: (micro_precision, micro_recall, micro_f1) 값을 반환
    """
    # 전체 TP, FP, FN 합계 계산
    total_tp = sum(len(tp) for tp in true_positives)
    total_fp = sum(len(fp) for fp in false_positives) 
    total_fn = sum(len(fn) for fn in false_negatives)

    # Micro Precision
    micro_precision = total_tp / (total_tp + total_fp) if (total_tp + total_fp) > 0 else 0

    # Micro Recall  
    micro_recall = total_tp / (total_tp + total_fn) if (total_tp + total_fn) > 0 else 0

    # Micro F1 Score
    micro_f1 = 2 * micro_precision * micro_recall / (micro_precision + micro_recall) \
        if (micro_precision + micro_recall) > 0 else 0
    
    return micro_precision, micro_recall, micro_f1


# Micro-average 방식으로 Precision, Recall, F1 Score 계산

micro_precision, micro_recall, micro_f1 = calculate_micro_metrics(true_positives, false_positives, false_negatives)

print("Micro-average Metrics:")
print(f"Micro-average Precision: {micro_precision:.2f}")
print(f"Micro-average Recall: {micro_recall:.2f}")
print(f"Micro-average F1 Score: {micro_f1:.2f}")

- 출력

Micro-average Metrics:
Micro-average Precision: 0.67
Micro-average Recall: 0.80
Micro-average F1 Score: 0.73

3️⃣ 테스트셋 실습

  • 이전에 합성한 테스트 데이터셋 활용

    • data/testset.xlsx
  • 이전에 생성해둔 벡터저장소를 로드

    • db_korean_cosine 컬렉션 사용
  • 유사도 기준 검색기(retriever)를 설정 : k=3

  • 검색된 문서의 텍스트가 참조(reference_contexts)에 포함되는지 여부를 평가

  • 테스트셋의 모든 쿼리에 대해서 계산 (Precision, Recall, F1 Score)


테스트셋 로드

import pandas as pd

testset = pd.read_excel('data/testset.xlsx')

벡터 저장소 로드

# 벡터 저장소 로드
from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings

embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")

vector_store = Chroma(
    collection_name='db_korean_cosine',
    embedding_function=embedding_model,
    persist_directory='./chroma_db'
)

retriever = vector_store.as_retriever(search_kwargs={'k': 3})

검색 수행

# 모든 쿼리에 대해서 검색을 수행하고 결과를 저장
retrieved_results = []

for i, row in testset.iterrows():
    query = row["user_input"]
    context = eval(row["reference_contexts"])
    
    # 검색 수행
    retrieved_docs = retriever.invoke(query)
    
    # 검색 결과 저장
    retrieved_results.append({
        "query": query,
        "context": context,
        "retrieved": [doc.page_content for doc in retrieved_docs]
    })

# 결과 확인
pprint(retrieved_results[:2])

- 출력

[{'context': ['Tesla, Inc.는 미국의 다국적 자동차 및 청정 에너지 회사입니다. 이 회사는 전기 자동차(BEV), 고정형 '
              '배터리 에너지 저장 장치, 태양 전지판, 태양광 지붕널 및 관련 제품/서비스를 설계, 제조 및 판매합니다. '
              '2003년 7월 Martin Eberhard와 Marc Tarpenning이 Tesla Motors로 설립했으며, '
              'Nikola Tesla를 기리기 위해 명명되었습니다. Elon Musk는 2004년 Tesla의 초기 자금 조달을 '
              '주도하여 2008년에 회장 겸 CEO가 되었습니다.'],
  'query': 'Tesla, Inc.는 미국에서 어떤 역할을 하고 있으며, 이 회사의 주요 제품과 서비스는 무엇인가요?',
  'retrieved': ['Tesla, Inc.는 미국의 다국적 자동차 및 청정 에너지 회사입니다. 이 회사는 전기 자동차(BEV), ',
  ...
  ]}]

검색 결과 저장

# 검색 결과 저장
with open("data/retrieved_results.json", "w", encoding="utf-8") as f:
    json.dump(retrieved_results, f, ensure_ascii=False, indent=2)

검색 결과 로드

# 검색 결과 로드
with open("data/retrieved_results.json", "r", encoding='utf-8') as f:
    retrieved_results = json.load(f)

# 검색 결과 확인
pprint(retrieved_results[:2])

쿼리 성능 분석

# 각 쿼리별 성능 분석

# TP 계산 (True Positive) - 예측 문서 중 실제 문서와 일치하는 문서
true_positives = [ 
    [doc for doc in result["retrieved"] if doc in result["context"]] 
    for result in retrieved_results
]

# FP 계산 (False Positive) - 예측 문서 중 실제 문서와 일치하지 않는 문서
false_positives = [
    [doc for doc in result["retrieved"] if doc not in result["context"]]
    for result in retrieved_results
]

# FN 계산 (False Negative) - 실제 문서 중 예측 문서와 일치하지 않는 문서
false_negatives = [
    [doc for doc in result["context"] if doc not in result["retrieved"]]
    for result in retrieved_results
]

# 각 쿼리별 성능 분석
for i, (tp, fp, fn) in enumerate(zip(true_positives, false_positives, false_negatives)):  
    print(f"\n쿼리 {i+1} 분석:")
    print(f"정확하게 검색된 문서 (TP): {len(tp)}")
    print(f"잘못 검색된 문서 (FP): {len(fp)}")
    print(f"놓친 문서 (FN): {len(fn)}")

- 출력

쿼리 1 분석:
정확하게 검색된 문서 (TP): 1
잘못 검색된 문서 (FP): 2
놓친 문서 (FN): 0

쿼리 2 분석:
...

Macro-Average 계산

# Macro-average 방식으로 Precision, Recall, F1 Score 계산
macro_precision, macro_recall, macro_f1 = calculate_macro_metrics(true_positives, false_positives, false_negatives)

print("Macro-average Metrics:")
print(f"Macro-average Precision: {macro_precision:.2f}")
print(f"Macro-average Recall: {macro_recall:.2f}")
print(f"Macro-average F1 Score: {macro_f1:.2f}")

Micro-Average 계산

# Micro-average 방식으로 Precision, Recall, F1 Score 계산
micro_precision, micro_recall, micro_f1 = calculate_micro_metrics(true_positives, false_positives, false_negatives)

print("Micro-average Metrics:")
print(f"Micro-average Precision: {micro_precision:.2f}")
print(f"Micro-average Recall: {micro_recall:.2f}")
print(f"Micro-average F1 Score: {micro_f1:.2f}")

0개의 댓글