# RAG

RAG 평가셋, 질문 30개로 설정을 골라도 될까?

@Hong2026-10-03

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
질문 30개짜리 평가셋으로 RAG 검색 설정을 비교해도 그 차이를 믿을 수 있을까?
답
질문 30개에서 12개 대 14개처럼 두 개 차이는 차이의 95% 구간 [-0.100, +0.233]이 0을 포함해 이 표본으로 가를 수 없었고, 한쪽만 맞힌 질문이 9 대 0으로 갈린 비교는 확률 0.004였다.
조건
intfloat/multilingual-e5-small, Qdrant 1.19.1 exact=True, 발행 글 68편, 분할 설정 4개, 질문 30개짜리 세트 2개

환경

임베딩
intfloat/multilingual-e5-small, 질문 앞 query: , 청크 앞 passage:
검색
Qdrant 1.19.1, 전체 비교(exact=True), 상위 50개 청크
실행
Docker의 Qdrant와 Python 클라이언트 컨테이너, 임베딩은 CPU 2개로 제한
데이터
이 사이트 발행 글 68편, 분할 설정 4개
질문
30개짜리 세트 2개(orig, para)
부트스트랩
10,000번 반복, 백분위 95% 구간
목차
  1. 세 지표
  2. 실습 준비
  3. 실습 1: 같은 코드, 다른 숫자
  4. 실습 2: 질문 두 개 차이는 우연일까
  5. 실습 3: 질문이 몇 개면 가를 수 있을까
  6. 정리

이번 시간에는 RAG 검색 평가의 점수를 얼마나 믿어도 되는지, 같은 문서와 같은 질문으로 Qdrant 검색을 돌려 측정하며 배워가는 시간입니다.

예를 들어서 우리가 이 사이트의 글 53편을 400자씩 잘라 검색한다고 해볼게요. 질문 30개 가운데 정답 문장이 든 청크(문서를 검색 단위로 자른 조각)를 상위 5개 안에 올린 질문은 15개였어요. 같은 400자에 앞뒤 겹침 80자를 주자 13개로 줄었습니다.

합계가 질문 한두 개 차이일 때 어느 쪽이 낫다고 말해도 될까요? 질문 두 개 차이가 우연인지 어떻게 가리는지 재 볼게요. 생성 단계, 곧 LLM이 만든 답의 평가는 다루지 않습니다.

세 지표

이 글의 점수는 세 지표로 냅니다. 세 지표 모두 질문 30개의 평균을 냅니다.

  • 문서 recall@5는 상위 5개 글 안에 정답 글이 있으면 1이에요.
  • 문단 적중@5는 상위 5개 청크 가운데 정답 글의 청크가 정답 문장을 온전히 담고 있으면 1이에요.
  • 글 단위 MRR@10은 정답 글이 글 순위 n위(10위 안)면 1/n이에요.

정답이 여럿이고 "부분적으로 맞음"이 있다면 nDCG가 순서와 등급을 함께 봐요. 대신 정답 목록을 등급까지 적어야 하고, 그 판정이 점수를 좌우합니다.

실습 준비

이 글의 명령은 모두 실습 코드의 rag-eval-set/ 디렉터리에서 실행합니다.

  • compose.yaml: Qdrant와 Python 클라이언트 컨테이너 정의
  • requirements.txt: 클라이언트 컨테이너 패키지 버전
  • prep.py: 문서 디렉터리의 발행 글을 out/docs.jsonl로 모음
  • ev.py: 하위 명령마다 실습 하나를 맡는 평가 코드
  • questions.jsonl: 질문 세트 orig(30개)
  • questions-copy.jsonl: 질문 세트 copy(30개)
  • questions-para.jsonl: 질문 세트 para(30개)

질문은 사이트 글에 관한 질문 30개(questions.jsonl, 개념 15개, 식별자 8개, 헷갈리는 비교 7개)이고, 질문마다 정답 글 하나와 정답 문장 하나를 적어 두었어요.

  • orig: 원래 질문 30개(questions.jsonl) 그대로예요.
  • copy: 정답 문장(gold_passage)을 그대로 질문으로 써요. 문서에서 문장을 뽑아 질문을 자동으로 만드는 방식의 극단이에요.
  • para: 정답 문장에 나오는 어절을 쓰지 않고 다시 썼어요. 설정 이름, 오류 코드, 제품 이름처럼 빼면 질문이 성립하지 않는 식별자만 남겼고, 정답 글에 나올 법한 다른 용어(예: last-write-wins)를 새로 끌어오지도 않았습니다.

