AI Engineering Academy · 강의

BM25 키워드 검색 구현

Python에서 rank_bm25를 사용해 BM25를 설정하고 문서 모음을 색인한 뒤, 정확한 용어와 기술 용어, 제품명을 안정적으로 처리하는 키워드 검색을 실행합니다.

레슨 2/413개 단계

BM25 키워드 검색 구현은(는) CoddyKit의 무료 AI Engineering Academy 강의입니다. 이것은 4개 중 2번째 강의입니다. 아래에서 전체 강의를 무료로 읽을 수 있으며, 내장 코드 에디터와 24/7 AI 튜터와 함께 브라우저에서 직접 실습할 수 있습니다. 이 강의는 AI Engineering Academy 학습 경로의 일부이며, 진행 상황이 웹과 CoddyKit 앱에 동기화됩니다. AI Engineering Academy 강의에는 총 4개의 강의가 포함되어 있습니다.

rank_bm25 설치

rank_bm25는 BM25 알고리즘의 BM25Okapi, BM25L, BM25Plus 변형을 제공하는 가벼운 Python 라이브러리입니다. 외부 서비스가 필요 없고 전적으로 메모리에서 실행되며, 일반적인 하드웨어에서 수천 개의 문서를 몇 초 만에 색인할 수 있습니다. pip install rank-bm25로 설치하면 별도의 인프라 설정 없이 키워드 검색을 구축할 수 있습니다.

# Install: pip install rank-bm25
from rank_bm25 import BM25Okapi

# BM25Okapi is the most common variant
# BM25L and BM25Plus handle very short documents better
# For most RAG use cases BM25Okapi is the right choice

corpus = [
    'Python decorator pattern explained with examples',
    'How to use context managers in Python',
    'JavaScript async await tutorial',
]
tokenized = [doc.lower().split() for doc in corpus]
bm25 = BM25Okapi(tokenized)
print('Index built with', len(corpus), 'documents')

토큰화: 가장 중요한 첫 단계

BM25는 원시 문자열이 아니라 토큰 목록을 대상으로 작동합니다. 토큰화 품질은 검색 품질에 직접적인 영향을 줍니다. 단순한 공백 분할만으로는 문장 부호 제거, 어간 추출, 불용어 제거를 처리할 수 없습니다. 운영 시스템에서는 텍스트를 소문자로 변환하고 문장 부호와 불용어를 제거하며, 필요에 따라 'run', 'runs', 'running'처럼 형태가 다른 단어를 일치시키도록 어간을 추출하는 제대로 된 토크나이저를 사용하십시오.

import re
from nltk.corpus import stopwords
from nltk.stem import PorterStemmer

STOP_WORDS = set(stopwords.words('english'))
stemmer = PorterStemmer()

def tokenize(text: str) -> list[str]:
    text = text.lower()
    text = re.sub(r'[^a-z0-9\s]', ' ', text)
    tokens = text.split()
    tokens = [t for t in tokens if t not in STOP_WORDS and len(t) > 1]
    tokens = [stemmer.stem(t) for t in tokens]
    return tokens

print(tokenize('Running Python decorators efficiently in production!'))
# ['run', 'python', 'decor', 'effici', 'product']

BM25 색인 구축

BM25 색인 생성은 한 번만 수행하는 오프라인 작업입니다. 토큰화된 말뭉치를 BM25Okapi에 전달하면 모든 용어의 역문서 빈도를 계산하고 정규화를 위해 문서 길이를 저장합니다. 색인은 가볍기 때문에 수만 개의 문서에도 몇 메가바이트 정도만 차지합니다. 말뭉치에 새 문서가 추가될 때마다 색인을 다시 구축해야 합니다.

from rank_bm25 import BM25Okapi

def build_bm25_index(documents: list[str]):
    tokenized = [tokenize(doc) for doc in documents]
    bm25 = BM25Okapi(tokenized)
    return bm25, tokenized

# Example with a small corpus
docs = [
    'Vector databases store dense embeddings for similarity search',
    'BM25 is a sparse keyword retrieval algorithm used in search engines',
    'Hybrid search combines dense and sparse retrieval for better recall',
    'PostgreSQL supports vector search via the pgvector extension',
]
bm25, tokenized = build_bm25_index(docs)
print(f'Index contains {bm25.corpus_size} documents')

