# RAG

벡터 검색 1위가 정답이 아닐 때, 재순위화하면 나아질까?

@Hong2026-10-04

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

실습 코드 내려받기

질문
벡터 검색 1위가 정답 문단이 아닐 때 재순위화를 붙이면 정답이 위로 올라올까?
답
후보 10개를 다시 정렬하자 정답 문단이 1위인 질문이 벡터 검색 순서 25개에서 bge-m3 토큰별 점수 35개, LLM 관련도 점수 40개로 늘었지만, 후보 10개 안에 정답이 든 54개를 넘지는 못했다.
조건
벡터 검색 multilingual-e5-small, 재순위화 bge-m3와 EXAONE 3.5 2.4B(Ollama 0.34.4, CPU), Qdrant 1.19.1, 청크 1,510개, 질문 60개

환경

1차 검색
multilingual-e5-small(384차원), Qdrant 1.19.1 전체 비교(exact=True)
재순위화 임베딩
bge-m3 dense·멀티벡터, transformers로 직접 계산(float32, 최대 512토큰)
재순위화 LLM
EXAONE 3.5 2.4B(exaone3.5:2.4b-instruct-q4_K_M), Ollama 0.34.4, CPU 추론, temperature: 0, seed: 42
Docker
메모리 약 9.7GB·CPU 8개 가운데 메모리 5.5GB·CPU 3개 안에서 실행
데이터
이 사이트 글 76편, 청크 1,510개
질문
60개(질문셋 A·B 각 30개)
목차
  1. 멀티벡터 점수
  2. 실습 준비
  3. 실습 1: 후보를 몇 개 가져올까
  4. 실습 2: 멀티벡터로 다시 점수 매기기
  5. 실습 3: 로컬 LLM에게 관련도를 묻기
  6. 정리

이번 시간에는 벡터 검색으로 가져온 후보를 다시 정렬하는 재순위화가 정답 문단을 얼마나 위로 올리는지, bge-m3와 로컬 LLM(Ollama)으로 측정하며 배워가는 시간입니다.

예를 들어서 RAG 검색을 평가하는 질문 가운데 "Kafka lag 숫자가 몇 초 늦었는지를 뜻하지 않는 이유는?"이라는 질문이 있다고 해볼게요. 벡터 검색은 정답 글인 Kafka consumer lag 글의 청크(문서를 검색 단위로 자른 조각)를 1위부터 다섯 개 모두 가져왔어요. 그런데 답이 적힌 문단은 15위였습니다.

이럴 때 흔히 붙이는 게 재순위화(reranking)예요. 벡터 검색(이 글에서는 1차 검색이라고 부를게요)으로 후보를 넉넉히 가져온 다음, 더 비싸지만 더 꼼꼼한 방법으로 후보마다 점수를 다시 매겨 순서를 바꿔요.

재순위화에는 보통 전용 reranker 모델을 쓰지만, 전용 모델 없이 할 수 있는 두 가지를 직접 재 볼게요. 하나는 임베딩 모델 bge-m3가 내는 토큰별 벡터(멀티벡터)로 점수를 다시 계산하는 방법이고, 다른 하나는 로컬 LLM에게 문단마다 관련도를 묻는 방법이에요.

멀티벡터 점수

bge-m3는 한 번 계산으로 세 가지를 내요. 문장 전체를 나타내는 1024차원 벡터(dense), 단어별 가중치(sparse), 토큰마다 하나씩 나오는 1024차원 벡터 묶음(multi-vector)이에요. 이 글은 dense와 멀티벡터만 써요.

dense 점수는 문단 전체를 벡터 하나로 줄여 질문 벡터 하나와 비교해요. 멀티벡터 점수는 질문 토큰마다 문단 토큰과의 내적 가운데 최댓값을 고르고, 그 값들을 평균해요(ColBERT 방식). 그래서 질문과 딱 맞는 한 문장이 문단 안에 있으면 그 토큰들이 따로 점수를 받습니다.

