Практика · RAG и поиск
Как выбрать поиск для RAG: SQL, BM25, векторы или гибрид
Векторный поиск полезен, когда запрос и документ выражают одну мысль разными словами. Но номер договора, ограничение доступа и только что изменившийся статус — не семантические догадки. Проверим четыре схемы retrieval на одном корпусе и зафиксируем не только качество, но также задержку и размер переданного модели контекста.
Что именно выбираем
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])
Проверьте четыре инварианта:
- Для
Покажи POL-1042результат принадлежит tenant alpha, а не beta. - Для
E_CONN_RESETсреди первых результатов присутствуетINC-7318. - Запрос с ролью support никогда не возвращает
SEC-9001. - Актуальная инструкция
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-систем
Выбор редко сводится к одному индексу. Устойчивая последовательность выглядит так:
- Разобрать детерминированные ограничения: tenant, ACL, ID, тип, статус и период действия.
- Сформировать только разрешенный и актуальный набор кандидатов.
- Для точного ID выполнить SQL lookup и при однозначном результате не расширять поиск без необходимости.
- Для текстового запроса получить кандидатов BM25 и векторной веткой.
- Объединить ранги, удалить дубликаты и при необходимости применить reranker.
- Обрезать результат по измеренному бюджету контекста.
- Передать генератору идентификаторы источников, версии и время обновления вместе с текстом.
SQL здесь не конкурент BM25 или векторам. Он задает границы допустимого и обслуживает точные условия. Лексический и семантический поиск ранжируют содержимое внутри этих границ.
Критерий выбора
Начните с распределения запросов, а не с модного индекса:
- Если преобладают коды, номера и структурированные условия — сделайте SQL-маршрут первым.
- Если важны редкие термины, имена функций и сообщения об ошибках — используйте BM25.
- Если пользователи часто перефразируют инструкции — добавьте векторный поиск.
- Если все классы встречаются одновременно — применяйте гибрид с явными фильтрами и маршрутизацией.
Победитель — не схема с максимальным одним числом. Подход должен пройти пороги по recall и ACL, уложиться в p95 и бюджет контекста, а затем показать приемлемую стоимость эксплуатации.
Что изучить дальше
Продолжите с практическими материалами в разделе «Гайды». Определения RAG, embeddings, BM25, reranking и других терминов собраны в глоссарии.