Когда один метод не справляется

Представьте корпоративную базу знаний: тысячи технических статей, логи ошибок, API-документация. Пользователь пишет «ORA-00942: table or view does not exist». Векторный поиск найдёт статьи о «работе с базами данных» или «проблемах с таблицами» — потому что эмбеддинг кодирует семантику, а не конкретные символы. Нужный же документ с точным кодом ошибки окажется на 15-м месте или не попадёт в результаты вовсе.

Обратная ситуация: пользователь спрашивает «как уменьшить задержку при работе с данными». Keyword-поиск ищет точное совпадение слов — но ни в одном документе нет фразы «уменьшить задержку при работе с данными» дословно. Статьи о latency, throughput, кешировании написаны иначе. BM25 вернёт нулевой результат или нерелевантный мусор.

Только semantic search
Провал на точных строках: коды ошибок, UUID, названия функций
Провал на редких терминах и аббревиатурах вне тренировочных данных
Версии библиотек: «v2.3.1» и «v2.4.0» одинаково близко семантически
Имена собственные: «Иван Петров» vs «Иван Сидоров» — похожие векторы
Только keyword (BM25)
Провал на синонимах: «авто», «машина», «автомобиль» — разные слова
Провал на перефразировках и разных формулировках одного вопроса
Не понимает контекст: «горячий» в еде и в базах данных — одинаково
Чувствителен к опечаткам и морфологии без дополнительной обработки

Hybrid search запускает оба метода параллельно и объединяет их результаты. Документ «выигрывает» если он высоко в хотя бы одном из списков. Это покрывает слабые стороны обоих подходов.

BM25: keyword поиск изнутри

BM25 (Best Match 25) — это эволюция TF-IDF, разработанная в 1990-х годах исследователями Роберто Роберсоном и Стивеном Уолкером. Индекс «25» в названии — это просто номер итерации в серии экспериментов, не математическая константа. BM25 по-прежнему лежит в основе Elasticsearch, Solr, Lucene и большинства традиционных поисковых систем.

Инвертированный индекс

Прежде чем считать BM25, нужно построить инвертированный индекс — структуру данных, которая для каждого уникального слова хранит список документов, где оно встречается. Это противоположность обычного хранения «документ → слова».

Документы:                         Инвертированный индекс:
──────────────────────             ────────────────────────────────────
doc1: "nginx ssl сертификат"       nginx    → [doc1(1), doc3(2), doc5(1)]
doc2: "redis кеш производительность"  ssl  → [doc1(1), doc3(1)]
doc3: "nginx прокси ssl"           сертификат → [doc1(1), doc4(2)]
doc4: "сертификат tls https"       кеш      → [doc2(1), doc5(3)]
doc5: "nginx кеш redis"            redis    → [doc2(1), doc5(2)]
                                   прокси   → [doc3(1)]

При запросе «nginx ssl» система находит пересечение: документы, содержащие оба слова — это doc1 и doc3. Затем ранжирует их по BM25-скору.

Формула BM25

BM25 вычисляет релевантность документа d для запроса q как сумму вкладов каждого слова запроса:

Score(d, q) = Σ IDF(tᵢ) · [ f(tᵢ, d) · (k₁ + 1) ] / [ f(tᵢ, d) + k₁ · (1 − b + b · |d|/avgdl) ]
IDF(tᵢ) = log( (N − n(tᵢ) + 0.5) / (n(tᵢ) + 0.5) + 1 ) /* штраф за частые слова */
f(tᵢ, d) — частота термина в документе (TF) N — всего документов в коллекции n(tᵢ) — документов, содержащих термин tᵢ |d| — длина документа (в словах) avgdl — средняя длина документа k₁ ≈ 1.2–2.0 — насыщение частоты термина b ≈ 0.75 — нормализация по длине документа

