Blog

RAG의 검색 품질은 어떻게 측정할 수 있을까?

10 minute read

Series 1 — RAG는 원하는 문서를 제대로 찾고 있는가? (1/5)

RAG를 만들어 본 사람이라면 한 번쯤 이런 대화를 했을 겁니다. “써 보니까 꽤 잘 찾던데요.” “어떤 질문에서요?” “음, 제가 몇 개 넣어 봤는데 대충 맞게 나오더라고요.” 여기서 대화가 끝나면 그 RAG는 평가된 적이 없는 겁니다. 몇 개를 넣어 봤는지, 그중 몇 개가 맞았는지, 맞았다는 게 1위로 나왔다는 건지 5개 중에 섞여 있었다는 건지 아무도 모릅니다.

이 시리즈는 그 대화를 숫자로 바꾸는 연습입니다. 거대한 RAG 챗봇을 하나 만드는 대신, 기업이 반복해서 겪는 품질 문제를 질문 하나짜리 실험으로 쪼개서 하나씩 확인합니다. 첫 실험의 질문은 가장 앞에 있는 것입니다. 검색이 제대로 되고 있는지, 어떻게 숫자로 말할 수 있을까.

코드와 데이터는 전부 rag-quality-lab 레포에 있고, 모든 실험은 맥북 한 대(M4 Pro 48GB)에서 로컬 모델로만 돌립니다.

숫자를 만들려면 세 가지가 필요하다

검색 품질을 재려면 답안지가 있어야 합니다. 답안지는 세 부분으로 되어 있습니다.

  1. 검색 대상 문서 묶음
  2. 질문과, 그 질문의 정답이 어느 문서인지 적은 라벨
  3. 검색 결과와 라벨을 비교해 점수를 내는 지표

셋 다 만들어야 했고, 셋 다 생각보다 결정할 게 많았습니다. 순서대로 갑니다.

문서: 인사팀이 실제로 펼치는 그 매뉴얼

문서는 하이코리아(법무부 출입국 포털)에 올라온 「체류민원 자격별 안내 매뉴얼」을 썼습니다. 외국인 직원을 채용하는 회사의 인사담당자, 행정사, 출입국 창구 직원이 실제로 보는 기준 문서입니다. “E-7 비자 직원 체류기간 연장하려면 서류 뭐 내야 하나”의 답이 여기 있습니다. 법령 조문도 후보였는데, 조문은 “제10조의2 제1항”으로 대답하고 실무자는 그렇게 묻지 않아서 뺐습니다.

파일은 HWP입니다. 한국 공공기관 문서를 다루면 피할 수 없는 형식이고, 여기서 첫 함정이 나왔습니다. 널리 쓰이는 변환 도구(pyhwp의 hwp5txt)가 표를 <표>라는 글자 하나로 바꿔 버립니다. 이 매뉴얼은 제출서류 목록이 거의 전부 표 안에 있어서, 표를 버리면 알맹이가 없는 문서가 됩니다. 결국 같은 패키지의 hwp5proc xml로 파일 내부 구조를 XML로 덤프한 뒤 문단과 표 셀을 직접 걸어 내려가며 텍스트를 뽑았습니다. 글상자 안에 중첩된 표가 두 번씩 뽑히는 문제를 한 번 더 고치고 나서야 6,884줄짜리 텍스트가 나왔습니다.

그다음이 진짜 결정입니다. 이 텍스트를 어디서 잘라 “문서 하나”로 볼 것인가. 매뉴얼은 외교(A-1)부터 관광취업(H-1)까지 자격 36개가 순서대로 나오고, 자격마다 “체류기간 상한 / 체류자격외 활동 / 근무처 변경 / 변경허가 / 연장허가 / 재입국허가 / 외국인등록” 같은 항목이 표 행으로 반복됩니다. 그래서 자격 하나 × 업무항목 하나를 문서 하나로 잘랐습니다. 문서 제목은 기업투자(D-8) > 체류기간 연장허가 같은 모양이 됩니다. 인사담당자의 질문 하나가 대체로 이 칸 하나에 대응한다는 가정입니다.

