RAG 검색 단계 평가: recall@k·MRR로 리트리버 품질을 수치로 재는 스크립트
평가셋 구성, 지표별 의미, 파이썬 계산 스크립트, 청크·임베딩 변경 전후 비교 순서

핵심 요약
- 검색 평가셋은 질문과 정답 문서 ID로 만들고, 청크 ID가 아니라 원본 문서·섹션 ID로 표시해야 청크 설정을 바꿔도 재사용할 수 있다.
- RAG에서는 LLM에 넘기는 개수와 같은 k로 recall@k를 가장 먼저 본다.
- MRR은 첫 정답의 순위, nDCG는 정답들의 순서까지 반영한다.
- 평균보다 한 건도 못 찾은 질문 목록이 개선 방향을 알려 준다.
- 설정은 한 번에 하나만 바꾸고 질문 단위로 전후를 비교한다.
RAG 답변이 틀렸을 때 원인은 크게 둘이다. 필요한 문서를 못 찾았거나, 찾았는데 모델이 잘못 썼거나. 답변만 보고 평가하면 두 원인이 섞여 무엇을 고쳐야 할지 알 수 없다. 이 글은 검색 단계만 따로 떼어 숫자로 재는 방법을 다룬다. 읽고 나면 50~200개 규모의 평가셋을 만들고, recall@k·MRR·nDCG를 계산하고, 청크 크기나 임베딩 모델을 바꿨을 때 좋아졌는지 나빠졌는지 판단할 수 있다.
평가셋: 질문 하나에 정답 청크 목록
검색 평가에 필요한 데이터는 단순하다. 질문과, 그 질문에 답하려면 반드시 검색돼야 하는 문서(또는 청크) ID 목록이다. 만드는 순서는 다음과 같다.
- 실제 질문을 모은다 — 서비스 로그, 고객 문의, 사내 메신저 질문에서 50개 이상 고른다. 자주 묻는 질문과 드물지만 중요한 질문을 섞는다.
- 정답 근거를 문서 단위로 표시한다 — 처음에는 청크가 아니라 원본 문서 ID나 섹션 ID로 표시한다. 청크 ID로 표시하면 청크 크기를 바꾸는 순간 평가셋이 무효가 된다.
- 정답이 여러 개인 질문을 표시한다 — 비교 질문("A와 B 차이")은 두 문서가 모두 필요하다.
- 답이 문서에 없는 질문도 넣는다 — 정답 목록을 비워 두고, 검색 단계 평가에서는 제외하되 답변 단계에서 "모른다"고 답하는지 따로 본다.
# eval_set.jsonl — 한 줄에 질문 하나
{"qid": "q001", "question": "연차 이월은 최대 며칠?", "relevant": ["hr-policy#leave"]}
{"qid": "q002", "question": "법인카드와 개인카드 경비 처리 차이", "relevant": ["finance#corp-card", "finance#personal-card"]}
LLM으로 질문을 대량 생성하는 방법도 있지만, 생성된 질문은 문서 표현을 그대로 따라 하는 경향이 있어 실제 사용자 질문보다 검색이 쉬워진다. 생성 질문을 쓰더라도 실제 질문과 섞고 결과를 따로 집계한다.
지표 세 개가 각각 알려 주는 것
| 지표 | 계산 | 답하는 질문 |
|---|---|---|
| recall@k | 상위 k개 안에 들어온 정답 수 ÷ 전체 정답 수 | LLM에 넘기는 k개 안에 필요한 근거가 다 들어갔나 |
| MRR | 첫 번째 정답 순위의 역수(1/rank)를 질문 전체에서 평균 | 정답이 얼마나 위에 나오나 |
| nDCG@k | 순위가 낮을수록 log₂(i+1)로 깎은 이득 합 ÷ 이상적 순서의 값 | 정답들의 순서까지 고려한 종합 점수 |
정의는 정보 검색 평가 지표 정리와 MRR 항목을 따랐다. RAG에서 가장 먼저 볼 지표는 recall@k다. LLM은 상위 k개만 받기 때문에, 그 안에 근거가 없으면 프롬프트를 아무리 다듬어도 정답이 나올 수 없다. k는 실제 파이프라인이 LLM에 넘기는 개수와 같게 맞춘다. 리랭커를 쓴다면 리랭크 전 후보 수(예: 50)와 리랭크 후 개수(예: 5) 두 지점에서 각각 잰다.
계산 스크립트
외부 라이브러리 없이 동작하는 최소 구현이다. search(question, k)는 각자의 검색 함수로 바꾸고, 반환값은 문서 ID 리스트(순위순)로 맞춘다. 청크를 반환하는 검색기라면 청크에서 원본 문서 ID를 꺼내 중복을 제거한 뒤 넘긴다.
import json, math
def load(path):
with open(path, encoding='utf-8') as f:
return [json.loads(l) for l in f if l.strip()]
def dedupe(ids):
seen, out = set(), []
for i in ids:
if i not in seen:
seen.add(i); out.append(i)
return out
def evaluate(rows, search, k=5):
recall, rr, ndcg, misses = [], [], [], []
for r in rows:
gold = set(r['relevant'])
if not gold:
continue # 답 없는 질문은 검색 평가에서 제외
got = dedupe(search(r['question'], k * 3))[:k]
hits = [d in gold for d in got]
recall.append(sum(hits) / len(gold))
first = next((i for i, h in enumerate(hits, 1) if h), None)
rr.append(1 / first if first else 0.0)
dcg = sum(1 / math.log2(i + 1) for i, h in enumerate(hits, 1) if h)
idcg = sum(1 / math.log2(i + 1) for i in range(1, min(len(gold), k) + 1))
ndcg.append(dcg / idcg)
if not any(hits):
misses.append(r['qid'])
n = len(recall)
return {'n': n, f'recall@{k}': sum(recall) / n, 'mrr': sum(rr) / n,
f'ndcg@{k}': sum(ndcg) / n, 'misses': misses}
if __name__ == '__main__':
from my_retriever import search # 각자의 검색 함수
print(json.dumps(evaluate(load('eval_set.jsonl'), search, k=5), ensure_ascii=False, indent=1))
misses에 모인 질문 ID가 가장 중요한 산출물이다. 평균 점수보다 "어떤 질문을 전혀 못 찾는가"를 읽어야 고칠 방향이 보인다. 동의어·약어 문제면 키워드 검색 병행이, 표·이미지 안에 답이 있으면 전처리가 원인인 경우가 많다.
변경 전후 비교 순서
- 기준선 기록 — 현재 설정으로 스크립트를 돌려 결과 JSON을 커밋한다. 설정값(청크 크기, 겹침, 임베딩 모델, k)도 함께 남긴다.
- 한 번에 한 가지만 바꾼다 — 청크 크기와 임베딩 모델을 동시에 바꾸면 무엇이 효과였는지 알 수 없다.
- 질문 단위로 비교한다 — 평균이 같아도 맞히던 질문을 놓치고 새 질문을 맞혔을 수 있다. 이전에 맞히던 질문 중 새로 놓친 목록을 따로 출력한다.
- 표본 크기를 감안한다 — 질문 50개에서 recall 0.02 차이는 질문 한 개 차이다. 작은 차이로 결론 내리지 말고 질문을 늘리거나 놓친 질문을 직접 읽는다.
청크 크기와 겹침 설정 자체는 PDF를 RAG용 데이터로 변환하는 청킹 전략에서, 키워드 검색 병행과 리랭킹은 하이브리드 검색과 리랭킹 기초에서 다뤘다. 이 스크립트는 그 설정들이 실제로 효과가 있었는지 확인하는 도구다.
답변 평가와 연결하기
검색 점수가 충분히 오른 뒤에야 답변 평가가 의미를 갖는다. 같은 평가셋에 기대 답변 요점을 추가하면 검색 평가와 답변 평가를 한 파일로 관리할 수 있다. 검색은 맞았는데 답변이 틀린 질문만 골라 프롬프트를 고치면 작업 범위가 좁아진다. 골든셋과 회귀 비교를 CI에 붙이는 방법은 프로덕션 배포 전 LLM 평가에서, 검색된 문장과 답변 문장을 대조하는 방법은 RAG 답변의 환각 줄이기에서 이어서 볼 수 있다.
초보자가 자주 실수하는 포인트
- 답변 정확도만 보고 검색과 생성 중 어디가 문제인지 구분하지 않는 경우
- 정답을 청크 ID로 표시해 청크 크기를 바꾸면 평가셋을 다시 만들어야 하는 경우
- LLM이 생성한 질문만으로 평가해 실제보다 높은 점수를 얻는 경우
- 질문 50개에서 0.01~0.02 차이로 설정 우열을 결정하는 경우
체크리스트
- 평가셋에 실제 사용자 질문이 50개 이상 포함됐는가
- 정답이 문서·섹션 ID로 표시됐는가
- k가 실제 파이프라인에서 LLM에 넘기는 개수와 같은가
- 기준선 결과와 설정값을 저장소에 기록했는가
- 새로 놓친 질문 목록을 전후 비교로 출력하는가
자주 묻는 질문
precision@k는 왜 빼나요?
RAG에서는 정답 문서 수가 질문마다 1~2개로 적어 precision@k가 k에 따라 기계적으로 낮아집니다. 관련 없는 청크가 많이 섞여 답변이 흐려지는 문제를 보고 싶다면 precision@k를 함께 기록해도 되지만, 개선 판단은 recall@k와 MRR이 더 직접적입니다.
평가 라이브러리를 쓰면 안 되나요?
써도 됩니다. 다만 지표 정의(중복 문서 처리, 정답 수가 k보다 많을 때의 IDCG 계산)가 라이브러리마다 다를 수 있으므로, 직접 구현한 결과와 몇 개 질문에서 값을 대조해 본 뒤 바꾸는 편이 안전합니다.
참고 자료 · 검증 기준
위 자료와 내용을 대조한 날짜: . 도구·서비스 정책은 이후 바뀔 수 있으므로 적용 전 공식 문서를 다시 확인하세요.
이 글은 실제 실무 환경 테스트 및 검증된 기술 자료를 바탕으로 작성되었으며, 환경 변화에 따라 지속적으로 갱신됩니다.