Три ключевые идеи, заложенные в формулу:

  • IDF (Inverse Document Frequency) — слово «nginx», которое встречается в 10 из 10 000 документов, гораздо информативнее слова «это», которое есть везде. IDF понижает вес частых слов (стоп-слова) и повышает редких.
  • Насыщение TF — параметр k₁ ограничивает рост скора при увеличении частоты. Документ с 10 вхождениями слова не в 10 раз лучше документа с 1 вхождением — кривая насыщается. При k₁=0 частота вообще не учитывается; при k₁=∞ — TF-IDF в чистом виде.
  • Нормализация длины — параметр b контролирует, насколько длинный документ «наказывается». Длинный документ накапливает больше вхождений просто потому что он длинный, а не потому что более релевантный. При b=1 — полная нормализация, при b=0 — нет.
Почему BM25 до сих пор актуален? Несмотря на появление нейросетевых методов, BM25 остаётся конкурентоспособным baseline для текстового поиска. Он детерминирован, не требует GPU, работает за миллисекунды на миллионах документов, и отлично справляется с точным поиском специализированных терминов — там, где нейросети слабее.

BM25 как разреженный вектор

Современные системы иногда представляют BM25-результаты как sparse vectors — разреженные векторы размерностью равной словарю (50 000–500 000 слов), где большинство элементов равны нулю, а ненулевые значения — это BM25-скоры для слов документа. Это позволяет хранить BM25 в тех же векторных БД, что и dense embeddings.

"nginx ssl сертификат"  →  sparse vector (vocab_size ≈ 100 000):

индекс 0:     0.00   (слово "а")
...
индекс 8421:  0.87   (слово "nginx")
индекс 9103:  0.74   (слово "ssl")
...
индекс 47821: 0.61   (слово "сертификат")
...
остальные 99 997 позиций: 0.00

Semantic search: что умеет и чего нет

Semantic search кодирует текст в dense embedding — плотный вектор из 384–3072 чисел, где близкие по смыслу тексты оказываются рядом в пространстве. Это позволяет находить документы без точных совпадений слов.

"уменьшить задержку"  ──[Encoder]──►  [0.12, -0.87, 0.43, ...]   ─┐
"снизить latency"     ──[Encoder]──►  [0.11, -0.85, 0.41, ...]   ─┤─► cosine ≈ 0.96  ✓ близко
"оптимизировать БД"   ──[Encoder]──►  [0.08, -0.79, 0.39, ...]   ─┘─► cosine ≈ 0.88  ✓ близко

"купить акции"        ──[Encoder]──►  [-0.54, 0.23, -0.61, ...]   ──► cosine ≈ 0.12  ✗ далеко

Слабость semantic search вытекает из природы эмбеддингов: модель обучена на большом корпусе текстов и учится «смыслу» на уровне общеупотребительного языка. Точные строки — коды, хеши, версии, имена переменных — не имеют «смысла» в обычном понимании, поэтому эмбеддинг-модель их плохо разделяет.

BM25 побеждает
  • Коды ошибок: ORA-00942, ECONNREFUSED
  • Точные имена: функции, переменные, классы
  • Версии: Python 3.11, FastAPI 0.104
  • Аббревиатуры: JWT, CORS, ACID
  • UUID и хеши
Semantic побеждает
  • Синонимы: авто/машина/транспортное средство
  • Перефразировки одного вопроса
  • Межъязыковой поиск (если multilingual)
  • Концептуальные запросы без точных слов
  • Поиск похожих примеров кода по описанию
Hybrid побеждает
  • Технические запросы с терминологией
  • Корпоративные базы знаний
  • Смешанные запросы: точное + смысловое
  • Большинство реальных RAG-задач
  • Когда нет времени на A/B-тест

Архитектура hybrid search

Запрос одновременно обрабатывается двумя независимыми системами. Каждая возвращает ранжированный список документов. Затем списки объединяются в итоговый рейтинг — это и называется fusion.