실습 준비

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

  • compose.yaml: embed 프로필(qdrant, client)과 llm 프로필(ollama, bench) 컨테이너 4개
  • requirements.txt: client 컨테이너에 설치할 고정 버전 패키지 5개
  • prep.py: 글 목록 파일(매니페스트)에 등록된 발행 글을 읽어 out/docs.jsonl로 모으는 스크립트
  • questions.jsonl: 질문셋 A(30개)
  • qa.jsonl: 질문셋 B(30개)
  • rr.py: 청크 나누기, 인코딩, 색인, 1차 검색, 재순위화, 지표 명령을 모은 스크립트
  • m3.py: transformers로 bge-m3의 dense와 멀티벡터를 계산하는 모듈
  • metrics.py: 검색 지표를 계산하는 모듈
  • llm.py: 1차 후보 문단마다 로컬 LLM에 관련도 점수(0~3)를 받아 out/llm_scores.jsonl에 남기는 스크립트
  • summary.py: LLM 채점 결과를 지표로 집계하는 스크립트

이 머신의 Docker에는 메모리 약 9.7GB와 CPU 8개가 있고 다른 작업과 함께 써요. 그래서 이 실습은 메모리 5.5GB, CPU 3개 안에서 돌리려고 두 단계로 나눌게요.

임베딩 단계에는 embed 프로필(qdrant, client)만 띄우고, LLM 단계에는 그걸 멈춘 뒤 llm 프로필(ollama, bench)만 띄워요. 1차 검색 결과는 파일로 저장해 두니까 두 단계가 동시에 떠 있을 일이 없어요.

dc는 이 실습의 docker compose를 부르고, py는 임베딩 단계의 client 컨테이너에서, llm은 LLM 단계의 bench 컨테이너에서 Python을 실행하는 함수예요.

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

export ARTICLES_DIR=/path/to/articles
dc() { docker compose -p rrank "$@"; }

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

llm() { dc --profile llm exec -T bench python "$@"; }

임베딩 단계를 띄우고 패키지를 설치할게요. FlagEmbedding은 쓰지 않고 bge-m3의 출력을 transformers로 직접 계산합니다.

--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

청크 분할은 para-800-title-nocode 방식이에요. 코드 블록을 지우고 문단을 이웃끼리 800자까지 합친 뒤, 글 제목과 글 안의 소제목을 붙여요. 문서는 이 사이트의 발행 글 76편(매니페스트 JSON과 MDX 본문)이라 글에 넣지 않았어요.

chunks는 문서를 위 방식으로 나눠 out/chunks.jsonl에 쓰고, 질문마다 정답 문장을 온전히 담은 청크를 찾아 out/gold.json에 저장해요.

mkdir -p out
dc --profile embed up -d
dc --profile embed exec -T client pip install -q -r requirements.txt
py prep.py
py rr.py chunks
문서 76편
전체 1,984,154자, 코드 블록을 뺀 산문 840,585자
가장 짧은 글 11,301자, 가장 긴 글 48,315자
청크 1510개, 평균 624자
질문셋 A 30개: 정답 청크가 하나로 정해짐 30, 경계에서 잘림 0 [], 여러 청크 []
질문셋 B 30개: 정답 청크가 하나로 정해짐 30, 경계에서 잘림 0 [], 여러 청크 []

질문은 두 질문셋을 합친 60개예요. 질문은 질문셋 이름과 번호를 붙여 A1, B17처럼 부를게요.

  • A(30개, questions.jsonl): "왜", "어떻게"를 묻는 개념 질문이 많은 질문셋이에요.
  • B(30개, qa.jsonl): 실습에서 나온 숫자나 설정 기본값처럼 답이 짧은 값인 질문셋이에요.

두 질문셋 모두 질문마다 정답 글(gold_slug)과 그 글에서 그대로 복사한 정답 문장(gold_passage)이 있어요. 정답 문장을 온전히 담은 청크를 정답 청크로 삼고, 지표는 모두 이 청크의 순위로 계산해요.

  • 적중@1, @3, @5: 정답 청크가 상위 1, 3, 5개 안에 든 질문 수예요.
  • MRR@10: 정답 청크가 n위면 1/n, 10위 밖이면 0을 주고 평균한 값이에요.
  • 상한: 1차 검색 상위 N개(재순위화할 후보 수) 안에 정답 청크가 든 질문 수예요.

이제 청크 1,510개와 질문 60개를 두 모델로 인코딩할게요. e5-small은 문단 앞에 passage: , 질문 앞에 query: 를 붙여요. bge-m3는 접두어 없이 최대 512토큰으로 잘라요.

