AI Engineering Academy · Lekcja

Implementacja wyszukiwania słów kluczowych BM25

Skonfiguruj BM25 za pomocą rank_bm25 w Pythonie, zindeksuj korpus dokumentów i uruchamiaj wyszukiwanie słów kluczowych niezawodnie obsługujące dokładne terminy, żargon techniczny oraz nazwy produktów.

Lekcja 2 z 413 kroki

Implementacja wyszukiwania słów kluczowych BM25 to bezpłatna lekcja AI Engineering Academy na CoddyKit. To lekcja 2 z 4. Możesz przeczytać całą lekcję poniżej za darmo — a potem ćwiczyć ją interaktywnie w przeglądarce z wbudowanym edytorem kodu i tutorem AI dostępnym 24/7. To część ścieżki edukacyjnej AI Engineering Academy, a Twój postęp synchronizuje się między webem a aplikacją CoddyKit. Kurs AI Engineering Academy zawiera 4 lekcji w sumie.

Instalowanie rank_bm25

rank_bm25 to lekka biblioteka języka Python udostępniająca warianty algorytmu BM25: BM25Okapi, BM25L i BM25Plus. Nie wymaga usług zewnętrznych, działa w całości w pamięci i może indeksować tysiące dokumentów w ciągu kilku sekund na standardowym sprzęcie. Zainstaluj ją poleceniem pip install rank-bm25, aby od razu rozpocząć tworzenie wyszukiwania słów kluczowych bez konfigurowania infrastruktury.

# 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')

Tokenizacja: kluczowy pierwszy krok

BM25 działa na listach tokenów, a nie na surowych ciągach znaków. Jakość tokenizacji bezpośrednio wpływa na jakość retrievalu. Proste dzielenie po białych znakach pomija usuwanie znaków interpunkcyjnych, stemming i usuwanie stopwords. W systemach produkcyjnych należy używać właściwego tokenizera, który zamienia tekst na małe litery, usuwa znaki interpunkcyjne i stopwords oraz opcjonalnie stosuje stemming, aby dopasowywać warianty morfologiczne, takie jak „run”, „runs” i „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']

Tworzenie indeksu BM25

Utworzenie indeksu BM25 jest jednorazową operacją wykonywaną offline. Przekazuje się stokenizowany korpus do BM25Okapi, a biblioteka oblicza odwrotne częstości dokumentowe dla wszystkich terminów i przechowuje długości dokumentów do celów normalizacji. Indeks jest niewielki — nawet dla dziesiątek tysięcy dokumentów zajmuje zaledwie kilka megabajtów. Należy go przebudować za każdym razem, gdy do korpusu zostaną dodane nowe dokumenty.

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')

Wykonywanie wyszukiwania BM25

Aby wyszukiwać, należy tokenizować zapytanie za pomocą tego samego tokenizera, którego użyto dla indeksu — niespójna tokenizacja jest częstą przyczyną słabej jakości retrievalu. Wywołanie get_scores zwraca wyniki trafności dla wszystkich dokumentów, a get_top_n bezpośrednio pobiera N najlepszych wyników. Zawsze należy używać tego samego potoku wstępnego przetwarzania podczas indeksowania i wysyłania zapytań.

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]}")

Dostrajanie hiperparametrów BM25

BM25Okapi przyjmuje dwa hiperparametry: k1 steruje nasyceniem częstości terminu (wyższe wartości pozwalają terminom o dużej częstości uzyskiwać wyższe wyniki), a b steruje normalizacją długości dokumentu (1.0 = pełna normalizacja, 0.0 = brak normalizacji). Wartości domyślne k1=1.5, b=0.75 dobrze sprawdzają się dla tekstu prozatorskiego. W przypadku krótkich fragmentów (poniżej 100 słów) warto wypróbować niższe wartości b, takie jak 0.3, aby zmniejszyć wpływ długości.

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

Obsługa żargonu technicznego i tokenów kodu

W przypadku baz kodu i dokumentacji technicznej tokenizer powinien zachowywać tokeny techniczne, zamiast poddawać je agresywnemu stemmingowi. Terminy takie jak BM25Okapi, pgvector i LLM powinny pozostać nienaruszone. Hybrydowy tokenizer, który pomija stemming dla tokenów pasujących do wzorców takich jak akronimy pisane wielkimi literami, identyfikatory CamelCase lub snake_case, zapewni lepsze wyniki w wyszukiwaniu przeznaczonym dla programistów.

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']

Utrwalanie indeksu BM25

Indeksy BM25 powinny być utrwalane na dysku między ponownymi uruchomieniami aplikacji, aby uniknąć kosztu ponownego indeksowania. Ponieważ obiekty rank_bm25 są zwykłymi obiektami języka Python, można je serializować za pomocą pickle. W przypadku większych korpusów należy zapisać zarówno indeks, jak i oryginalną listę dokumentów, aby po obliczeniu wyników móc pobrać tekst. Nigdy nie należy przechowywać poufnych danych w plikach pickle, ponieważ nie są one bezpieczne w przypadku niezaufanych danych wejściowych.

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')

Przyrostowe aktualizacje indeksu

BM25 nie obsługuje aktualizacji przyrostowych — po pojawieniu się nowych dokumentów trzeba przebudować cały indeks. W przypadku często zmieniających się korpusów praktycznym rozwiązaniem są aktualizacje wsadowe: należy zbierać nowe dokumenty przez określony czas, a następnie przebudowywać indeks poza ścieżką krytyczną. Należy użyć wzorca podwójnego buforowania, w którym jeden indeks obsługuje bieżący ruch, podczas gdy drugi jest przebudowywany, a następnie indeksy są atomowo zamieniane.

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)