100%
колёсико — масштаб  ·  зажать и тянуть — перемещение
BM25 / Keyword pipeline Semantic / Dense pipeline Запрос пользователя "как настроить nginx ssl сертификат" Инвертированный индекс токенизация → BM25 scorer Keyword Scores #1 doc_nginx_ssl 0.87 #2 doc_ssl_cert 0.74 #3 doc_tls_setup 0.61 #4 doc_proxy... 0.33 Модель эмбеддингов query → dense vector[1536] Semantic Scores (ANN) #1 doc_tls_setup 0.92 #2 doc_nginx_ssl 0.88 #3 doc_https_cert 0.79 #4 doc_ssl_cert... 0.71 Fusion Layer RRF · Weighted score · Normalize + combine

Обратите внимание: doc_nginx_ssl занимает #1 у BM25 и #2 у semantic. doc_tls_setup — #3 у BM25 и #1 у semantic. После fusion оба окажутся в топе — что логично, ведь оба релевантны запросу с разных сторон.

Reciprocal Rank Fusion (RRF)

RRF — самый простой и при этом удивительно эффективный метод объединения ранжированных списков. Предложен в 2009 году и до сих пор остаётся стандартным baseline. Идея: не доверяй абсолютным скорам (они несравнимы между методами), доверяй только позициям в списке.

1
Получить два ranked списка
BM25 возвращает top-k документов по keyword-скору. Semantic search — top-k по cosine similarity. Скоры несравнимы напрямую.
2
Считаем RRF-скор для каждого
Для каждого документа суммируем 1/(k + rank) по всем спискам, где rank — позиция в списке. Константа k=60 — стандартное значение.
3
Финальный рейтинг
Сортируем документы по убыванию RRF-скора. Документ, высокий в обоих списках, получит самый высокий суммарный скор.

Формула RRF для документа d по списку списков R:

RRF(d) = Σᵣ∈R 1 / (k + rank_r(d))
R — набор ranked списков (BM25, semantic, ...) rank_r(d) — позиция документа d в списке r (начиная с 1) k = 60 — константа сглаживания; защищает от доминирования #1-позиции

Пример расчёта для нашего запроса «nginx ssl сертификат»:

Документ BM25 rank Semantic rank RRF = 1/(60+r_bm25) + 1/(60+r_sem) Итог
doc_nginx_ssl #1 #2 1/61 + 1/62 = 0.0164 + 0.0161 0.0325
doc_tls_setup #3 #1 1/63 + 1/61 = 0.0159 + 0.0164 0.0323
doc_ssl_cert #2 #4 1/62 + 1/64 = 0.0161 + 0.0156 0.0317
doc_https_cert #3 0 + 1/63 = 0 + 0.0159 0.0159
doc_proxy #4 1/64 + 0 = 0.0156 + 0 0.0156
Зачем k=60? Если бы k=0, то документ на #1 получал бы скор 1.0, на #2 — 0.5, на #3 — 0.33. Разрыв огромный. С k=60: #1 → 0.0164, #2 → 0.0161 — разница минимальна. Это делает RRF устойчивым к «шумным» первым позициям и даёт шанс документам, которые стабильно высоко в нескольких списках, обогнать того, кто #1 только в одном.

Weighted hybrid: параметр alpha

Второй популярный подход — нормализовать скоры обоих методов к диапазону [0,1] и взвешенно сложить. Параметр alpha управляет балансом:

hybrid_score = alpha · semantic_score + (1 − alpha) · bm25_score
alpha = 0 — только BM25, semantic игнорируется alpha = 0.5 — равный вес (типичный старт) alpha = 1 — только semantic, BM25 игнорируется
Параметр alpha: от BM25 к semantic
0.0 0.25 0.5 0.75 1.0
0.0
100% BM25
коды, термины
0.25
Уклон к
keyword
0.5
Баланс
(default)
0.75
Уклон к
semantic
1.0
100% semantic
синонимы, смысл
Нормализация обязательна. Перед взвешенным сложением скоры нужно привести к одному диапазону. BM25-скоры могут быть 0–15+, cosine similarity — 0–1. Используйте min-max нормализацию по результатам запроса: norm = (score − min) / (max − min). Без этого один метод просто перебьёт другой по масштабу.

