RAG 검색 평가: Recall@k와 MRR로 누락과 순위를 나누기

이 카테고리 글 목록

전체글 보기

RAG가 필요한 문서를 검색하지 못한 것과, 문서는 찾았지만 순위가 뒤로 밀린 것은 다른 문제다. Recall@k는 필요한 문서를 얼마나 포함했는지, MRR@k는 첫 관련 문서가 얼마나 앞에 나타났는지를 본다. 두 값을 함께 계산하면 누락과 순위 지연을 나눠 볼 수 있다.

이 글은 고정된 문서 집합과 사람이 정한 관련성 정답을 바탕으로 검색 순위만 평가한다. 아래 D1~D9와 네 질의는 모두 설명용 합성 자료다. 실제 검색기·임베딩·생성 모델을 호출하지 않았고, 점수는 최종 답변의 정확도나 운영 품질을 측정한 결과가 아니다.

검색 결과 밖에 있는 관련 문서도 분모에 넣는다

평가 corpus는 서로 다른 문서 ID D1~D9 아홉 개다. 질의마다 이 corpus 전체에 대한 관련 문서 집합 G를 미리 정했다고 가정한다. 관련성은 0/1이고, 문서 하나를 단위로 센다. 순위 목록에는 중복 ID가 없으며 왼쪽이 1위다.

합성 정답 집합과 검색 순위: 대괄호 안 순서는 1위부터
질의 전체 관련 문서 G 검색 순위
q1 {D1, D2, D3} [D7, D1, D8, D2, D3]
q2 {D4} [D8, D7, D4, D9, D1]
q3 {D5, D6} [D9, D8, D7, D6, D5]
q4 빈 집합: 관련 문서 없음 [D1, D2, D3]

질의 q의 Recall@k는 상위 k개에 포함된 서로 다른 관련 문서 수 / |G|다. q1의 상위 3개는 D7·D1·D8이다. 전체 관련 문서 세 개 중 D1만 찾았으므로 Recall@3=1/3이다. 상위 3개 안에서 발견한 관련 문서 한 개만 분모로 삼아 1/1이라고 계산하면, 놓친 D2·D3가 평가에서 사라진다.

TorchMetrics의 공식 retrieval_recall 구현도 상위 k개 관련 항목 수를 전달된 target 전체의 양성 수로 나눈다. 따라서 라이브러리에 이미 잘린 후보만 넘기면 그 후보 안의 정답이 분모가 된다. 전체 corpus에 대한 재현율을 원한다면 검색 밖의 gold를 보존하거나, 그 전체 분모를 유지하는 평가 입력을 갖춰야 한다. 라이브러리가 누락된 정답을 알아서 복원해 주지는 않는다.

첫 관련 문서의 역순위와 평균을 구한다

상위 k 안에 처음 나타난 관련 문서의 순위를 r이라고 하면 reciprocal rank, 즉 RR@k는 1/r이다. 관련 문서가 상위 k에 없으면 0이다. MRR@k는 평가에 포함한 질의들의 RR@k 산술평균이다. 이 글에서 @k는 첫 관련 문서도 상위 k까지만 찾는다는 뜻이다. 제한 없이 계산한 MRR과 이름만 보고 같은 값으로 비교하지 않는다.

k=3에서 질의별 누락과 첫 발견 순위
질의 관련 문서 수 상위 3개 적중 수 Recall@3 첫 관련 순위 RR@3
q1 3 1 1/3 2 1/2
q2 1 1 1 3 1/3
q3 2 0 0 상위 3개에 없음 0
q4 0 해당 없음 평균에서 제외 해당 없음 평균에서 제외

이 정책에서 질의별 평균 Recall@3=(1/3+1+0)/3=0.444444, MRR@3=(1/2+1/3+0)/3=0.277778이다. 반면 적중 수를 모두 더해 gold 수로 나눈 pooled recall은 (1+1+0)/(3+1+2)=0.333333이다. 앞의 평균은 질의 하나마다 같은 비중, 뒤의 계산은 관련 문서 건수에 비례한 비중을 준다. 보고서에는 어느 집계인지 적어야 한다.

