ПараметрЗначение
BloomL4 (Анализ)
SFIAУровень 3
DreyfusCompetent
АртефактBM25 encoder + RRF fusion скрипт
ПроверкаHybrid search находит результаты, которые dense и sparse пропускают по отдельности

TL;DR

Dense-векторы находят похожее по смыслу, но теряют точные совпадения команд. BM25 находит ключевые слова, но не понимает синонимы. Гибридный поиск запускает оба параллельно и сливает результаты через RRF (Reciprocal Rank Fusion). Веса: dense 0.7, sparse 0.3. Итого ~80ms на запрос.


Проблема: dense search теряет точные совпадения

В прошлом посте мы нарезали текст на чанки и отправили в Qdrant. Каждый чанк – вектор из 1024 чисел. Поиск – это косинусная близость между вектором запроса и вектором чанка.

Проблема: для embedding-модели "настройка" и "конфигурирование" – одно и то же. Это хорошо. Но "docker compose" и "оркестрация контейнеров" – тоже почти одно и то же. Это плохо, когда вы ищете конкретную команду.

Реальный пример из моего pipeline. Запрос: "kubectl apply deployment yaml".

Dense search (только вектора):

#ScoreРезультат
10.81611.2.1 КТ3: deployment.yaml – просто имя файла
20.793Task 11.1.2: KT6 — Simplify deployment.yaml – тоже имя файла
30.774Ошибка с kustomize – нужно запускать... kubectl apply напрямую к deployment.yaml

Dense нашёл что-то “про deployment.yaml”, но первые два результата – списки файлов, а не объяснение, как работает kubectl apply. Модель видит семантику (“deployment”, “yaml”), но не различает упоминание от объяснения.

BM25 search (только ключевые слова):

#ScoreРезультат
1203.1kubectl apply -f pod.yaml / kubectl apply -f deployment.yaml – полный пример с объяснением декларативного подхода
2172.8kubectl apply напрямую к deployment.yaml
3167.0kubectl apply -n argocd -f install.yaml – реальные команды из лабы

BM25 нашёл точные совпадения с kubectl apply. Первый результат – полное объяснение перехода от императивного к декларативному подходу.

Hybrid search (оба + RRF fusion):

#RRF ScoreРезультат
10.016Ошибка с kustomize – используйте kubectl apply напрямую к deployment.yaml
20.014Task 9.1.5: YAML Manifests – декларативный подход: kubectl apply -f deployment.yaml
30.014Жизненный цикл Deployment: от kubectl apply до работающего пода (ASCII-диаграмма API Server)

Hybrid поднял наверх чанк, который содержит и ключевые слова (kubectl apply), и семантику (объяснение lifecycle). Ни dense, ни sparse по отдельности не дали такой результат в топ-3.

Dense:   "deployment.yaml" ←── семантические соседи (имена файлов)
Sparse:  "kubectl apply -f" ←── точные совпадения команд
Hybrid:  "от kubectl apply до работающего пода" ←── и команды, и смысл

Как работает BM25 для русского текста

BM25 – это формула ранжирования из 1994 года. Она считает, насколько важно каждое слово запроса для каждого документа. Основа: если слово встречается часто в одном документе, но редко в корпусе – оно важное.

Формула

score(q, d) = Σ IDF(t) × TF(t, d) × (k1 + 1) / (TF(t, d) + k1 × (1 - b + b × |d| / avg_dl))

Где:

  • IDF(t) – обратная документная частота: log((N - df + 0.5) / (df + 0.5) + 1). Слово в 2 документах из 200 000 – ценнее, чем слово в 50 000.
  • TF(t, d) – сколько раз термин t встречается в документе d.
  • k1 = 1.5 – насыщение TF. При k1=0 количество повторений не важно. При k1=2 каждое повторение ещё добавляет вес.
  • b = 0.75 – нормализация по длине. При b=1 длинные документы штрафуются сильно. При b=0 длина не важна.
  • |d| / avg_dl – отношение длины документа к средней длине в корпусе.
class SimpleBM25:
    def __init__(self, k1: float = 1.5, b: float = 0.75, use_stemming: bool = True):
        self.k1 = k1
        self.b = b
        self.use_stemming = use_stemming
        self.vocab: Dict[str, int] = {}
        self.idf: Dict[str, float] = {}
        self.avg_dl: float = 0
        self.doc_count: int = 0

    def encode(self, text: str) -> Dict[str, List]:
        """Encode text -> sparse vector for Qdrant."""
        tokens = self._tokenize(text)
        tf = Counter(tokens)
        doc_len = len(tokens)

        indices = []
        values = []

        for term, freq in tf.items():
            if term not in self.vocab:
                continue
            idx = self.vocab[term]
            idf = self.idf.get(term, 0)

            numerator = freq * (self.k1 + 1)
            denominator = freq + self.k1 * (1 - self.b + self.b * doc_len / max(self.avg_dl, 1))
            score = idf * (numerator / denominator)

            if score > 0:
                indices.append(idx)
                values.append(round(score, 4))

        return {"indices": indices, "values": values}