Ручная реализация на Python

Начнём с «голой» реализации без специализированных БД — это поможет понять механику до перехода к Weaviate и Qdrant.

bash
pip install rank-bm25 sentence-transformers numpy
python — реализация hybrid search + RRF
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer
import numpy as np
from typing import Optional


# ── Документы ──────────────────────────────────────────────────────────
docs = [
    "nginx ssl сертификат настройка https",
    "tls протокол шифрование сертификат letsencrypt",
    "nginx прокси обратный proxy upstream",
    "ssl сертификат openssl генерация csr",
    "redis кеш производительность latency",
    "postgresql индексы оптимизация запросов",
    "docker nginx конфигурация reverse proxy ssl",
    "certbot letsencrypt автоматическое обновление сертификата",
]

# ── Индексирование ─────────────────────────────────────────────────────
# BM25: токенизируем по пробелам (в production — использовать нормальный токенизатор)
tokenized = [doc.split() for doc in docs]
bm25 = BM25Okapi(tokenized)

# Semantic: кодируем все документы заранее
model = SentenceTransformer("all-MiniLM-L6-v2")
doc_embeddings = model.encode(docs, normalize_embeddings=True)


def bm25_search(query: str, top_k: int = 10) -> list[tuple[int, float]]:
    """Возвращает [(doc_idx, score), ...] отсортированный по убыванию."""
    scores = bm25.get_scores(query.split())
    ranked = sorted(enumerate(scores), key=lambda x: x[1], reverse=True)
    return ranked[:top_k]


def semantic_search(query: str, top_k: int = 10) -> list[tuple[int, float]]:
    """Возвращает [(doc_idx, score), ...] отсортированный по убыванию."""
    q_emb = model.encode([query], normalize_embeddings=True)[0]
    # Cosine similarity через dot product (оба нормализованы)
    scores = doc_embeddings @ q_emb
    ranked = sorted(enumerate(scores), key=lambda x: x[1], reverse=True)
    return ranked[:top_k]


def reciprocal_rank_fusion(
    *ranked_lists: list[tuple[int, float]],
    k: int = 60,
) -> list[tuple[int, float]]:
    """
    Объединяет произвольное число ranked списков через RRF.

    Args:
        *ranked_lists: каждый список — [(doc_idx, score), ...] по убыванию
        k: константа сглаживания (обычно 60)

    Returns:
        Новый ranked список [(doc_idx, rrf_score), ...]
    """
    rrf_scores: dict[int, float] = {}

    for ranked in ranked_lists:
        for rank, (doc_idx, _score) in enumerate(ranked, start=1):
            rrf_scores[doc_idx] = rrf_scores.get(doc_idx, 0.0) + 1.0 / (k + rank)

    return sorted(rrf_scores.items(), key=lambda x: x[1], reverse=True)


def weighted_hybrid(
    bm25_results: list[tuple[int, float]],
    semantic_results: list[tuple[int, float]],
    alpha: float = 0.5,
) -> list[tuple[int, float]]:
    """
    Нормализует скоры обоих методов и складывает с весом alpha.

    alpha = 0   → только BM25
    alpha = 0.5 → равный вес
    alpha = 1   → только semantic
    """
    def min_max_normalize(results: list[tuple[int, float]]) -> dict[int, float]:
        if not results:
            return {}
        scores = [s for _, s in results]
        lo, hi = min(scores), max(scores)
        if hi == lo:
            return {idx: 1.0 for idx, _ in results}
        return {idx: (s - lo) / (hi - lo) for idx, s in results}

    bm25_norm = min_max_normalize(bm25_results)
    sem_norm  = min_max_normalize(semantic_results)

    all_ids = set(bm25_norm) | set(sem_norm)
    hybrid: dict[int, float] = {}
    for doc_idx in all_ids:
        b_score = bm25_norm.get(doc_idx, 0.0)
        s_score = sem_norm.get(doc_idx, 0.0)
        hybrid[doc_idx] = alpha * s_score + (1 - alpha) * b_score

    return sorted(hybrid.items(), key=lambda x: x[1], reverse=True)