결과는 672개 문서. 길이 중앙값은 262자인데 가장 긴 것은 11만 자입니다. 마지막 항목(외국인등록) 뒤에 붙은 부록 표가 통째로 그 문서에 들어가서 그렇습니다. E-7 외국인등록 문서가 94개 허용직종 표를 안고 있습니다. 일부러 안 잘랐습니다. 청킹은 이 시리즈의 3번, 4번 글 주제이고, 지금 자르면 긴 문서에서 검색이 어떻게 실패하는지 볼 기회가 사라집니다.

질문: LLM이 초안을 쓰고 사람이 다시 썼다

질문 76개를 만들었습니다. 과정은 이렇습니다.

먼저 로컬 LLM(LM Studio에서 돌린 Qwen3-30B-A3B-Instruct)에 672개 문서를 하나씩 주고 “인사담당자가 검색창에 칠 법한 질문 2개”를 받았습니다. 목적은 좋은 질문을 얻는 게 아니라, 제가 아는 주제(D-8, E-7)에만 쏠리지 않도록 문서 전체를 한 번 훑는 것이었습니다.

여기서 실수를 하나 했습니다. 프롬프트에 “자격 코드(예: D-8)를 질문에 넣어라”고 썼더니, 제목에 D-8이 없는 문서 330개(75%)의 질문에도 D-8이 들어갔습니다. 동포 지침 문서가 “D-8 자격으로 재외동포 F-4 변경 시…” 같은 질문을 받은 겁니다. 예시를 넣으면 모델이 그걸 정답 형식으로 베끼는 흔한 누수인데, 당하고 나서야 보였습니다.

그래서 후보는 참고만 하고 76개를 전부 다시 썼습니다. 검색창에 치는 말투로(“D-8 비자 체류기간 연장할 때 서류 뭐 내야 해?”), 자격과 업무항목이 골고루 섞이게, 그리고 일부러 자격 코드가 없는 질문을 9개 넣었습니다. “항공기 조종사 채용하려면 어떤 비자로 초청하나” 같은 것입니다. 인사담당자가 코드를 모를 때 던지는 질문이 이런 형태이고, 이 9개가 나중에 중요해집니다.

정답 라벨을 붙이는 일은 예상보다 어려웠습니다. 76개 중 39개에 판단 근거를 메모로 남겨야 했습니다. 가장 큰 문제는 같은 문구가 여러 자격에 반복된다는 것입니다. “E-3 연구원 재입국허가 면제 조건”의 답은 E-1, E-2, E-3, E-4, E-5 재입국허가 문서에 글자 하나 안 틀리고 똑같이 들어 있습니다. 다섯 개 다 정답인가, E-3 하나만 정답인가. 저는 질문이 가리키는 자격의 문서 하나만 정답으로 했습니다. E-3 직원 얘기를 하는데 E-1 문서를 보여 주면 내용이 같더라도 “이게 우리 직원한테 적용되는 게 맞나?”를 다시 확인해야 하니까요. 이 결정이 결과에 직접 영향을 줍니다. 반대로 정하면 아래에서 볼 실패 상당수가 사라집니다. 어느 쪽이 맞는지는 정답이 없고, 어떻게 정했는지를 적어 두는 것이 재현성입니다.

검색기: 임베딩 두 개, 코사인 한 줄

검색기는 가장 단순한 형태입니다. 문서 672개를 임베딩 모델로 벡터로 바꿔 두고, 질문도 벡터로 바꾼 뒤, 코사인 유사도가 높은 순으로 10개를 돌려줍니다. numpy 행렬곱 한 줄이고 벡터 DB는 없습니다. 672개면 필요 없습니다.

모델은 BAAI/bge-m3와, 그것을 한국어 검색 데이터로 추가 학습한 nlpai-lab/KURE-v1을 썼습니다. 둘 다 1024차원에 입력 8,192토큰이라 차이는 한국어 추가 학습 하나뿐입니다. 처음에는 둘째 모델로 한국어 문장 유사도 모델(ko-sroberta-multitask)을 썼다가 바꿨는데, 그 얘기는 뒤에서 합니다.

지표: 순위를 숫자로 바꾸는 규칙

지표는 세 개를 썼습니다. 라이브러리 없이 20줄로 직접 구현했고, 정답 순위를 아는 케이스로 테스트를 붙였습니다.

  • Recall@K: 정답이 상위 K개 안에 있으면 1, 없으면 0
  • MRR: 첫 정답 순위의 역수. 1위면 1, 2위면 0.5, 없으면 0
  • nDCG@5: 정답 순위에 로그 할인을 준 것. 1위 1, 2위 0.63, 3위 0.5, 6위부터 0

