이번 시간에는 임베딩 모델을 작은 모델에서 큰 모델로 바꾸면 검색이 얼마나 좋아지는지, Docker 위의 Qdrant와 sentence-transformers로 세 모델을 나란히 측정하며 배워가는 시간입니다.
예를 들어서 이런 상황을 생각해 볼게요. 이 사이트의 글로 만든 RAG 검색(질문과 가까운 글 조각을 찾아 LLM에 넘겨 답하게 하는 방식)은 지금까지 다국어 소형 임베딩 모델 intfloat/multilingual-e5-small을 써 왔어요.
여기에 "더 큰 모델로 바꾸면 검색이 좋아지지 않을까요?"라는 제안이 들어왔다고 해 볼게요. 후보는 같은 계열의 큰 모델 intfloat/multilingual-e5-large와, 최대 8192토큰까지 읽는 BAAI/bge-m3입니다. 그래서 세 모델로 같은 문서를 색인하고 같은 질문으로 검색해서, 품질과 비용을 차례로 재 볼게요.
세 모델
e5-small은 12층에 384차원, e5-large와 bge-m3는 24층에 1024차원 벡터를 만들어요. 두 e5 모델은 최대 512토큰까지만 읽고, bge-m3는 8192토큰까지 읽습니다. e5 두 모델은 질문에 query: , 문서에 passage: 를 붙여야 하고, bge-m3는 모델 카드에 "질문에 지시문을 붙일 필요가 없다"고 적혀 있어서 접두어를 붙이지 않았어요. 모델 디렉터리 크기는 471MB, 2.2GB, 2.2GB예요.
실습 준비
이 글의 명령은 모두 실습 코드의 embedding-model-compare/ 디렉터리에서 실행합니다.
compose.yaml: Qdrant와 Python 클라이언트 컨테이너, 모델 파일(hf)·Qdrant 저장소 볼륨requirements.txt: 클라이언트에 설치할 패키지 버전 6개prep.py: 글 목록 파일(매니페스트)에 등록된 글만 읽어out/docs.jsonl로 모으는 스크립트cmp.py: 색인·검색·평가·비용 명령을 모은 실습 스크립트questions.jsonl: 검색 질문 30개와 정답 글·정답 문장
compose.yaml로 Qdrant와 Python 클라이언트 컨테이너를 띄울게요. 문서 디렉터리는 ARTICLES_DIR로 받아 읽기 전용으로 붙이고, 모델 파일은 hf 볼륨에 받습니다. Qdrant 저장소 볼륨은 클라이언트에도 읽기 전용으로 붙어 있지만 이 글에서는 쓰지 않아요. 자원은 일부러 작게 잡았어요.
- 메모리: 클라이언트 3.5GB(
3584m), Qdrant 512MB예요. 1024차원 모델은 float32 가중치만 2GB가 넘어서 클라이언트에 대부분을 줬습니다. - CPU: 클라이언트 2.5개, Qdrant 0.5개예요.
- 스레드:
OMP_NUM_THREADS로 PyTorch 스레드를 2개로 고정했어요. 실행마다 스레드 수가 달라져서 시간 비교가 흔들리지 않게 하려는 겁니다.
패키지 버전은 requirements.txt에 고정했어요. 메모리를 잴 psutil도 넣었습니다.
--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
psutil==7.2.2
이제 명령을 짧게 쓸 수 있도록 셸 함수 두 개를 만들고 컨테이너를 띄울게요. dc는 이 실습의 docker compose를, py는 클라이언트 컨테이너에서 Python 스크립트를 실행하는 함수예요.
prep.py는 매니페스트에 등록된 글만 읽어 out/docs.jsonl로 모읍니다. 색인할 문서는 이 사이트에 발행된 글 64편(1,635,094자)이라 글에 따로 싣지 않았어요. 그러니 ARTICLES_DIR에는 같은 매니페스트 형식으로 만든 여러분 문서의 디렉터리를 주시면 됩니다.
아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
export ARTICLES_DIR=/path/to/articles
mkdir -p out
dc() { docker compose -p embcmp "$@"; }
py() { dc exec -T client python "$@"; }
dc up -d
dc exec -T client pip install --no-cache-dir -q -r requirements.txt
py prep.py
문서 64편
전체 1,635,094자, 코드 블록을 뺀 산문 681,011자
가장 짧은 글 11,301자, 가장 긴 글 48,315자
cmp.py는 세 모델을 e5s, e5l, m3라는 짧은 이름으로 부르고, 모델마다 질문과 청크(검색 단위로 잘라 둔 글 조각) 앞에 붙일 접두어를 함께 적어 뒀어요. py cmp.py <명령> [인자...]로 부릅니다.
실습 1: 같은 문서, 같은 질문으로 비교
평가 세트는 questions.jsonl이에요. 질문 30개마다 정답 글과 정답 문장(gold_passage)이 정해져 있습니다.
유형은 개념(concept) 15개, 식별자(identifier, 설정 이름·오류 번호 같은 고유 이름이 든 질문) 8개, 헷갈림(confusable, 비슷한 주제의 다른 글과 헷갈리기 쉬운 질문) 7개예요. 질문마다 상위 20개 청크를 가져와 세 지표를 계산합니다.
- 문서 recall@5: 중복을 지운 글 순서의 앞 5개 안에 정답 글이 있는 질문 수
- 문단 적중@5: 상위 5개 청크 가운데 정답 문장을 온전히 담은 청크가 있는 질문 수
- MRR@10: 정답 글이 n번째면 1/n을 주고, 10위 밖이면 0을 줘서 평균한 값
분할은 문단을 800자까지 묶고, 코드 블록을 빼고, 글 제목과 소제목을 붙여요. 검색은 Qdrant에 exact=True를 줘서 전체 비교로 했어요. 근사 검색의 오차가 섞이지 않게 하려는 겁니다.
색인할 문서 범위는 줄였어요. 이 머신에서 큰 두 모델은 e5-small보다 10배 넘게 느렸고, 64편 전체(청크 1,228개)를 색인하면 모델 하나에 1시간이 넘게 걸릴 것으로 보였거든요. 그래서 질문 30개의 정답 글 30편(청크 562개)만 색인하고, 이 범위를 para-gold라고 부를게요. Qdrant 컬렉션 이름은 <모델>-<분할>입니다(예: e5s-para-gold).
py cmp.py index e5s para-gold
py cmp.py index e5l para-gold
py cmp.py index m3 para-gold
e5s-para-gold 청크 562개 평균 619자 최대 500토큰 512토큰 초과 0개(0.0%) 임베딩 151초
e5l-para-gold 청크 562개 평균 619자 최대 500토큰 512토큰 초과 0개(0.0%) 임베딩 1725초
m3-para-gold 청크 562개 평균 619자 최대 498토큰 512토큰 초과 0개(0.0%) 임베딩 1604초
세 모델 모두 512토큰을 넘는 청크가 없어요. 가장 긴 청크가 500토큰 안팎이라, 이 분할에서는 세 모델이 같은 글을 끝까지 읽고 비교됩니다.
질문 30개로 검색하고 지표를 유형별로 나눠 출력해 볼게요. pairs는 모델 쌍마다 한쪽만 문단 적중에 성공한 질문 id를 보여 줍니다.
py cmp.py search e5s e5s-para-gold
py cmp.py search e5l e5l-para-gold
py cmp.py search m3 m3-para-gold
py cmp.py eval e5s-para-gold e5l-para-gold m3-para-gold
py cmp.py pairs e5s-para-gold e5l-para-gold m3-para-gold
e5s-para-gold recall@5 30/30 문단적중@5 25/30 MRR@10 0.975
concept 15개 recall@5 15/15 문단적중@5 11/15 MRR@10 1.000
identifier 8개 recall@5 8/8 문단적중@5 8/8 MRR@10 1.000
confusable 7개 recall@5 7/7 문단적중@5 6/7 MRR@10 0.893
e5l-para-gold recall@5 30/30 문단적중@5 27/30 MRR@10 0.983
concept 15개 recall@5 15/15 문단적중@5 14/15 MRR@10 1.000
identifier 8개 recall@5 8/8 문단적중@5 8/8 MRR@10 1.000
confusable 7개 recall@5 7/7 문단적중@5 5/7 MRR@10 0.929
m3-para-gold recall@5 30/30 문단적중@5 25/30 MRR@10 0.983
concept 15개 recall@5 15/15 문단적중@5 12/15 MRR@10 1.000
identifier 8개 recall@5 8/8 문단적중@5 8/8 MRR@10 1.000
confusable 7개 recall@5 7/7 문단적중@5 5/7 MRR@10 0.929
e5s-para-gold 만 [26] e5l-para-gold 만 [7, 9, 12]
e5s-para-gold 만 [14, 26] m3-para-gold 만 [1, 9]
e5l-para-gold 만 [7, 12, 14] m3-para-gold 만 [1]
모두 놓친 질문 [30]
세 모델을 나란히 놓으면 이렇습니다.
- 문서 recall@5: 세 모델 모두 30/30이에요. 정답 글을 찾는 데는 차이가 없습니다.
- 문단 적중@5: e5-small 25, e5-large 27, bge-m3 25예요. e5-large가 질문 2개 앞섰고 bge-m3는 e5-small과 같습니다.
- 유형별: 식별자 8개는 세 모델 모두 문단 적중 8/8이에요. 개념 유형에서 큰 모델이 1~3개 더 맞혔고, 헷갈림 유형에서는 오히려 1개씩 덜 맞혔습니다.
비율로 보면 질문 하나가 0.033이니 2개 차이는 0.067이고, 질문 30개로는 이 정도 차이가 우연인지 가리기 어려워요.
pairs 출력을 보면 e5-large는 3개(7, 9, 12)를 새로 맞히고 1개(26)를 놓쳤고, bge-m3는 2개(1, 9)를 새로 맞히고 2개(14, 26)를 놓쳤어요. 모델을 바꾸면 합계는 비슷해도 맞히는 질문이 바뀌는 거죠.
실습 2: 시간과 메모리
비용은 모델마다 새 프로세스를 띄워서 잴게요. 한 프로세스에서 모델을 차례로 바꾸면 앞 모델이 남긴 메모리가 뒤 모델의 측정에 섞이기 때문이에요. cost가 재는 값은 다음과 같습니다.
- 로드: 모델을 불러오는 데 걸린 시간과 직후의 프로세스 RSS(실제로 물리 메모리에 올라온 크기)
- 청크 임베딩: 정답 글 30편의 청크 가운데 고정 시드로 고른 32개를 배치 크기 32로 임베딩하는 시간, 그동안 0.05초마다 잰 RSS의 최댓값, 끝난 뒤 RSS
- 질문 임베딩: 질문 30개를 하나씩 임베딩했을 때 걸린 시간의 중앙값. 검색 요청 하나가 질문을 벡터로 바꾸는 데 쓰는 시간이에요.
세 모델을 번갈아 세 번 돌립니다.
for r in 1 2 3; do for k in e5s e5l m3; do py cmp.py cost $k; done; done
e5s 스레드 2 시작 229MB 로드 6.4초 후 793MB 청크 32개 10.0초 최대 1207MB 끝난 뒤 1180MB 질문 1개 중앙값 24ms
e5l 스레드 2 시작 229MB 로드 7.1초 후 798MB 청크 32개 118.1초 최대 2749MB 끝난 뒤 2629MB 질문 1개 중앙값 296ms
m3 스레드 2 시작 229MB 로드 6.3초 후 757MB 청크 32개 124.8초 최대 2718MB 끝난 뒤 2600MB 질문 1개 중앙값 282ms
e5s 스레드 2 시작 229MB 로드 6.0초 후 792MB 청크 32개 9.0초 최대 1222MB 끝난 뒤 1186MB 질문 1개 중앙값 23ms
e5l 스레드 2 시작 229MB 로드 6.5초 후 798MB 청크 32개 115.9초 최대 2749MB 끝난 뒤 2630MB 질문 1개 중앙값 283ms
m3 스레드 2 시작 229MB 로드 5.9초 후 789MB 청크 32개 111.2초 최대 2747MB 끝난 뒤 2630MB 질문 1개 중앙값 277ms
e5s 스레드 2 시작 229MB 로드 6.8초 후 793MB 청크 32개 8.6초 최대 1207MB 끝난 뒤 1187MB 질문 1개 중앙값 24ms
e5l 스레드 2 시작 229MB 로드 5.3초 후 798MB 청크 32개 123.2초 최대 2748MB 끝난 뒤 2629MB 질문 1개 중앙값 296ms
m3 스레드 2 시작 229MB 로드 5.9초 후 758MB 청크 32개 115.8초 최대 2718MB 끝난 뒤 2601MB 질문 1개 중앙값 290ms
세 번 잰 값의 범위로 읽어 볼게요.
- 청크 32개 임베딩: e5-small 8.6~10.0초, e5-large 115.9~123.2초, bge-m3 111.2~124.8초예요. 중앙값끼리 나누면 e5-large는 13.1배, bge-m3는 12.9배입니다.
- 질문 하나: e5-small 23~24ms, e5-large 283~296ms, bge-m3 277~290ms로 약 12배예요. 검색 요청마다 질문 임베딩에 0.3초 가까이 더 듭니다.
- 메모리: 최대 RSS는 e5-small 1.2GB, 큰 두 모델 2.7GB예요. 큰 모델을 서비스 프로세스 안에서 돌린다면 프로세스 하나에 2.7GB를 잡아야 해요.
- 로드 직후 RSS: 가중치 크기가 4배 넘게 다른데도 세 모델 모두 760~800MB로 비슷해요. 그러니 모델 메모리는 로드 직후가 아니라 실제로 임베딩을 돌린 뒤의 값으로 판단해 주세요.
벡터 저장 크기는 계산으로 바로 나와요. float32 하나가 4바이트이니 벡터 하나가 1,536바이트에서 4,096바이트로 1024/384배, 약 2.67배 커지고, 청크 100만 개라면 1.5GB가 4.1GB가 됩니다.
정리
범위: Qdrant 1.19.1, sentence-transformers 6.1.0, CPU 2.5개(PyTorch 스레드 2개), multilingual-e5-small, multilingual-e5-large, bge-m3, 정답 글 30편(문단 청크 562개)과 질문 30개.
- 문단 분할에서 문단 적중@5는 e5-small 25, e5-large 27, bge-m3 25였고 문서 recall@5는 셋 다 30/30이었으며, e5-large는 3개를 새로 맞히고 1개를 놓쳤습니다. 질문 30개로는 2개 차이가 우연인지 가리기 어려우니 합계가 아니라 질문 단위로 비교하세요.
- 큰 두 모델은 청크 32개 임베딩이 약 13배(e5-small 8.6~10.0초, e5-large 115.9~123.2초), 질문 하나가 23~24ms에서 277~296ms였습니다. 질문 임베딩이 검색 요청마다 0.3초 가까이 더해지는 것을 지연 예산에 넣어 주세요.
- 최대 RSS는 e5-small 1.2GB, 큰 두 모델 2.7GB였고, 로드 직후 RSS는 세 모델 모두 760~800MB로 비슷했습니다. 모델 메모리는 로드 직후가 아니라 임베딩을 돌린 뒤의 값으로 잡으세요.