# ── Пример использования ───────────────────────────────────────────────
query = "nginx ssl сертификат"

bm25_res    = bm25_search(query, top_k=8)
semantic_res = semantic_search(query, top_k=8)

rrf_res     = reciprocal_rank_fusion(bm25_res, semantic_res)
weighted_res = weighted_hybrid(bm25_res, semantic_res, alpha=0.5)

print("=== BM25 ===")
for rank, (idx, score) in enumerate(bm25_res[:5], 1):
    print(f"  #{rank}  [{score:.3f}]  {docs[idx][:60]}")

print("\n=== Semantic ===")
for rank, (idx, score) in enumerate(semantic_res[:5], 1):
    print(f"  #{rank}  [{score:.3f}]  {docs[idx][:60]}")

print("\n=== RRF (hybrid) ===")
for rank, (idx, score) in enumerate(rrf_res[:5], 1):
    print(f"  #{rank}  [{score:.4f}]  {docs[idx][:60]}")

print("\n=== Weighted (alpha=0.5) ===")
for rank, (idx, score) in enumerate(weighted_res[:5], 1):
    print(f"  #{rank}  [{score:.3f}]  {docs[idx][:60]}")
output
=== BM25 ===
  #1  [3.241]  nginx ssl сертификат настройка https
  #2  [2.187]  ssl сертификат openssl генерация csr
  #3  [1.943]  tls протокол шифрование сертификат letsencrypt
  #4  [1.102]  docker nginx конфигурация reverse proxy ssl
  #5  [0.871]  certbot letsencrypt автоматическое обновление сертификата

=== Semantic ===
  #1  [0.921]  tls протокол шифрование сертификат letsencrypt
  #2  [0.905]  nginx ssl сертификат настройка https
  #3  [0.887]  ssl сертификат openssl генерация csr
  #4  [0.874]  certbot letsencrypt автоматическое обновление сертификата
  #5  [0.841]  docker nginx конфигурация reverse proxy ssl

=== RRF (hybrid) ===
  #1  [0.0325]  nginx ssl сертификат настройка https        ← #1 в BM25, #2 в semantic
  #2  [0.0323]  tls протокол шифрование сертификат          ← #3 в BM25, #1 в semantic
  #3  [0.0317]  ssl сертификат openssl генерация csr        ← #2 в BM25, #3 в semantic
  #4  [0.0160]  certbot letsencrypt автоматическое...
  #5  [0.0158]  docker nginx конфигурация reverse proxy ssl

=== Weighted (alpha=0.5) ===
  #1  [0.924]  nginx ssl сертификат настройка https
  #2  [0.891]  tls протокол шифрование сертификат
  #3  [0.867]  ssl сертификат openssl генерация csr

Weaviate: встроенный hybrid search

Weaviate — одна из немногих векторных БД с нативным hybrid search прямо в API. Внутри он запускает BM25 (через встроенный Lucene-индекс) и ANN-поиск параллельно, объединяет через RRF. Параметр alpha управляет балансом.

bash
pip install weaviate-client
python — Weaviate hybrid search
import weaviate
import weaviate.classes as wvc

client = weaviate.connect_to_local()  # или connect_to_weaviate_cloud(...)

# ── Создание коллекции ─────────────────────────────────────────────────
client.collections.create(
    "KnowledgeBase",
    vectorizer_config=wvc.config.Configure.Vectorizer.text2vec_openai(),
    properties=[
        wvc.config.Property(name="content", data_type=wvc.config.DataType.TEXT),
        wvc.config.Property(name="source",  data_type=wvc.config.DataType.TEXT),
        wvc.config.Property(name="section", data_type=wvc.config.DataType.TEXT),
    ],
)

kb = client.collections.get("KnowledgeBase")

# ── Загрузка документов ────────────────────────────────────────────────
with kb.batch.dynamic() as batch:
    for doc in documents:
        batch.add_object({"content": doc["text"], "source": doc["url"], "section": doc["title"]})