BM25 검색 수행

검색할 때는 색인과 동일한 토크나이저를 사용해 질의를 토큰화하십시오. 토큰화가 일관되지 않으면 검색 품질이 저하되는 경우가 많습니다. get_scores를 호출하면 모든 문서의 관련도 점수를 얻을 수 있고, get_top_n을 호출하면 상위 N개 결과를 직접 가져올 수 있습니다. 색인 생성과 질의 처리에는 항상 동일한 전처리 파이프라인을 사용하십시오.

def bm25_search(bm25, documents: list[str], query: str, top_k: int = 3):
    query_tokens = tokenize(query)
    scores = bm25.get_scores(query_tokens)

    # Get indices sorted by score descending
    ranked = sorted(enumerate(scores), key=lambda x: x[1], reverse=True)

    results = []
    for idx, score in ranked[:top_k]:
        results.append({
            'document': documents[idx],
            'score': round(score, 4),
            'rank': len(results) + 1,
        })
    return results

results = bm25_search(bm25, docs, 'sparse keyword search engine')
for r in results:
    print(f"Rank {r['rank']} (score {r['score']}): {r['document'][:60]}")

BM25 하이퍼파라미터 조정

BM25Okapi는 두 개의 하이퍼파라미터를 사용합니다. k1은 용어 빈도 포화를 제어하며, 값이 클수록 빈도가 높은 용어의 점수가 더 높아집니다. b는 문서 길이 정규화를 제어합니다(1.0 = 완전한 정규화, 0.0 = 정규화하지 않음). 일반적인 글에는 k1=1.5, b=0.75라는 기본값이 잘 맞습니다. 짧은 구간(100단어 미만)에서는 길이에 따른 편향을 줄이기 위해 0.3과 같이 더 낮은 b 값을 사용해 보십시오.

from rank_bm25 import BM25Okapi

# Default hyperparameters — good starting point
bm25_default = BM25Okapi(tokenized, k1=1.5, b=0.75)

# Tuned for short document chunks
bm25_short = BM25Okapi(tokenized, k1=1.2, b=0.3)

# Tuned for long documents
bm25_long = BM25Okapi(tokenized, k1=2.0, b=0.9)

# Always benchmark hyperparameters against a golden eval set
# before deploying to production

기술 전문 용어와 코드 토큰 처리

코드베이스와 기술 문서에서는 토크나이저가 기술 토큰을 과도하게 어간 추출하지 말고 그대로 보존해야 합니다. BM25Okapi, pgvector, LLM과 같은 용어는 원형을 유지해야 합니다. 대문자 약어, CamelCase, snake_case 식별자와 일치하는 토큰은 어간 추출을 건너뛰는 하이브리드 토크나이저를 사용하면 개발자 대상 검색에서 더 나은 결과를 얻을 수 있습니다.

import re

def technical_tokenize(text: str) -> list[str]:
    text = text.lower()
    # preserve underscores in snake_case and dots in version numbers
    text = re.sub(r'[^a-z0-9_.\s]', ' ', text)
    tokens = text.split()
    # keep tokens that look like identifiers (contain _ or .)
    tokens = [
        t for t in tokens
        if len(t) > 1 and t not in STOP_WORDS
    ]
    return tokens

print(technical_tokenize('Install pgvector 0.5.1 extension in PostgreSQL 16'))
# ['pgvector', '0.5.1', 'extension', 'postgresql', '16']

BM25 색인 영속화

애플리케이션을 다시 시작할 때 색인을 다시 만드는 비용을 피하려면 BM25 색인을 디스크에 저장해야 합니다. rank_bm25 객체는 일반적인 Python 객체이므로 pickle로 직렬화할 수 있습니다. 더 큰 말뭉치에서는 점수 계산 후 텍스트를 가져올 수 있도록 색인과 원본 문서 목록을 모두 저장하십시오. pickle 파일은 신뢰할 수 없는 입력에 안전하지 않으므로 민감한 데이터를 절대 저장하지 마십시오.

