검색 평가에는 질문마다 어떤 문서(청크)가 정답인지 표시된 데이터가 필요합니다. 아래 예시를 계속 사용합니다.
질문 q1의 정답 문서: {d1, d2, d5}
검색 결과 (순위순, top-5): [d3, d1, d7, d2, d9]
1 2 3 4 5
✗ ✓ ✗ ✓ ✗
3-1. Precision@k
상위 k개 중 정답 문서의 비율입니다. "가져온 것 중에 쓸모 있는 게 얼마나 되나?"
precision@5 = (top-5 중 정답 수) / 5 = 2 / 5 = 0.4
LLM에 넘기는 문서 중 쓸모없는 문서가 많으면 비용이 낭비되고 답변이 흐트러지므로, precision은 노이즈를 봅니다.
3-2. Recall@k
전체 정답 문서 중 상위 k개 안에 들어온 비율입니다. "찾아야 할 것 중에 얼마나 찾았나?"
recall@5 = (top-5 중 정답 수) / (전체 정답 수) = 2 / 3 ≈ 0.667
RAG에서는 보통 recall이 더 중요합니다. 정답 문서가 컨텍스트에 아예 없으면 LLM이 맞는 답을 만들 방법이 없기 때문입니다. 질문당 정답 문서가 하나뿐인 데이터셋에서는 recall@k가 "top-k 안에 정답이 있는가(0 또는 1)"가 되며, hit rate라고도 부릅니다.
3-3. MRR (Mean Reciprocal Rank)
첫 번째 정답 문서가 몇 위에 나왔는지의 역수를 질문들에 대해 평균 낸 값입니다.
q1: 첫 정답 d1이 2위 → 1/2 = 0.5
q2: 첫 정답이 1위 → 1/1 = 1.0
q3: top-k 안에 정답 없음 → 0
MRR = (0.5 + 1.0 + 0) / 3 = 0.5
- "정답 하나만 있으면 된다"는 상황(FAQ 검색 등)에 잘 맞습니다.
- 두 번째 이후 정답 문서의 위치는 반영하지 않습니다.
3-4. nDCG@k (normalized Discounted Cumulative Gain)
정답이 위쪽에 있을수록 점수를 더 주고, 이상적인 순서와 비교해 0~1로 정규화한 값입니다. 관련도를 "정답/오답"뿐 아니라 "매우 관련(3), 조금 관련(1)"처럼 등급으로 줄 수도 있습니다.
DCG@k = Σ rel_i / log2(i + 1) (i = 순위, 1부터)
IDCG@k = 정답을 가장 이상적인 순서로 놓았을 때의 DCG
nDCG@k = DCG@k / IDCG@k
이진 관련도(정답=1, 오답=0)로 q1을 계산하면:
DCG@5 = 1/log2(3) + 1/log2(5) (2위, 4위가 정답)
= 0.631 + 0.431 = 1.062
IDCG@5 = 1/log2(2) + 1/log2(3) + 1/log2(4) (정답 3개가 1~3위에 있는 이상적 경우)
= 1 + 0.631 + 0.5 = 2.131
nDCG@5 = 1.062 / 2.131 ≈ 0.498
등급 관련도를 쓸 때는 분자를 2^rel − 1로 두는 변형도 널리 쓰입니다. 어떤 정의를 쓰는지 팀 안에서 고정해야 숫자를 비교할 수 있습니다.
3-5. 지표 비교
| 지표 | 보는 것 | 순서 반영 | 언제 중요한가 |
|---|
| Precision@k | 가져온 것 중 쓸모 있는 비율 | ✗ | 컨텍스트 노이즈, 토큰 비용 |
| Recall@k | 찾아야 할 것 중 찾은 비율 | ✗ | 정답 누락. RAG에서 가장 기본 |
| MRR | 첫 정답의 위치 | ✓ (첫 정답만) | 정답 하나면 충분한 검색 |
| nDCG@k | 정답들의 위치 전체, 등급 | ✓ | 리랭킹 품질 비교 |
3-6. 코드
import math
def precision_at_k(retrieved: list[str], relevant: set[str], k: int) -> float:
return sum(d in relevant for d in retrieved[:k]) / k
def recall_at_k(retrieved: list[str], relevant: set[str], k: int) -> float:
return sum(d in relevant for d in retrieved[:k]) / len(relevant)
def reciprocal_rank(retrieved: list[str], relevant: set[str]) -> float:
for i, d in enumerate(retrieved, start=1):
if d in relevant:
return 1 / i
return 0.0
def ndcg_at_k(retrieved: list[str], relevant: set[str], k: int) -> float:
dcg = sum(1 / math.log2(i + 2) for i, d in enumerate(retrieved[:k]) if d in relevant)
idcg = sum(1 / math.log2(i + 2) for i in range(min(len(relevant), k)))
return dcg / idcg
if __name__ == "__main__":
r, rel = ["d3", "d1", "d7", "d2", "d9"], {"d1", "d2", "d5"}
assert precision_at_k(r, rel, 5) == 0.4
assert abs(recall_at_k(r, rel, 5) - 2 / 3) < 1e-9
assert reciprocal_rank(r, rel) == 0.5
assert abs(ndcg_at_k(r, rel, 5) - 0.498) < 1e-3
MRR은 질문별 reciprocal_rank의 평균이고, 다른 지표들도 질문별로 계산해 평균을 냅니다.