Практика · RAG и поиск

Как выбрать поиск для RAG: SQL, BM25, векторы или гибрид

Векторный поиск полезен, когда запрос и документ выражают одну мысль разными словами. Но номер договора, ограничение доступа и только что изменившийся статус — не семантические догадки. Проверим четыре схемы retrieval на одном корпусе и зафиксируем не только качество, но также задержку и размер переданного модели контекста.

Уровень: средний Чтение: до 12 минут Результат: воспроизводимый тест 4 схем
Читайте Agent Lab в TelegramПрактика AI, автоматизации и разборы новых инструментов

Что именно выбираем

RAG — это схема, в которой система сначала извлекает подходящие фрагменты, а затем передает их языковой модели вместе с запросом. Ошибка retrieval не исправляется хорошим промптом: если нужного фрагмента нет в контексте, генератору остается отказаться от ответа или предположить его.

У четырех подходов разные сильные стороны:

Схема Лучше всего работает с Типичный риск
SQL точными ID, статусами, датами, владельцами и ACL не понимает перефразирование
BM25 названиями, кодами ошибок и редкими терминами зависит от совпадения слов и токенизации
Векторы смыслом, синонимами и естественными вопросами может приблизительно сопоставить то, где нужна точность
Гибрид смешанными запросами сложнее настраивать, измерять и объяснять

Практический вывод заранее не задаем. Вместо общего вопроса «какой поиск лучше?» проверим, какой механизм лучше для каждого класса запросов и какие ограничения должны выполняться до ранжирования.

Дизайн эксперимента

Ниже используется небольшой синтетический корпус служебных документов. Это учебный пример, а не результат промышленного бенчмарка. В нем нет реальных клиентов, ключей, договоров или внутренних данных.

Каждый документ имеет текст и структурированные поля:

  • doc_id — точный идентификатор;
  • tenant — организация, которой принадлежит документ;
  • acl — роли с правом чтения;
  • updated_at — время обновления;
  • status — актуальный структурированный статус;
  • text — содержимое для полнотекстового и векторного поиска.

Набор запросов размечается вручную: для каждого запроса известны релевантные документы и их градуированная полезность. Один и тот же список кандидатов оценивается для SQL, BM25, векторов и гибрида.

Что измеряем

recall@k
Доля релевантных документов, найденных среди первых k результатов. Метрика отвечает на вопрос: «Не потеряли ли мы нужный материал?»
nDCG@k
Качество порядка с учетом разной степени релевантности. Самый полезный документ должен стоять выше частично полезного.
Задержка
Медиана и 95-й перцентиль времени retrieval. Разовый первый запуск отделяем от прогретых запусков.
Размер контекста
Количество символов и приблизительное число токенов в выбранных фрагментах. Для оперативной оценки используем ceil(characters / 4). Это только приближение: точное значение зависит от токенизатора конкретной модели.

Для ACL дополнительно нужна бинарная проверка: запрещенных документов в выдаче должно быть ровно ноль. Высокий recall не компенсирует утечку доступа.

Шаг 1. Подготовьте изолированное окружение

Команды создают локальное виртуальное окружение и устанавливают зафиксированные версии библиотек. Они не требуют прав администратора и не изменяют системный Python.

python3 -m venv .venv
. .venv/bin/activate
python -m pip install --upgrade pip
python -m pip install \
  numpy==2.1.3 \
  rank-bm25==0.2.2 \
  sentence-transformers==3.3.1

Векторная модель при первом запуске может загружаться из настроенного репозитория моделей. Для полностью автономного запуска заранее сохраните проверенный артефакт во внутреннем хранилище, зафиксируйте его контрольную сумму и передайте локальный путь через переменную MODEL_PATH. Не помещайте токены доступа в код или командную историю.

Шаг 2. Создайте корпус и разметку

Сохраните следующий файл как benchmark.py. Данные вымышлены специально для теста. В запросах представлены точный ID, терминологическое совпадение, перефразирование, свежий статус и проверка прав.

import math
import os
import re
import statistics
import time
from datetime import datetime, timezone

import numpy as np
from rank_bm25 import BM25Okapi
from sentence_transformers import SentenceTransformer

