Precision, Recall, NDCG, MRR, MAP 등 검색 품질 메트릭의 원리와 계산법, 오프라인/온라인 평가 방법론, A/B 테스트와 평가 데이터셋 구축을 다룹니다.
검색 시스템을 개선하려면 현재 품질을 객관적으로 측정할 수 있어야 합니다. "검색 결과가 좋아 보인다"는 주관적 판단으로는 임베딩 모델 교체, 리랭킹 도입, 파라미터 조정 등의 변경이 실제로 개선을 가져왔는지 확인할 수 없습니다.
검색 품질 메트릭은 크게 두 가지 관점에서 측정됩니다.
상위 K개 결과 중 관련 문서의 비율입니다. 사용자가 실제로 보는 결과의 품질을 직접 반영합니다.
def precision_at_k(retrieved: list[int], relevant: set[int], k: int) -> float:
"""
P@K 계산
retrieved: 검색 결과 문서 ID 리스트 (순서대로)
relevant: 관련 문서 ID 집합
k: 상위 K개
"""
top_k = retrieved[:k]
relevant_in_top_k = sum(1 for doc_id in top_k if doc_id in relevant)
return relevant_in_top_k / k
# 예시: 상위 5개 중 3개가 관련 문서
retrieved = [101, 205, 303, 404, 102]
relevant = {101, 102, 303, 507}
print(f"P@5 = {precision_at_k(retrieved, relevant, 5)}") # 0.6전체 관련 문서 중 상위 K개에 포함된 비율입니다. 관련 문서를 얼마나 빠짐없이 찾아내는지를 측정합니다.
def recall_at_k(retrieved: list[int], relevant: set[int], k: int) -> float:
"""R@K 계산"""
top_k = retrieved[:k]
relevant_in_top_k = sum(1 for doc_id in top_k if doc_id in relevant)
return relevant_in_top_k / len(relevant) if relevant else 0.0
# 전체 관련 문서 4개 중 3개를 상위 5개에서 찾음
print(f"R@5 = {recall_at_k(retrieved, relevant, 5)}") # 0.75Precision과 Recall은 트레이드오프 관계입니다. K를 늘리면 Recall은 오르지만 Precision은 떨어질 수 있습니다. 실제 서비스에서는 P@5나 P@10처럼 사용자가 실제로 보는 범위에 맞춰 K를 설정합니다.
첫 번째 관련 문서의 순위 역수를 평균한 값입니다. "원하는 결과를 얼마나 빨리 찾을 수 있는가"를 측정합니다.
def reciprocal_rank(retrieved: list[int], relevant: set[int]) -> float:
"""단일 쿼리의 Reciprocal Rank"""
for i, doc_id in enumerate(retrieved):
if doc_id in relevant:
return 1.0 / (i + 1)
return 0.0
def mean_reciprocal_rank(queries_results: list[tuple[list[int], set[int]]]) -> float:
"""MRR: 여러 쿼리의 RR 평균"""
rr_scores = [reciprocal_rank(ret, rel) for ret, rel in queries_results]
return sum(rr_scores) / len(rr_scores)
# 예시: 3개 쿼리
queries = [
([10, 20, 30], {20}), # 첫 관련 문서가 2위 -> RR = 0.5
([40, 50, 60], {40}), # 첫 관련 문서가 1위 -> RR = 1.0
([70, 80, 90], {90}), # 첫 관련 문서가 3위 -> RR = 0.333
]
print(f"MRR = {mean_reciprocal_rank(queries):.4f}") # 0.6111각 관련 문서가 발견되는 위치에서의 Precision을 평균한 값입니다. 관련 문서의 순위 분포를 종합적으로 반영합니다.
def average_precision(retrieved: list[int], relevant: set[int]) -> float:
"""단일 쿼리의 Average Precision"""
hits = 0
sum_precision = 0.0
for i, doc_id in enumerate(retrieved):
if doc_id in relevant:
hits += 1
sum_precision += hits / (i + 1)
return sum_precision / len(relevant) if relevant else 0.0
def mean_average_precision(queries_results: list[tuple[list[int], set[int]]]) -> float:
"""MAP: 여러 쿼리의 AP 평균"""
ap_scores = [average_precision(ret, rel) for ret, rel in queries_results]
return sum(ap_scores) / len(ap_scores)**NDCG(Normalized Discounted Cumulative Gain)**는 가장 널리 사용되는 검색 품질 메트릭입니다. 관련성 판단이 이진(관련/비관련)이 아닌 다단계(0, 1, 2, 3...)일 때 특히 유용합니다.
import numpy as np
def dcg_at_k(relevance_scores: list[float], k: int) -> float:
"""DCG@K: 순위에 따른 감가 누적 이득"""
relevance = np.array(relevance_scores[:k])
positions = np.arange(1, len(relevance) + 1)
return np.sum(relevance / np.log2(positions + 1))
def ndcg_at_k(relevance_scores: list[float], k: int) -> float:
"""NDCG@K: 이상적 순서 대비 정규화된 점수"""
dcg = dcg_at_k(relevance_scores, k)
ideal_scores = sorted(relevance_scores, reverse=True)
idcg = dcg_at_k(ideal_scores, k)
return dcg / idcg if idcg > 0 else 0.0
# 예시: 관련성 점수 (3=매우 관련, 2=관련, 1=약간 관련, 0=무관)
scores = [3, 0, 2, 1, 0]
print(f"NDCG@5 = {ndcg_at_k(scores, 5):.4f}") # 0.8542NDCG의 핵심 아이디어는 두 가지입니다.
log2(position + 1)로 나누어 상위 순위에 가중치를 부여합니다.| 메트릭 | 관련성 유형 | 순위 고려 | 주요 관점 |
|---|---|---|---|
| P@K | 이진 | 부분적 (K 범위) | 상위 결과 정밀도 |
| R@K | 이진 | 부분적 | 관련 문서 커버리지 |
| MRR | 이진 | 첫 관련 결과 위치 | 빠른 답변 찾기 |
| MAP | 이진 | 전체 순위 | 종합적 순위 품질 |
| NDCG@K | 다단계 | 전체 순위 | 등급별 관련성 반영 |
**오프라인 평가(Offline Evaluation)**는 미리 구축한 평가 데이터셋을 사용하여 검색 시스템의 품질을 측정하는 방식입니다. 시스템 변경 전후의 메트릭을 비교하여 개선 효과를 확인합니다.
평가 데이터셋은 쿼리-문서 쌍에 관련성 레이블을 부여한 것입니다.
from dataclasses import dataclass
@dataclass
class RelevanceJudgment:
query_id: str
query_text: str
doc_id: str
relevance: int # 0: 무관, 1: 약간 관련, 2: 관련, 3: 매우 관련
# 평가 데이터셋 예시
judgments = [
RelevanceJudgment("q1", "파이썬 웹 프레임워크", "d101", 3),
RelevanceJudgment("q1", "파이썬 웹 프레임워크", "d102", 2),
RelevanceJudgment("q1", "파이썬 웹 프레임워크", "d103", 0),
RelevanceJudgment("q2", "데이터베이스 인덱스", "d201", 3),
RelevanceJudgment("q2", "데이터베이스 인덱스", "d202", 1),
]평가 데이터셋 구축은 비용이 많이 드는 작업입니다. 최소 50-100개의 쿼리와 쿼리당 10-30개의 문서에 대한 관련성 레이블이 필요합니다. 레이블러 간의 일관성을 위해 명확한 가이드라인과 복수 레이블러의 합의가 중요합니다.
def evaluate_search_system(search_fn, judgments: list[RelevanceJudgment], k: int = 10):
"""검색 시스템 오프라인 평가"""
queries = {}
for j in judgments:
if j.query_id not in queries:
queries[j.query_id] = {"text": j.query_text, "relevance": {}}
queries[j.query_id]["relevance"][j.doc_id] = j.relevance
ndcg_scores = []
for query_id, query_data in queries.items():
results = search_fn(query_data["text"], k=k)
relevance_scores = [
query_data["relevance"].get(doc_id, 0) for doc_id in results
]
ndcg_scores.append(ndcg_at_k(relevance_scores, k))
return {
"mean_ndcg": sum(ndcg_scores) / len(ndcg_scores),
"per_query_ndcg": dict(zip(queries.keys(), ndcg_scores)),
}오프라인 평가만으로는 실제 사용자 경험을 완전히 반영하기 어렵습니다. **온라인 평가(Online Evaluation)**는 실제 트래픽을 활용하여 검색 품질을 측정합니다.
두 가지 검색 시스템(대조군 A와 실험군 B)에 트래픽을 무작위로 분배하고, 사용자 행동 지표를 비교합니다.
온라인 메트릭으로 주로 사용되는 지표는 다음과 같습니다.
A/B 테스트의 대안으로, 두 시스템의 결과를 하나의 목록에 섞어서 보여주는 방법입니다. 같은 사용자가 두 시스템의 결과를 동시에 평가하므로, A/B 테스트보다 적은 트래픽으로 빠르게 우열을 판단할 수 있습니다.
def team_draft_interleave(results_a: list, results_b: list, k: int) -> tuple[list, dict]:
"""팀 드래프트 인터리빙"""
interleaved = []
team_a = set()
team_b = set()
idx_a, idx_b = 0, 0
while len(interleaved) < k:
if len(team_a) <= len(team_b):
# A에서 선택
while idx_a < len(results_a) and results_a[idx_a] in interleaved:
idx_a += 1
if idx_a < len(results_a):
interleaved.append(results_a[idx_a])
team_a.add(results_a[idx_a])
idx_a += 1
else:
# B에서 선택
while idx_b < len(results_b) and results_b[idx_b] in interleaved:
idx_b += 1
if idx_b < len(results_b):
interleaved.append(results_b[idx_b])
team_b.add(results_b[idx_b])
idx_b += 1
return interleaved, {"team_a": team_a, "team_b": team_b}인터리빙은 A/B 테스트 대비 약 10배 적은 트래픽으로 동일한 통계적 검정력을 달성할 수 있다고 알려져 있습니다. 트래픽이 제한된 서비스에서 특히 유용합니다.
실제 서비스에서는 사용자 클릭 로그가 검색 품질의 중요한 신호입니다. 다만 클릭 데이터에는 여러 편향이 존재하므로 주의가 필요합니다.
위치 편향(Position Bias): 상위 결과가 단순히 위치 때문에 더 많이 클릭됩니다. 관련성과 무관하게 1위 결과의 CTR이 가장 높습니다.
신뢰 편향(Trust Bias): 사용자가 검색 엔진을 신뢰하여, 상위 결과가 관련 있을 것이라고 가정하고 클릭합니다.
이러한 편향을 보정하기 위해 IPW(Inverse Propensity Weighting), 위치 모델(Position Model) 등의 기법을 적용합니다.
이번 장에서는 검색 품질을 객관적으로 측정하는 메트릭과 평가 방법론을 다루었습니다. P@K, R@K, MRR, MAP, NDCG 각각의 특성과 계산법을 이해했고, 오프라인 평가(레이블 데이터셋 기반)와 온라인 평가(A/B 테스트, 인터리빙)의 차이와 적용 시점을 학습했습니다. 클릭 로그의 편향 문제도 확인했습니다.
다음 장에서는 검색의 시작점인 사용자 쿼리를 깊이 있게 다룹니다. 쿼리 분류, 의도 인식, 동의어 확장부터 LLM 기반 쿼리 확장, HyDE(가상 문서 생성)까지, 쿼리 이해와 확장 기법을 살펴보겠습니다.
이 글이 도움이 되셨나요?
쿼리 분류, 의도 인식, 엔티티 인식부터 LLM 기반 쿼리 확장, HyDE(가상 문서 생성), 다국어 처리까지 쿼리 이해 파이프라인을 다룹니다.
Bi-encoder 기반 시맨틱 검색의 작동 원리, 임베딩 모델 선택, 문서 청킹 전략, ANN 검색, 벡터 데이터베이스 연동을 Python 구현과 함께 다룹니다.
Bi-encoder와 Cross-encoder의 구조적 차이, Elastic Rerank의 DeBERTa v3 모델, 점수 퓨전(RRF), 학습 랭킹(LTR)을 심층적으로 다룹니다.