copy 세트는 search가 함께 검색해 두지만 이 글의 비교에는 쓰지 않아요.

질문과 분할 방식은 첫머리의 53편 측정과 같고, 분할 설정은 아래 네 가지만 다시 만들게요.

  • fixed-400, fixed-400-o80: 400자 고정 분할과 80자 겹침이에요. 질문 두 개 차이를 볼 짝입니다.
  • fixed-800, para-800-title-nocode: 800자 고정 분할과, 문단으로 묶고 제목을 붙이고 코드 블록을 뺀 분할이에요. 차이가 큰 짝입니다.

설정의 좋고 나쁨은 이 글의 관심사가 아니에요. 네 설정은 평가 방법을 시험할 재료라고 보시면 됩니다.

compose.yaml로 Qdrant와 Python 클라이언트 컨테이너를 띄워요. 두 컨테이너의 메모리와 CPU에는 상한을 두었습니다.

패키지 버전은 requirements.txt에 고정해 두었어요.

--extra-index-url https://download.pytorch.org/whl/cpu
torch==2.14.1+cpu
sentence-transformers==6.1.0
transformers==5.18.0
qdrant-client==1.19.1
numpy==2.5.3

prep.py는 매니페스트(*.json, 발행 글 목록을 담은 파일)에 등록된 발행 글만 읽어 out/docs.jsonl 한 파일로 모으고, 문서 수와 글자 수를 출력해요.

아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.

export ARTICLES_DIR=/path/to/articles

dc() { docker compose -p reval "$@"; }

py() { dc exec -T client python "$@"; }

dc up -d
dc exec -T client pip install --no-cache-dir -q -r requirements.txt
mkdir -p out
py prep.py
문서 68편
전체 1,741,230자, 코드 블록을 뺀 산문 729,028자
가장 짧은 글 11,301자, 가장 긴 글 48,315자

평가 코드는 ev.py 하나에 모았어요. 모델은 처음 불러올 때 Hugging Face에서 받습니다.

이제 네 설정을 색인하고, 질문 세트 세 개로 상위 50개 청크를 받아 둘게요. 검색은 전체 비교(exact=True)라서 다시 돌려도 결과가 같아요.

py ev.py index
py ev.py search
fixed-400              청크  4386개  평균  397자  임베딩 933초
fixed-400-o80          청크  5459개  평균  398자  임베딩 1001초
fixed-800              청크  2211개  평균  788자  임베딩 864초
para-800-title-nocode  청크  1317개  평균  620자  임베딩 388초

임베딩 시간은 CPU 2개로 제한한 컨테이너에서 잰 값이에요.

실습 1: 같은 코드, 다른 숫자

앞에서 정의한 세 지표, 문서 recall@5와 문단 적중@5, 글 단위 MRR@10을 orig 세트로 내 볼게요.

py ev.py base
fixed-400              recall@5 1.000 (30/30)  문단적중@5 0.400 (12/30)  MRR@10 0.758
fixed-400-o80          recall@5 0.967 (29/30)  문단적중@5 0.467 (14/30)  MRR@10 0.697
fixed-800              recall@5 0.933 (28/30)  문단적중@5 0.533 (16/30)  MRR@10 0.887
para-800-title-nocode  recall@5 1.000 (30/30)  문단적중@5 0.833 (25/30)  MRR@10 0.973
fixed-400              문단 적중을 놓친 질문 [1, 2, 3, 7, 9, 10, 12, 13, 14, 16, 17, 18, 20, 22, 24, 26, 27, 30]
fixed-400-o80          문단 적중을 놓친 질문 [1, 2, 7, 9, 11, 12, 13, 14, 16, 20, 22, 24, 27, 28, 29, 30]
fixed-800              문단 적중을 놓친 질문 [1, 2, 4, 5, 7, 8, 9, 12, 13, 14, 16, 17, 20, 30]
para-800-title-nocode  문단 적중을 놓친 질문 [1, 7, 9, 12, 30]

글이 53편일 때 같은 코드로 잰 문단 적중은 15, 13, 18, 24였어요. 지금은 12, 14, 16, 25입니다. 코드도 질문도 같은데 400자와 겹침 80자의 순서가 뒤집혔죠.

  • 문서가 늘었어요. 53편에서 68편이 됐고, 새 글이 정답 글과 경쟁합니다.
  • 기존 글의 본문도 바뀌었어요. 글자 수가 바뀌니 고정 길이 청크의 경계도 모두 옮겨졌어요.

