Настроить 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

Если нет реального датасета — симулируем:

  1. Скачать BEIR/scifact (200 документов, 300 запросов) pip install beir
  2. Разделить: 60% train, 20% val, 20% test
  3. Для каждого запроса подготовить ground truth (список id релевантных документов)

3. Технологический стек

КомпонентИнструментыНазначение
BM25rank_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 мин)

Действия

  1. Установить зависимости: pip install beir rank_bm25 sentence-transformers faiss-cpu numpy pandas scipy scikit-learn nltk
  2. Загрузить датасет 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")
    
  3. Извлечь корпус, запросы, qrels:
    from beir.datasets.data_loader import GenericDataLoader
    corpus, queries, qrels = GenericDataLoader(data_folder=data_path).load(split="test")
    
  4. Разделить запросы на 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 час)

Действия

  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)
    
  2. Построить векторный индекс через 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)
    
  3. Создать функцию hybrid_search(query, w, topk=10), внутри:
    • Получить BM25 scores для всех документов (или топ-100 через bm25.get_top_n)
    • Получить топ-100 по косинусному сходству
    • Объединить: для каждого документа в union(top100_bm25, top100_vector) вычислить w * vector_score + (1-w) * bm25_score
    • Возвращать topk по финальному скору
  4. Нормализовать scores к [0,1] перед взвешиванием (min-max scaling отдельно для BM25 и vector).

Ожидаемый результат этапа Функции bm25_search, vector_search, hybrid_search. Для проверки — запустить на 5 случайных запросах с w=0.5 и вывести результаты.


Этап 3: Метрика Recall@10 и baseline (30 мин)

Действия

  1. Реализовать 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)
    
  2. Вычислить 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}")
    
  3. Зафиксировать baseline (среднее по val).

Ожидаемый результат этапа Базовые метрики: recall_BM25, recall_vector. Значения выведены и записаны.


Этап 4: Оптимизация веса w (1.5 часа)

Действия

  1. Определить функцию цели objective(w):
    • Для каждого запроса из val_qids выполнить hybrid_search(w=w), усреднить Recall@10
  2. Просканировать сетку w ∈ [0, 1] с шагом 0.05:
    w_values = np.linspace(0, 1, 21)
    val_recalls = [objective(w) for w in w_values]
    
  3. Найти w с максимальным Recall@10.
  4. Уточнить оптимум с помощью scipy.optimize.minimize_scalar (метод Brent) вокруг найденного максимума.
  5. Построить график w → Recall@10 (matplotlib).

Ожидаемый результат этапа Оптимальное w* (с точностью 0.01). График зависимости.


Этап 5: Финальная оценка на тестовом наборе (30 мин)

Действия

  1. Вычислить Recall@10 на test_qids для:
    • w=1 (pure vector)
    • w=0 (pure BM25)
    • w=w* (оптимальный гибрид)
  2. Убедиться, что гибрид даёт прирост ≥10% относительно pure vector.
  3. Сохранить результаты в таблицу и вывести.
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 со всеми этапами.
  • Содержание
    • Загрузка и подготовка данных
    • Реализация BM25, vector, hybrid search
    • Оценка baseline
    • Оптимизация w
    • Финальная таблица и график
  • Дополнительно
    • Файл 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 ч
Метрика и baseline30 мин
Оптимизация w1.5 ч
Финальная оценка30 мин
Итого4 ч

Примечание: Для первого раза рекомендуется заложить +1 час на отладку и нормализацию скоров.


9. Связанные вопросы из базы знаний

ВопросТема
12BM25: формула и параметры k1, b
14Оценка качества IR: precision, recall, MAP, NDCG
23Sentence transformers: выбор модели и fine-tuning
31Hybrid search: early vs late fusion
42Взвешенная комбинация скоров в поиске
55FAISS: IndexFlatIP vs IndexHNSW
68Нормализация фичей в ML
73Оптимизация гиперпараметров (grid / random / bayesian)
89BEIR benchmark: структура, форматы данных
120Recall@k vs Precision@k: когда что использовать

10. Чек-лист самопроверки

  • Я загрузил и корректно разделил датасет на train/val/test.
  • Я токенизировал и стеммировал корпус для BM25 одинаково.
  • Я нормализовал vector scores и BM25 scores перед взвешиванием.
  • Я проверил hybrid_search на нескольких примерах вручную (порядок результатов выглядит разумно).
  • Я построил график и вижу явный пик Recall@10 при некотором w, отличном от 0 и 1.
  • Финальный гибрид превзошёл чистый векторный поиск минимум на 10% на тестовой выборке.
  • Я закоммитил код, графики, результаты в Git.