DOCS = [
    {
        "doc_id": "POL-1042", "tenant": "alpha", "acl": {"support", "admin"},
        "updated_at": "2026-06-10T09:00:00Z", "status": "active",
        "text": "Регламент возврата: заявку можно отменить до передачи заказа курьеру."
    },
    {
        "doc_id": "POL-2091", "tenant": "alpha", "acl": {"finance", "admin"},
        "updated_at": "2026-06-12T11:00:00Z", "status": "active",
        "text": "Правила компенсации комиссии при ошибочном двойном списании."
    },
    {
        "doc_id": "INC-7318", "tenant": "alpha", "acl": {"support", "admin"},
        "updated_at": "2026-06-15T14:00:00Z", "status": "resolved",
        "text": "Ошибка E_CONN_RESET возникает при разрыве соединения с платежным шлюзом."
    },
    {
        "doc_id": "OPS-3307", "tenant": "alpha", "acl": {"support", "admin"},
        "updated_at": "2026-06-18T08:00:00Z", "status": "active",
        "text": "Если доставка еще не началась, покупатель может отозвать заказ."
    },
    {
        "doc_id": "SEC-9001", "tenant": "alpha", "acl": {"security", "admin"},
        "updated_at": "2026-06-20T10:00:00Z", "status": "active",
        "text": "Инструкция по ротации служебных учетных данных."
    },
    {
        "doc_id": "POL-1042", "tenant": "beta", "acl": {"support", "admin"},
        "updated_at": "2026-06-21T09:00:00Z", "status": "active",
        "text": "Регламент организации beta: возврат оформляется после проверки склада."
    },
    {
        "doc_id": "KB-5520", "tenant": "alpha", "acl": {"support", "admin"},
        "updated_at": "2026-06-22T12:00:00Z", "status": "deprecated",
        "text": "Старый порядок отмены заказа через обращение оператору."
    },
    {
        "doc_id": "KB-5521", "tenant": "alpha", "acl": {"support", "admin"},
        "updated_at": "2026-06-27T12:00:00Z", "status": "active",
        "text": "Новый порядок: отмена доступна в карточке заказа до начала доставки."
    }
]

QUERIES = [
    {
        "name": "exact_id", "text": "Покажи POL-1042",
        "tenant": "alpha", "role": "support",
        "relevance": {"POL-1042": 3}
    },
    {
        "name": "rare_error", "text": "Что означает E_CONN_RESET?",
        "tenant": "alpha", "role": "support",
        "relevance": {"INC-7318": 3}
    },
    {
        "name": "paraphrase", "text": "Можно ли передумать до приезда курьера?",
        "tenant": "alpha", "role": "support",
        "relevance": {"OPS-3307": 3, "POL-1042": 2, "KB-5521": 1}
    },
    {
        "name": "fresh_status", "text": "Как сейчас отменить заказ?",
        "tenant": "alpha", "role": "support",
        "relevance": {"KB-5521": 3, "POL-1042": 1}
    },
    {
        "name": "acl", "text": "Как ротировать служебные учетные данные?",
        "tenant": "alpha", "role": "support",
        "relevance": {}
    }
]

TOKEN_RE = re.compile(r"[A-Za-zА-Яа-яЁё0-9_+-]+")

def tokenize(text):
    return TOKEN_RE.findall(text.lower())

def allowed(doc, query):
    return doc["tenant"] == query["tenant"] and query["role"] in doc["acl"]

def exact_ids(text):
    return set(re.findall(r"\b[A-Z]{2,5}-\d{3,8}\b", text.upper()))

def sql_rank(query):
    ids = exact_ids(query["text"])
    candidates = [d for d in DOCS if allowed(d, query)]
    if ids:
        return [d for d in candidates if d["doc_id"] in ids]
    terms = set(tokenize(query["text"]))
    scored = []
    for doc in candidates:
        score = 0
        if doc["status"].lower() in terms:
            score += 2
        if doc["doc_id"].lower() in terms:
            score += 4
        if score:
            scored.append((score, doc))
    return [d for _, d in sorted(scored, key=lambda x: x[0], reverse=True)]