fixed-400-o80만 맞힌 질문은 3, 10, 17, 18, 26 다섯 개인데, 이 가운데 3, 17, 18, 26 네 개가 fixed-400에서 정답 문장이 청크 경계에서 잘린 질문입니다. 잘린 질문은 py ev.py copymiss 출력의 경계에서 잘림 목록으로 확인했고, 그 출력은 싣지 않았어요. 문서를 조금 고치기만 해도 질문 두 개 차이는 뒤집힐 수 있어요.

실습 2: 질문 두 개 차이는 우연일까

질문이 30개면 질문 하나가 0.033이에요. fixed-400 12개와 fixed-400-o80 14개의 차이 0.067은 질문 두 개입니다. 이 차이를 보는 방법 두 가지를 써 볼게요.

  • 짝 비교: 같은 질문에서 두 설정의 결과를 나란히 놓으면 둘 다 맞힘, 둘 다 놓침, A만, B만으로 나뉘어요. 앞의 두 칸은 두 설정을 가르는 데 아무 정보가 없어요. 그래서 A만 맞힌 수(b)와 B만 맞힌 수(c)를 봅니다. 두 설정이 같다면 한쪽만 맞힌 질문이 반반으로 갈릴 거라고 가정하고, 지금만큼 쏠릴 확률을 이항 분포(p = 0.5)로 계산해요. 짝지은 맞음/틀림 결과에 쓰는 정확 McNemar 검정과 같은 계산입니다.
  • 부트스트랩: 질문 30개에서 30개를 복원 추출로 다시 뽑아 두 설정의 점수 차이를 계산하는 일을 10,000번 반복하고, 차이의 2.5%와 97.5% 분위수를 95% 구간으로 써요(백분위 부트스트랩). 같은 표본에서 두 설정을 함께 계산하니 짝이 유지돼요. 이 구간은 지금의 질문 30개가 실제 사용자 질문을 대표한다는 가정 위에 있어요.

orig 30개와, 같은 30개를 말만 바꾼 para를 더한 60개로 각각 계산합니다.

py ev.py paired
== fixed-400 대 fixed-400-o80
[orig 30개] fixed-400 12/30, fixed-400-o80 14/30, 차이 +0.067
  둘 다 맞힘 9, 둘 다 놓침 13, fixed-400만 3, fixed-400-o80만 5
  한쪽만 맞힌 8개 중 5개 이상이 한쪽으로 쏠릴 확률(양쪽) 0.727
  fixed-400 점수 95% 구간 [0.233, 0.567]
  fixed-400-o80 점수 95% 구간 [0.300, 0.633]
  차이(fixed-400-o80 - fixed-400) 95% 구간 [-0.100, +0.233] 폭 0.333  0 포함: 예
[orig+para 60개] fixed-400 20/60, fixed-400-o80 23/60, 차이 +0.050
  둘 다 맞힘 16, 둘 다 놓침 33, fixed-400만 4, fixed-400-o80만 7
  한쪽만 맞힌 11개 중 7개 이상이 한쪽으로 쏠릴 확률(양쪽) 0.549
  fixed-400 점수 95% 구간 [0.217, 0.450]
  fixed-400-o80 점수 95% 구간 [0.267, 0.500]
  차이(fixed-400-o80 - fixed-400) 95% 구간 [-0.050, +0.150] 폭 0.200  0 포함: 예
  같은 id 둘을 묶어 30묶음으로 뽑으면 차이 95% 구간 [-0.083, +0.167] 폭 0.250
== fixed-800 대 para-800-title-nocode
[orig 30개] fixed-800 16/30, para-800-title-nocode 25/30, 차이 +0.300
  둘 다 맞힘 16, 둘 다 놓침 5, fixed-800만 0, para-800-title-nocode만 9
  한쪽만 맞힌 9개 중 9개 이상이 한쪽으로 쏠릴 확률(양쪽) 0.004
  fixed-800 점수 95% 구간 [0.367, 0.700]
  para-800-title-nocode 점수 95% 구간 [0.700, 0.967]
  차이(para-800-title-nocode - fixed-800) 95% 구간 [+0.133, +0.467] 폭 0.333  0 포함: 아니오