k를 5로 늘리면 q1·q2·q3의 Recall은 모두 1이 된다. 그러나 첫 관련 순위는 각각 2·3·4이므로 MRR@5=(1/2+1/3+1/4)/3=0.361111이다. Recall이 같아도 첫 근거를 앞에 배치하는 능력은 다를 수 있다. MRR은 첫 적중 뒤의 관련 문서가 몇 개 더 있는지에는 반응하지 않는다.

질의별 묶음·평균·top_k와 무정답 처리 옵션은 TorchMetrics RetrievalMRR 공식 문서에서 확인했다. 이 글의 코드는 점수 tensor 대신 확정된 문서 ID 순위를 직접 입력받는 별도 검산이다.

무정답·중복·동률 정책이 바뀌면 점수도 달라진다

q4처럼 corpus 안에 관련 문서가 없다고 판정한 질의는 recall의 분모가 0이다. 여기서는 q4를 두 평균에서 제외하고 제외 건수를 별도로 공개한다. gold가 하나 이상 있는데 검색 결과가 비어 있거나 전부 틀린 질의는 제외하지 않고 두 점수에 0을 넣는다. 두 상황을 섞으면 실패한 질의가 평균에서 사라질 수 있다.

공식 RetrievalRecall 문서에는 무정답 질의를 0·1로 처리하거나 제외·오류로 다루는 선택지가 있다. 이 글의 전체 질의가 모두 제외되는 경우는 평균을 None으로 표시한다. 해당 라이브러리의 skip에서 모두 제외됐을 때 반환하는 0과도 구별한다. 선택한 정책과 포함·제외 질의 수를 함께 기록해야 점수를 비교할 수 있다.

문서 단위 평가라면 여러 청크를 먼저 원문 ID에 연결하고, 첫 등장 순서를 유지해 중복을 제거한 뒤 k를 적용한다는 식으로 기준을 고정할 수 있다. 아래 코드는 이미 문서 단위로 정리된 순위를 요구하며 중복은 오류로 거절한다. 청크를 단위로 평가하려면 gold와 분모도 청크 단위로 새로 정의해야 한다. 동일 점수의 동률은 고정된 문서 ID 등 일관된 기준으로 순서를 정하고 그 정책을 남긴다. 이 예는 순위가 주어져 있어 동률 해소 자체를 실행하지 않는다.

본문 코드로 두 k와 경계값을 검산한다

Python 표준 라이브러리로 계산한다. Windows의 Python 3.14.3에서 본문 HTML의 코드 텍스트를 추출·실행했다. TorchMetrics·PyTorch를 설치해 실행하거나 실제 corpus의 관련성을 판정한 시험은 아니다. corpus 밖 ID, 중복 질의 ID, 중복 순위와 0 이하의 k는 거절한다.

from fractions import Fraction
import json

def evaluate(queries, k, corpus):
    if type(k) is not int or k <= 0:
        raise ValueError("positive integer k required")
    ids = [q["id"] for q in queries]
    if len(ids) != len(set(ids)):
        raise ValueError("duplicate query id")
    rows, excluded = [], []
    for q in queries:
        gold, ranked = set(q["gold"]), q["ranked"]
        if len(ranked) != len(set(ranked)):
            raise ValueError("duplicate document id in ranking")
        if not (gold | set(ranked)) <= corpus:
            raise ValueError("document outside corpus")
        if not gold:
            excluded.append(q["id"])
            continue
        top = ranked[:k]
        hits = len(gold.intersection(top))
        first = next((rank for rank, doc in enumerate(top, 1)
                      if doc in gold), None)
        rows.append(dict(id=q["id"], gold=len(gold), hits=hits,
                         first=first, recall=Fraction(hits, len(gold)),
                         rr=Fraction(1, first) if first else Fraction(0)))
    count = len(rows)
    summary = dict(evaluated=count, excluded=excluded,
                   mean_recall=sum(r["recall"] for r in rows)/count if count else None,
                   mrr=sum(r["rr"] for r in rows)/count if count else None,
                   pooled_recall=Fraction(sum(r["hits"] for r in rows),
                                          sum(r["gold"] for r in rows)) if count else None)
    return rows, summary

def shown(summary):
    return {key: round(float(value), 6) if isinstance(value, Fraction) else value
            for key, value in summary.items()}