def prepare_bm25():
    return BM25Okapi([tokenize(d["doc_id"] + " " + d["text"]) for d in DOCS])

def bm25_rank(query, engine):
    scores = engine.get_scores(tokenize(query["text"]))
    pairs = [(float(scores[i]), d) for i, d in enumerate(DOCS) if allowed(d, query)]
    pairs.sort(key=lambda x: x[0], reverse=True)
    return [d for score, d in pairs if score > 0]

def prepare_vectors():
    model_path = os.environ.get(
        "MODEL_PATH",
        "sentence-transformers/paraphrase-multilingual-MiniLM-L12-v2"
    )
    model = SentenceTransformer(model_path)
    texts = [d["doc_id"] + ". " + d["text"] for d in DOCS]
    matrix = model.encode(texts, normalize_embeddings=True)
    return model, np.asarray(matrix)

def vector_rank(query, model, matrix):
    q = model.encode([query["text"]], normalize_embeddings=True)[0]
    pairs = [(float(matrix[i] @ q), d) for i, d in enumerate(DOCS) if allowed(d, query)]
    pairs.sort(key=lambda x: x[0], reverse=True)
    return [d for _, d in pairs]

def rrf(*rankings, constant=60):
    scores = {}
    docs = {}
    for ranking in rankings:
        for position, doc in enumerate(ranking, start=1):
            key = (doc["tenant"], doc["doc_id"])
            docs[key] = doc
            scores[key] = scores.get(key, 0.0) + 1.0 / (constant + position)
    ordered = sorted(scores, key=scores.get, reverse=True)
    return [docs[key] for key in ordered]

def hybrid_rank(query, engine, model, matrix):
    exact = sql_rank(query)
    lexical = bm25_rank(query, engine)
    semantic = vector_rank(query, model, matrix)
    ids = exact_ids(query["text"])
    if ids and exact:
        return exact
    return rrf(lexical, semantic)

def gain(query, doc):
    return query["relevance"].get(doc["doc_id"], 0)

def recall_at_k(query, ranking, k):
    relevant = {doc_id for doc_id, grade in query["relevance"].items() if grade > 0}
    if not relevant:
        return 1.0 if not ranking[:k] else 0.0
    found = {d["doc_id"] for d in ranking[:k]}
    return len(relevant & found) / len(relevant)

def ndcg_at_k(query, ranking, k):
    grades = [gain(query, d) for d in ranking[:k]]
    dcg = sum((2 ** grade - 1) / math.log2(i + 2) for i, grade in enumerate(grades))
    ideal = sorted(query["relevance"].values(), reverse=True)[:k]
    idcg = sum((2 ** grade - 1) / math.log2(i + 2) for i, grade in enumerate(ideal))
    return dcg / idcg if idcg else (1.0 if not ranking[:k] else 0.0)

def context_size(ranking, k):
    chars = sum(len(d["text"]) for d in ranking[:k])
    return chars, math.ceil(chars / 4)

def percentile(values, p):
    ordered = sorted(values)
    index = math.ceil(p * len(ordered)) - 1
    return ordered[max(0, index)]

def benchmark(name, search, repeats=100, k=3):
    recalls, ndcgs, chars, tokens, latencies = [], [], [], [], []
    leaks = 0
    for query in QUERIES:
        ranking = search(query)
        recalls.append(recall_at_k(query, ranking, k))
        ndcgs.append(ndcg_at_k(query, ranking, k))
        size_chars, size_tokens = context_size(ranking, k)
        chars.append(size_chars)
        tokens.append(size_tokens)
        leaks += sum(not allowed(doc, query) for doc in ranking[:k])

        for _ in range(repeats):
            started = time.perf_counter_ns()
            search(query)
            latencies.append((time.perf_counter_ns() - started) / 1_000_000)

    print({
        "scheme": name,
        "recall@3": round(statistics.mean(recalls), 3),
        "nDCG@3": round(statistics.mean(ndcgs), 3),
        "latency_p50_ms": round(statistics.median(latencies), 3),
        "latency_p95_ms": round(percentile(latencies, 0.95), 3),
        "context_chars_avg": round(statistics.mean(chars)),
        "context_tokens_est_avg": round(statistics.mean(tokens)),
        "acl_leaks": leaks
    })

