Как RAG теряет документы (и почему вы этого не замечаете)
В 2026 году RAG-пайплайны перестали быть игрушкой. Их пихают в продакшн банки, медицинские системы, юридические конторы. И каждый раз одно и то же: запрос приходит, векторный поиск находит ближайшие точки в эмбеддинговом пространстве, а пользователь получает ответ, который выглядит убедительно, но упускает критически важный документ. Почему? Потому что вы ищете смысл, а не факты.
Классический RAG — это монопоиск. Один ретривер, один индекс, одна метрика расстояния. Но в реальном мире запрос пользователя — это сложная конструкция: "покажи договоры с контрагентами, которые нарушили сроки поставки в мае 2026 и имеют сумму иска выше 10 млн рублей". Векторный поиск схлопнет это в один эмбеддинг и начнет искать похожие документы по смыслу, а не по ключевым полям. Результат — релевантность < 40% на тестах enterprise. Как с этим бороться?
В предыдущей статье RAG 2026: От гибридного поиска до production — roadmap, который работает мы разбирали гибридный подход. Но там всё ещё был один этап слияния. Здесь мы пойдём дальше — введём anchor detection.
Anchor Detection: когда поиск становится перекрёстным допросом
Идея проста: вместо того чтобы один раз ударить по индексу, мы сначала разбираем запрос на "якоря" — ключевые сущности, даты, суммы, имена, отраслевые термины. Каждый якорь отправляется в свой специализированный ретривер параллельно. Keyword-детектор лезет в инвертированный индекс, Embedding-детектор — в векторную базу, Rule-based — в граф отношений. Затем агрегатор собирает кандидатов, дедуплицирует и передаёт одному LLM, который делает финальный реранкинг.
Суть: один LLM-вызов, множество источников. Раньше LLM перебирал кучу кандидатов по очереди — теперь он видит все сразу и выбирает лучших за один проход.
Зачем это? Во-первых, снижение latency: вместо 5–7 последовательных вызовов к LLM (по одному на чанк) — один. Во-вторых, качество: LLM получает полную картину и может честно сравнить документы. В-третьих, контролируемость: вы сами решаете, какие анкоры важнее. Не нравится — меняете детектор, а не весь пайплайн.
Архитектура в деталях: три типа детекторов и один судья
Разберём архитектуру снизу вверх. В основе — класс AnchorDetector, который определяет стратегию для каждого якоря.
| Тип детектора | Источник | Когда использовать |
|---|---|---|
| Keyword (BM25, Elasticsearch) | Инвертированный индекс | Даты, коды, точные названия |
| Embedding (FAISS, Qdrant) | Векторная база | Семантически похожие чанки |
| Rule-based (SPARQL, regex) | Знаниевый граф | Compliance, строгие схемы |
1 Keyword Detector — старый друг лучше новых двух
Не верьте маркетингу: keyword-поиск не умер. Для операций с точными совпадениями ("договор №1234", "от 2024-01-01") BM25 всё ещё непобедим. В 2026 году Elasticsearch 9.0 с поддержкой sparse embedding в ранжировании даёт гибридное преимущество без потери скорости. Используем asyncio для параллельного опроса всех шардов.
2 Embedding Detector — семантика, но не панацея
OpenAI text-embedding-3-large или Cohere embed-english-v3.0 для мультиязычных корпоративных баз. Здесь важно: embedding-детектор ищет не точные совпадения, а смысл. Он лучший для абстрактных запросов ("какие риски у нашего подрядчика?"). Но без keyword он слеп к сухим фактам. Баланс — настраиваемые веса при агрегации.
3 Rule-based Detector — хардкор для compliance
Для банков и ретейла, где каждый документ обязан соответствовать XML-схеме или ISO 20022. Rule-based детектор не ищет, а фильтрует: вытаскивает только те записи, которые удовлетворяют жёстким условиям.
Совмещать это с векторным — сложно. Здесь помогает ментальная модель "поиск как фильтрация", описанная в статье Почему поиск в RAG должен быть фильтрацией, а не поиском. Мы фильтруем якорем, а потом ранжируем.
Код: собираем пайплайн за 200 строк
Теперь к практике. Python 3.13, asyncio, FAISS (GPU-ускорение для batch-запросов), LLM — Claude 4 Opus для реранкинга (на 24.06.2026 он дешевле GPT-5 и точнее на enterprise-текстах). Весь код — в блоке ниже. Комментирую ключевые моменты.
import asyncio, faiss, numpy as np
from openai import AsyncOpenAI
from elasticsearch import AsyncElasticsearch
class Anchor:
def __init__(self, text, anchor_type, weight=1.0):
self.text = text
self.type = anchor_type # 'keyword', 'embedding', 'rule'
self.weight = weight
class AnchorDetector:
def __init__(self, llm_client, es_client, faiss_index, graph_db):
self.llm = llm_client
self.es = es_client
self.faiss = faiss_index
self.graph = graph_db
async def extract_anchors(self, query: str) -> list[Anchor]:
# Один LLM-вызов для разбора запроса на якоря
prompt = f"""Извлеки из запроса ключевые сущности: даты, суммы, имена, коды.
Формат JSON: [{{"text": "...", "type": "keyword|embedding|rule", "weight": 1.0}}]
Запрос: {query}"""
response = await self.llm.chat.completions.create(
model="claude-4-opus-20260624",
messages=[{"role": "user", "content": prompt}]
)
anchors_data = json.loads(response.choices[0].message.content)
return [Anchor(**a) for a in anchors_data]
async def retrieve(self, anchor: Anchor) -> list[dict]:
if anchor.type == 'keyword':
resp = await self.es.search(index='docs', body={
"query": {"match": {"text": anchor.text}},
"size": 10
})
return [r['_source'] for r in resp['hits']['hits']]
elif anchor.type == 'embedding':
emb = await self._get_embedding(anchor.text)
distances, indices = self.faiss.search(np.array([emb], dtype='float32'), k=10)
return [self._get_doc_by_id(i) for i in indices[0]]
elif anchor.type == 'rule':
return await self.graph.query(f"MATCH (d:Doc) WHERE d.{anchor.text} RETURN d")
async def aggregate(self, all_candidates: list[list[dict]], anchors: list[Anchor]) -> list[dict]:
# Дедупликация и взвешенное суммирование
seen = {}
for i, candidates in enumerate(all_candidates):
w = anchors[i].weight
for doc in candidates:
doc_id = doc['id']
seen[doc_id] = seen.get(doc_id, 0) + w
sorted_ids = sorted(seen, key=seen.get, reverse=True)[:20]
return [self._get_doc_by_id(i) for i in sorted_ids]Не советую использовать один и тот же LLM для extraction и reranking, если цена критична. Лучше выделить маленькую модель (e.g., Llama 3.2 3B) для детекции якорей, а большой — только для финального реранка.
Финальный реранкинг — один вызов к LLM с контекстом из 20 топ-кандидатов и запросом: "Отранжируй по релевантности запросу. Верни список id от лучшего к худшему". Всё. Один LLM-звонок, но каждый кандидат имеет взвешенную поддержку от детекторов. Скорость? На практике ~400 мс при 3 детекторах, 20 кандидатах и Claude 4 Opus.
Полную реализацию дедупликации и кеширования эмбеддингов я опускаю — они есть в статье LLM-Independent Adaptive RAG, где всё разобрано с прицелом на production.
Как НЕ надо: фатальные ошибки в anchor detection
Ошибка №1 — одинаковые веса для всех якорей. Дата в запросе "покажи контракты за 2026" должна весить больше, чем семантическое совпадение "контракты". Иначе keyword-детектор выдаст всё подряд, embedding — размажет, а LLM утонет в шуме. Настраивайте веса A/B-тестами на ваших данных.
Ошибка №2 — синхронный запуск детекторов. Если вы вызываете их последовательно, latency растёт линейно. Параллельный запуск (asyncio.gather) обязателен. В 2026 году это база, но многие до сих пор пишут блокирующий код.
Ошибка №3 — игнорирование дублирования. Один документ может быть найден keyword и embedding детектором. Если не агрегировать, LLM получит дубли и может занизить разнообразие. Используйте взвешенное голосование.
Ошибка №4 — отсутствие fallback. Embedding-детектор может вернуть 0 результатов, если запрос новый. Тогда пайплайн ломается. Запасной вариант — keyword-only или rule-only. Или использовать безвекторный RAG, как в статье Безвекторный RAG: 2 мс поиска, 87% точности и полная приватность, для критически важных запросов.
FAQ: ответы на вопросы, которые вы боитесь задать
Сколько анкоров оптимально?
3-5. Больше — шум, меньше — узость. Проверяйте на датасете: если точность падает при добавлении 6-го, значит он коррелирует с одним из существующих.
Что делать, если LLM реранкинга перегружает промпт?
Уменьшайте k до 10. Или используйте cascade: сначала быстрый реранкер (ко-синусная близость + weight averaging), потом LLM только для топ-5.
Нужен ли отдельный детектор для каждого типа сущностей?
Нет. Достаточно трёх стратегий: keyword, embedding, rule. Внутри каждой — больше стабильности.
Что дальше: эра self-supervised anchor detection
Anchor detection — не панацея, но шаг в сторону адаптивного ранжирования. Следующий логический этап — Self-supervised Anchor Detection: когда модель сама определяет, какие якоря работают на ваших логах, и перераспределяет веса без участия инженера. Уже есть прототипы (например, самовосстанавливающийся RAG использует похожие метрики обратной связи). Я бы на вашем месте уже начал внедрять хотя бы два детектора + один LLM. Потом донастройка весов — вопрос недели.
И да, не забудьте про мониторинг: замеряйте recall@k для каждого детектора отдельно. Если keyword просел — проверьте, не сломали ли вы анализатор. Если embedding тупит — обновите эмбеддинги. Агрегированный recall должен быть не ниже 85% на вашем тестовом сете.
Попробуйте Qdrant Cloud для быстрой векторной базы с гибридным поиском. А для реранкинга — OpenAI API (или Claude).