정답이 하나뿐인 이 질문셋에서는 세 지표 모두 “첫 정답이 몇 위인가” 하나로 결정됩니다. 순위를 지표로 바꾸면 이렇습니다.

첫 정답 순위Recall@1Recall@3Recall@5MRRnDCG@5
11111.0001.000
20110.5000.631
30110.3330.500
50010.2000.387
60000.1670
100000.1000

이 표만 봐도 성격이 보입니다. Recall@K는 계단이라 2위와 5위를 구분하지 않습니다. MRR은 10위에도 0.1을 주고, nDCG@5는 6위를 버립니다. 어느 것이 옳은 게 아니라, 파이프라인이 검색 결과를 몇 개 쓰느냐에 따라 골라야 합니다. LLM에 1위 문서만 넘기면 Recall@1이 곧 성능이고, 5개를 넘기면 Recall@5와 그 안의 순위를 같이 봐야 합니다.

결과

모델Recall@1Recall@3Recall@5MRRnDCG@5
bge-m30.5660.7370.7890.6760.691
KURE-v10.5260.6970.7760.6310.655

첫 정답 문서의 순위 분포

bge-m3 기준으로 76개 질문 중 43개는 정답이 1위, 17개는 2위에서 5위 사이, 16개는 6위 아래거나 10위 안에 없습니다.

Recall@5 0.79는 나쁘지 않은 숫자입니다. 그런데 이 숫자가 “성공”으로 세는 60개 중 17개는 정답이 1위가 아닙니다. 1위에 무엇이 왔는지 열어 보면 이렇습니다.

질문정답 문서1위 문서정답 순위
D-8 비자 체류기간 연장할 때 서류 뭐 내야 해?기업투자(D-8) > 체류기간 연장허가문화예술(D-1) > 체류기간 연장허가2
E-7 체류기간 연장 신청 서류 목록특정활동(E-7) > 체류기간 연장허가연구(E-3) > 체류기간 연장허가2
사내 영어회화 가르치는 건 E-7 직원도 허가 없이 되나요특정활동(E-7) > 체류자격외 활동주재(D-7) > 체류자격외 활동2

전부 같은 업무항목, 다른 자격입니다. 첫 번째 질문의 코사인 점수를 보면 1위 D-1 문서가 0.715, 정답 D-8 문서가 0.691, 그 아래 D-5, D-6, F-3 연장허가 문서가 0.68대로 다섯 문서가 0.03 안에 몰려 있습니다. 임베딩은 “체류기간 연장 서류”라는 뜻을 정확히 잡았습니다. 다섯 개 다 연장허가 문서니까요. 다만 D-8과 D-1을 가르는 코드 한 토큰은 뜻의 차이로 보지 않습니다. D-1이 1위인 데 특별한 이유는 없고, 이 정도 차이는 문장 길이나 어순으로 뒤바뀝니다.

이게 무슨 뜻이냐면, 이 상태에서 LLM에 1위 문서만 넘기면 다른 비자의 서류 목록으로 그럴듯한 답을 만듭니다. 5개를 넘기면 맞는 문서가 섞여 들어갑니다. Recall@5는 두 경우를 구분하지 못하고, MRR과 nDCG는 구분합니다. 첫 가설 “Recall@K만으로 충분한가”의 답은 아니오입니다.

실패를 유형으로 세어 보면

정답이 1위가 아닌 33개를 “1위에 무엇이 왔나”로 분류했습니다. 한 질문이 두 유형에 걸치기도 합니다.

유형건수
같은 업무항목, 다른 자격8D-8 연장 서류 → D-1 연장허가가 1위
같은 자격, 다른 업무항목8E-10 재고용 시 최대 체류 → “체류기간 상한”이 1위, 답은 “연장허가”에
질문에 자격 코드 없음8“항공기 조종사는 어떤 비자로 초청하나” → 10위 밖
부록 문서가 자격 문서를 밀어냄6H-2 동포 채용 절차 → 부록의 이웃 문단이 1위
정답 문서가 5,000자 이상5E-7 여권 갱신 신고 기한 → 11만 자 문서, 10위 밖
질문에 자격 코드가 둘3“D-8으로 3년 체류하면 F-2 받을 수 있나” → 먼저 나온 D-8 문서가 1위