import pickle

def save_bm25_index(bm25, documents: list[str], path: str):
    with open(path, 'wb') as f:
        pickle.dump({'bm25': bm25, 'documents': documents}, f)
    print(f'Index saved to {path}')

def load_bm25_index(path: str):
    with open(path, 'rb') as f:
        data = pickle.load(f)
    return data['bm25'], data['documents']

save_bm25_index(bm25, docs, '/tmp/bm25_index.pkl')
bm25_loaded, docs_loaded = load_bm25_index('/tmp/bm25_index.pkl')

색인 증분 업데이트

BM25는 증분 업데이트를 지원하지 않으므로 새 문서가 들어오면 전체 색인을 다시 구축해야 합니다. 자주 변경되는 말뭉치에는 일괄 업데이트가 실용적인 해결책입니다. 일정 시간 동안 새 문서를 모은 다음 핵심 처리 경로 밖에서 색인을 다시 구축하십시오. 한 색인이 실시간 요청을 처리하는 동안 다른 색인을 다시 구축한 후 원자적으로 교체하는 이중 버퍼링 패턴을 사용하십시오.

import threading

class SwappableBM25Index:
    def __init__(self):
        self._index = None
        self._docs = []
        self._lock = threading.RLock()

    def rebuild(self, new_docs: list[str]):
        tokenized = [tokenize(d) for d in new_docs]
        new_index = BM25Okapi(tokenized)
        with self._lock:
            self._index = new_index
            self._docs = new_docs
        print(f'Index rebuilt with {len(new_docs)} documents')

    def search(self, query: str, top_k: int = 5):
        with self._lock:
            return bm25_search(self._index, self._docs, query, top_k)

LangChain과 BM25 통합

LangChain은 BM25 검색을 표준 검색기 인터페이스에 통합하는 BM25Retriever 래퍼를 제공합니다. 따라서 BM25를 LCEL 체인 안에서 바로 사용할 수 있는 구성 요소로 활용하고, EnsembleRetriever를 사용해 벡터 검색기와 결합할 수 있습니다. 가중치 매개변수는 최종 순위에서 BM25와 밀집 검색기가 각각 얼마나 영향을 미치는지 제어합니다.

from langchain_community.retrievers import BM25Retriever
from langchain.retrievers import EnsembleRetriever
from langchain_core.documents import Document

langchain_docs = [Document(page_content=d) for d in docs]

bm25_retriever = BM25Retriever.from_documents(langchain_docs)
bm25_retriever.k = 5

# Combine with a vector retriever (assuming vector_retriever is already defined)
# ensemble = EnsembleRetriever(
#     retrievers=[bm25_retriever, vector_retriever],
#     weights=[0.4, 0.6],  # 40% BM25, 60% dense
# )

results = bm25_retriever.invoke('sparse keyword search')
for doc in results:
    print(doc.page_content[:80])

BM25 품질 평가

BM25 검색 품질을 측정하려면 질의와 해당 질의에 대해 관련성이 알려진 문서를 짝지은 기준 데이터셋을 만드십시오. K에서의 적중률(관련 문서가 상위 K개 결과에 포함되는지 여부)과 MRR(평균 역순위)을 계산하십시오. 동일한 테스트 세트에서 밀집 검색과 이 수치를 비교하여 하이브리드 시스템의 최적 가중치를 결정하십시오.

def hit_rate_at_k(bm25, documents, queries, relevant_docs, k=5):
    hits = 0
    for query, relevant in zip(queries, relevant_docs):
        results = bm25_search(bm25, documents, query, top_k=k)
        retrieved = [r['document'] for r in results]
        if relevant in retrieved:
            hits += 1
    return hits / len(queries)

# Example evaluation
test_queries = ['BM25 algorithm', 'hybrid search systems']
test_relevant = [
    'BM25 is a sparse keyword retrieval algorithm used in search engines',
    'Hybrid search combines dense and sparse retrieval for better recall',
]
hit_rate = hit_rate_at_k(bm25, docs, test_queries, test_relevant, k=3)
print(f'Hit rate @3: {hit_rate:.2%}')

대규모 운영 환경의 BM25