py rr.py embed
e5-small 청크 1510개 535초
bge-m3 80/1510  426초
bge-m3 160/1510  391초
...
bge-m3 1510/1510  330초
bge-m3 이번 실행 6604초

e5-small은 9분, bge-m3는 1시간 50분이 걸렸어요. dense와 멀티벡터는 같은 계산에서 함께 나오니 이 시간에 둘 다 들어 있습니다.

컬렉션 하나에 이름 붙은 벡터 세 개를 둘게요.

  • e5: 384차원, Cosine. 1차 검색용이에요.
  • m3: 1024차원, Cosine. bge-m3 dense예요.
  • m3col: 1024차원 벡터 여러 개를 점 하나에 담는 멀티벡터예요. 비교 함수는 max_sim, 저장 형식은 float16이고 디스크에 둬요.

Qdrant의 max_sim은 질문 쪽 벡터마다 상대 쪽 벡터와의 유사도 최댓값을 구해 모두 더한 값이에요. bge-m3 점수는 평균이고 Qdrant는 합이지만, 한 질문 안에서는 질문 토큰 수가 같아서 순위는 같아요. 저장 공간을 비교하려고 m3col을 뺀 컬렉션(rerank_nocol)도 하나 더 만들게요.

py rr.py index
py rr.py storage
rerank_nocol  점 1510개 넣는 데 2초
rerank        점 1510개 넣는 데 210초
청크당 서브벡터(토큰) 평균 316개, 최대 505개, 전체 476,468개
저장 크기  rerank_nocol 19MiB  rerank 977MiB  차이 958MiB

청크 하나에 평균 316개의 토큰 벡터가 붙어서, 멀티벡터를 더하면 저장 공간이 958MiB 늘었어요.

실습 1: 후보를 몇 개 가져올까

먼저 1차 검색만으로 지표를 재 볼게요. e5 벡터로 질문마다 상위 50개를 전체 비교(exact=True)로 가져오고, 기본 검색(HNSW)으로도 가져와 결과가 같은지 봅니다.

py rr.py first
exact 검색과 기본 검색의 상위 50개 순서가 다른 질문 0개
적중@1/@3/@5 개수와 MRR@10. 전체 60개, A 30개, B 30개
1차(e5) 순서 그대로             전체 25/46/50 0.594  |  A 13/23/25 0.602  |  B 12/23/25 0.587
상한 N=10  1차 상위 10개 안에 정답 청크  전체 54  A 27  B 27
상한 N=20  1차 상위 20개 안에 정답 청크  전체 57  A 28  B 29
상한 N=50  1차 상위 50개 안에 정답 청크  전체 60  A 30  B 30

출력에서 지표 한 칸은 "적중@1/@3/@5 MRR@10" 순서예요. 1차 순서 그대로 60개 가운데 정답 청크가 1위인 질문은 25개, 5위 안인 질문은 50개예요.

  • N=10: 54개. 6개 질문은 1차 상위 10개 안에 정답 청크가 없어서, N=10으로 재순위화하면 어떤 방법을 써도 이 6개는 못 맞혀요.
  • N=20: 57개
  • N=50: 60개. 60개 모두 50위 안에 정답 청크가 있어요.

적중@1은 25개인데 상한@10이 54개이니, 상위 10개 안에 있지만 1위가 아닌 질문이 29개예요. 적중@5는 50개이니 N=10 안에서 5위 밖에 있는 질문은 4개예요. 그러니 정답 청크를 1위로 끌어올리는 쪽의 여지가 훨씬 크다고 보시면 됩니다.

실습 2: 멀티벡터로 다시 점수 매기기

Qdrant Query API의 prefetch로 e5 벡터 1차 후보 N개를 받고, 그 후보만 미리 저장해 둔 m3col로 다시 점수를 매겨요. 질의 한 번으로 두 단계가 끝나요. 함께 dense와 멀티벡터를 같은 비중으로 더한 점수도 계산해요.

py rr.py colbert
적중@1/@3/@5 개수와 MRR@10. 전체 60개, A 30개, B 30개
멀티벡터 N=10                 전체 35/50/52 0.701  |  A 18/24/26 0.703  |  B 17/26/26 0.699
  정답 청크 순위가 바뀐 질문 33개: A4:2→1 A5:3→1 A7:7→5 A8:2→1 A9:8→2 A13:3→2 A14:1→3 A15:4→1 ...