Результат encode() – sparse vector: массив пар (index, value). Qdrant хранит их компактно: только ненулевые элементы. Типичный запрос из 4 слов даёт вектор с 4 ненулевыми позициями из словаря в 125 000+ терминов.

Проблема #1: русская морфология

Без морфологии BM25 на русском работает значительно хуже. “Настройка”, “настройки”, “настройку”, “настроить” – для наивного BM25 это четыре разных слова. Запрос “настройка docker” не найдёт документ с “настроить docker”.

Решение: лемматизация через pymorphy.

import pymorphy3
MORPH = pymorphy3.MorphAnalyzer()

def _stem(self, word: str) -> str:
    """Привести слово к нормальной форме."""
    if re.match(r'^[а-яё]+$', word):
        parsed = MORPH.parse(word)
        if parsed:
            return parsed[0].normal_form
    return word

Результат:

настройка  → настройка
настройки  → настройка
настройку  → настройка
настроить  → настроить   # другая часть речи, но всё равно найдётся по IDF

Pymorphy работает только с кириллицей. Латиница (docker, kubectl, nginx) проходит без изменений – технические термины и так неизменяемы.

Проблема #2: стоп-слова vs защищённые токены

Стоп-слова – высокочастотные слова, бесполезные для поиска. “На”, “в”, “с”, “это”, “для” – они встречаются в каждом документе и имеют нулевой IDF.

Но в DevOps-контексте многие “обычные” слова – технические команды:

СловоОбычный языкDevOps-контекст
get“получить”kubectl get pods
set“установить”redis SET key value
run“бежать”docker run nginx
from“от, из”FROM ubuntu:22.04
if“если”if [ -f /etc/nginx.conf ]
in“в”for pod in $(kubectl get pods)
on“на”ON DELETE CASCADE
as“как”import pandas as pd

Наивный BM25 выкинет get, set, run, from как стоп-слова. А это ключевые команды.

Решение: три списка стоп-слов и один список защиты.

# Русские общие стоп-слова (НЕ из NLTK, вручную подобранные)
RUSSIAN_STOPWORDS = {
    # Предлоги
    'на', 'в', 'во', 'с', 'со', 'к', 'ко', 'о', 'об', 'по', 'за', 'из', 'от', 'до',
    'у', 'при', 'для', 'без', 'под', 'над', 'через', 'между',
    # Союзы
    'и', 'а', 'но', 'или', 'да', 'же', 'ли', 'ни', 'что', 'как', 'если', 'когда',
    'чтобы', 'потому', 'поэтому', 'так', 'тоже', 'также',
    # Местоимения
    'я', 'ты', 'он', 'она', 'оно', 'мы', 'вы', 'они', 'это', 'то',
    'мой', 'твой', 'его', 'её', 'их', 'наш', 'ваш', 'свой', 'кто', 'сам',
    # Частицы, наречия, связки
    'не', 'бы', 'вот', 'уже', 'ещё', 'только', 'очень', 'там', 'тут', 'где',
    'быть', 'есть', 'был', 'была', 'было', 'были', 'будет',
    'можно', 'нужно', 'надо', 'нельзя',
    # ...ещё ~20 слов (вводные, наречия)
}

# Стоп-слова для чатов с LLM-ассистентом
CHAT_STOPWORDS = {
    # Приветствия / вежливость
    'привет', 'здравствуйте', 'пожалуйста', 'спасибо',
    # Обращения к ассистенту
    'подскажи', 'помоги', 'объясни', 'расскажи', 'покажи', 'сделай', 'напиши', 'создай',
    # Реакции
    'отлично', 'хорошо', 'понял', 'понятно', 'готово', 'сделано',
    # Фразы-паразиты
    'давай', 'ладно', 'окей', 'типа', 'короче', 'вообще', 'просто',
}

# Английские общие стоп-слова
ENGLISH_STOPWORDS = {
    'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
    'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
    'this', 'that', 'it', 'its', 'and', 'or', 'but', 'if',
    'of', 'at', 'by', 'for', 'with', 'about', 'into', 'from',
    'no', 'not', 'only', 'so', 'than', 'too', 'very', 'just',
    # ...ещё ~30 слов
}

