3편 마지막에 "둘을 합치는 게 하이브리드 검색"이라고 적었다. 지금 내 서비스도 하이브리드 검색에 재정렬(reranker)을 붙여서 쓰고 있다. 이번 편은 합치면 무엇이 좋아지고 무엇이 그대로인지 하나씩 비교해 본다.
그래서 1~3편에서 쓴 문서를 전부 모아 14개로 만들고, 같은 질문을 키워드, 벡터, 하이브리드, 재정렬까지 한 번에 돌려 봤다. 결론부터 말하면 하이브리드가 항상 이기지는 않았다. ㅡ,.ㅡ; (1편 · 2편 · 3편)
점수가 아니라 순위로 합친다: RRF
하이브리드 검색은 키워드 검색과 벡터 검색을 둘 다 돌린 뒤 결과를 하나로 합친다. 문제는 점수의 단위가 다르다는 것이다. 이번 실험에서 같은 1위 문서가 키워드(BM25)로는 4.40점, 벡터로는 0.901점이었다. 그냥 더하면 키워드 점수가 다 이긴다.
그래서 많이 쓰는 방법이 1편에서 소개한 RRF(Reciprocal Rank Fusion)다. 점수는 버리고 순위만 쓴다. 각 검색에서 1 ÷ (60 + 순위)를 구해 더한다. 코드로는 몇 줄이다.
from collections import defaultdict
def rrf(*rank_lists, k=60):
"""각 검색 결과의 '순위'만 보고 합친다. 점수 단위가 달라도 상관없다."""
score = defaultdict(float)
for ranks in rank_lists: # ranks = [문서번호, 문서번호, ...] (1등부터)
for rank, doc in enumerate(ranks, 1):
score[doc] += 1 / (k + rank) # 1등은 1/61, 2등은 1/62 ...
return sorted(score, key=lambda d: -score[d])
keyword_ranks = bm25_search(question) # 2편의 키워드 검색
vector_ranks = vector_search(question) # 3편의 벡터 검색
hybrid_ranks = rrf(keyword_ranks, vector_ranks)
점수를 0~1로 맞춘 뒤 가중치를 줘서 더하는 방법도 있다. 다만 벡터 점수가 3편에서 본 것처럼 0.8 근처에 몰려 있어서, 맞추는 방식에 따라 결과가 많이 흔들린다. 순위만 보는 RRF가 손이 덜 간다. Elasticsearch와 OpenSearch의 최근 버전에도 RRF 기능이 들어 있다(버전과 라이선스에 따라 다르니 확인 필요).
실험 1: 질문 5개를 네 가지 방법으로
질문마다 "정답 문서가 몇 위에 나왔는지"만 정리했다. 재정렬은 하이브리드 상위 5개를 다시 줄 세운 결과다.
| 질문 (정답 문서) | 키워드 | 벡터 | 하이브리드(RRF) | + 재정렬 |
|---|---|---|---|---|
| GPU 서버는 최대 며칠까지 쓸 수 있어? (제3장 사용 시간) | 1위 | 1위 | 1위 | 1위 |
| 회사 돈으로 쓴 택시비 돌려받는 법 (출장비 정산 규정) | 없음 | 1위 | 3위 | 1위 |
| 머신러닝 (기계학습 교육 문서도 나와야 함) | 없음 | 2위 | 3위 | 1위 |
| 개인정보가 있는 데이터를 올려도 돼? (제4장 데이터) | 1위 | 1위 | 1위 | - |
| TD-2024-0137 (장애 대응 절차) | 1위 | 1위 | 1위 | - |
'최대 며칠', '개인정보', '문서번호'는 모두 잘 찾았다. 그런데 표를 보면 이상한 줄이 두 개 있다. 벡터 검색은 1·2위로 찾았는데, 하이브리드로 합치니 3위로 밀렸다.
참고로 3편에서 벡터 검색이 놓쳤던 '최대 며칠' 질문을 이번에는 벡터도 1위로 찾았다(0.901). 문서 내용은 같고, 달라진 건 조각마다 앞에 "GPU 서버 사용 규정"이라는 문서 제목을 붙인 것 하나다. 3편에서 청킹 요령으로 적은 "조각마다 문서 제목을 붙인다"가 실제로 효과가 있었다.
하이브리드가 오히려 망친 질문
'택시비' 질문에서 키워드 검색은 엉뚱하게 GPU 서버 규정 제3장(1.57)과 제2장(1.16)을 올렸다. 질문의 '쓴'과 규정의 '쓸 수 있고', '쓰려는'이 형태소 분석으로 모두 '쓰다'가 되면서 겹친 거다. 출장비 문서와는 겹치는 단어가 하나도 없으니 키워드 쪽 순위에 아예 없다.
RRF는 어느 쪽이 맞는지 모른다. 두 순위를 공평하게 더할 뿐이다. 키워드 쪽 1·2위였던 GPU 규정은 벡터 쪽에서도 중간쯤 순위를 받아 점수가 두 번 더해졌고, 벡터 쪽에서만 1위이고 키워드 쪽에는 아예 없던 출장비 문서는 한 번만 더해졌다. 그래서 출장비 문서가 졌다.
| 1위 | 2위 | 3위 | |
|---|---|---|---|
| 키워드 | 규정 제3장 (1.57) | 규정 제2장 (1.16) | - |
| 벡터 | 출장비 정산 규정 (0.832) | GPU서버 신청 (0.797) | 딥러닝 설치 (0.792) |
| RRF | 규정 제3장 (0.0313) | 규정 제2장 (0.0308) | 출장비 정산 규정 (0.0164) |
'머신러닝'도 같다. 키워드 쪽에서 '머신러닝'이 '머신'과 '러닝'으로 쪼개지면서 '딥러닝' 문서가 2위로 올라왔고(2편에서 본 그 문제), 그 바람에 벡터 쪽 2위였던 기계학습 교육 문서가 3위로 밀렸다.
즉 하이브리드는 키워드 검색의 잡음까지 같이 섞는다. 그래서 키워드 쪽을 다듬는 일이 여전히 중요하다. 2편의 사용자 사전·동의어 사전, 검색에 의미 없는 품사('쓰다', '하다' 같은 동사) 빼기, 그리고 다음에 볼 재정렬이 그 역할을 한다.
재정렬(reranker): 질문과 문서를 같이 읽고 다시 줄 세운다
벡터 검색은 질문과 문서를 따로 숫자로 바꾼 뒤 비교한다. 빠르지만 거칠다. 재정렬 모델은 질문과 문서를 한 쌍으로 같이 읽고 "이 문서가 이 질문의 답인가"를 점수로 낸다. 훨씬 정확하지만 느려서, 하이브리드로 추린 상위 몇 개에만 쓴다. 이번에는 한국어를 지원하는 bge-reranker-v2-m3로 상위 5개를 다시 줄 세웠다.
from sentence_transformers import CrossEncoder
reranker = CrossEncoder("BAAI/bge-reranker-v2-m3") # 한국어 지원, 처음에 약 2.3GB
candidates = hybrid_ranks[:5] # 하이브리드 상위 5개만
scores = reranker.predict([(question, texts[i]) for i in candidates])
reranked = [i for _, i in sorted(zip(scores, candidates), reverse=True)]
| 질문 | 재정렬 1위 | 점수 | 2위 점수 | 5개 처리 시간 |
|---|---|---|---|---|
| GPU 서버는 최대 며칠까지 쓸 수 있어? | 규정 제3장 사용 시간 | 0.994 | 0.020 | 514 ms |
| 회사 돈으로 쓴 택시비 돌려받는 법 | 출장비 정산 규정 | 0.001 | 0.000 | 540 ms |
| 머신러닝 | 기계학습 기초 교육 과정 안내 | 0.648 | 0.025 | 307 ms |
하이브리드가 3위로 밀어냈던 두 문서가 모두 1위로 돌아왔다. 점수 모양도 벡터와 완전히 다르다. 벡터는 1위 0.901, 2위 0.867처럼 다 비슷했는데, 재정렬은 0.994와 0.020처럼 확실하게 갈린다.
'택시비' 줄도 눈여겨볼 만하다. 출장비 문서를 1위로 올리긴 했는데 점수가 0.001이다. 이 문서는 제목 한 줄뿐이고 택시비 이야기는 없으니, "이 문서로는 답할 수 없다"는 판단에 가깝다. 벡터 검색은 관계없는 문서에도 0.79를 줬다. 재정렬 점수는 이렇게 낮게 나오니 "관련 문서 없음"을 거르는 기준으로 쓸 수 있을지도 모른다. 정말 그런지는 5편에서 확인한다.
대신 비용이 있다. 모델이 약 2.3GB이고 처음 불러오는 데 53초가 걸렸다. CPU에서 문서 5개를 다시 줄 세우는 데 0.3~0.5초가 들었다. 그래서 전체 문서가 아니라 하이브리드 상위 5~20개에만 쓴다. GPU 서버에 올리면 훨씬 빨라진다.
R1·R2와 문서번호는 검색이 아니라 필터로 푼다
3편에서 '최신 버전'을 물으면 벡터 검색은 R1과 R2에 똑같이 0.899를 줬다. 키워드 검색도 3.82로 똑같았다. 하이브리드로 합쳐도 R1이 1위였다. 검색 방법을 아무리 섞어도 "R2가 더 새것"이라는 정보는 문서 글자 안에 없다.
그래서 문서마다 문서번호, 버전, 날짜를 메타데이터로 따로 달아 두고, 검색 결과를 걸렀다.
import re
# ① 질문에 문서번호가 있으면 그 문서만 남긴다
m = re.search(r"TD-\d{4}-\d{4}", question)
if m:
hybrid_ranks = [i for i in hybrid_ranks if docs[i].get("doc_no") == m.group()]
# ② 같은 문서번호는 가장 높은 버전만 남긴다
latest = {}
for i, d in enumerate(docs):
if "doc_no" in d and d["ver"] > latest.get(d["doc_no"], (0, -1))[0]:
latest[d["doc_no"]] = (d["ver"], i)
keep = {i for _, i in latest.values()} | {i for i, d in enumerate(docs) if "doc_no" not in d}
hybrid_ranks = [i for i in hybrid_ranks if i in keep]
| 질문 | 필터 전 (RRF) | 필터 후 |
|---|---|---|
| GPU 서버 운영 매뉴얼 최신 버전 | 1위 R1, 2위 R2 | 1위 R2 (R1은 빠짐) |
| TD-2024-0137 장애 나면 어떻게 해? | 1위 0137, 2위 0131 R2, 3위 2025-0012 | 0137 하나만 |
실제 서비스라면 Elasticsearch의 filter 절에 문서번호(term)를 걸고, 색인할 때 "최신 버전인지"를 필드로 달아 두면 된다. 우리 문서처럼 R1, R2로 버전이 올라가는 문서라면 이 필드가 없으면 같은 내용이 계속 중복으로 나온다.
참고로 이번에 코드를 고친 곳이 하나 있다. 형태소 분석기 Kiwi는 '2024-0137' 같은 일련번호를 W_SERIAL이라는 별도 품사로 분류하는데, 2편 코드는 이 품사를 버리고 있었다. 그래서 2편 코드로 문서번호를 검색하면 'TD' 두 글자만 맞춰서 찾게 된다. 남길 품사에 W_SERIAL을 넣고 나서야 문서번호가 제대로 맞았다.
정리: 이번 실험으로 본 하이브리드 검색의 순서
- 질문에 문서번호 같은 정확한 값이 있으면 먼저 필터로 범위를 좁힌다.
- 키워드 검색과 벡터 검색을 같이 돌린다.
- RRF로 순위를 합친다.
- 최신 버전만 남기는 식으로 메타데이터 필터를 건다.
- 상위 5~20개만 재정렬한다. 점수가 너무 낮으면 "관련 문서 없음" 후보로 본다(기준점은 5편에서).
- 남은 조각을 생성형 AI에게 근거로 넘긴다.
직접 돌려 보니 하이브리드는 "합치면 끝"이 아니었다. 합치는 순간 양쪽의 잡음도 같이 들어온다. 그 잡음을 걸러 주는 게 재정렬과 필터였다. 그런데 오늘처럼 질문 5개를 눈으로 보는 걸로는 정말 좋아졌는지 말하기 어렵다. 그래서 다음 편은 검색 품질을 숫자로 재는 방법이다.
이상 하이브리드 검색 만들기 끝!


댓글
댓글 쓰기