AI/Natural language processing

[NLP] Milvus + BM25 = Hybrid Search

hjjummy 2025. 8. 22. 14:53

이전에 Hybrid Search 전반적인 흐름과 개념적인 내용을 공부했었다.
오늘은 그 연장선에서, 실제 RAG를 구현하면서 검색모듈 부분에서  주로 사용하는 라이브러리와 기술 스택에 대해 다뤄보려고 한다. 오늘은 Milvus와 BM25 각각이 어떤 역할을 하는지, 그리고 두 가지를 결합하여 어떻게 Hybrid Search를 구현하는지에 정리하려 한다.

 

 


1. BM25: Sparse Search

BM25는 키워드 기반 문서 검색 알고리즘이다. 간단히 말하면 “질문에 들어간 단어가 얼마나 많이, 또 얼마나 중요한 위치에 등장했는가”를 점수화한다.

"어떻게 하면 사용자의 질의(query)와 문서(document) 사이의 연관성을 정량적으로 계산할 수 있을까?"

 

 

단순히 문서에 단어가 포함되어 있냐/없냐만 본다면, 너무 단순해서 실제 검색 품질이 좋지 않다. 그래서 등장한 것이 TF-IDF 계열 알고리즘이고, 그중에서도 가장 널리 쓰이는 방식이 바로 BM25이다.

엘라스틱서치(ElasticSearch)에서도 5.0 버전 이후 BM25가 기본 랭킹 알고리즘으로 채택될 만큼 표준처럼 사용된다!

 

BM25 핵심 개념 요약

BM25 알고리즘은 크게 세 가지 아이디어를 사용한다.

  1. 단어 빈도(TF, Term Frequency)
    • 문서 안에 검색어가 여러 번 등장하면 그 문서가 더 관련 있을 확률이 높다.
    • 단, 같은 단어가 지나치게 많이 등장하면 오히려 중요도가 떨어진다(광고성 스팸 방지).
  2. 역문서 빈도(IDF, Inverse Document Frequency)
    • 흔한 단어(예: "그리고", "합니다")는 검색어로서 의미가 약하다.
    • 반대로 특정 문서에만 등장하는 드문 단어는 검색 신호로서 강하다.
  3. 문서 길이 보정(Document Length Normalization)
    • 같은 단어가 등장하더라도, 긴 문서보다는 짧은 문서에 등장할 때 더 중요한 단서가 된다.
    • 그래서 BM25는 평균 문서 길이와 비교해 보정값을 적용한다.

이제 알고리즘을 수식으로 확인해보자!

쿼리 Q={q1,q2,…,qn}가 주어지고, 특정 문서 D에 대해 BM25 점수는 다음과 같이 계산한다.

 

여기서,

  • f(q,D): 단어 가 문서 D에 등장한 횟수
  • |D|: 문서 D의 전체 길이(토큰 개수)
  • avgdl: 전체 문서의 평균 길이
  • k1: TF에 대한 스무딩 파라미터 (보통 1.2~2.0)
  • b: 문서 길이 보정 파라미터 (보통 0.75)
  • IDF(q): 역문서 빈도
  • 단어가 많을수록 점수 ↑
  • 너무 길면 점수 ↓
  • 희귀한 단어일수록 점수 ↑

직접 BM25를 구현하면 내부 동작을 훨씬 쉽게 이해할 수 있다. 아래는 Mecab 형태소 분석기를 활용한 간단한 구현 예시이다.

from konlpy.tag import Mecab
import math
import numpy as np
from collections import defaultdict

mecab = Mecab()

class BM25:
    def __init__(self, documents, k1=1.2, b=0.75):
        self.documents = documents
        self.k1 = k1
        self.b = b
        self.doc_count = len(documents)
        self.doc_len = []
        self.doc_freqs = []
        self.idf = {}
        self.avgdl = 0
        self._calc_idf()

    def _calc_idf(self):
        tokenized_docs = [mecab.morphs(doc) for doc in self.documents]
        total_len = 0
        word_freq = defaultdict(int)

        for doc in tokenized_docs:
            self.doc_len.append(len(doc))
            total_len += len(doc)
            freqs = defaultdict(int)
            for word in doc:
                freqs[word] += 1
            self.doc_freqs.append(freqs)
            for word in freqs:
                word_freq[word] += 1

        self.avgdl = total_len / self.doc_count
        for word, freq in word_freq.items():
            self.idf[word] = math.log(1 + (self.doc_count - freq + 0.5) / (freq + 0.5))

    def get_scores(self, query):
        query_tokens = mecab.morphs(query)
        scores = np.zeros(self.doc_count)
        for q in query_tokens:
            if q not in self.idf:
                continue
            idf = self.idf[q]
            for i, freqs in enumerate(self.doc_freqs):
                f = freqs.get(q, 0)
                denom = f + self.k1 * (1 - self.b + self.b * self.doc_len[i] / self.avgdl)
                scores[i] += idf * (f * (self.k1 + 1)) / denom
        return scores

    def get_top_n(self, query, n=5):
        scores = self.get_scores(query)
        top_n = np.argsort(scores)[::-1][:n]
        return [self.documents[i] for i in top_n]

 