Три списка покрывают три источника шума: русская грамматика, болтовня с ассистентом, английские артикли/предлоги. Но без защиты они выкосят половину полезных терминов.

# ЗАЩИЩЁННЫЕ токены — НЕ удалять даже если в стоп-листе!
PROTECTED_TOKENS = {
    # Bash команды
    'cd', 'ls', 'rm', 'cp', 'mv', 'cat', 'grep', 'sed', 'awk', 'find',
    'head', 'tail', 'echo', 'touch', 'mkdir', 'chmod', 'chown',
    'curl', 'wget', 'ssh', 'scp', 'rsync',
    'ps', 'kill', 'top', 'df', 'du', 'free', 'tar', 'zip', 'unzip',
    # Docker
    'up', 'down', 'run', 'exec', 'build', 'push', 'pull',
    'stop', 'start', 'restart', 'logs', 'images', 'prune', 'compose',
    # Git
    'add', 'commit', 'push', 'pull', 'fetch', 'merge', 'rebase', 'checkout',
    'clone', 'init', 'status', 'diff', 'log', 'reset', 'stash', 'tag',
    # Kubernetes
    'get', 'set', 'apply', 'delete', 'describe', 'logs', 'exec',
    'scale', 'rollout', 'expose', 'create', 'edit', 'patch',
    # Программирование (без этого поиск по коду не работает)
    'if', 'else', 'for', 'while', 'do', 'case', 'try', 'catch',
    'return', 'break', 'continue', 'pass', 'raise',
    'import', 'from', 'as', 'with', 'in', 'on', 'not', 'is', 'or', 'and',
    'def', 'class', 'fn', 'func', 'function', 'let', 'const', 'var', 'mut',
    'true', 'false', 'null', 'none', 'nil',
    # HTTP
    'get', 'post', 'put', 'patch', 'delete', 'head', 'options',
    # API/Протоколы
    'api', 'http', 'https', 'tcp', 'udp', 'ssh', 'ftp', 'dns', 'ssl', 'tls',
    # SQL
    'select', 'insert', 'update', 'delete', 'from', 'where', 'join',
    'order', 'by', 'group', 'having', 'limit', 'offset',
    # DevOps инструменты
    'docker', 'kubernetes', 'k8s', 'helm', 'ansible', 'terraform',
    'nginx', 'redis', 'postgres', 'mysql', 'mongo', 'elastic',
    'prometheus', 'grafana', 'loki', 'vault', 'consul',
    # Языки и фреймворки
    'python', 'rust', 'go', 'java', 'node', 'npm', 'yarn', 'pip', 'cargo',
    'react', 'vue', 'django', 'flask', 'fastapi', 'axum', 'tokio',
}

# Финальный список: три источника шума МИНУС защита
ALL_STOPWORDS = (RUSSIAN_STOPWORDS | ENGLISH_STOPWORDS | CHAT_STOPWORDS) - PROTECTED_TOKENS

В моём pipeline: 232 стоп-слова, 179 защищённых токенов. Пересечение (слова, которые есть в обоих списках, но защита побеждает): get, set, from, in, on, as, if, do, for, with, not, is, or, and, no, all, by.

Демонстрация на реальном запросе:

Input: "Привет! Подскажи как настроить docker compose для production"
                ↓ токенизация + стемминг + фильтрация
Output: ['настроить', 'docker', 'compose', 'production']

Удалены:
  "Привет"    → чат-стоп-слово
  "Подскажи"  → чат-стоп-слово (обращение к ассистенту)
  "как"       → русское стоп-слово (союз)
  "для"       → русское стоп-слово (предлог)

Защищены:
  "docker"    → в PROTECTED_TOKENS (DevOps-инструмент)
  "compose"   → в PROTECTED_TOKENS (Docker-команда)

Построение словаря (fit)

BM25 требует обучения на корпусе – нужен словарь и IDF для каждого термина:

def fit(self, documents: List[str]) -> 'SimpleBM25':
    """Построить словарь и IDF по корпусу."""
    doc_freqs: Counter = Counter()
    total_len = 0

    for doc in documents:
        tokens = self._tokenize(doc)
        total_len += len(tokens)
        for token in set(tokens):  # unique per document
            doc_freqs[token] += 1

    self.doc_count = len(documents)
    self.avg_dl = total_len / self.doc_count

    # Фильтр: только термины в 2+ документах
    min_df = 2
    for term, df in sorted(doc_freqs.items()):
        if df >= min_df:
            self.vocab[term] = len(self.vocab)
            self.idf[term] = math.log(
                (self.doc_count - df + 0.5) / (df + 0.5) + 1
            )

min_df = 2 – фильтруем термины, встречающиеся только в одном документе. Это опечатки, hash-фрагменты, UUID. Они бесполезны для поиска и раздувают словарь.

