Hybrid search в RAG: когда BM25 и embeddings должны работать вместе
Два retrieval signal дополняют друг друга, если policy и evidence contract остаются едиными
BM25 для exact match, embeddings для paraphrase, trusted filters и проверяемый fusion
Авторский HYBRID-8 contract с failure modes и production acceptance gate
hybrid search RAG, BM25 и embeddings, архитектура RAG, lexical search, semantic search, RRF и HYBRID-8

$ retrieve hybrid --contract HYBRID-8
> scope: tenant / role / lifecycle / locale
> search: bm25 / embeddings / trusted corpus
> fuse: dedupe / ranks / diversity
> verify: source / locator / evidence / no-answer
> route: answer / review / fail-closedHybrid search — не галочка, которая автоматически делает RAG-систему «умнее». Это retrieval-дизайн, где лексический и семантический поиск дают кандидатов на один вопрос, а затем контролируемый pipeline решает, какое evidence вообще может попасть к модели. Задача не в том, чтобы заставить два ранжировщика соревноваться. Задача — закрыть разные типы retrieval-ошибок, не потеряв доступы, происхождение источника и безопасный сценарий no-answer.
BM25 полезен, когда значение несёт сама формулировка: артикул, номер ошибки, пункт договора, название релиза, акроним, редкая фраза или точная цитата. Embeddings полезны, когда вопрос сформулирован иначе, чем источник: клиент описывает результат, а инструкция — процедуру; сотрудник спрашивает разговорным языком, а policy написана формально. Ни один сигнал сам по себе не доказывает, что passage актуален, разрешён или достаточен для ответа.
За широким продуктовым контекстом стоит идти на страницу RAG-систем. Этот разбор намеренно уже: как совместить lexical и semantic retrieval так, чтобы итоговый ответ не стал непрозрачной смесью score.
Проблема: один запрос, разные формы evidence
Пользователь поддержки может спросить: «Почему счёт 7F отклонён после approval?» Лексический индекс найдёт документ с 7F, а vector index — troubleshooting-заметку о состоянии «approval прошёл, но validation не прошла». При одном методе система рискует потерять важный путь. Если же просто склеить два списка, в context попадут дубликаты, старые или неразрешённые passages.
Поэтому требование — не «найти больше», а получить допустимое evidence, достаточно разное для ответа, с видимым источником и ограничениями. До выбора fusion weights команде стоит зафиксировать:
- классы запросов, которым нужны exact match, semantic match или оба сигнала;
- trusted identity attributes для tenant, role, product, locale и scope;
- правила lifecycle для current, draft, archived и revoked документов;
- locator и version для каждого candidate;
- явный результат, когда evidence отсутствует, конфликтует или недоступно.
То же относится к бизнес-задачам. Hybrid search имеет смысл, только когда есть owner процесса, допустимый corpus и понятная цена неверного ответа. Технический аудит можно начать с хаба кейсов или project brief; не с обещания, что качество поиска «решит» одна модель.
Production-архитектура: два retrieval lane, один evidence contract
Ниже — минимальный контракт HYBRID-8. У lane могут быть разные движки, но policy boundary должен быть единым.
- Нормализовать запрос. Сохранить query text, trusted caller identity, locale, product scope, request ID и версию retrieval configuration. Не выводить права из текста вопроса.
- Применить deterministic eligibility filters. Tenant, audience, lifecycle и document type сужают corpus до запуска обоих ranking path. Фильтр после retrieval — ненадёжная граница приватности.
- Запустить lexical lane. Токенизировать и анализировать вопрос по тем же language-aware правилам, что и corpus. BM25 или другой lexical ranker отдаёт candidates и объяснимый exact-match signal.
- Запустить semantic lane. Закодировать нормализованный query версионированной embedding-моделью и найти semantic neighbors только в уже допустимом corpus.
- Слить candidates по stable identity. Убрать дубликаты по document version и passage locator, сохранить ranks обоих lane и не считать raw score разных движков напрямую сопоставимыми.
- Rerank или verify. Второй ranker может сравнить ограниченный набор candidates, но не должен обходить filters, source status или требования к citations.
- Собрать bounded context. Держать passage, source title, locator и version вместе. Если context не подтверждает планируемое утверждение, ответить ограничением, запросить уточнение или направить на review.
Такой дизайн разводит три часто смешиваемых решения: relevance, eligibility и answerability. Relevance — ranking signal. Eligibility — policy decision. Answerability — evidence decision. Раздельность делает сбои диагностируемыми.
Fusion: почему normalised rank безопаснее арифметики raw score
BM25 score и vector similarity обычно живут в разных шкалах и распределениях. Их прямое сложение даёт число с видом точности, но без устойчивого смысла между типами запросов, размерами corpus и версиями модели. Безопасный старт — rank-based fusion, например reciprocal rank fusion (RRF): он сочетает позиции, а не притворяется, будто score измеряют одно и то же.
eligible = filter(corpus, trustedScope, lifecycle === "current")
lexical = bm25.search(query, eligible, topK = 40)
semantic = vector.search(embed(query), eligible, topK = 40)
candidates = dedupeBy(lexical + semantic, "passageVersionId")
fused = reciprocalRankFusion(candidates, ["lexicalRank", "semanticRank"])
verified = rerank(fused.slice(0, 20), query)
if (!hasCitableEvidence(verified)) return noAnswerOrReview()
return buildContext(verified.slice(0, 6))RRF не универсальное решение, а видимый baseline. Weighted fusion оправдан, когда evaluation показывает повторяемый класс запросов, где один lane должен влиять сильнее. Weights нужно версионировать, проверять на held-out queries и пересматривать при изменении corpus или embedding model. Ручной boost любимого документа не заменяет evaluation rule.
Ключевые компоненты и их контракты
Lexical index
Lexical lane требует language-aware analysis, fields для точных идентификаторов и путь обновления источника. Для многоязычного corpus analyzer выбирается сознательно: универсальная tokenization может по-разному обработать армянские, русские и английские слова и identifiers. Индексируйте поля, которые люди действительно ищут, но не отдавайте модели приватные metadata только потому, что они полезны ranker.
Vector index
Semantic lane требует versioned embeddings, lineage source-to-chunk и тот же trusted scope filter, что lexical lane. Re-embedding после обновления источника недостаточно: старый passage должен стать ineligible или быть удалён проверяемым способом. Контракт scope подробно разобран в материале Метаданные и фильтры в RAG.
Candidate registry
Каждый fused candidate должен иметь stable passage ID, source ID, source version, locator, lifecycle state, evidence permissions и ranks обоих lane. Такой registry помогает оператору объяснить выбор passage и позволяет отличить retrieval-failure от generation-failure во время evaluation.
Reranker и answer gate
Reranking улучшает порядок только после того, как candidate set признан trusted. Это не слой авторизации. Финальный gate проверяет, что selected passages current, permitted, не противоречат друг другу и достаточны для планируемого ответа. При нарушении условий система не должна придумывать связку между слабыми источниками.
Важен и выбор storage. Hybrid pipeline требует lexical index, vector index или vector-capable store и update strategy, который держит document versions обоих lane синхронными. Этот boundary раскрыт в материале Как выбрать vector database для RAG: решение зависит от workload, filters, lifecycle, evaluation и operations, а не от универсального vendor ranking. Hybrid retrieval не отменяет этот выбор — он делает рассинхрон source state заметнее.
Failure modes, которые нужно проверить до rollout
Потерян точный identifier. Semantic query находит общий материал, но пропускает ERR-7F. Добавьте identifier-heavy queries в evaluation set и отдельно смотрите lexical lane.
Потерян semantic paraphrase. Lexical query возвращает только буквальные совпадения, когда source использует другой business term. Добавьте paraphrases от support, sales и subject-matter experts.
В fusion побеждает stale duplicate. Старый passage имеет сильные terms и высокий vector score. Enforce lifecycle eligibility до retrieval и дедуплицируйте по source version, а не по display title.
Filters применены слишком поздно. Candidate исключён из final response, но успел попасть к reranker или модели. Применяйте mandatory filters в query construction и тестируйте denied access по audit trace.
Score drift принимают за улучшение. Новая embedding model меняет distribution score, старый numeric threshold незаметно ломается. Версионируйте retrieval configuration, делайте evaluation до promotion и храните rollback evidence.
Слишком много near-duplicates съедают context. Оба lane находят варианты одного paragraph. Diversify по source и section, оставляйте evidence для ответа, а не просто максимальный top-k.
Конфликтующие sources сжаты в один ответ. Система получила два current documents с разными правилами. Сохраните conflict, назовите owner или направьте на human review: fusion score не определяет policy truth.
Целевой production test plan
Начните с labeled set реальных, но безопасно sampled questions. В каждом кейсе должны быть trusted caller scope, ожидаемые allowed sources, forbidden sources, требуемое evidence и допустимое no-answer behavior. Измеряйте не один aggregate score:
- lexical recall для identifiers, имён и дословных формулировок;
- semantic recall для paraphrases и concept matches;
- eligibility precision: ни один denied, archived или wrong-tenant passage не попадает в context;
- citation coverage: claims ответа привязаны к видимым locator и version;
- diversity и duplication в final context;
- latency и failure behavior каждого retrieval lane;
- regression после изменения analyzers, embeddings, fusion или ingestion.
Негативные тесты нужны намеренно: expired policy, документ другого tenant, переименованный product, удалённый source, конфликтующее обновление и вопрос без evidence. Для каждого теста зафиксируйте наблюдаемое в logs, не сохраняя лишний пользовательский контент.
Degraded mode и эксплуатация
Production-системе нужно решение и для partial failure. Lexical index может быть недоступен, пока vector retrieval работает; embedding provider может timeout-иться, пока exact search остаётся доступен; ingestion lag может оставить один lane позади source of record. Ни одно из этих состояний не должно молча выдавать ту же уверенность, что healthy hybrid path.
Определите degraded modes до запуска. Для low-risk public help center допустим проверенный lexical-only или semantic-only response, если интерфейс показывает ограничение и citations остаются корректны. Для role-scoped internal assistant или high-impact operation безопаснее fail closed, отдать ссылку на поиск по источникам или создать review task. Это решение владельца бизнес-риска, а не универсального retry loop.
Operational telemetry должна отделять доступность lane от качества evidence. Полезны request volume по query classes, candidate counts до и после filtering, overlap lane, stale-source rejections, reranker latency, citation coverage, no-answer rate и policy denials. Резкий рост semantic-only results сам по себе не плох, но это повод проверить corpus changes, analyzers и embedding version. В logs остаются только request и configuration identifiers, нужные для diagnosis, с учётом правил хранения данных продукта.
Index updates требуют reconciliation, а не только successful jobs. Когда source создан, изменён, перемещён или удалён, подтвердите, что records обоих lane указывают на ожидаемую version, а предыдущая version больше не возвращается. Небольшая reconciliation sample после каждого ingestion release полезнее неограниченного заявления, что index «fresh». Назначьте owner этой проверки, rollback procedure и решения приостановить hybrid answers, когда source state нельзя считать trusted.
Начинайте с evaluation matrix, а не с подбора score
До tuning weights разметьте первый набор вопросов по необходимому сигналу. Exact identifiers, структурированные names, policy sections и quoted phrases проверяют lexical lane. Paraphrases, symptom descriptions и широкие concept questions проверяют semantic lane. Mixed questions проверяют fusion. В каждом test case должен быть expected passage или source set, а не только впечатление, что ответ модели звучит правдоподобно.
Сравните четыре режима на одинаковых кейсах: lexical only, semantic only, baseline fusion и proposed tuned configuration. Разберите примеры, где меняется winner. Если fusion улучшает общий metric, но поднимает forbidden source или опускает обязательный exact identifier за context cutoff, production contract не выполнен. Здесь особенно полезен human reviewer: он заметит вводящий в заблуждение ответ, который всё ещё выглядит fluent и relevant.
Держите matrix достаточно компактной, чтобы запускать её после изменения source pipeline, analyzer, embedding model, fusion rule или reranker. Цель не создать leaderboard, а сделать release decision повторяемым и сохранить evidence, нужное для отмены решения.
Production-check перед включением hybrid answers
У готового hybrid path есть named owner для ingestion, index updates, access policy, evaluation data и incident response. Не нужен один «универсальный» quality number, но нужна согласованная acceptance boundary:
require(request.trustedScope && request.configVersion)
require(candidate.sourceVersion && candidate.locator)
require(candidate.lifecycle === "current")
require(policy.allows(candidate, request.trustedScope))
require(testSet.exact && testSet.paraphrase && testSet.denied && testSet.noAnswer)
if (!answer.evidenceIsSufficient) route = "no-answer-or-review"
if (lane.failed && !degradedMode.wasEvaluated) route = "fail-closed"Hybrid search ценен, когда расширяет coverage, не ослабляя evidence contract. Начните с малого representative corpus, сравните lexical, semantic и fused results, разберите failure modes и только потом расширяйте rollout — когда команда может объяснить, что именно изменилось. Для архитектурного ревью или ограниченного retrieval-аудита используйте страницу RAG-систем как продуктовую точку входа.
require(request.trustedScope && request.configVersion);
require(candidate.sourceVersion && candidate.locator);
require(candidate.lifecycle === "current");
require(policy.allows(candidate, request.trustedScope));
require(testSet.exact && testSet.paraphrase && testSet.denied && testSet.noAnswer);
if (!answer.evidenceIsSufficient) route = "no-answer-or-review";
if (lane.failed && !degradedMode.wasEvaluated) route = "fail-closed";