dense+멀티벡터 N=10           전체 33/51/52 0.694  |  A 17/25/26 0.700  |  B 16/26/26 0.688
멀티벡터 N=20                 전체 34/51/55 0.705  |  A 18/24/27 0.709  |  B 16/27/28 0.701
  정답 청크 순위가 바뀐 질문 38개: A1:15→5 A4:2→1 A5:3→1 A7:7→5 A8:2→1 A9:8→2 A13:3→2 A14:1→3 ...
dense+멀티벡터 N=20           전체 33/52/55 0.710  |  A 17/25/27 0.709  |  B 16/27/28 0.712
멀티벡터 N=50                 전체 35/51/57 0.722  |  A 18/23/28 0.709  |  B 17/28/29 0.734
  정답 청크 순위가 바뀐 질문 41개: A1:15→5 A4:2→1 A5:3→1 A7:7→5 A8:2→1 A9:8→2 A12:21→5 A13:3→2 ...
dense+멀티벡터 N=50           전체 34/52/56 0.724  |  A 17/24/27 0.703  |  B 17/28/29 0.745

정답 청크 순위가 바뀐 질문은 1차 순위에서 재점수 순위로 어떻게 움직였는지를 질문:1차→재점수로 보여 줘요(출력이 길어 일부만 옮겼어요).

N=10에서 적중@1이 25개에서 35개, MRR@10이 0.594에서 0.701로 올랐어요. 적중@5는 50개에서 52개로 차이가 작아요. 상한이 54개라 남은 여지 자체가 작거든요.

멀티벡터도 1위였던 정답을 밀어낸 질문이 있어요(A25 1위→3위, B29 1위→3위 등). 그래도 1위로 올린 질문이 더 많았습니다. dense와 멀티벡터를 더한 점수는 적중@1이 33~34개로 멀티벡터만 쓸 때와 비슷하거나 조금 낮았어요.

N을 늘리면 적중@5와 상한은 늘지만 적중@1은 35, 34, 35로 거의 그대로예요. 1번 질문(A1)은 N=20부터 15위에서 5위가 됩니다.

실습 3: 로컬 LLM에게 관련도를 묻기

이번에는 1차 후보 문단마다 따로 LLM을 불러 "이 문단이 질문에 답하는 데 얼마나 쓸모 있는가"를 점수로 받고, 그 점수 순으로 다시 정렬해 볼게요. 문단 하나에 호출 한 번이라 비용이 가장 크니까, 후보는 N=10만 봅니다.

모델은 LG AI연구원의 EXAONE 3.5 2.4B(exaone3.5:2.4b-instruct-q4_K_M, 1.6GB)예요. EXAONE 3.5의 라이선스는 ollama show --license로 보면 "EXAONE AI Model License Agreement 1.1 - NC"로, 결과물을 포함해 상업적 이용을 금지해요.

llm.py는 out/cands.jsonl의 1차 후보 문단마다 관련도 점수를 받아 out/llm_scores.jsonl에 남겨요. 점수 기준은 0~3의 네 단계로 정하고 system 프롬프트에 적었어요. 요청은 Ollama의 /api/chat에 보내요.

  • format: {"score": integer} 모양의 JSON 스키마예요. 이 모양을 벗어나는 토큰이 나오지 않게 막아요.
  • options: temperature: 0, seed: 42, num_predict: 16, num_ctx: 2048, num_thread: 2예요.
  • logprobs: true, top_logprobs: 5: 답의 토큰마다 확률이 높았던 후보 5개와 그 로그 확률을 함께 받아요.

최종 순서는 LLM 점수 내림차순이고, 점수가 같으면 1차 순위를 따라요. 집계는 summary.py가 해요. 채점 결과 파일(out/llm_scores.jsonl)만 읽고 모델은 부르지 않아요.

임베딩 단계를 멈추고 LLM 단계를 띄워 질문 60개의 후보 600개를 채점할게요.