Обученная модель сериализуется в JSON (~5 MB для 200K+ документов) и загружается при старте за миллисекунды.


Sparse vectors в Qdrant

Qdrant хранит два типа векторов в одной коллекции:

from qdrant_client import QdrantClient
from qdrant_client.models import (
    VectorParams, SparseVectorParams,
    Distance
)

client.create_collection(
    collection_name="hybrid_collection",
    vectors_config={
        "dense": VectorParams(
            size=1024,        # mxbai-embed-large
            distance=Distance.COSINE
        )
    },
    sparse_vectors_config={
        "sparse": SparseVectorParams()
    }
)

При загрузке каждый чанк получает оба вектора:

from qdrant_client.models import PointStruct, SparseVector

point = PointStruct(
    id=uuid4().hex,
    vector={
        "dense": embedding,    # [0.023, -0.117, ...] — 1024 float
        "sparse": SparseVector(
            indices=[42, 1337, 8080],   # номера слов в словаре
            values=[2.31, 1.87, 0.94]   # BM25-скоры
        )
    },
    payload={"text": chunk_text, "file": "guide.md", ...}
)

Dense vector: 1024 float32 = 4 KB на точку. Sparse vector: в среднем 30-50 ненулевых элементов = ~400 байт. Накладные расходы sparse – ~10% от dense.


RRF: Reciprocal Rank Fusion

У нас два ранжированных списка: dense top-50 и sparse top-50. Нужно объединить их в один.

Наивный подход (объединить по скорам напрямую) не работает. Dense score (cosine similarity) лежит в диапазоне [0, 1]. BM25 score – в диапазоне [0, ∞]. Они несопоставимы.

RRF решает это иначе: забываем про скоры, используем только позиции в ранжировании.

Формула

RRF_score(d) = Σ weight_i / (K + rank_i(d))

Для двух списков:

RRF_score(d) = 0.7 / (60 + rank_dense(d)) + 0.3 / (60 + rank_sparse(d))
  • K = 60 – сглаживающий параметр. Чем выше K, тем меньше разница между позициями. При K=2 (стандарт Qdrant) позиция 1 в 20 раз ценнее позиции 60. При K=60 только в 2 раза.
  • weight_dense = 0.7 – семантика доминирует.
  • weight_sparse = 0.3 – ключевые слова подтягивают точные совпадения.

Почему K=60, а не стандартные 2?

Стандартный RRF (K=2) слишком агрессивно штрафует позицию. Документ на 10-й позиции в dense получает score 0.7 / 12 = 0.058. На 1-й: 0.7 / 3 = 0.233. Разница в 4 раза. Это значит, что sparse-бустинг почти не может поднять документ с 10-й позиции.

При K=60: 10-я позиция = 0.7 / 70 = 0.010, 1-я = 0.7 / 61 = 0.011. Разница 10%. Sparse-бустинг реально влияет на ранжирование даже для документов не из топ-5.

Реализация

def hybrid_search(self, query: str, limit: int = 10, mode: str = "hybrid"):
    # Получаем embedding от Ollama (~50ms)
    dense_vec = get_embedding(query)
    # Получаем BM25 sparse vector (<5ms)
    sparse_vec = self.bm25.encode(query)

    # Prefetch: 5x кандидатов от каждого метода
    prefetch_limit = max(limit * 5, 50)

    # Два параллельных запроса к Qdrant
    dense_results = qdrant.query_points(
        query=dense_vec, using="dense", limit=prefetch_limit
    ).points

    sparse_results = qdrant.query_points(
        query=SparseVector(
            indices=sparse_vec["indices"],
            values=sparse_vec["values"]
        ),
        using="sparse", limit=prefetch_limit
    ).points

    # Weighted RRF fusion
    DENSE_WEIGHT = 0.7
    SPARSE_WEIGHT = 0.3
    RRF_K = 60

    scores = {}
    payloads = {}
    for rank, r in enumerate(dense_results):
        rid = str(r.id)
        scores[rid] = DENSE_WEIGHT / (RRF_K + rank + 1)
        payloads[rid] = r.payload

    for rank, r in enumerate(sparse_results):
        rid = str(r.id)
        # Документ из обоих списков получает сумму обоих скоров
        scores[rid] = scores.get(rid, 0) + SPARSE_WEIGHT / (RRF_K + rank + 1)
        if rid not in payloads:
            payloads[rid] = r.payload

    # Сортировка: лучшие — те, кто набрал скор из ОБОИХ списков
    ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:limit]