Integracja BM25 z LangChain

LangChain udostępnia opakowanie BM25Retriever, które integruje wyszukiwanie BM25 ze standardowym interfejsem retrievera. Dzięki temu można używać BM25 jako komponentu typu drop-in w łańcuchach LCEL oraz łączyć go z retrieverami wektorowymi za pomocą EnsembleRetriever. Parametr weights określa, jak duży wpływ na końcową kolejność ma BM25 w porównaniu z retrieverem gęstym.

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])

Ocena jakości BM25

Aby zmierzyć jakość retrievalu BM25, należy utworzyć zbiór wzorcowy zawierający zapytania powiązane ze znanymi trafnymi dokumentami. Następnie należy obliczyć hit rate at K (czy trafny dokument znajduje się wśród K najlepszych wyników) oraz MRR (średnią odwrotność rangi). Te wartości należy porównać z wynikami retrievalu gęstego na tym samym zbiorze testowym, aby określić optymalne wagi w systemie hybrydowym.

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 w produkcji na dużą skalę

W przypadku korpusów zawierających miliony dokumentów biblioteka rank_bm25 napisana w czystym Pythonie będzie zbyt wolna. BM25 na poziomie produkcyjnym jest dostępny w systemach Elasticsearch i OpenSearch (w obu jest domyślną funkcją oceniania), a także w systemie Typesense i w trybie rzadkich wektorów Qdrant. Systemy te utrzymują indeksy odwrócone na dysku, obsługują częściowe aktualizacje i realizują współbieżne zapytania bez przebudowy całego indeksu.

Szybki sprawdzian

Sprawdź swoją wiedzę na temat implementacji wyszukiwania słów kluczowych BM25 przedstawionej w tej lekcji.

Podsumowanie lekcji

W tej lekcji poznali Państwo: rank_bm25 udostępnia indeks BM25 przechowywany w pamięci, który wymaga stokenizowanych danych wejściowych; spójna tokenizacja podczas indeksowania i wysyłania zapytań jest niezbędna do dokładnego oceniania; a hiperparametry k1 i b można dostroić do konkretnego rozkładu długości dokumentów. W produkcji na dużą skalę należy używać Elasticsearch lub OpenSearch zamiast BM25 przechowywanego w pamięci. W następnej części zaimplementujemy reciprocal rank fusion, aby połączyć wyniki retrievalu BM25 i gęstego.

Bezpłatny start

Ucz się Python dzięki korepetycjom AI — za darmo

Pisz i uruchamiaj kod w przeglądarce, otrzymuj natychmiastową pomoc od korepetytora AI dostępnego 24/7 i kontynuuj naukę w sieci lub w aplikacji.

Kursy
30
Lekcje
120

Często zadawane pytania

Czy lekcja „Implementacja wyszukiwania słów kluczowych BM25” jest bezpłatna?

Tak — pełny tekst „Implementacja wyszukiwania słów kluczowych BM25” jest dostępny za darmo tutaj w sieci. Aby ćwiczyć ją interaktywnie (wbudowany edytor kodu i tutor AI dostępny 24/7) i odblokować resztę kursu AI Engineering Academy, przejdź na CoddyKit PRO. Kurs AI Engineering Academy zawiera 4 lekcji w sumie.

Co nauczysz się w „Implementacja wyszukiwania słów kluczowych BM25”?

Skonfiguruj BM25 za pomocą rank_bm25 w Pythonie, zindeksuj korpus dokumentów i uruchamiaj wyszukiwanie słów kluczowych niezawodnie obsługujące dokładne terminy, żargon techniczny oraz nazwy produktów. Ćwiczysz AI Engineering Academy z praktycznym kodem, który uruchamiasz bezpośrednio w przeglądarce, a tutor AI dostępny 24/7 odpowiada na Twoje pytania podczas pracy nad lekcją.

Czy potrzebuję doświadczenia, aby zacząć AI Engineering Academy?

Nie wymagamy żadnego doświadczenia. AI Engineering Academy w CoddyKit jest strukturyzowany dla początkujących i zaawansowanych użytkowników, więc możesz zacząć tutaj lub od początku i uczyć się w swoim tempie. To lekcja 2 z 4.

Ile czasu zajmuje lekcja „Implementacja wyszukiwania słów kluczowych BM25”?

Większość lekcji CoddyKit trwa około 5–10 minut. Każda lekcja to mały, interaktywny krok, dzięki czemu robisz systematyczne postępy i zawsze wracasz dokładnie do tego samego miejsca — na webie i w aplikacji.

Czy mogę pisać i uruchamiać kod w tej lekcji AI Engineering Academy?

Tak. Każda lekcja AI Engineering Academy zawiera wbudowany edytor kodu, więc piszesz i uruchamiasz prawdziwy kod bezpośrednio w przeglądarce i od razu otrzymujesz sprzężenie zwrotne od AI — bez konfiguracji na komputerze.

Wszystkie lekcje w tym kursie

  1. Wyszukiwanie gęste a rzadkie: kompromisy
  2. Implementacja wyszukiwania słów kluczowych BM25
  3. Reciprocal Rank Fusion do łączenia wyników
  4. Wyszukiwanie hybrydowe w Pinecone i pgvector
← Powrót do AI Engineering Academy