[orig+para 60개] fixed-800 27/60, para-800-title-nocode 42/60, 차이 +0.250
  둘 다 맞힘 24, 둘 다 놓침 15, fixed-800만 3, para-800-title-nocode만 18
  한쪽만 맞힌 21개 중 18개 이상이 한쪽으로 쏠릴 확률(양쪽) 0.001
  fixed-800 점수 95% 구간 [0.333, 0.583]
  para-800-title-nocode 점수 95% 구간 [0.583, 0.817]
  차이(para-800-title-nocode - fixed-800) 95% 구간 [+0.117, +0.383] 폭 0.267  0 포함: 아니오
  같은 id 둘을 묶어 30묶음으로 뽑으면 차이 95% 구간 [+0.100, +0.400] 폭 0.300
  • fixed-400 대 fixed-400-o80: 두 설정이 다르게 답한 질문은 8개이고 3 대 5로 갈렸어요. 두 설정이 같아도 이 정도로 쏠릴 확률이 0.727이에요. 차이의 95% 구간은 -0.100에서 +0.233으로 0을 포함합니다. 글이 53편일 때의 15 대 13이 이번에 12 대 14로 뒤집힌 것도 이 구간 안의 일이에요.
  • fixed-800 대 para-800-title-nocode: 다르게 답한 9개가 모두 한쪽이었고 확률은 0.004예요. 구간은 +0.133에서 +0.467로 0을 포함하지 않아요. 질문 30개로도 이 정도 차이는 우연으로 보기 어렵습니다.

orig와 para를 합친 60개에서는 fixed-400 짝의 구간 폭이 0.333에서 0.200으로 줄었어요. 다만 두 세트는 같은 30개의 뜻을 다르게 쓴 거라 서로 독립이 아니에요. 같은 id의 두 질문을 한 묶음으로 뽑으면 폭은 0.250이고, 이쪽이 실제에 가까워요.

실습 3: 질문이 몇 개면 가를 수 있을까

결과 비율이 그대로인 채 질문 수만 늘었다고 가정하면 폭이 어떻게 줄어드는지 볼게요. orig 30개의 결과를 2, 4, 10번 이어 붙여 같은 부트스트랩을 돌립니다.

py ev.py width
질문  30개  차이 +0.067  95% 구간 [-0.100, +0.233] 폭 0.333
질문  60개  차이 +0.067  95% 구간 [-0.067, +0.200] 폭 0.267
질문 120개  차이 +0.067  95% 구간 [-0.025, +0.158] 폭 0.183
질문 300개  차이 +0.067  95% 구간 [+0.007, +0.123] 폭 0.117

출력을 보면 구간 폭은 대략 질문 수의 제곱근에 반비례해 줄어들어요. 질문을 10배 늘리면 폭은 3분의 1 정도가 됩니다.

이 짝이 정말 지금 비율대로 차이가 난다면, 0을 벗어나는 데 질문이 300개쯤 필요해요.

정리

범위: intfloat/multilingual-e5-small, Qdrant 1.19.1 전체 비교 검색(exact=True), 발행 글 68편, 분할 설정 4개, 질문 30개짜리 세트 2개(orig, para).

  • 같은 코드와 같은 질문인데 문서가 53편에서 68편으로 늘고 본문이 고쳐지자 문단 적중이 15, 13, 18, 24에서 12, 14, 16, 25로 바뀌어 400자와 겹침 80자의 순서가 뒤집혔어요. 문서를 고친 뒤에는 비교할 설정을 모두 같은 문서로 다시 재 주세요.
  • fixed-400 12개 대 fixed-400-o80 14개는 한쪽만 맞힌 질문이 3 대 5(확률 0.727)였고 차이의 95% 구간 [-0.100, +0.233]이 0을 포함했지만, fixed-800 16개 대 para-800-title-nocode 25개는 9 대 0(확률 0.004)이었어요. 합계 차이만으로 설정을 고르지 말고 한쪽만 맞힌 질문 수와 차이의 구간을 함께 봐 주세요.
  • 결과 비율이 그대로라고 가정하면 질문 두 개 차이의 구간이 0을 벗어나는 데 질문이 300개쯤 필요했고, 같은 30개를 말만 바꿔 60개로 늘리면 묶어서 뽑은 구간 폭이 0.333에서 0.250으로만 줄었어요. 질문을 늘릴 때는 말을 바꾼 질문이 아니라 새 질문을 더하세요.
내 링크 만들기