AI Engineering Academy · Урок

Реализация поиска по ключевым словам BM25

Настройте BM25 с помощью rank_bm25 в Python, проиндексируйте корпус документов и выполняйте поиск по ключевым словам, надёжно обрабатывая точные термины, технический жаргон и названия продуктов.

Урок 2 из 413 шагов

«Реализация поиска по ключевым словам BM25» — бесплатный урок AI Engineering Academy на CoddyKit. Это урок 2 из 4. Ты можешь прочитать весь урок бесплатно ниже — а потом практиковать его прямо в браузере с встроенным редактором кода и ИИ-репетитором 24/7. Это часть пути обучения AI Engineering Academy, и твой прогресс синхронизируется между веб-версией и приложением CoddyKit. Курс AI Engineering Academy содержит 4 уроков всего.

Установка rank_bm25

rank_bm25 — это лёгкая библиотека Python, предоставляющая варианты BM25Okapi, BM25L и BM25Plus алгоритма BM25. Она не требует внешних служб, полностью работает в памяти и может индексировать тысячи документов за считанные секунды на обычном оборудовании. Установите её с помощью 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 слов) попробуйте меньшие значения b, например 0.3, чтобы уменьшить влияние длины.

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)

Интеграция BM25 с LangChain

LangChain предоставляет оболочку BM25Retriever, которая интегрирует поиск BM25 в стандартный интерфейс средства поиска. Это позволяет использовать BM25 как взаимозаменяемый компонент в цепочках LCEL и объединять его с поисковыми средствами по векторам с помощью EnsembleRetriever. Параметр weights определяет, насколько 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 в производственной среде и при больших масштабах

Для корпусов, содержащих миллионы документов, rank_bm25 на чистом Python будет работать слишком медленно. BM25 производственного масштаба доступен в Elasticsearch и OpenSearch (в обоих системах BM25 используется как функция оценки по умолчанию), а также в Typesense и режиме разреженных векторов Qdrant. Эти системы поддерживают инвертированные индексы на диске, частичные обновления и параллельные запросы без перестроения всего индекса.

Быстрая проверка

Проверьте, насколько хорошо Вы поняли реализацию поиска по ключевым словам BM25 из этого урока.

Итоги урока

В этом уроке Вы узнали, что rank_bm25 предоставляет индекс BM25 в памяти, которому нужны токенизированные входные данные, что последовательная токенизация при индексации и выполнении запросов необходима для точного вычисления оценок, а гиперпараметры k1 и b можно настроить под конкретное распределение длины документов. Для производственной эксплуатации в больших масштабах используйте Elasticsearch или OpenSearch вместо BM25 в памяти. Далее мы реализуем объединение по обратным рангам, чтобы объединить результаты BM25 и плотного поиска.

Можно начать бесплатно

Изучай Python с ИИ-репетитором — бесплатно

Пиши и запускай код прямо в браузере, получай мгновенную помощь от ИИ-репетитора 24/7 и продолжи учиться на сайте или в приложении.

Курсы
30
Уроки
120

Часто задаваемые вопросы

Урок «Реализация поиска по ключевым словам BM25» бесплатный?

Да — полный текст урока «Реализация поиска по ключевым словам BM25» бесплатно доступен здесь в веб-версии. Чтобы практиковать его интерактивно (встроенный редактор кода и ИИ-репетитор 24/7) и разблокировать остальной курс AI Engineering Academy, подпишись на CoddyKit PRO. Курс AI Engineering Academy содержит 4 уроков всего.

Чему я научусь в уроке «Реализация поиска по ключевым словам BM25»?

Настройте BM25 с помощью rank_bm25 в Python, проиндексируйте корпус документов и выполняйте поиск по ключевым словам, надёжно обрабатывая точные термины, технический жаргон и названия продуктов. Ты практикуешь AI Engineering Academy с помощью реального кода, который запускаешь прямо в браузере, и ИИ-репетитор 24/7 отвечает на твои вопросы во время урока.

Нужен ли мне опыт, чтобы начать AI Engineering Academy?

Предыдущий опыт не требуется. AI Engineering Academy на CoddyKit структурирован для всех уровней — от новичков до продвинутых, поэтому ты можешь начать отсюда или с самого начала и учиться в своем темпе. Это урок 2 из 4.

Сколько времени занимает урок «Реализация поиска по ключевым словам BM25»?

Большинство уроков CoddyKit занимают около 5–10 минут. Каждый из них компактный и интерактивный, поэтому ты постоянно делаешь прогресс и продолжаешь с того же места в веб-версии и приложении.

Можно ли писать и запускать код в этом уроке AI Engineering Academy?

Да. Каждый урок AI Engineering Academy включает встроенный редактор кода, поэтому ты пишешь и запускаешь реальный код прямо в браузере и получаешь моментальную обратную связь от AI — локальная установка не требуется.

Все уроки этого курса

  1. Плотный и разреженный поиск: компромиссы
  2. Реализация поиска по ключевым словам BM25
  3. Объединение оценок с помощью слияния обратных рангов
  4. Гибридный поиск в Pinecone и pgvector
← Назад к AI Engineering Academy