문서가 수백만 개에 이르는 말뭉치에서는 순수 Python으로 실행되는 rank_bm25가 너무 느립니다. 대규모 운영 환경에 적합한 BM25는 Elasticsearch와 OpenSearch(둘 다 BM25를 기본 점수 함수로 사용), Typesense, Qdrant의 희소 벡터 모드에서 사용할 수 있습니다. 이러한 시스템은 디스크에 역색인을 유지하고 부분 업데이트를 지원하며, 전체 색인을 다시 구축하지 않고도 동시 질의를 처리합니다.

빠른 확인

이 수업에서 배운 BM25 키워드 검색 구현을 얼마나 이해했는지 확인해 보십시오.

수업 요약

이 수업에서는 다음을 배웠습니다. rank_bm25는 토큰화된 입력이 필요한 메모리 기반 BM25 색인을 제공하고, 정확한 점수 계산을 위해 색인 생성과 질의 처리 사이의 일관된 토큰화가 필수이며, 특정 문서 길이 분포에 맞게 하이퍼파라미터 k1과 b를 조정할 수 있습니다. 대규모 운영 환경에서는 메모리 기반 BM25 대신 Elasticsearch 또는 OpenSearch를 사용하십시오. 다음으로 BM25와 밀집 검색 결과를 병합하는 reciprocal rank fusion을 구현합니다.

무료로 시작

AI 튜터와 함께 Python을(를) 배우세요 — 무료

브라우저에서 실제 코드를 작성하고 실행하며, 24/7 AI 튜터로부터 즉각적인 도움을 받고, 웹이나 앱에서 중단한 부분부터 계속 학습하세요.

코스
30
레슨
120

자주 묻는 질문

“BM25 키워드 검색 구현” 강의는 무료인가요?

네 — “BM25 키워드 검색 구현” 전체 내용을 이 웹사이트에서 무료로 읽을 수 있습니다. 인터랙티브하게 실습하려면(내장 코드 에디터와 24/7 AI 튜터), CoddyKit PRO로 업그레이드하면 AI Engineering Academy 강의 전체를 잠금 해제할 수 있습니다. AI Engineering Academy 강의에는 총 4개의 강의가 포함되어 있습니다.

“BM25 키워드 검색 구현”에서 뭘 배우나요?

Python에서 rank_bm25를 사용해 BM25를 설정하고 문서 모음을 색인한 뒤, 정확한 용어와 기술 용어, 제품명을 안정적으로 처리하는 키워드 검색을 실행합니다. 브라우저에서 직접 실행하는 실습 코드로 AI Engineering Academy을(를) 배우며, 24/7 AI 튜터가 강의를 진행하면서 질문에 답변해줍니다.

AI Engineering Academy을(를) 시작하는 데 경험이 필요한가요?

사전 경험은 필요하지 않습니다. CoddyKit의 AI Engineering Academy은(는) 초급자부터 고급 학습자까지를 위해 구성되어 있으므로, 여기서 시작하거나 처음부터 시작할 수 있으며 자신의 속도대로 진행할 수 있습니다. 이것은 4개 중 2번째 강의입니다.

“BM25 키워드 검색 구현” 강의는 얼마나 걸리나요?

대부분의 CoddyKit 강의는 약 5~10분이 소요됩니다. 각 강의는 간결하고 인터랙티브하여 꾸준한 진행이 가능하며, 웹과 앱에서 중단한 부분부터 바로 시작할 수 있습니다.

이 AI Engineering Academy 강의에서 코드를 작성하고 실행할 수 있나요?

네. 모든 AI Engineering Academy 강의에는 내장 코드 에디터가 포함되어 있으므로, 브라우저에서 바로 실제 코드를 작성하고 실행한 후 즉시 AI 피드백을 받을 수 있습니다 — 로컬 설정이 필요 없습니다.

이 강의의 모든 강의

  1. 밀집 검색과 희소 검색의 비교: 상충 관계
  2. BM25 키워드 검색 구현
  3. 점수 병합을 위한 상호 순위 융합
  4. Pinecone과 pgvector의 하이브리드 검색
← AI Engineering Academy(으)로 돌아가기