corpus = {f"D{i}" for i in range(1, 10)}
queries = [
    dict(id="q1", gold={"D1", "D2", "D3"}, ranked=["D7", "D1", "D8", "D2", "D3"]),
    dict(id="q2", gold={"D4"}, ranked=["D8", "D7", "D4", "D9", "D1"]),
    dict(id="q3", gold={"D5", "D6"}, ranked=["D9", "D8", "D7", "D6", "D5"]),
    dict(id="q4", gold=set(), ranked=["D1", "D2", "D3"]),
]
for k in (3, 5):
    rows, summary = evaluate(queries, k, corpus)
    print(k, json.dumps(shown(summary)))
    print("first_ranks", [row["first"] for row in rows])
empty_result = [dict(id="missing", gold={"D1"}, ranked=[])]
print("no_hit", json.dumps(shown(evaluate(empty_result, 3, corpus)[1])))
print("no_gold", json.dumps(shown(evaluate([queries[-1]], 3, corpus)[1])))
3 {"evaluated": 3, "excluded": ["q4"], "mean_recall": 0.444444, "mrr": 0.277778, "pooled_recall": 0.333333}
first_ranks [2, 3, None]
5 {"evaluated": 3, "excluded": ["q4"], "mean_recall": 1.0, "mrr": 0.361111, "pooled_recall": 1.0}
first_ranks [2, 3, 4]
no_hit {"evaluated": 1, "excluded": [], "mean_recall": 0.0, "mrr": 0.0, "pooled_recall": 0.0}
no_gold {"evaluated": 0, "excluded": ["q4"], "mean_recall": null, "mrr": null, "pooled_recall": null}

검색 점수를 최종 답변의 정답률로 읽지 않는다

판정자가 일부 문서만 확인했다면 gold는 불완전할 수 있다. 미판정 문서를 곧바로 무관한 문서로 바꾸지 말고, 판정 범위와 정책을 명시하고 새로 찾은 관련 문서를 재검토한다. q4의 무정답 판정도 관련 문서를 충분히 확인했다는 전제가 있어야 한다.

Recall@k가 높아도 생성 모델이 찾은 근거를 잘못 해석하거나 조건을 빠뜨릴 수 있다. AI 답변의 인용 근거 확인처럼 답변의 주장과 원문을 별도로 대조해야 한다. 반대로 최신 문서가 인덱스에 없거나 사용자 권한 때문에 합법적인 검색 범위가 다르면, 정답 집합부터 그 시점·권한에 맞춰야 한다. 그 운영 문제는 데이터 갱신과 인덱싱 병목 및 RAG 운영 동기화에서 다룬다.

실제 비교에는 corpus 판본, 질의 목록, 관련성 기준과 판정 범위, 문서·청크 단위, 중복·동률·무정답 정책, k와 질의 평균 방식을 함께 고정한다. 이 조건을 맞춘 뒤 질의별 실패를 읽어야 검색기나 reranker 변경이 무엇을 개선했는지 설명할 수 있다.

자주 묻는 질문

MRR이 높으면 필요한 근거를 모두 찾았다는 뜻인가요?

아니다. 첫 관련 문서가 1위라면 RR은 1이지만 나머지 관련 문서를 전부 놓칠 수도 있다. 여러 근거가 필요한 질문에는 Recall@k와 실제 근거 충족 여부를 함께 본다.

k를 키워 점수가 오르면 무조건 좋은가요?

같은 순위 목록에서는 k를 늘려 적중을 더 포함할 수 있다. 그러나 읽을 문맥·지연·비용이 늘 수 있고 생성 답변이 개선된다는 보장도 없다. 서로 다른 k의 점수를 같은 예산의 개선으로 표현하지 않는다.

정답 문서가 없는 질의는 모두 빼면 되나요?

평균에서 제외하는 것은 이 글의 명시적 정책이다. 실제 서비스에서 답이 없는 질문을 잘 거절하는지도 중요하다면 별도 평가가 필요하다. 제외 건수와 판정 근거를 숨기지 않고 무정답 대응을 따로 확인한다.

이 카테고리의 다른 글

전체글 보기

댓글 남기기