코드 없는 질문 9개 중 8개가 실패한 것이 저는 제일 아팠습니다. 검색이 사실상 자격 코드라는 토큰에 기대고 있다는 뜻이고, 코드를 모르는 사용자에게는 이 RAG가 거의 작동하지 않는다는 뜻입니다. 긴 문서 5건은 답이 둘째 문단에 있는데도 묻혔습니다. 6.5만 토큰짜리 문서를 bge-m3는 앞 8,192토큰만 보는데, 그 안에서도 직종 표가 벡터를 지배합니다.

이 표가 이 실험의 진짜 산출물입니다. 지표 표는 “0.79”라고만 말하지만 이 표는 “키워드 검색을 섞으면 1행과 3행이 줄 것이고, 청킹하면 5행이 줄 것이고, 부록 제목에 상위 맥락을 붙이면 4행이 줄 것”이라고 말합니다. 각 줄이 다음 실험의 가설입니다. 다음 글(02. RAG는 왜 정답 문서를 찾지 못할까?)은 이 표에서 시작합니다.

모델 둘의 평균이 비슷해도 맞히는 질문은 다르다

bge-m3와 KURE-v1은 Recall@5가 0.013 차이입니다. 그런데 76개 중 34개 질문에서 첫 정답 순위가 서로 다릅니다. “조선용접공 E-7 자격요건”은 bge-m3 7위, KURE-v1 1위이고 “E-7 연장 신청 서류”는 반대로 2위와 9위입니다. 한국어 추가 학습이 이 코퍼스에서 평균을 올리지는 못했지만 실패하는 질문의 집합은 바꿔 놓았습니다. 모델을 평균 한 숫자로 고르면 이 교차가 보이지 않습니다.

하다가 틀린 것들

블로그에 결과만 적으면 실험이 매끈해 보이는데, 실제로는 세 번 되돌아갔습니다.

처음에 둘째 모델로 쓴 ko-sroberta-multitask는 입력이 128토큰, 그러니까 200자쯤에서 잘립니다. 672개 문서의 절반 이상을 제목과 첫 두어 문장만 보고 색인한 셈이라, 결과 차이가 모델 품질 때문인지 읽은 분량 때문인지 분리가 안 됐습니다. 그래서 같은 길이를 읽는 KURE-v1으로 바꿨습니다. 임베딩 모델을 고를 때 “몇 토큰까지 읽나”를 먼저 확인해야 한다는 걸 이렇게 배웠습니다.

nDCG@5는 이번 질문셋에서 새 정보를 주지 않았습니다. 정답이 하나뿐이면 nDCG@5는 정확히 1/log2(순위+1)이고, MRR과 같은 것을 다른 눈금으로 잰 것입니다. nDCG가 제 몫을 하려면 정답이 여러 문서에 나뉜 질문이 있어야 합니다. 지표를 고르기 전에 라벨의 모양부터 봐야 했습니다.

그리고 프롬프트 누수. 위에 썼습니다.

이 실험에서 남는 것

처음 목표는 “감각이 아니라 재현 가능한 숫자”였습니다. 끝내고 보니 숫자 자체보다 숫자를 만드는 과정에서 내린 결정들이 재현성의 실체였습니다. 문서를 어디서 자를지, 정답을 하나로 할지 여럿으로 할지, 어떤 질문을 넣을지, 어느 K를 볼지. 이 결정들이 corpus.jsonl, questions.jsonl, metrics.py에 남아 있어서 다른 사람이 같은 숫자를 얻을 수 있고, 다른 결정을 하면 숫자가 어떻게 바뀌는지도 볼 수 있습니다.

76개는 적은 수입니다. 질문 하나가 Recall@1을 1.3%p 움직입니다. 그래서 이 글의 숫자에서 0.01 차이는 아무 뜻이 없고, 0.1 차이와 유형별 건수 8과 0의 차이만 뜻이 있습니다. 그 정도 해상도로도 “Recall@K 하나로는 부족하다”와 “이 검색기는 자격 코드에 기대고 있다”는 두 문장은 확실히 말할 수 있었습니다.

재현하려면 레포를 받아서 이렇게 하면 됩니다.

uv sync
uv run python scripts/fetch_hikorea.py
uv run python scripts/build_corpus.py
uv run experiments/01-retrieval-metrics/run.py

다음 글에서는 위 실패 유형표의 각 줄을 하나씩 파 봅니다.