# ── Hybrid search ──────────────────────────────────────────────────────
response = kb.query.hybrid(
    query="nginx ssl сертификат настройка",
    alpha=0.5,          # 0 = только BM25, 1 = только semantic, 0.5 = баланс
    limit=10,
    return_metadata=wvc.query.MetadataQuery(score=True, explain_score=True),
    return_properties=["content", "source", "section"],
)

for obj in response.objects:
    print(f"Score: {obj.metadata.score:.4f}")
    print(f"  {obj.properties['content'][:100]}")
    print(f"  Explain: {obj.metadata.explain_score}")
    print()

client.close()
python — настройка BM25-индекса в Weaviate
# Weaviate позволяет настроить BM25-параметры на уровне коллекции
client.collections.create(
    "KnowledgeBase",
    vectorizer_config=wvc.config.Configure.Vectorizer.text2vec_openai(),
    inverted_index_config=wvc.config.Configure.inverted_index(
        bm25_b=0.75,          # нормализация по длине (default)
        bm25_k1=1.2,          # насыщение частоты (default)
        index_null_state=False,
        index_property_length=False,
        index_timestamps=False,
    ),
    properties=[
        wvc.config.Property(
            name="content",
            data_type=wvc.config.DataType.TEXT,
            tokenization=wvc.config.Tokenization.WORD,  # или LOWERCASE, FIELD
        ),
    ],
)

# ── Hybrid с фильтром ──────────────────────────────────────────────────
from weaviate.classes.query import Filter

response = kb.query.hybrid(
    query="ssl сертификат",
    alpha=0.7,          # уклон к semantic (для смысловых запросов)
    limit=5,
    filters=Filter.by_property("section").equal("Безопасность"),
)
explain_score в Weaviate возвращает строку вида "BM25F(content) = 1.23, vector distance = 0.08". Используйте для отладки: видно, какой метод «вытянул» документ наверх.

Qdrant: sparse + dense векторы

Qdrant реализует hybrid search через концепцию sparse vectors. Dense вектор (эмбеддинг) и sparse вектор (BM25/SPLADE) хранятся в одном документе. При поиске оба индекса опрашиваются параллельно, результаты объединяются через RRF.

Для генерации sparse-векторов Qdrant рекомендует fastembed — лёгкую библиотеку с встроенной поддержкой sparse моделей (SPLADE).

bash
pip install qdrant-client fastembed
python — Qdrant hybrid search (sparse + dense)
from qdrant_client import QdrantClient
from qdrant_client.models import (
    Distance, VectorParams, SparseVectorParams, SparseIndexParams,
    PointStruct, SparseVector, NamedSparseVector, NamedVector,
    SearchRequest, FusionQuery, Prefetch, Fusion,
)
from fastembed import TextEmbedding, SparseTextEmbedding

# ── Клиент и модели ────────────────────────────────────────────────────
client = QdrantClient(":memory:")  # или QdrantClient(url="http://localhost:6333")

dense_model  = TextEmbedding("BAAI/bge-small-en-v1.5")         # 384-мерный dense
sparse_model = SparseTextEmbedding("prithvida/Splade_PP_en_v1") # SPLADE sparse

COLLECTION = "kb_hybrid"

# ── Создание коллекции с двумя типами векторов ─────────────────────────
client.create_collection(
    collection_name=COLLECTION,
    vectors_config={
        "dense": VectorParams(size=384, distance=Distance.COSINE),
    },
    sparse_vectors_config={
        "sparse": SparseVectorParams(
            index=SparseIndexParams(on_disk=False),
        ),
    },
)

# ── Индексирование: генерируем оба вектора для каждого документа ────────
docs = [
    {"id": 1, "text": "nginx ssl сертификат настройка https"},
    {"id": 2, "text": "tls протокол шифрование letsencrypt certbot"},
    {"id": 3, "text": "redis кеш производительность latency оптимизация"},
    {"id": 4, "text": "ssl openssl генерация csr приватный ключ"},
]