Ключевое: scores.get(rid, 0) +. Документ, найденный обоими методами, получает сумму скоров. Документ только из dense получает только dense-скор. Документ только из sparse получает только sparse-скор. Пересечение побеждает.

Fallback

Если BM25-модель не загружена или запрос состоит из одних стоп-слов (sparse vector пустой) – автоматический откат на dense-only:

if not sparse_vec or not sparse_vec.get("indices"):
    # Fallback: dense only
    results = qdrant.query_points(
        query=dense_vec, using="dense", limit=limit
    ).points

Сравнение на живых данных

Запрос: "docker compose настройка". Корпус: 235 000+ точек в Qdrant (3K чанков базы знаний из поста 3/N + рабочие сессии).

Dense (семантический):

1. [0.865] "docker-compose не найден. Попробую через docker compose (новая версия)"
2. [0.862] "Теперь обновлю docker-compose и перезапущу"
3. [0.860] "Контейнер в правильной сети. Попробую пересоздать его через docker compose up"

Все три – короткие операционные фразы. Dense видит “docker compose” как тему, но не различает упоминание от инструкции.

Sparse (BM25):

1. [143.1] Токенизация: "настроить docker compose production" — тест BM25-encoder
2. [126.3] "Удалены: Привет, Подскажи. Стемминг: настройки → настройка. Защищены: docker, compose"
3. [117.7] Сравнение search методов: "Query: docker compose настройка → DENSE vs SPARSE"

BM25 нашёл точные совпадения всех трёх слов запроса. Первый результат – документация самого BM25-encoder, где есть буквально "настроить docker compose". Точное попадание по ключевым словам.

Hybrid (RRF):

Топ-результаты содержат и семантическую релевантность (чанки про работу с docker compose), и ключевые слова (конкретные команды и настройки). Документы, попавшие в оба списка, получают бустинг.


Тайминг

ЭтапВремя
Dense embedding (Ollama, mxbai-embed-large)~50ms
BM25 encode (Python, in-memory vocab)~5ms
Qdrant dense search (1024d, cosine, 235K points)~15ms
Qdrant sparse search~10ms
RRF fusion (Python dict merge + sort)<1ms
Итого hybrid~80ms

Для сравнения: один запрос к LLM – 2-10 секунд. 80ms на поиск – копейки на фоне LLM inference.


Когда какой режим

ЗапросЛучший режимПочему
"docker compose настройка"hybridНужны и ключевые слова, и семантика
"как работает Service в Kubernetes"denseСемантический вопрос, нет конкретных команд
"kubectl get pods -n kube-system"sparseТочная команда, семантика не нужна
"проблемы с сетью контейнеров"denseАбстрактный запрос, BM25 не поможет
"nginx proxy_pass upstream"hybridТехнические термины + контекст

В моём pipeline по умолчанию всегда hybrid. Fallback на dense – только если BM25-модель не загружена.


Мини-тест

1. Вы ищете "docker run nginx". Почему naive BM25 (без protected tokens) найдёт всё, кроме того, что нужно?

Ответ

run – английское стоп-слово (“бежать”). Naive BM25 его выкинет. Останется только docker и nginx. Поиск вернёт любые чанки, где упоминаются Docker и Nginx вместе: Dockerfile, docker-compose, конфиг nginx – но не конкретно docker run. С protected tokens run сохраняется, и BM25 находит именно запуск контейнера.

2. Запрос: "как поднять сервисы в фоне". Какой режим поиска сработает лучше и почему?

Ответ

Dense. В запросе нет ни одной конкретной команды – только человеческий язык. BM25 будет искать слова “поднять”, “сервисы”, “фоне” буквально и, скорее всего, ничего релевантного не найдёт. Dense поймёт смысл и свяжет запрос с docker compose up -d, systemctl start, nohup – потому что embedding-модель знает, что “поднять в фоне” семантически близко к “запустить как демон”.

Hybrid тоже сработает (dense-часть вытянет), но sparse-часть ничего полезного не добавит.

3. Вы добавили в корпус 10 000 новых документов про Cilium, но забыли переобучить BM25. Что сломается?

Ответ

Dense search найдёт новые документы без проблем – embedding-модель знает, что такое Cilium. А BM25 – нет: слова “cilium”, “ebpf”, “hubble” отсутствуют в словаре, и sparse vector для этих терминов будет пустым. Hybrid search деградирует до чистого dense для любых запросов про Cilium. Хуже того, IDF старых терминов тоже устарел – частотности сдвинулись, но BM25 об этом не знает.

Вывод: при существенном обновлении корпуса BM25 нужно переобучать (fit на новых данных).


Полный рабочий скрипт. Требует: qdrant-client, pymorphy3 (опционально), Ollama с mxbai-embed-large.