dc --profile embed stop
dc --profile llm up -d
dc --profile llm exec -T ollama ollama pull exaone3.5:2.4b-instruct-q4_K_M
llm llm.py run
llm summary.py llm
채점한 질문 60개, 호출 600번
적중@1/@3/@5 개수와 MRR@10. 전체 60개, A 30개, B 30개
1차(e5) N=10               전체 25/46/50 0.594  |  A 13/23/25 0.602  |  B 12/23/25 0.587
LLM 점수 N=10               전체 32/50/52 0.691  |  A 17/24/25 0.689  |  B 15/26/27 0.692
점수 분포 {3: 39, 2: 378, 1: 91, 0: 92}  형식 오류 0
정답 청크가 받은 점수 {3: 22, 2: 32, 1: 0, 0: 0}
동점 그룹(질문 안에서 같은 점수를 받은 후보 2개 이상) 104개, 1위 점수를 2개 이상이 나눠 가진 질문 41개
LLM 기댓값(logprobs) N=10    전체 40/47/53 0.746  |  A 18/22/26 0.692  |  B 22/25/27 0.800
프롬프트 토큰 평균 466개(캐시에서 꺼낸 토큰 평균 145개), 생성 토큰 평균 9.2개
프롬프트 처리 초당 28.0토큰, 생성 초당 7.5토큰
후보 하나 1.6~26.2초(중앙값 13.1초), 질문 하나(후보 10개) 55~169초(평균 128초)

여기서 눈여겨볼 건 점수가 한쪽으로 몰렸다는 점이에요.

  • 600개 가운데 378개가 2점이에요. 후보 10개는 1차 검색이 고른 비슷한 문단이라, "답의 일부나 직접 관련된 내용"이라는 2점 기준에 대부분 걸려요.
  • 41개 질문에서 1위 점수를 후보 두 개 이상이 나눠 가졌어요. 이때 순서는 동점 처리 규칙인 1차 순위가 정합니다.

그래서 점수로만 정렬한 결과(LLM 점수)는 적중@1 32개, MRR@10 0.691이에요. 1차보다 낫지만 멀티벡터(35개, 0.701)에 못 미쳐요.

동점을 푸는 데는 logprobs를 썼어요. 점수 숫자가 나온 자리에서 0~3 토큰의 확률을 꺼내 기댓값(점수 × 확률의 합)을 계산하면, 같은 2점이라도 1.6과 2.3처럼 연속된 값이 나와요. 이 값으로 정렬한 LLM 기댓값은 적중@1 40개, MRR@10 0.746이었어요.

다만 적중@3은 47개로 1차(46개)와 거의 같고 멀티벡터(50개)보다 낮았어요. 1위는 잘 골랐지만 2~3위의 순서는 나아지지 않은 거죠. 시간은 질문 하나에 55~169초예요.

정리

범위: 1차 검색 e5-small(384차원), 2차 점수 bge-m3 dense·멀티벡터와 EXAONE 3.5 2.4B(Ollama 0.34.4, CPU), Qdrant 1.19.1, 이 사이트 글 76편의 청크 1,510개, 질문 60개.

  • 1차 상위 10개에 정답 청크가 든 질문은 60개 중 54개였고 어떤 재순위화도 이 상한을 넘지 못했으며, N=50에서는 60개 모두 들어왔어요. 재순위화를 붙이기 전에 후보 수 N별 상한부터 재 보세요.
  • 멀티벡터를 미리 저장하면 청크 1,510개에 958MiB가 더 들었어요. 이 글에 싣지 않은 추가 측정에서는 저장하지 않고 질의 때 계산하면 N=10에 36~49초가 걸렸어요. CPU 환경에서 멀티벡터를 쓰려면 미리 저장하고 그만큼의 저장 공간을 잡아 두세요.
  • 로컬 LLM의 0~3점 채점은 600개 중 378개가 2점에 몰려 41개 질문에서 1위가 동점이었고, 점수만으로 정렬한 적중@1은 32개였어요. LLM에게 점수를 받을 때는 logprobs로 기댓값을 계산해 동점을 풀어 주세요.
  • 기댓값으로 정렬한 LLM은 적중@1 40개로 가장 높았지만 이 글에 싣지 않은 짝 비교에서 멀티벡터와 한쪽만 맞힌 질문이 14 대 9(p=0.405)였고, 질의당 55~169초가 걸렸어요. 질문 60개로 난 이 차이를 결론으로 쓰지 말고, 이 CPU 환경에서는 LLM 채점을 질의 경로에 넣지 마세요.
내 링크 만들기