texts = [d["text"] for d in docs]

# fastembed возвращает генераторы — превращаем в списки
dense_vecs  = list(dense_model.embed(texts))
sparse_vecs = list(sparse_model.embed(texts))

points = []
for i, doc in enumerate(docs):
    sp = sparse_vecs[i]
    points.append(PointStruct(
        id=doc["id"],
        payload={"text": doc["text"]},
        vector={
            "dense":  dense_vecs[i].tolist(),
            "sparse": SparseVector(indices=sp.indices.tolist(), values=sp.values.tolist()),
        },
    ))

client.upsert(collection_name=COLLECTION, points=points)


# ── Hybrid поиск через Query API с Fusion ──────────────────────────────
def hybrid_search(query: str, top_k: int = 5):
    # Кодируем запрос обоими моделями
    q_dense  = list(dense_model.embed([query]))[0].tolist()
    q_sparse = list(sparse_model.embed([query]))[0]
    q_sparse_vec = SparseVector(
        indices=q_sparse.indices.tolist(),
        values=q_sparse.values.tolist(),
    )

    results = client.query_points(
        collection_name=COLLECTION,
        prefetch=[
            # Сначала получаем top-k по каждому методу
            Prefetch(query=q_dense,       using="dense",  limit=top_k * 2),
            Prefetch(query=q_sparse_vec,  using="sparse", limit=top_k * 2),
        ],
        # Объединяем через RRF
        query=FusionQuery(fusion=Fusion.RRF),
        limit=top_k,
        with_payload=True,
    )
    return results.points


# ── Пример ────────────────────────────────────────────────────────────
for point in hybrid_search("nginx ssl сертификат"):
    print(f"id={point.id}  score={point.score:.4f}  {point.payload['text']}")
SPLADE vs BM25. Qdrant с fastembed использует SPLADE — нейросетевую модель, которая тоже генерирует sparse векторы, но умнее чистого BM25: она расширяет запрос семантически связанными терминами. Например, для «авто» SPLADE может добавить «машина», «автомобиль», «транспорт» в sparse-представление. Это гибрид между keyword и semantic подходами на уровне самого индекса.

Как выбрать alpha и стратегию

Не существует универсального значения alpha. Выбор зависит от характера данных и типичных запросов пользователей. Вот практическое руководство:

Тип данных / запросов Рекомендуемый alpha Почему
Техническая документация, код, логи 0.2 – 0.4 Точные термины, коды ошибок, имена функций важнее смысла
FAQ, база знаний по продукту 0.5 – 0.6 Баланс: и точные вопросы, и перефразировки
Юридические документы, договоры 0.3 – 0.5 Точная терминология критична, но запросы бывают смысловые
Научные статьи, research 0.6 – 0.8 Концептуальный поиск важнее точных совпадений
Новости, блоги, общий контент 0.7 – 0.9 Семантический смысл превалирует над точными словами
E-commerce, каталоги товаров 0.3 – 0.5 Артикулы, модели, бренды — точные совпадения важны

A/B-тест и автоматическая настройка

Правильный способ выбрать alpha — измерить. Нужна размеченная выборка запросов с «правильными» ответами. Оцениваем NDCG@10 или MRR при разных alpha.

python — поиск оптимального alpha
from dataclasses import dataclass
import numpy as np


@dataclass
class LabeledQuery:
    query: str
    relevant_ids: set[int]  # ids документов, которые должны быть в top-k


def mean_reciprocal_rank(results: list[tuple[int, float]], relevant_ids: set[int]) -> float:
    """MRR: обратный ранг первого релевантного документа."""
    for rank, (doc_id, _) in enumerate(results, start=1):
        if doc_id in relevant_ids:
            return 1.0 / rank
    return 0.0