#!/usr/bin/env python3
"""
hybrid-search-demo.py -- демонстрация гибридного поиска Dense + BM25 + RRF.

Требования:
  pip install qdrant-client requests pymorphy3
  # Ollama с mxbai-embed-large запущен на localhost:11434
  # Qdrant запущен на localhost:6333

Запуск:
  python3 hybrid-search-demo.py "docker compose настройка"
  python3 hybrid-search-demo.py "kubectl apply" --mode sparse
  python3 hybrid-search-demo.py "как работает Service" --mode dense
"""
import argparse
import math
import re
import time
import requests
from collections import Counter
from typing import Dict, List, Optional

from qdrant_client import QdrantClient
from qdrant_client.models import SparseVector

# ============================================
# BM25 ENCODER
# ============================================

try:
    import pymorphy3
    MORPH = pymorphy3.MorphAnalyzer()
except ImportError:
    try:
        import pymorphy2
        MORPH = pymorphy2.MorphAnalyzer()
    except ImportError:
        MORPH = None

RUSSIAN_STOPWORDS = {
    'на', 'в', 'во', 'с', 'со', 'к', 'ко', 'о', 'об', 'по', 'за', 'из', 'от', 'до',
    'у', 'при', 'для', 'без', 'под', 'над', 'через', 'между',
    'и', 'а', 'но', 'или', 'да', 'же', 'ли', 'ни', 'что', 'как', 'если', 'когда',
    'чтобы', 'потому', 'поэтому', 'так', 'тоже', 'также',
    'я', 'ты', 'он', 'она', 'оно', 'мы', 'вы', 'они', 'это', 'то',
    'мой', 'твой', 'его', 'её', 'их', 'наш', 'ваш', 'свой', 'кто', 'сам',
    'не', 'бы', 'вот', 'уже', 'ещё', 'только', 'очень', 'там', 'тут', 'где',
    'быть', 'есть', 'был', 'была', 'было', 'были', 'будет',
    'можно', 'нужно', 'надо', 'нельзя',
    'кстати', 'например', 'конечно', 'наверное', 'возможно',
}

CHAT_STOPWORDS = {
    'привет', 'здравствуйте', 'пожалуйста', 'спасибо',
    'подскажи', 'помоги', 'объясни', 'расскажи', 'покажи', 'сделай', 'напиши', 'создай',
    'отлично', 'хорошо', 'понял', 'понятно', 'готово', 'сделано',
    'давай', 'ладно', 'окей', 'типа', 'короче', 'вообще', 'просто',
}

ENGLISH_STOPWORDS = {
    'the', 'a', 'an', 'is', 'are', 'was', 'were', 'be', 'been',
    'have', 'has', 'had', 'do', 'does', 'did', 'will', 'would',
    'this', 'that', 'it', 'its', 'and', 'or', 'but', 'if',
    'of', 'at', 'by', 'for', 'with', 'about', 'into', 'from',
    'no', 'not', 'only', 'so', 'than', 'too', 'very', 'just',
}

PROTECTED = {
    # Bash
    'cd', 'ls', 'rm', 'cp', 'mv', 'cat', 'grep', 'sed', 'awk', 'find',
    'head', 'tail', 'echo', 'touch', 'mkdir', 'chmod', 'chown',
    'curl', 'wget', 'ssh', 'scp', 'rsync',
    'ps', 'kill', 'top', 'df', 'du', 'free', 'tar', 'zip', 'unzip',
    # Docker
    'up', 'down', 'run', 'exec', 'build', 'push', 'pull',
    'stop', 'start', 'restart', 'logs', 'images', 'prune', 'compose',
    # Git
    'add', 'commit', 'push', 'pull', 'fetch', 'merge', 'rebase', 'checkout',
    'clone', 'init', 'status', 'diff', 'log', 'reset', 'stash', 'tag',
    # Kubernetes
    'get', 'set', 'apply', 'delete', 'describe', 'logs', 'exec',
    'scale', 'rollout', 'expose', 'create', 'edit', 'patch',
    # Программирование
    'if', 'else', 'for', 'while', 'do', 'case', 'try', 'catch',
    'return', 'break', 'continue', 'pass', 'raise',
    'import', 'from', 'as', 'with', 'in', 'on', 'not', 'is', 'or', 'and',
    'def', 'class', 'fn', 'func', 'function', 'let', 'const', 'var', 'mut',
    'true', 'false', 'null', 'none', 'nil',
    # HTTP / API
    'get', 'post', 'put', 'patch', 'delete', 'head', 'options',
    'api', 'http', 'https', 'tcp', 'udp', 'ssh', 'ftp', 'dns', 'ssl', 'tls',
    # SQL
    'select', 'insert', 'update', 'delete', 'from', 'where', 'join',
    'order', 'by', 'group', 'having', 'limit', 'offset',
    # DevOps инструменты
    'docker', 'kubernetes', 'k8s', 'helm', 'ansible', 'terraform',
    'nginx', 'redis', 'postgres', 'mysql', 'mongo', 'elastic',
    'prometheus', 'grafana', 'loki', 'vault', 'consul',
    # Языки и фреймворки
    'python', 'rust', 'go', 'java', 'node', 'npm', 'yarn', 'pip', 'cargo',
    'react', 'vue', 'django', 'flask', 'fastapi', 'axum', 'tokio',
}

