Настроить hybrid search с весами и оптимизировать w
ТЕХНИЧЕСКОЕ ЗАДАНИЕ: Настроить hybrid search с весами и оптимизировать w
1. Цель задачи
Разработать и протестировать гибридный поиск, комбинирующий векторный косинусный поиск и ранжирование по BM25. Настроить весовой коэффициент w, такой что финальный скор вычисляется как score = w * vector_similarity + (1-w) * BM25_score. Оптимизировать w на валидационном наборе запросов так, чтобы метрика Recall@10 улучшилась минимум на 10% относительно чистого векторного поиска (w=1).
Ключевой результат Гибридный поиск с оптимальным w, демонстрирующий прирост Recall@10 ≥ 10% на тестовом наборе.
2. Исходные данные
| Что нужно | Откуда взять |
|---|---|
| Корпус документов (≥500) | Открытые датасеты: BEIR (scifact, nfcorpus), MS MARCO pass (подвыборка) или свой набор вопрос-ответ |
| Запросы с релевантными документами | Из того же датасета (train/val/test split) |
| BM25 индекс | Реализовать через rank_bm25 или elasticsearch |
| Векторный индекс | Qdrant / FAISS / Chroma + sentence-transformers (all-MiniLM-L6-v2) |
| Инфраструктура | Python 3.10+, Jupyter / VS Code / Colab |
Если нет реального датасета — симулируем:
- Скачать BEIR/scifact (200 документов, 300 запросов) pip install beir
- Разделить: 60% train, 20% val, 20% test
- Для каждого запроса подготовить ground truth (список id релевантных документов)
3. Технологический стек
| Компонент | Инструменты | Назначение |
|---|---|---|
| BM25 | rank_bm25, nltk | Классический текстовый поиск |
| Векторный поиск | sentence-transformers, numpy / faiss | Векторное представление и косинусная близость |
| Оптимизация гиперпараметра | scipy.optimize.minimize_scalar / Optuna / Grid search | Поиск оптимального w |
| Метрики | sklearn.metrics, recall@k | Оценка качества поиска |
| Датасет | BEIR (scifact) | Стандартный бенчмарк для IR |
| Dev-среда | Python, Jupyter, Git | Разработка и версионирование |
4. Этапы выполнения
Этап 1: Загрузка и подготовка данных (30 мин)
Действия
- Установить зависимости: pip install beir rank_bm25 sentence-transformers faiss-cpu numpy pandas scipy scikit-learn nltk
- Загрузить датасет BEIR scifact:
from beir import util url = "https://public.ukp.informatik.tu-darmstadt.de/thakur/BEIR/datasets/scifact.zip" data_path = util.download_and_unzip(url, "scifact") - Извлечь корпус, запросы, qrels:
from beir.datasets.data_loader import GenericDataLoader corpus, queries, qrels = GenericDataLoader(data_folder=data_path).load(split="test") - Разделить запросы на train/val/test (60/20/20) по номеру запроса:
import random qids = list(queries.keys()) random.shuffle(qids) train_qids = qids[:int(0.6*len(qids))] val_qids = qids[int(0.6*len(qids)):int(0.8*len(qids))] test_qids = qids[int(0.8*len(qids)):]
Ожидаемый результат этапа Загруженный и размеченный датасет; списки train_qids, val_qids, test_qids.
Этап 2: Реализация BM25 и векторного поиска (1 час)
Действия
- Токенизировать корпус для BM25 (использовать nltk.word_tokenize после стемминга Porter):
from nltk.stem import PorterStemmer stemmer = PorterStemmer() tokenized_corpus = [ [stemmer.stem(w.lower()) for w in word_tokenize(doc['text'])] for doc in corpus.values() ] bm25 = BM25Okapi(tokenized_corpus) - Построить векторный индекс через FAISS:
model = SentenceTransformer('all-MiniLM-L6-v2') doc_embeddings = model.encode([doc['text'] for doc in corpus.values()], show_progress_bar=True) d = doc_embeddings.shape[1] index = faiss.IndexFlatIP(d) # inner product = cosine for normalized vectors faiss.normalize_L2(doc_embeddings) index.add(doc_embeddings) - Создать функцию hybrid_search(query, w, topk=10), внутри:
- Нормализовать scores к [0,1] перед взвешиванием (min-max scaling отдельно для BM25 и vector).
Ожидаемый результат этапа Функции bm25_search, vector_search, hybrid_search. Для проверки — запустить на 5 случайных запросах с w=0.5 и вывести результаты.
Этап 3: Метрика Recall@10 и baseline (30 мин)
Действия
- Реализовать
recall_at_k(retrieved_ids, relevant_ids, k=10):def recall_at_k(retrieved, relevant, k=10): if len(relevant) == 0: return 0.0 return len(set(retrieved[:k]) & set(relevant)) / len(relevant) - Вычислить Recall@10 для чистого BM25 (w=0) и чистого vector (w=1) на валидационной выборке.
bm25_recall = measure_recall_on_set(val_qids, w=0) vector_recall = measure_recall_on_set(val_qids, w=1) print(f"BM25 Recall@10: {bm25_recall:.3f}, Vector Recall@10: {vector_recall:.3f}") - Зафиксировать baseline (среднее по val).
Ожидаемый результат этапа Базовые метрики: recall_BM25, recall_vector. Значения выведены и записаны.
Этап 4: Оптимизация веса w (1.5 часа)
Действия
- Определить функцию цели
objective(w):- Для каждого запроса из val_qids выполнить hybrid_search(w=w), усреднить Recall@10
- Просканировать сетку w ∈ [0, 1] с шагом 0.05:
w_values = np.linspace(0, 1, 21) val_recalls = [objective(w) for w in w_values] - Найти w с максимальным Recall@10.
- Уточнить оптимум с помощью scipy.optimize.minimize_scalar (метод Brent) вокруг найденного максимума.
- Построить график w → Recall@10 (matplotlib).
Ожидаемый результат этапа Оптимальное w* (с точностью 0.01). График зависимости.
Этап 5: Финальная оценка на тестовом наборе (30 мин)
Действия
- Вычислить Recall@10 на test_qids для:
- w=1 (pure vector)
- w=0 (pure BM25)
- w=w* (оптимальный гибрид)
- Убедиться, что гибрид даёт прирост ≥10% относительно pure vector.
- Сохранить результаты в таблицу и вывести.
results = {}
for name, w in [("Pure Vector", 1.0), ("Pure BM25", 0.0), ("Hybrid", w_opt)]:
results[name] = measure_recall_on_set(test_qids, w)
print(pd.Series(results))
Ожидаемый результат этапа Таблица метрик. Если прирост <10% — повторить этап 4 с более мелким шагом или использовать более сложную нормализацию.
5. Критерии приемки (Definition of Done)
- Загружен и размечен датасет BEIR/scifact (train/val/test)
- Реализован BM25 и векторный поиск с нормализацией скоров
- Функция hybrid_search принимает w и возвращает топ-k с комбинированным скором
- Вычислены baseline метрики (Recall@10 для w=0 и w=1 на val)
- Проведена оптимизация w (сетка + численная минимизация)
- Оптимальный w найден с точностью 0.01
- На тестовом наборе гибридный поиск показывает прирост Recall@10 ≥10% относительно w=1
- Построен график зависимости Recall@10 от w
- Код размещён в git-репозитории с README и воспроизводимыми инструкциями
6. Ожидаемый результат
- Файл/артефакт Python-скрипт (Jupyter notebook)
hybrid_search_optimization.ipynbсо всеми этапами. - Содержание
- Дополнительно
- Файл
config.yamlс параметрами (модель эмбеддингов, top-k, шаг сетки) - Отчёт с выводами:
optimal_w = 0.47, vector_recall=0.35, hybrid_recall=0.42 (+20%)
- Файл
7. Возможные сложности и их решение
| Сложность | Решение |
|---|---|
| BM25 индексирует весь корпус каждый раз, медленно | Предвычислить BM25 scores для всех запросов (сохранить в numpy массиве) или использовать Elasticsearch |
| Scores не в одном диапазоне | Min-max нормализация отдельно для BM25 и vector scores |
| Оптимизация «застревает» в локальном максимуме | Сначала грубая сетка, затем minimize_scalar с границами [0,1] |
| Маленькая выборка — высокая дисперсия метрик | Использовать K-fold cross-validation на train (или bootstrap на val) |
| Нет улучшения >10% | Проверить корректность normalisation, попробовать другие модели эмбеддингов (e.g., BAAI/bge-small-en-v1.5), увеличить top-k до 20 |
8. Бюджет времени (оценка)
| Этап | Время |
|---|---|
| Загрузка и подготовка данных | 30 мин |
| Реализация BM25 и векторного поиска | 1 ч |
| Метрика и baseline | 30 мин |
| Оптимизация w | 1.5 ч |
| Финальная оценка | 30 мин |
| Итого | 4 ч |
Примечание: Для первого раза рекомендуется заложить +1 час на отладку и нормализацию скоров.
9. Связанные вопросы из базы знаний
| Вопрос | Тема |
|---|---|
| 12 | BM25: формула и параметры k1, b |
| 14 | Оценка качества IR: precision, recall, MAP, NDCG |
| 23 | Sentence transformers: выбор модели и fine-tuning |
| 31 | Hybrid search: early vs late fusion |
| 42 | Взвешенная комбинация скоров в поиске |
| 55 | FAISS: IndexFlatIP vs IndexHNSW |
| 68 | Нормализация фичей в ML |
| 73 | Оптимизация гиперпараметров (grid / random / bayesian) |
| 89 | BEIR benchmark: структура, форматы данных |
| 120 | Recall@k vs Precision@k: когда что использовать |
10. Чек-лист самопроверки
- Я загрузил и корректно разделил датасет на train/val/test.
- Я токенизировал и стеммировал корпус для BM25 одинаково.
- Я нормализовал vector scores и BM25 scores перед взвешиванием.
- Я проверил hybrid_search на нескольких примерах вручную (порядок результатов выглядит разумно).
- Я построил график и вижу явный пик Recall@10 при некотором w, отличном от 0 и 1.
- Финальный гибрид превзошёл чистый векторный поиск минимум на 10% на тестовой выборке.
- Я закоммитил код, графики, результаты в Git.