def evaluate_alpha(
    labeled_queries: list[LabeledQuery],
    alpha_values: list[float],
    top_k: int = 10,
) -> dict[float, float]:
    """Для каждого alpha считаем средний MRR по размеченной выборке."""
    results = {}
    for alpha in alpha_values:
        mrr_scores = []
        for lq in labeled_queries:
            bm25_res    = bm25_search(lq.query, top_k=top_k * 2)
            semantic_res = semantic_search(lq.query, top_k=top_k * 2)
            hybrid_res   = weighted_hybrid(bm25_res, semantic_res, alpha=alpha)
            mrr = mean_reciprocal_rank(hybrid_res[:top_k], lq.relevant_ids)
            mrr_scores.append(mrr)
        results[alpha] = np.mean(mrr_scores)
    return results


# ── Пример ────────────────────────────────────────────────────────────
labeled = [
    LabeledQuery("nginx ssl настройка",          relevant_ids={0, 3, 6}),
    LabeledQuery("кеш redis производительность", relevant_ids={4}),
    LabeledQuery("tls шифрование сертификат",    relevant_ids={1, 3, 7}),
]

alphas  = [0.0, 0.25, 0.5, 0.75, 1.0]
mrr_by_alpha = evaluate_alpha(labeled, alphas)

for alpha, mrr in sorted(mrr_by_alpha.items(), key=lambda x: x[1], reverse=True):
    print(f"alpha={alpha:.2f}  MRR={mrr:.3f}")

best_alpha = max(mrr_by_alpha, key=mrr_by_alpha.get)
print(f"\nОптимальный alpha: {best_alpha}")
Частая ошибка: настраивать alpha на тренировочных запросах без отдельного validation set. Из-за переобучения вы получите alpha=0.73 для 50 запросов, которое не обобщается на реальные данные. Минимум: 80% для выбора alpha, 20% для финальной оценки. Лучше — 3-fold cross-validation.

Шпаргалка

КонцепцияКлючевое
BM25 Keyword поиск через инвертированный индекс + TF*IDF с насыщением. Точные совпадения токенов.
Semantic search Dense embeddings + ANN-поиск (cosine/dot product). Понимает синонимы, перефразировки.
Hybrid search Оба метода параллельно → fusion. Покрывает слабости каждого.
RRF score = Σ 1/(k+rank), k=60. Не требует нормализации скоров. Надёжный baseline.
Weighted alpha α·semantic + (1-α)·bm25. Требует нормализации. Даёт контроль над балансом.
Sparse vectors BM25/SPLADE как вектор размером словаря. Хранится в той же векторной БД.
Weaviate alpha query.hybrid(query=..., alpha=0.5). Встроенный RRF. Нативная поддержка.
Qdrant RRF Prefetch dense + sparse → FusionQuery(Fusion.RRF). Sparse через SPLADE/fastembed.
Выбор alpha 0.0 = keyword, 1.0 = semantic. Техдок → 0.3, FAQ → 0.5, блоги → 0.7. Измеряй через MRR.
k₁ и b (BM25) k₁=1.2, b=0.75 — defaults Elasticsearch. k₁ — насыщение TF, b — штраф за длину.

Практика

  1. Сравните методы на своих данных. Возьмите любые 20–30 текстовых документов (статьи, README, документация). Напишите 5 запросов: 2 с точными терминами, 2 смысловых, 1 смешанный. Запустите BM25, semantic и RRF, сравните топ-5 результатов. Убедитесь, что hybrid не хуже лучшего из двух.
  2. Реализуйте автоматическую настройку alpha. Разметьте 15–20 запросов с «правильными» документами. Прогоните evaluate_alpha() для alpha в диапазоне 0.0–1.0 с шагом 0.1. Постройте график MRR vs alpha. Найдите оптимум и объясните, почему он именно такой для вашего датасета.
  3. Hybrid search в Weaviate или Qdrant. Загрузите датасет в одну из БД. Реализуйте hybrid endpoint: принимает query и alpha, возвращает top-10 с explain_score. Добавьте логирование: для каждого запроса пишите использованный alpha и топ-3 документа. Сделайте alpha параметром конфигурации, не хардкодом.