Реализовать BM25 с нуля
ТЕХНИЧЕСКОЕ ЗАДАНИЕ: Реализовать BM25 с нуля
1. Цель задачи
Разработать собственную реализацию ранжирующей функции BM25 (Okapi BM25) на основе инвертированного индекса, вычисления TF-IDF и нормализации длины документа. Реализация должна быть выполнена с использованием только стандартной библиотеки Python и базовых математических операций без готовых IR-фреймворков. Финальное качество retrieval (по метрикам nDCG@10, MAP, Recall@10) должно быть не ниже, чем у эталонной реализации Elasticsearch на том же корпусе и наборе запросов.
Ключевой результат Рабочий модуль bm25.py, который принимает коллекцию документов и запрос, возвращает ранжированный список документов с баллами BM25, с качеством, эквивалентным Elasticsearch (разница метрик ≤ 2%).
2. Исходные данные
| Что нужно | Откуда взять |
|---|---|
| Корпус документов (текстовые файлы или строки) | Любая доступная коллекция: 20 Newsgroups (scikit-learn), собственные статьи, дамп вики — минимум 1000 документов, каждый 200–2000 слов |
| Набор запросов (10–20 штук) с релевантными ответами | Сформировать вручную или использовать готовый (например, TREC-8 subset) |
| Эталонная реализация BM25 (Elasticsearch) | Локально поднятый Elasticsearch + elasticsearch-py или Docker-контейнер |
| Инструмент для расчёта метрик | Python-библиотека pytrec_eval или ranx |
Если нет реального Elasticsearch — симулируем:
- Установить библиотеку
rank_bm25(pip install rank_bm25) и реализовать эталонный scorer на ней. - Использовать ту же коллекцию и запросы — сравнить свои баллы с эталонными (допустимое расхождение из-за параметров k1, b — фиксируем их одинаковыми).
3. Технологический стек
| Компонент | Инструменты | Назначение |
|---|---|---|
| Язык реализации | Python 3.10+ (только math, json, collections, pickle) | Написание BM25 |
| Обработка текста | re, nltk (только word_tokenize), string | Токенизация, стемминг (PorterStemmer) |
| Эталонное сравнение | Elasticsearch 8.x + elasticsearch-py или rank_bm25 | Бенчмарк |
| Метрики | pytrec_eval (или ranx) | nDCG@10, MAP, Recall@10 |
| Визуализация (опционально) | matplotlib | Графики сравнения |
4. Этапы выполнения
Этап 1: Подготовка данных и разметка (1 час)
Действия
-
Выбрать корпус
Рекомендуетсяfetch_20newsgroups(subset='train')изsklearn.datasets— первые 500 документов (или произвольная выборка). Сохранить списокdocuments(строки) иdoc_ids. -
Сформировать запросы и релевантные документы
- Для каждого запроса указать список релевантных doc_id (можно вручную: 5–10 запросов, 3–5 релевантных каждый).
- Формат:
queries.json(Query ID → текст) иqrels.json(Query ID → список doc_id). - Пример
queries.json:{ "q1": "space exploration rockets nasa", "q2": "machine learning neural networks" } - Пример
qrels.json:{ "q1": [0, 12, 45], "q2": [3, 17, 88, 99] }
-
Выполнить предобработку текста
-
Разделить данные на train/validation (если потребуется настройка k1, b).
Можно использовать 80% корпуса для построения индекса, 20% для тестирования или весь корпус.
Ожидаемый результат этапа
- Файлы
corpus.json(doc_id → токенизированный список слов),queries.json,qrels.json - Функция
preprocess(text: str) -> list[str]вutils.py
Этап 2: Построение инвертированного индекса (1.5 часа)
Действия
-
Определить структуру
Словарьinverted_index: dict[str, dict[int, int]]— токен → {doc_id: term_frequency}.
Дополнительно хранитьdoc_lengths: list[int]— длина каждого документа в словах. -
Реализовать класс
InvertedIndexclass InvertedIndex: def __init__(self): self.index: dict[str, dict[int, int]] = {} self.doc_lengths: list[int] = [] self.num_docs: int = 0 self.avg_doc_length: float = 0.0 def build(self, corpus: dict[int, list[str]]): # проход по документам, подсчёт tf и заполнение index # после построения вычислить avg_doc_length pass -
Построить индекс
- Для каждого документа увеличить
num_docs, записать его длину. - Для каждого токена добавить запись
{doc_id: tf}. Использоватьdefaultdict. - После всех документов вычислить среднюю длину.
- Для каждого документа увеличить
-
Добавить метод
doc_frequency(token) -> int— количество документов, содержащих токен (длинаself.index[token]). -
Сериализовать индекс в файл
inverted_index.pkl(pickle) для повторного использования.
Ожидаемый результат этапа
- Класс
InvertedIndexс построителем и атрибутами. - Файл
inverted_index.pkl - Тестовый вывод: количество уникальных токенов, средняя длина документа.
Этап 3: Реализация BM25 scoring (2 часа)
Действия
-
Реализовать функцию
bm25_score(query_tokens, doc_id, index, k1=1.2, b=0.75):- Формула Okapi BM25:
score(q, d) = sum_{t in q} IDF(t) * (tf(t,d) * (k1+1)) / (tf(t,d) + k1 * (1 - b + b * (|d| / avgdl))) - IDF(t) = log((N - df(t) + 0.5) / (df(t) + 0.5)) (вариант с +0.5, чтобы избежать деления на ноль)
- N — общее число документов
- df(t) — docs frequency (число документов с токеном t)
- tf(t,d) — term frequency токена t в документе d
- |d| — длина документа d
- avgdl — средняя длина документа
- Формула Okapi BM25:
-
Обойти все документы, которые содержат хотя бы один токен из запроса (используя
index.index). Если запрос – несколько токенов, пересечение не обязательно; можно брать объединение doc_id из всех токенов. -
Оптимизация
- Вычислить IDF для каждого токена заранее (словарь
idf_cache), обновлять при изменении k1, b не нужно, т.к. IDF не зависит от параметров. - Для каждого документа, содержащего токен, вычислить частичные баллы и суммировать.
- Вычислить IDF для каждого токена заранее (словарь
-
Написать метод
score_query(query_tokens, index, k1, b) -> list[(doc_id, score)]:- Создать
doc_scores = defaultdict(float) - Для каждого токена в запросе:
- Если токен не в индексе – пропустить
- Вытащить словарь
{doc_id: tf} - Для каждого
(doc_id, tf)вычислить компонент балла и добавить кdoc_scores[doc_id]
- Отсортировать по убыванию балла.
- Создать
-
Параметры по умолчанию
k1=1.2, b=0.75. Можно сделать возможность их настройки через аргументы.
Тестирование на маленьком примере (2 документа, короткий запрос) вручную:
- doc1: "the cat sat on the mat", doc2: "the dog sat on the mat", query: "cat dog"
- Рассчитать вручную, сверить с выводом функции.
Ожидаемый результат этапа
- Файл
bm25.pyс функциейbm25_scoreи методомscore_query. - Возможность запустить
bm25.score("query text") -> list[(doc_id, float)]
Этап 4: Оценка качества и сравнение с Elasticsearch (1.5 часа)
Действия
-
Подготовить эталонный ранжирование с помощью Elasticsearch:
-
Запустить свою реализацию на тех же запросах:
- Те же параметры k1, b (1.2, 0.75) – чтобы сравнение было корректным.
- Получить top-100 для каждого запроса, записать в том же формате.
-
Рассчитать метрики nDCG@10, MAP, Recall@10 с помощью
pytrec_eval:- Сначала на своих результатах, затем на эталонных.
- Убедиться, что разница не превышает 2%.
-
Проанализировать разницу
- Если есть расхождения >2%, найти причину: разная обработка стоп-слов, стемминг, параметры IDF, округление.
- Исправить (например, синхронизировать стемминг).
-
Написать отчёт в формате
- Метрики своей реализации
- Метрики эталона
- Разница в процентах
- Примеры запросов, где есть отличие (если есть)
Ожидаемый результат этапа
- Файл
retrieval_results/own_run.txtиelastic_run.txt - Файл
metrics_report.txtс цифрами
Этап 5: Оптимизация и документирование (1 час)
Действия
-
Оптимизировать скорость
- Закешировать
idfдля всех токенов при инициализации. - Вместо вычисления
doc_lengthкаждый раз, хранитьnorm_factor = k1 * (1 - b + b * doc_length / avgdl)предвычисленным для каждого документа (вычисляется один раз после построения индекса, меняется только при изменении k1, b).
- Закешировать
-
Добавить поддержку нескольких запросов сразу (батчинг).
-
Написать README.md
- Описание алгоритма
- Как запустить
- Пример использования
- Результаты сравнения с Elasticsearch
-
Оформить код
- Разделить на модули:
preprocessing.py,index.py,bm25.py,evaluate.py. - Добавить докстринги, типизацию.
- Разделить на модули:
Ожидаемый результат этапа
- Финальный репозиторий (папка проекта) с кодом, данными и отчётом.
requirements.txt(только сторонние библиотеки для сравнения: nltk, elasticsearch-py, pytrec_eval).
5. Критерии приемки (Definition of Done)
- Инвертированный индекс построен и сериализован,
avg_doc_lengthкорректен (разница с прямым расчётом <1%). - BM25 реализован без использования готовых IR-библиотек (только Python stdlib +
math). - На тестовом наборе из 2 документов и запроса результат совпадает с ручным расчётом (допустимо расхождение 1e-9 из-за float).
- Сравнение с эталоном (Elasticsearch или rank_bm25) на всем корпусе: разница nDCG@10 ≤ 0.02, MAP ≤ 0.02, Recall@10 ≤ 0.02.
- Параметры k1, b можно менять через аргументы функции (по умолчанию 1.2, 0.75).
- Код оформлен в модульной структуре, есть докстринги, README с примерами.
- Метрики собраны и представлены в текстовом файле
metrics_report.txt. - Воспроизводимость: достаточно выполнить
python main.py(или аналогичный скрипт) для получения результатов.
6. Ожидаемый результат
Основной артефакт
bm25.py— класс/функция для ранжирования по BM25.index.py— классInvertedIndex.preprocessing.py— функции нормализации текста.evaluate.py— скрипт расчёта метрик и сравнения с эталоном.metrics_report.txt— отчёт с метриками.README.md— документация.
Опционально
- График сравнения Precision–Recall.
- Тесты (pytest) на маленьких данных.
- Ноутбук Jupyter с визуализацией процесса.
7. Возможные сложности и их решение
| Сложность | Решение |
|---|---|
| Расхождение IDF с Elasticsearch из-за алгоритма сглаживания | Использовать IDF по формуле Lucene: IDF(t) = 1 + log((N - df + 0.5)/(df + 0.5)) — совпадает с BM25Okapi в rank_bm25. |
| Разный стемминг (Elasticsearch использует EnglishAnalyzer с Porter stemmer) | Установить nltk.stem.porter.PorterStemmer и применять к обоим (своему и эталону). В эталонном Elasticsearch задать analyzer: english. |
| Пробелы производительности на больших корпусах (миллионы доков) | Оптимизировать: предвычислять нормы, хранить idf; не хранить все баллы для всех документов, а только для имеющих пересечение; использовать генераторы. |
| Отсутствие релевантных разметок (qrels) | Использовать автоматическую разметку: для каждого запроса первые 10 результатов Elasticsearch считать релевантными (approximate qrels — для сравнения скора, а не абсолютного качества). |
| Несоответствие токенизации (дефисы, апострофы) | В preprocessing обрабатывать их явно: разбивать "can't" → ["can", "t"], "state-of-the-art" → ["state", "of", "the", "art"] — то же самое делает Elasticsearch. |
8. Бюджет времени (оценка)
| Этап | Время |
|---|---|
| Этап 1: Подготовка данных и разметка | 1 час |
| Этап 2: Построение инвертированного индекса | 1.5 часа |
| Этап 3: Реализация BM25 scoring | 2 часа |
| Этап 4: Оценка качества и сравнение с ES | 1.5 часа |
| Этап 5: Оптимизация и документирование | 1 час |
| Итого | 7 часов |
Примечание: Для первого раза с изучением теории BM25 может потребоваться до 10 часов. Рекомендуется выполнять в спринте 1-2 дня.
9. Связанные вопросы из базы знаний
| Вопрос | Тема |
|---|---|
| 5 | Что такое TF-IDF? |
| 9 | Формулы BM25 (Okapi BM25) |
| 14 | Инвертированный индекс — структура и построение |
| 21 | Метрики информационного поиска: nDCG, MAP, Recall |
| 33 | Разница между TF-IDF и BM25 |
| 45 | Стемминг и лемматизация в IR |
| 67 | Использование Elasticsearch для поиска |
| 102 | Параметры k1 и b в BM25, их влияние |
| 189 | Оценка качества retrieval: open-source инструменты |
| 310 | Оптимизация BM25 для реальных систем |
10. Чек-лист самопроверки
- Я реализовал инвертированный индекс с поддержкой term frequency и document frequency.
- Я реализовал BM25 в строгом соответствии с формулой Okapi BM25 (k1, b, IDF).
- Я протестировал на маленьком примере вручную и получил совпадающий балл.
- Я сравнил свои метрики с эталоном (Elasticsearch или rank_bm25) и убедился, что разница ≤2%.
- Я оформил код в модули, добавил docstring и README с инструкцией по запуску.