def main():
    np.random.seed(7)
    bm25 = prepare_bm25()
    model, matrix = prepare_vectors()

    schemes = {
        "sql": sql_rank,
        "bm25": lambda q: bm25_rank(q, bm25),
        "vector": lambda q: vector_rank(q, model, matrix),
        "hybrid": lambda q: hybrid_rank(q, bm25, model, matrix)
    }

    for search in schemes.values():
        for query in QUERIES:
            search(query)

    for name, search in schemes.items():
        benchmark(name, search)

if __name__ == "__main__":
    main()

Повторяющийся doc_id у организаций alpha и beta добавлен намеренно: он проверяет, что точный поиск не обходит tenant-фильтр. Запрос к закрытому документу SEC-9001 должен вернуть пустой результат для роли support.

Шаг 3. Запустите тест

. .venv/bin/activate
python benchmark.py

Не копируйте ожидаемые числа из чужого запуска. Задержка зависит от процессора, версии библиотек, фоновой нагрузки, модели и состояния кэша. Скрипт печатает фактически измеренные результаты вашей машины.

Для фиксации окружения сохраните версии рядом с результатом:

python --version
python -m pip freeze
python benchmark.py > results.txt

Перенаправление перезапишет только файл results.txt в текущем каталоге. Если он уже содержит нужные измерения, выберите другое имя.

Как читать результат

Каждая строка содержит метрики одной схемы. Сначала проверьте acl_leaks: допустимое значение — только 0. Затем сравните качество, задержку и объем контекста.

  • Низкий recall@3 означает, что retrieval пропускает документы из разметки.
  • При похожем recall более высокий nDCG@3 означает лучший порядок.
  • Большой контекст при том же качестве — лишняя стоимость и дополнительный шум для генератора.
  • p95 важнее среднего значения для пользовательского SLA: он показывает медленные запросы в хвосте распределения.

SQL в этом примере намеренно узок: он уверенно обслуживает точные поля, но не изображает семантический поиск. В production SQL-ветка обычно также обрабатывает диапазоны дат, типы сущностей, версии, статусы и другие детерминированные условия.

BM25 должен получить преимущество на редком токене вроде E_CONN_RESET. Векторная схема имеет шанс лучше обработать перефразирование. Гибрид объединяет ранги BM25 и векторов через Reciprocal Rank Fusion, а точный ID направляет по детерминированной ветке. Это гипотезы конструкции теста; подтверждением служит только ваш фактический вывод.

Проверка результата по отдельным запросам

Сводная метрика может скрыть ошибку в конкретном классе. Для проверки добавьте перед вызовами benchmark следующий диагностический блок:

for query in QUERIES:
    print("\nQUERY", query["name"], repr(query["text"]))
    for name, search in schemes.items():
        ranking = search(query)[:3]
        print(name, [(d["tenant"], d["doc_id"]) for d in ranking])

Проверьте четыре инварианта:

  1. Для Покажи POL-1042 результат принадлежит tenant alpha, а не beta.
  2. Для E_CONN_RESET среди первых результатов присутствует INC-7318.
  3. Запрос с ролью support никогда не возвращает SEC-9001.
  4. Актуальная инструкция KB-5521 ранжируется выше устаревшей KB-5520 после добавления политики свежести.

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

Как учесть свежесть без магии

Свежесть лучше задавать явным правилом. Если устаревшие документы нельзя использовать, исключайте их до поиска:

def allowed(doc, query):
    return (
        doc["tenant"] == query["tenant"]
        and query["role"] in doc["acl"]
        and doc["status"] != "deprecated"
    )

Если старые версии иногда полезны, не удаляйте их безусловно. Добавьте версию документа, период действия и отдельный режим исторического запроса. Временной бонус к score уместен лишь тогда, когда «новее» действительно означает «релевантнее» для предметной области.

После изменения фильтра запустите весь тест заново и сохраните результаты под новым именем. Сравнение до и после покажет, улучшили ли вы свежесть, не потеряв исторические ответы.

Типовые ошибки

Один embedding для всех задач