FINAL_STOPWORDS = (RUSSIAN_STOPWORDS | ENGLISH_STOPWORDS | CHAT_STOPWORDS) - PROTECTED


class SimpleBM25:
    def __init__(self, k1: float = 1.5, b: float = 0.75):
        self.k1 = k1
        self.b = b
        self.vocab: Dict[str, int] = {}
        self.idf: Dict[str, float] = {}
        self.avg_dl: float = 0
        self.doc_count: int = 0

    def _stem(self, word: str) -> str:
        if MORPH and re.match(r'^[а-яё]+$', word):
            parsed = MORPH.parse(word)
            if parsed:
                return parsed[0].normal_form
        return word

    def _tokenize(self, text: str) -> List[str]:
        text = text.lower()
        raw = re.findall(r'\b[a-zA-Zа-яА-ЯёЁ0-9_]{2,}\b', text)
        return [self._stem(t) for t in raw if t not in FINAL_STOPWORDS]

    def fit(self, documents: List[str]) -> 'SimpleBM25':
        doc_freqs: Counter = Counter()
        total_len = 0
        valid_docs = [d for d in documents if d]

        for doc in valid_docs:
            tokens = self._tokenize(doc)
            total_len += len(tokens)
            for token in set(tokens):
                doc_freqs[token] += 1

        self.doc_count = len(valid_docs)
        self.avg_dl = total_len / self.doc_count if self.doc_count else 1

        for term, df in sorted(doc_freqs.items()):
            if df >= 2:  # min_df: фильтр мусора
                self.vocab[term] = len(self.vocab)
                self.idf[term] = math.log(
                    (self.doc_count - df + 0.5) / (df + 0.5) + 1
                )
        return self

    def encode(self, text: str) -> Dict[str, List]:
        tokens = self._tokenize(text)
        tf = Counter(tokens)
        doc_len = len(tokens)
        indices, values = [], []

        for term, freq in tf.items():
            if term not in self.vocab:
                continue
            idx = self.vocab[term]
            idf = self.idf.get(term, 0)
            num = freq * (self.k1 + 1)
            den = freq + self.k1 * (1 - self.b + self.b * doc_len / max(self.avg_dl, 1))
            score = idf * (num / den)
            if score > 0:
                indices.append(idx)
                values.append(round(score, 4))

        if indices:
            pairs = sorted(zip(indices, values))
            indices, values = [list(x) for x in zip(*pairs)]
        return {"indices": indices, "values": values}


# ============================================
# HYBRID SEARCH
# ============================================

OLLAMA_URL = "http://localhost:11434/api/embed"
EMBED_MODEL = "mxbai-embed-large"


def get_embedding(text: str) -> Optional[List[float]]:
    """Dense embedding через Ollama."""
    try:
        resp = requests.post(OLLAMA_URL, json={
            "model": EMBED_MODEL, "input": text[:800]
        }, timeout=30)
        if resp.status_code == 200:
            return resp.json().get("embeddings", [None])[0]
    except Exception:
        pass
    return None


