코드베이스 지식 베이스 시리즈 (07): 하이브리드 검색 BM25 + Vector — Q8은 여전히 실패했고, 총점은 하락했다
요약
벡터 검색의 한계를 극복하기 위해 BM25 키워드 검색을 결합한 하이브리드 검색을 시도했으나, 특정 쿼리(Q8) 해결에 실패하고 전체 성능이 하락한 실험 결과를 다룹니다. 이번 실패를 통해 텍스트 매칭 경로의 경계와 근본적인 문제 원인을 분석합니다.
핵심 포인트
- BM25와 벡터 검색을 결합한 하이브리드 검색 구현
- 의미론적 검색과 키워드 빈도 기반 검색의 상호 보완 시도
- 실험 결과 특정 쿼리 해결 실패 및 전체 점수 하락 발생
- 실패 분석을 통해 텍스트 매칭 기술의 한계점 파악
보스전, 다섯 번째 시도
만약 03번 아티클부터 쭉 읽어오셨다면, 여러분은 이미 우리의 오래된 적 Q8을 만나보셨을 것입니다.
이는 12개의 쿼리 테스트 세트 중 8번째 쿼리인 process payment and create Stripe charge입니다. 이 쿼리의 정답(ground truth)에는 두 개의 함수가 포함되어 있으며, 그중 하나인 calculate_order_total은 네 개의 아티클 내내 우리를 피해 왔습니다.
- 아티클 03, 벡터 검색 (vector-retrieval) 베이스라인 — 실패했습니다.
- 아티클 04, 세 가지 청킹 (chunking) 전략 — 다시 실패했습니다.
- 아티클 05, 그래프 증강 검색 (graph-augmented retrieval)이 마침내 이를 찾아냈지만, Q1의 성능 저하(regression)를 대가로 치렀고, 결과적으로 총점은 변하지 않았습니다.
- 아티클 06,
called_by구조 정보를 임베딩 (embedding)에 인코딩하여 유사도를 0.46에서 0.51로 높였고 올바른 방향으로 나아갔으나, 문자 그대로의 어휘(literal vocabulary)라는 벽을 넘지 못해 다시 한번 실패했습니다.
네 개의 아티클, 다섯 번의 조우 (아티클 04에서 세 가지 청킹 변형 시도), 그리고 Q8은 마치 계속해서 부활하는 보스 같습니다. 빈틈을 찾았다고 생각할 때마다 새로운 자세로 다시 일어섭니다.
이번 아티클에서는 업계에서 보편적으로 권장하는 "궁극의 무기"인 **BM25 키워드 검색 (keyword retrieval)**을 가져와 벡터 검색과 결합하여 **하이브리드 검색 (hybrid search)**을 구현했습니다. 이 조합은 수많은 프로덕션 시스템에서 벡터 검색을 위한 최선의 증강(augmentation) 방식임이 검증되었습니다. 논리는 간단합니다. 벡터 검색은 의미론(semantics)에 의존하고, BM25는 문자 그대로의 용어 빈도(term frequency)에 의존합니다. 두 방식의 실패 모드(failure modes)가 다르기 때문에, 이론적으로는 서로의 단점을 보완할 수 있어야 합니다. 이것이 바로 제가 06번 아티클 끝에서 계획했던 바였습니다. 두 개의 직교하는(orthogonal) 경로를 사용하여 서로의 사각지대를 메운다면, Q8을 마침내 해결할 수 있을 것이라고 말이죠.
중간에 성급히 환호하지 않도록 결론부터 말씀드리겠습니다. Q8은 여전히 실패했습니다. 설상가상으로, 하이브리드 검색은 다른 쿼리에서 BM25의 성능 저하를 그대로 물려받아, 총점이 0.958에서 0.931로 떨어졌습니다. 순수 벡터 검색보다 결과가 더 나빠졌습니다.
하지만 이 실패는 전체 시리즈를 통틀어 가장 가치 있는 실패입니다. 왜냐하면 이것은 단순히 "결과가 좋지 않았던 또 다른 접근 방식"이 아니라, Q8의 근본 원인을 완전히 드러냈으며 전체 텍스트 매칭 (text-matching) 경로의 경계가 정확히 어디에 위치하는지를 측정했기 때문입니다. 이 글의 끝에 도달할 때쯤, 여러분은 이전 네 편의 글에서 발생한 모든 실패가 왜 불가피했는지, 그리고 그 실패들이 사실은 모두 동일한 일을 하고 있었던 이유를 이해하게 될 것입니다.
먼저 두 가지 개념: BM25와 RRF
직접 실습해 보기 전에, 두 가지 용어를 명확히 하는 데 2분을 할애해 봅시다.
BM25는 고전적인 정보 검색 (information-retrieval) 알고리즘입니다. 이를 "가중치가 부여된 키워드 매칭 (weighted keyword matching)"이라고 생각하십시오. BM25는 의미론 (semantics)을 이해하지 못합니다. 대신 용어 빈도 (term frequency)를 계산합니다. 즉, 쿼리 용어가 문서에 더 자주 등장할수록, 그리고 해당 용어가 전체 코퍼스 (corpus)에서 더 희귀할수록 해당 문서의 점수가 높아집니다. 검색 엔진과 Elasticsearch의 기본 관련성 순위 지정 (relevance ranking)은 내부적으로 이 알고리즘을 기반으로 작동합니다.
비유를 들자면: 벡터 검색 (vector retrieval)은 박학다식한 언어학자와 같습니다. 예를 들어 '돈을 모으다 (collect money)'라고 말하면, 그들은 당신이 '결제 (checkout)', '청구 (charge)', '결제 게이트웨이 (payment gateway)'를 의미할 수도 있다는 것을 압니다. 반면 BM25는 꼼꼼한 사서와 같습니다. '돈을 모으다 (collect money)'라고 말하면, 그들은 '돈을 모으다'라는 정확한 단어가 가장 많이 포함된 책이 무엇인지 찾아냅니다. 언어학자는 의미를 이해하지만 과하게 생각할 수 있고, 사서는 문자 그대로 해석하지만 절대 혼동하지 않습니다.
**RRF (Reciprocal Rank Fusion, 상호 순위 융합)**는 여러 검색 결과 리스트를 하나의 순위로 병합하는 방법입니다. RRF는 각 채널의 원시 점수 (raw scores)를 무시합니다 (벡터 코사인 유사도와 BM25 점수는 스케일 자체가 다르기 때문에 단순히 더할 수 없습니다). 대신 오직 **순위 (rank)**만을 고려합니다. 만약 어떤 함수가 벡터 결과에서는 2위, BM25 결과에서는 5위를 기록했다면, RRF는 이를 1/(k+2) + 1/(k+5)로 계산합니다. 여기서 k는 평활화 상수 (smoothing constant)이며, 업계 표준은 60입니다. 여러 채널에 걸쳐 상위권에 위치한 항목일수록 가장 높은 융합 점수를 얻게 됩니다.
RRF 코드는 매력적일 정도로 짧습니다:
def rrf_fuse(ranked_lists: list[list[str]], k: int = 60) -> list[str]:
"""상호 순위 융합 (Reciprocal Rank Fusion). k=60은 표준 상수입니다."""
scores: dict[str, float] = {}
...
이것이 전부입니다. RRF의 장점은 다음과 같습니다: 가중치 튜닝이 필요 없고, 스케일 (scale)에 민감하지 않으며, 견고합니다. 즉, 하이브리드 검색 (hybrid search)에서 유지보수가 가장 적게 드는 융합 방법입니다.
따라서 이 글의 세 가지 후보는 다음과 같습니다:
- A_vector: 순수 벡터 검색 (vector retrieval). 아티클 03부터 사용된 기존 베이스라인 (baseline)입니다.
- B_bm25: 순수 BM25 키워드 검색 (keyword retrieval).
- C_hybrid: 벡터 + BM25를 RRF로 융합한 방식.
[IMG:1]
결정적인 단계: BM25가 코드를 토큰화하는 방식
BM25의 효과는 거의 전적으로 토큰화 (tokenization) 에 달려 있습니다. BM25는 자신이 분리해낸 토큰만을 인식하며, 만약 잘못 분리한다면 이후의 모든 과정이 무너집니다. 코드는 자연어 (natural language)가 아니며, calculate_order_total, processCheckout, create_payment_intent와 같은 이름들을 일반적인 토커나이저 (tokenizer)에 넣으면 단순히 매칭되지 않는 연결된 문자열 덩어리만 생성될 뿐입니다.
따라서 우리는 코드 명명 규칙 (naming conventions)을 이해하는 토커나이저가 필요합니다. 먼저 비알파뉴메릭 (non-alphanumeric, 비영숫자) 문자를 기준으로 분리한 다음, snake_case를 나누고, 그 다음 camelCase를 나누어야 합니다:
def tokenize(text: str) -> list[str]:
# 비영숫자 문자를 기준으로 분리
raw = re.split(r"[^a-zA-Z0-9_]", text)
...
이렇게 하면 create_payment_intent는 ['create', 'payment', 'intent']로 분리되고, processCheckout는 ['process', 'checkout']으로 분리됩니다. 단어들이 가장 미세한 단위로 쪼개지므로, BM25가 이를 쿼리 (query)의 단어들과 매칭할 수 있게 됩니다.
이제 최종 결과를 공개하기 전에, 한 가지를 해봅시다. Q8에 토크나이저 (tokenizer)를 실행하여 반복적으로 놓치고 있는 calculate_order_total이 실제로 무엇과 매칭될 수 있는지 확인해 보겠습니다. 이 단계가 이번 글의 결정적인 대목입니다.
Q8 쿼리 (query), 토큰화 (tokenized):
Query: 'process payment and create Stripe charge'
Tokens: ['process', 'payment', 'and', 'create', 'stripe', 'charge']
6개의 토큰입니다. 이제 세 개의 후보 함수를 살펴보고, 각 함수의 전체 토큰 세트가 쿼리와 얼마나 겹치는지(overlap) 확인해 보겠습니다.
calculate_order_total:
Token overlap with query: set() <- 겹치는 단어 없음!
Total tokens: 59
...
첫 번째 줄을 보십시오. calculate_order_total의 59개 전체 토큰 중에서 쿼리와의 겹침은 — set()입니다. 공집합 (empty set)입니다. 제로(0)입니다. 단 하나의 단어도 일치하지 않습니다.
이것은 "매칭이 충분히 좋지 않았다"가 아니라, "매칭할 대상이 아예 없다"는 뜻입니다. BM25는 말 그대로 사서 (librarian)와 같습니다. "payment, stripe, charge가 가장 자주 등장하는 책이 무엇인가요?"라고 물으면, BM25는 calculate_order_total이라는 책 전체를 훑어보고 그런 단어를 단 하나도 찾지 못한 채, 자신 있게 0점이라는 점수를 부여합니다.
create_payment_intent 및 process_checkout과 대조해 보십시오. 이들의 코드에는 payment, stripe, create, process가 명확히 포함되어 있어 수많은 겹침이 발생하며, BM25는 즉시 이들을 상위 순위로 올립니다.
이제 아마 결과를 짐작하실 수 있을 것입니다. "궁극의 무기"인 BM25는 calculate_order_total에 대해 완전히 무용지물입니다. 쿼리와 단 한 단어도 공유하지 않기 때문입니다. 그리고 벡터 검색 (vector retrieval)은 이미 06편에서 (의미론적으로 너무 멀어서) 이 함수에 도달할 수 없음을 증명했습니다. 의미론적 경로와 빈도수 경로, 두 가지 경로 모두 동일한 함수에서 실패합니다.
결과를 실행하여 이 예감이 맞는지 확인해 봅시다.
결과: 해결되지 않았을 뿐만 아니라, 오히려 퇴보했다
12개의 쿼리에 대해 세 가지 접근 방식을 모두 실행한 총합은 다음과 같습니다.
Approach R@3 R@5 vs Vector
───────────────────────── ─────── ─────── ──────────
A_vector 0.889 0.958 base
...
첫눈에 봐도 쓰라린 결과입니다. BM25와 하이브리드(Hybrid) 모두 Recall@5가 0.931로, 순수 벡터(pure vector)의 0.958보다 0.028 낮습니다. 우리는 간절한 마음으로 키워드 검색(keyword-retrieval) 채널을 추가했지만, 융합(fusion) 이후 총점은 올라가기는커녕 오히려 _하락_했습니다.
이제 정확히 어디에서 점수가 깎였는지 확인하기 위해 쿼리별 Recall@5를 살펴보겠습니다:
Query Vec BM25 Hyb
────────────────────────────────────────────────── ────── ────── ──────
verify user identity and check JWT token validity 1.00 1.00 1.00
...
두 개의 화살표가 모든 상황을 설명해 줍니다:
- Q8 (
process payment and create Stripe charge): Vec, BM25, Hyb 모두 0.50입니다. 이전 네 개의 아티클과 동일하게 — 두 개의 정답(ground-truth) 함수 중 하나만 맞췄습니다. 우리의 "최종 병기"가 빗나간 것입니다. - Q7 (
execute SQL query safely against the database): 벡터(vector)는 완벽한 1.00이지만, BM25는 0.67로 떨어졌고, 하이브리드(Hybrid)도 이를 따라 0.67로 떨어졌습니다. 이것이 총점을 깎아먹은 주범입니다. 하이브리드는 BM25의 결과를 융합했기 때문에, 이 성능 저하(regression)를 고스란히 물려받았습니다.
수정하고자 했던 하나의 쿼리는 여전히 고장 난 상태로 남았고, 완벽했던 하나의 쿼리는 BM25에 의해 끌어내려졌으며, 그 피해가 하이브리드(Hybrid)로 확산되었습니다. 이것이 "총점 저하(total score regression)"의 전체 원인입니다.
먼저 Q8의 세부 사항을 살펴보고 추측을 확정 지어 봅시다.
Q8: 의미론(Semantics)도, 빈도(Frequency)도 도달하지 못했다
Q8에 대해 세 가지 방식 모두의 상위 5개(top-5) 결과를 뽑아보겠습니다:
Q8 top-5:
A_vector: ['process_checkout', 'process_refund', 'create_payment_intent', 'get_payment_history', 'verify_webhook_signature']
B_bm25: ['process_checkout', 'process_refund', 'create_payment_intent', 'verify_webhook_signature', 'get_payment_history']
...
세 가지 접근 방식의 Top-5 결과에는 모두 동일한 "결제 관련 어휘가 풍부한 (payment-vocabulary-rich)" 함수들이 포함되어 있습니다: process_checkout, process_refund, create_payment_intent, get_payment_history, verify_webhook_signature. 이 함수들의 이름과 본문은 payment, stripe, charge, refund로 가득 차 있으며, 의미론적 (semantics) 측면에서 측정하든 빈도수 (frequency) 측면에서 측정하든 안정적으로 상위권에 위치합니다.
반면, 우리가 실제로 원하는 calculate_order_total은 세 가지 방식 중 어느 것에서도 Top-5에 진입하지 못했습니다. 그 이유는 앞서 확인했던 set() 때문입니다:
- 벡터 검색 (Vector retrieval)은 도달할 수 없음 — Article 06에서 이미 측정했듯이, 쿼리와의 의미론적 거리 (semantic distance)가 너무 멀며, 구조적 접두사 (structural prefix)를 사용하더라도 0.51에 그쳤고, 0.51이라는 벽을 넘지 못했습니다.
- BM25는 훨씬 더 도달하지 못함 — 쿼리와의 토큰 중복 (token overlap)이 **공집합 (empty set)**이므로, BM25는 0점을 부여하며 순위 산정(ranking)에 참여할 권리조차 박탈합니다.
- 하이브리드 RRF 융합 (Hybrid RRF fusion) — RRF는 "여러 채널에서 상위권에 근접한 것"을 기준으로 점수를 높여줍니다. 하지만
calculate_order_total은 벡터 검색에서 Top-5 밖에 위치하고, BM25에서는 0점으로 꼴찌입니다. 두 채널 모두 낮은 순위를 부여했으므로, 융합이 무엇을 만들어낼 수 있을까요? 도달할 수 없는 두 신호를 더해도 여전히 도달할 수 없습니다. RRF는 마법이 아닙니다. 어느 채널에서도 부여하지 않은 높은 순위를 조작해낼 수는 없습니다.
이것이 Q8의 가장 본질적인 진실입니다: Q8은 단순히 6위로 아깝게 놓친 것이 아니라, 그 어떤 종류의 텍스트 신호도 그 존재를 인지조차 할 수 없다는 것입니다. 의미론 (Semantics)과 빈도수 (frequency)는 텍스트 검색의 두 기둥이며, Q8은 이 두 기둥을 모두 뚫고 지나가 버렸습니다.
[IMG:1]
Q7: BM25의 붕괴는 06번 아티클과 유사하다
방금 쿼리 BM25가 Hybrid 검색을 끌어내리고 오염시킨 사례는 Q7: execute SQL query safely against the database입니다. 이 쿼리의 정답(ground truth)은 execute_query, bulk_insert, paginate_query라는 세 개의 함수입니다.
Vector 검색(Vector retrieval)은 세 함수 모두를 깔끔하게 찾아냈지만(1.00), BM25는 하나를 놓치며 0.67로 떨어졌습니다.
이유가 무엇일까요? 문제는 execute라는 단어입니다. execute_query는 전형적인 허브 함수(hub function, 높은 out-degree 유틸리티)로, 데이터베이스 모듈 내의 수많은 함수에 의해 호출됩니다. 그리고
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기