이번 시간에는 벡터 검색으로 가져온 후보를 다시 정렬하는 재순위화가 정답 문단을 얼마나 위로 올리는지, 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 채점을 질의 경로에 넣지 마세요.