이번 시간에는 벡터 검색과 키워드 검색(BM25), 그리고 둘을 섞은 하이브리드 검색이 식별자 질문을 얼마나 잘 찾는지 Qdrant와 bge-m3로 측정하며 배워가는 시간입니다.
벡터 검색만으로 RAG를 만들면 설정 이름이나 오류 코드를 물었을 때 엉뚱한 문서가 올라온다는 이야기를 자주 들어요. 임베딩은 문장의 뜻을 담은 벡터라서 "1205" 같은 문자열을 정확히 맞추지는 못한다는 설명이 따라붙고, 해법으로 키워드 검색(BM25)을 함께 쓰는 하이브리드 검색을 권하죠.
정말 그런지 질문 50개를 개념 질문과 식별자 질문으로 나누고, 아래 검색기들을 같은 지표로 비교해 볼게요.
- 키워드 쪽: BM25(띄어쓰기로 나눈 토큰, 형태소로 나눈 토큰, 식별자를 보존한 토큰), bge-m3 희소 벡터(모델이 토큰마다 매긴 가중치)
- 벡터 쪽: bge-m3 밀집 벡터, 참고용 multilingual-e5-small 밀집 벡터
- 섞은 쪽: Qdrant Query API의 RRF(순위 합치기) 세 가지 조합과 DBSF(점수 분포 정규화) 한 가지
한국어 BM25는 토큰 나누기가 먼저입니다
밀집 벡터는 질문과 청크(검색 단위로 잘게 나눈 글 조각)를 임베딩 모델로 같은 공간의 벡터로 바꾸고, 코사인 유사도로 가까운 것을 찾아요. BM25 같은 키워드 검색은 질문과 문서를 같은 방식으로 단어(토큰)로 나눈 뒤 겹치는 토큰을 세요. "오류"처럼 흔한 토큰보다 "13114"처럼 드문 토큰이 점수를 더 많이 받습니다.
영어는 띄어쓰기로 나눠도 대체로 맞지만, 한국어는 명사 뒤에 조사가 붙어요. 그래서 세 가지 방법을 비교해 볼게요.
- 띄어쓰기: 소문자로 바꾸고 공백과 문장부호에서 잘라요. 정규식
\w+로 뽑으니 밑줄은 단어의 일부로 남고, 점과 하이픈에서는 잘립니다. - Kiwi 형태소: Kiwi로 형태소를 나누고 일반 명사(NNG), 고유 명사(NNP), 동사·형용사 어간(VV, VA), 어근(XR), 영문(SL), 숫자(SN), 한자(SH)만 남겨요. 조사와 어미, 문장부호는 버립니다.
- Kiwi + 식별자 보존: 먼저 정규식
[A-Za-z0-9][A-Za-z0-9_.\-]*[A-Za-z0-9]로 영문, 숫자, 밑줄, 점, 하이픈이 이어진 두 글자 이상 덩어리를 뽑아 통째로 토큰으로 두고, 남은 부분만 Kiwi로 나눠요.
실습 준비
이 글의 명령은 모두 실습 코드의 rag-hybrid-search/ 디렉터리에서 실행합니다.
compose.yaml: Qdrant와 Python 클라이언트 컨테이너requirements.txt: 클라이언트 패키지 버전prep.py: 문서 디렉터리에서 발행 글만 읽어out/docs.jsonl로 모으는 스크립트hyb.py: 분할, 임베딩, 색인, 검색, 평가, 융합 하위 명령을 모은 스크립트questions.jsonl: 평가 질문 50개와 정답 글
Qdrant와 Python 클라이언트 컨테이너 두 개를 띄울게요. 문서 디렉터리는 ARTICLES_DIR로 받아 읽기 전용으로 붙이고, 모델 파일은 hf 볼륨에 받아요.
패키지 버전은 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
FlagEmbedding==1.4.2
kiwipiepy==0.24.0
kiwipiepy-model==0.24.0
bm25s==0.3.11
prep.py는 매니페스트에 등록된 글만 읽어 out/docs.jsonl로 모아요. 문서 디렉터리는 이 사이트에 발행된 글 63편의 매니페스트(*.json)와 본문(*.mdx), 약 160만 자라서 글에 넣지 않았어요.
아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.
mkdir -p out
export ARTICLES_DIR=/path/to/articles
dc() { docker compose -p rhyb "$@"; }
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
문서 63편
전체 1,602,476자, 코드 블록을 뺀 산문 665,635자
가장 짧은 글 11,301자, 가장 긴 글 48,315자
글은 para-800-title 방식으로 나눠요. 빈 줄로 나눈 문단을 800자까지 합치고, 청크 앞에 글 제목과 글 안의 소제목을 붙이는 방식이에요.
코드 블록은 지우지 않고 남겨 둡니다. 설정 이름과 오류 코드는 명령과 출력 안에 들어 있는 경우가 많아서, 코드를 지우면 식별자 질문의 근거가 사라지기 때문이에요.
hyb.py는 이 글의 실습 코드를 모은 파일이고, python hyb.py <명령> [인자...]처럼 불러요. 모델을 처음 불러올 때 Hugging Face에서 파일을 받습니다.
토큰은 클라이언트에서 직접 나누고, 그 결과를 희소 벡터로 넣어요. IDF(드문 토큰일수록 크게 주는 가중치)는 넣지 않아요. 희소 벡터 설정에 modifier: idf를 주면 Qdrant가 검색할 때 컬렉션 통계로 계산해 질문 쪽 값에 곱해 주거든요.
컬렉션 하나(hyb)에 검색기별 벡터를 이름을 붙여 모두 넣을게요.
dense: bge-m3 밀집 벡터, 1024차원, Cosinee5: e5-small 밀집 벡터, 384차원, Cosinebm25_ws,bm25_kiwi,bm25_kiwi_id: 토큰 나누기 세 방식의 BM25 희소 벡터,modifier: idfm3_sparse: bge-m3 희소 벡터, modifier 없음
출력은 앞의 두 줄과 마지막 두 줄만 옮겼어요(발췌).
py hyb.py embed
py hyb.py index
e5-small 청크 2876개 임베딩 1319초
bge-m3 200/2876 이번 구간 659초
bge-m3 청크 2876개 임베딩 완료 (이번 실행 9471초)
points 2876 사전 크기 {'bm25_ws': 27398, 'bm25_kiwi': 11084, 'bm25_kiwi_id': 14568}
임베딩 시간은 같은 머신에서 다른 작업이 함께 돌던 중에 잰 값이라 참고만 해 주세요. bge-m3는 스레드 2개로 청크 하나에 3초 남짓 걸렸어요.
실습 1: 검색기 여섯 개를 질문 유형별로
질문셋은 이 사이트 글에 대한 기존 질문 30개(청크 크기를 비교할 때 쓴 질문으로 개념 15, 식별자 8, 헷갈림 7개)에 식별자를 묻는 질문 20개를 더한 50개예요. 질문마다 정답 글(gold_slug)과 답의 근거가 되는 원문 문자열(gold_passage)이 있어요.
같은 식별자가 여러 글에 나오면 헷갈림 유형으로 분류했어요. 그래서 유형별 개수는 개념 15개, 식별자 25개, 헷갈림 10개예요.
지표는 세 가지를 봐요.
- 문서 recall@5: 상위 5개 글 안에 정답 글이 있는 질문의 비율이에요.
- 문단 적중@5: 상위 5개 청크 안에 정답 문자열을 온전히 담은 청크가 있는 비율이에요.
- MRR@10: 정답 글 순위의 역수 평균이에요.
질문마다 검색기별로 상위 20개 청크를 가져와 지표를 계산할게요. 밀집 검색은 exact=True로 전체 비교를 시켜서, 근사 검색(HNSW)이 만드는 오차가 비교에 섞이지 않게 했어요.
py hyb.py eval
BM25 띄어쓰기 전체 50개 recall@5 46/50 문단적중@5 26/50 MRR@10 0.839
concept 15개 recall@5 15/15 문단적중@5 11/15 MRR@10 0.900
identifier 25개 recall@5 22/25 문단적중@5 11/25 MRR@10 0.824
confusable 10개 recall@5 9/10 문단적중@5 4/10 MRR@10 0.785
BM25 Kiwi 전체 50개 recall@5 49/50 문단적중@5 31/50 MRR@10 0.922
concept 15개 recall@5 15/15 문단적중@5 8/15 MRR@10 0.900
identifier 25개 recall@5 25/25 문단적중@5 17/25 MRR@10 1.000
confusable 10개 recall@5 9/10 문단적중@5 6/10 MRR@10 0.762
BM25 Kiwi+식별자 전체 50개 recall@5 49/50 문단적중@5 32/50 MRR@10 0.914
concept 15개 recall@5 15/15 문단적중@5 8/15 MRR@10 1.000
identifier 25개 recall@5 25/25 문단적중@5 18/25 MRR@10 0.913
confusable 10개 recall@5 9/10 문단적중@5 6/10 MRR@10 0.784
bge-m3 희소 전체 50개 recall@5 48/50 문단적중@5 33/50 MRR@10 0.906
concept 15개 recall@5 15/15 문단적중@5 9/15 MRR@10 0.967
identifier 25개 recall@5 25/25 문단적중@5 19/25 MRR@10 0.980
confusable 10개 recall@5 8/10 문단적중@5 5/10 MRR@10 0.631
bge-m3 밀집 전체 50개 recall@5 50/50 문단적중@5 36/50 MRR@10 0.965
concept 15개 recall@5 15/15 문단적중@5 10/15 MRR@10 1.000
identifier 25개 recall@5 25/25 문단적중@5 21/25 MRR@10 1.000
confusable 10개 recall@5 10/10 문단적중@5 5/10 MRR@10 0.825
e5-small 밀집 전체 50개 recall@5 48/50 문단적중@5 25/50 MRR@10 0.918
concept 15개 recall@5 15/15 문단적중@5 7/15 MRR@10 0.967
identifier 25개 recall@5 25/25 문단적중@5 14/25 MRR@10 0.960
confusable 10개 recall@5 8/10 문단적중@5 4/10 MRR@10 0.742
- 식별자 25개: bge-m3 밀집이 25개 모두 정답 글을 1위로 올렸고(MRR@10 1.000), 정답 문자열이 든 청크도 21개를 상위 5개에 넣었어요. BM25 Kiwi+식별자는 MRR@10 0.913, 문단 적중 18개였습니다. 식별자 질문에서는 BM25가 앞설 거라고 예상했지만, 이 질문셋에서는 밀집 벡터가 같거나 조금 나았어요.
- 개념 15개: 밀집과 BM25 Kiwi+식별자 모두 MRR@10 1.000이고, 문단 적중은 10개와 8개예요.
- 헷갈림 10개: 밀집이 MRR@10 0.825로 가장 높았고, bge-m3 희소가 0.631로 가장 낮았어요.
유형 사이의 차이는 대부분 질문 한두 개, 많아야 세 개예요. 이 정도 차이로 "식별자 질문에는 BM25"나 "밀집이면 충분하다" 같은 결론을 내리기는 어렵습니다.
차이가 분명한 곳은 BM25의 토큰 나누기예요. 띄어쓰기에서 Kiwi로 바꾸자 문서 recall@5가 46개에서 49개로, 문단 적중이 26개에서 31개로, MRR@10이 0.839에서 0.922로 올랐어요.
띄어쓰기 방식이 놓친 4개 가운데 3개가 식별자 질문이었어요. 질문에는 "relay_log_space_limit은"처럼 조사가 붙은 식별자가 들어오는데, 띄어쓰기 방식은 이걸 문서의 relay_log_space_limit과 다른 토큰으로 보기 때문이에요.
식별자 보존은 기대와 달랐어요. Kiwi 단독과 비교해 전체 지표는 거의 같고(문단 적중 31개와 32개), 식별자 유형의 MRR@10은 1.000에서 0.913으로 오히려 내려갔어요.
실습 2: RRF로 섞기
밀집과 BM25는 점수 단위가 달라서 점수를 그대로 더할 수 없어요. 그래서 RRF(Reciprocal Rank Fusion)는 검색기마다 받은 순위로 점수를 매겨 더해요. Qdrant 문서에 적힌 식은 이래요.
score(d) = Σ 1 / (k + r)
- r: 그 검색기 결과에서 청크 d의 순위예요. Qdrant는 0부터 세서 1위가 0입니다.
- k: 상수예요. Qdrant의 기본값은 2예요. RRF를 처음 제안한 논문(Cormack 외, 2009)에서 쓴 60이 흔히 기본값처럼 알려져 있지만, Qdrant는 아니에요.
- 어느 검색기 결과에도 없는 청크는 그 검색기 몫이 0이에요.
DBSF는 검색기마다 결과 점수의 평균과 표준편차를 구해 평균 ± 3 표준편차를 0과 1로 옮겨 정규화한 뒤 더하는 방식이에요. 하이브리드의 BM25는 실험 전에 정해 둔 Kiwi+식별자 보존 방식입니다.
- 밀집+BM25: bge-m3 밀집 + BM25 Kiwi+식별자
- 밀집+m3희소: bge-m3 밀집 + bge-m3 희소
- 밀집+BM25+m3희소: 셋 다
- 밀집+BM25 DBSF
출력의 마지막 줄은 Qdrant가 계산한 RRF를 numpy로 다시 계산해 맞춰 본 결과예요.
py hyb.py fuse
밀집+BM25 RRF 전체 50개 recall@5 50/50 문단적중@5 35/50 MRR@10 0.975
concept 15개 recall@5 15/15 문단적중@5 9/15 MRR@10 1.000
identifier 25개 recall@5 25/25 문단적중@5 20/25 MRR@10 1.000
confusable 10개 recall@5 10/10 문단적중@5 6/10 MRR@10 0.875
밀집+m3희소 RRF 전체 50개 recall@5 49/50 문단적중@5 39/50 MRR@10 0.950
concept 15개 recall@5 15/15 문단적중@5 11/15 MRR@10 0.967
identifier 25개 recall@5 25/25 문단적중@5 22/25 MRR@10 1.000
confusable 10개 recall@5 9/10 문단적중@5 6/10 MRR@10 0.798
밀집+BM25+m3희소 RRF 전체 50개 recall@5 49/50 문단적중@5 38/50 MRR@10 0.923
concept 15개 recall@5 15/15 문단적중@5 12/15 MRR@10 0.967
identifier 25개 recall@5 25/25 문단적중@5 20/25 MRR@10 0.973
confusable 10개 recall@5 9/10 문단적중@5 6/10 MRR@10 0.733
밀집+BM25 DBSF 전체 50개 recall@5 50/50 문단적중@5 37/50 MRR@10 0.952
concept 15개 recall@5 15/15 문단적중@5 11/15 MRR@10 1.000
identifier 25개 recall@5 25/25 문단적중@5 20/25 MRR@10 1.000
confusable 10개 recall@5 10/10 문단적중@5 6/10 MRR@10 0.758
numpy로 계산한 RRF(k=2)와 상위 5개의 청크와 점수가 같은 질문 50/50, 그중 점수가 같은 청크끼리 순서만 다른 질문 8개
50개 질문 모두 상위 5개 청크와 점수가 같았고, 식이 문서와 같다는 것(순위 0부터, k=2)을 확인했어요. 다만 8개 질문은 점수가 같은 청크끼리 순서만 달랐습니다.
결과를 밀집 단독(문단 적중 36개, MRR@10 0.965)과 비교해 볼게요.
- 밀집+BM25: MRR@10은 0.975로 올랐고, 헷갈림 유형의 MRR@10이 0.825에서 0.875로 오른 몫이에요. 문단 적중은 35개로 하나 줄었어요.
- 밀집+m3희소: 문단 적중이 39개로 모든 설정 가운데 가장 많았어요. 대신 MRR@10은 0.950으로 밀집 단독보다 낮고, 문서 recall@5도 49개로 하나 줄었습니다.
- 셋 다 섞으면: 문단 적중 38개, MRR@10 0.923으로 둘씩 섞은 것보다 나은 점이 없었어요.
- DBSF: 문단 적중 37개, MRR@10 0.952예요. 같은 두 검색기를 RRF로 섞을 때와 비교해 문단 적중은 2개 많고 MRR은 낮아요.
모든 지표에서 밀집 단독보다 나은 조합은 없었고, 조합마다 어떤 지표는 오르고 어떤 지표는 내려갔어요.
정리
범위: 이 사이트의 글 63편(청크 2,876개, para-800-title), 질문 50개(개념 15, 식별자 25, 헷갈림 10), bge-m3와 multilingual-e5-small, Qdrant 1.19.1.
- 식별자 질문 25개에서 bge-m3 밀집은 MRR@10 1.000, 문단 적중 21개로 BM25 Kiwi+식별자(0.913, 18개)와 같거나 나았어요. 벡터 검색이 오류 코드를 못 찾는다고 가정하지 말고 질문을 유형별로 나눠 직접 재 보세요.
- BM25의 토큰 나누기를 띄어쓰기에서 Kiwi 형태소로 바꾸자 문단 적중이 26개에서 31개로, MRR@10이 0.839에서 0.922로 올랐어요. 한국어 BM25는 형태소로 나눠 주세요.
- 식별자를 통째로 보존하는 규칙은 전체 지표를 거의 바꾸지 못했고 식별자 유형 MRR@10을 1.000에서 0.913으로 내렸어요. 식별자 보존 규칙은 여러분 질문셋에서 확인한 뒤에 넣으세요.
- Qdrant RRF의 k 기본값은 2이고 순위를 0부터 셌어요. 이 글에 싣지 않은 추가 측정에서는 이 질문셋에서 k를 10, 60으로 바꾸거나 검색기별 가중치를 줘도 1위가 바뀐 질문은 50개 중 0~4개였어요. k와 가중치는 질문셋을 둘로 나눠 한쪽으로 고르고 다른 쪽으로 확인하세요.
- 밀집+BM25는 MRR@10을 0.965에서 0.975로 올리고 문단 적중을 하나 잃었어요. 이 글에 싣지 않은 질문 단위 비교에서는 밀집이나 BM25 중 하나가 상위 5개에 넣은 정답 청크를 하이브리드만 놓친 질문이 8개, 하이브리드만 넣은 질문이 1개였어요. 하이브리드 도입은 평균이 아니라 여러분 질문셋에서 놓치는 질문 수를 세어 본 뒤 정해 주세요.