Идентификатор INV-00421 — не смысловая категория. Нормализуйте формат и ищите его точным оператором. Векторную ветку подключайте для текста вокруг сущности.

ACL после top-k

Такой порядок одновременно ухудшает полноту и создает риск утечки. Область разрешенных документов должна быть сформирована до вычисления окончательного top-k.

Смешивание score

Косинусная близость и BM25 имеют разные шкалы. Складывать их как есть ненадежно. RRF объединяет позиции, а не несопоставимые значения. Если используется взвешенная сумма, нормализацию и веса нужно подбирать на отдельной разметке.

Оценка только «приятных» запросов

Добавляйте точные коды, опечатки, короткие запросы, длинные вопросы, отсутствие ответа, запрещенный доступ и конфликт версий. Доля каждого класса должна отражать рабочий трафик, а не удобство демонстрации.

Измерение вместе с генерацией

Сначала измеряйте retrieval отдельно. Иначе задержка модели и вариативность ответа скроют изменение поискового слоя.

Проверка на тех же запросах, где подбирались веса

Разделяйте наборы настройки и оценки. Иначе гибрид может запомнить особенности маленького теста и не перенестись на новые формулировки.

Сравнение разного размера контекста

Схема, которой разрешили десять фрагментов, получает преимущество перед схемой с тремя. Сравнивайте одинаковый k или одинаковый бюджет токенов, а лучше публикуйте оба среза.

Ограничения этого теста

  • Корпус мал и синтетичен; числа нельзя переносить на production.
  • Разметка содержит мало запросов и служит проверкой процедуры, а не статистически надежным выводом.
  • BM25 работает в памяти и использует простую токенизацию без морфологии русского языка.
  • Векторный поиск выполняется полным перебором, а не через ANN-индекс. На большом корпусе профиль задержки будет другим.
  • Приблизительный подсчет токенов не заменяет токенизатор выбранной модели.
  • Тест не измеряет качество финального ответа, стоимость генерации и устойчивость к prompt injection в документах.
  • Модель эмбеддингов и зависимости нужно проверять по правилам безопасности вашей инфраструктуры.

Следующий практический шаг — заменить DOCS обезличенной выборкой собственного корпуса, а QUERIES — размеченными рабочими запросами. Сохраните одинаковые фильтры, k, бюджет контекста и метод измерения для всех схем.

Рабочая архитектура для большинства RAG-систем

Выбор редко сводится к одному индексу. Устойчивая последовательность выглядит так:

  1. Разобрать детерминированные ограничения: tenant, ACL, ID, тип, статус и период действия.
  2. Сформировать только разрешенный и актуальный набор кандидатов.
  3. Для точного ID выполнить SQL lookup и при однозначном результате не расширять поиск без необходимости.
  4. Для текстового запроса получить кандидатов BM25 и векторной веткой.
  5. Объединить ранги, удалить дубликаты и при необходимости применить reranker.
  6. Обрезать результат по измеренному бюджету контекста.
  7. Передать генератору идентификаторы источников, версии и время обновления вместе с текстом.

SQL здесь не конкурент BM25 или векторам. Он задает границы допустимого и обслуживает точные условия. Лексический и семантический поиск ранжируют содержимое внутри этих границ.

Критерий выбора

Начните с распределения запросов, а не с модного индекса:

  • Если преобладают коды, номера и структурированные условия — сделайте SQL-маршрут первым.
  • Если важны редкие термины, имена функций и сообщения об ошибках — используйте BM25.
  • Если пользователи часто перефразируют инструкции — добавьте векторный поиск.
  • Если все классы встречаются одновременно — применяйте гибрид с явными фильтрами и маршрутизацией.

Победитель — не схема с максимальным одним числом. Подход должен пройти пороги по recall и ACL, уложиться в p95 и бюджет контекста, а затем показать приемлемую стоимость эксплуатации.

Что изучить дальше

Продолжите с практическими материалами в разделе «Гайды». Определения RAG, embeddings, BM25, reranking и других терминов собраны в глоссарии.

Нужна такая автоматизация?
Разработаем бота, интеграцию или AI-систему под ваши задачи. От ТЗ до запуска — берём всё на себя.
Обсудить проект