def hybrid_search(
    client: QdrantClient,
    bm25: SimpleBM25,
    collection: str,
    query: str,
    limit: int = 10,
    mode: str = "hybrid",
):
    """Гибридный поиск: Dense + BM25 + RRF."""
    dense_vec = get_embedding(query)
    sparse_vec = bm25.encode(query) if mode != "dense" else None

    prefetch = max(limit * 5, 50)
    DENSE_W, SPARSE_W, K = 0.7, 0.3, 60

    if mode == "dense":
        return client.query_points(
            collection_name=collection,
            query=dense_vec, using="dense",
            limit=limit, with_payload=True
        ).points

    if mode == "sparse":
        return client.query_points(
            collection_name=collection,
            query=SparseVector(
                indices=sparse_vec["indices"],
                values=sparse_vec["values"]
            ),
            using="sparse",
            limit=limit, with_payload=True
        ).points

    # Hybrid: два запроса + RRF
    dense_res = client.query_points(
        collection_name=collection,
        query=dense_vec, using="dense",
        limit=prefetch, with_payload=True
    ).points

    if sparse_vec and sparse_vec.get("indices"):
        sparse_res = client.query_points(
            collection_name=collection,
            query=SparseVector(
                indices=sparse_vec["indices"],
                values=sparse_vec["values"]
            ),
            using="sparse",
            limit=prefetch, with_payload=True
        ).points
    else:
        sparse_res = []

    # RRF fusion
    scores, payloads = {}, {}
    for rank, r in enumerate(dense_res):
        rid = str(r.id)
        scores[rid] = DENSE_W / (K + rank + 1)
        payloads[rid] = r.payload

    for rank, r in enumerate(sparse_res):
        rid = str(r.id)
        scores[rid] = scores.get(rid, 0) + SPARSE_W / (K + rank + 1)
        if rid not in payloads:
            payloads[rid] = r.payload

    ranked = sorted(scores.items(), key=lambda x: x[1], reverse=True)[:limit]

    class _Result:
        def __init__(self, score, payload):
            self.score = score
            self.payload = payload

    return [_Result(s, payloads[rid]) for rid, s in ranked]


# ============================================
# CLI
# ============================================

if __name__ == "__main__":
    parser = argparse.ArgumentParser(description="Hybrid search demo")
    parser.add_argument("query", help="Search query")
    parser.add_argument("--mode", default="hybrid",
                        choices=["dense", "sparse", "hybrid"])
    parser.add_argument("--limit", type=int, default=5)
    parser.add_argument("--collection", default="hybrid_collection")
    args = parser.parse_args()

    client = QdrantClient(host="localhost", port=6333)

    # BM25 needs to be fitted on your corpus first
    # bm25 = SimpleBM25().fit(your_documents)
    # For demo, load pre-fitted model:
    # bm25 = SimpleBM25.load("bm25_model.json")

    print(f"Query: {args.query}")
    print(f"Mode:  {args.mode}")
    print(f"---")

    t0 = time.time()
    # results = hybrid_search(client, bm25, args.collection,
    #                         args.query, args.limit, args.mode)
    elapsed = (time.time() - t0) * 1000

    # for r in results:
    #     text = r.payload.get("text", "")[:100]
    #     print(f"  [{r.score:.4f}] {text}")
    # print(f"\n{elapsed:.0f}ms")

    # Demo: show tokenization
    bm25 = SimpleBM25()
    tokens = bm25._tokenize(args.query)
    print(f"BM25 tokens: {tokens}")
    print(f"Stopwords removed: {len(FINAL_STOPWORDS)}")
    print(f"Protected tokens: {len(PROTECTED)}")

Продакшен-параметры

ПараметрЗначениеПочему
Dense modelmxbai-embed-large (1024d)Лучшее качество на русском (пост 2/N)
Sparse modelSimpleBM25 (k1=1.5, b=0.75)Стандартные BM25 параметры, проверенные на корпусе
Стеммингpymorphy3 (лемматизация)Русская морфология: “настройка/настройки/настроить” → один стем
Стоп-слова232 (рус + англ + chat)Ручная подборка, не NLTK
Защищённые токены179DevOps-команды, которые нельзя фильтровать
Словарь BM25~125 000 терминовmin_df=2, обучен на 235K+ сообщений
RRF K60Сглаживание: все 50 prefetch-кандидатов значимы
Dense weight0.7Семантика доминирует
Sparse weight0.3Ключевые слова буcтят точные совпадения
Prefetch5x от limit (min 50)Достаточно кандидатов для RRF fusion
Dense latency~50msOllama, localhost, GPU
BM25 encode~5msIn-memory vocab, Python
Qdrant search~15ms + ~10ms235K+ points, localhost
RRF fusion<1msDict merge + sort
Total~80msПренебрежимо мало vs LLM inference
Коллекция Qdrantdense (1024d, cosine) + sparseОдин upsert – два вектора
Корпус235 000+ сообщенийРабочие сессии + web

Эволюция

ВерсияМетодПроблема
v1Dense only (cosine)Теряет kubectl get pods при запросе “получить список подов”
v2Dense + BM25 + RRFНаходит и по смыслу, и по ключевым словам

Что дальше

Гибридный поиск находит релевантные фрагменты. Но 10 результатов – это ещё не ответ. Нужно:

  • RAG Pipeline 5/N: Reranking. У нас 10 результатов из hybrid search, но порядок может быть неоптимальным. Cross-encoder reranking переоценивает каждую пару (запрос, документ) и выдаёт финальный ранг.

Telegram: @DevITWay Сайт: devopsway.ru