실행예시

docs = ["오늘 날씨가 참 좋다",
        "내일 날씨는 흐릴 것 같다",
        "서울의 미세먼지가 심하다",
        "오늘은 미세먼지가 없다"]

bm25 = BM25(docs)

query = "날씨"
print(bm25.get_top_n(query, n=2))
  • 장점
    • 특정 키워드가 정확히 들어간 문서를 잘 찾아낸다.
    • 구현이 빠르고, 인덱싱/검색 속도가 빠르다.
    • 법률, 규정집처럼 전문 용어가 중요할 때 특히 강력하다.
  • 단점
    • 동의어나 표현 변형에 취약하다.
      • 예: 질문에 자동차라고 했는데 문서에는 차량이라고 적혀 있으면 점수를 낮게 준다.
    • 단순 빈도 기반이기 때문에, 문맥적 유사도(semantic similarity)는 잡아내지 못한다.

즉, BM25는 "딱 떨어지는 키워드 매칭"에 사용하는 알고리즘이다. TF-IDF를 개선한 문서 랭킹 알고리즘이며 문서 길이 보정과 단어 빈도 포화 효과를 고려해 실제 검색 엔진에 훨씬 잘 맞음.

 

직접 구현해보면 수식이 단순한 원리에서 나왔다는 걸 알 수 있다.
검색 품질을 높이는 첫걸음으로 BM25를 이해하고 적용해보면 좋다.


2. Milvus: Dense Search (시멘틱 검색)

Milvus는 벡터 데이터베이스(Vector DB)다. 여기서는 문서와 질문을 임베딩(Embedding)으로 바꿔서 벡터 공간에 저장하고, 벡터 간 거리를 비교해서 "가장 의미가 비슷한 문서"를 찾아낸다.

  • 장점
    • 동의어, 유사한 표현까지 잡아낼 수 있다.
      • 예: 자동차와 차량은 임베딩 공간에서 가깝게 매핑된다.
    • 문맥을 반영하므로, 키워드가 직접 안 들어있어도 의미가 맞으면 검색 가능하다.
  • 단점
    • 모델이 학습한 임베딩 품질에 크게 좌우된다.
    • 특정 키워드를 정확히 집어내는 데는 오히려 약하다.
    • 계산량이 많아서 BM25보다 느릴 수 있다.

즉, Milvus는 "문맥적으로 비슷한 것까지 찾아주는 시멘틱 검색에 사용되는 vector DB공간"이다.

 

3. Hybrid Search

실무에서 써보니, BM25만 쓰면 놓치는 경우가 있고, Milvus만 쓰면 잡음이 많은 경우가 있었다. 예를 들어

  • BM25만 쓰면 →
    질문: 5월 초 경제 보고서
    문서: "5월 상순 경제동향" → 키워드 매칭이 애매해서 못 잡음.
  • Milvus만 쓰면 →
    질문: AI 인력 수급 방안
    문서: "AI 윤리 가이드라인" → 의미가 조금이라도 비슷하면 가져와서 정확도가 떨어짐.

그래서 Hybrid Search를 쓴다. 방법은 간단하다.

final_score = bm25_score * sparse_weight + dense_score * dense_weight

이렇게 BM25 점수와 Milvus 점수를 가중 평균하거나,
아니면 RRF(Reciprocal Rank Fusion) 같은 기법으로 순위를 다시 합친다.

  • 키워드 정확도는 BM25로,
  • 의미적 유사도는 Milvus로 보완하는 셈이다.
 
사용자 질문
   ↓
[BM25] 키워드 매칭 점수 ┐
                       ├─ Hybrid Scoring → Top N 문서
[Milvus] 임베딩 유사도  ┘

그 후에는 보통 Reranker 모델을 붙여서 LLM이 보기 좋은 순서로 정렬한다.

이 부분에 대한 자세한 내용은 이전 글 참고,,

2025.08.19 - [인공지능/자연어처리] - RAG 구축하기 - 3.2 성능 최적화 : Hybrid Search(CC& RRF) 와 Rerank

 

 

글을 쓰면서 느낀 건, 검색기는 결국 정확도(precision)와 재현율(recall) 사이에서 줄타기를 하는 게임이라는 점이다. BM25와 Milvus는 각자 한쪽 극단을 맡고 있고, Hybrid Search는 그 사이의 균형을 잡아주는 전략이라고 볼 수 있다.