Anchor Detection в RAG: параллельный поиск и один LLM вызов | AiManual
AiManual Logo Ai / Manual.
24 Июн 2026 Гайд

Anchor Detection для RAG: параллельный поиск + один LLM вызов – архитектура и код

Гайд по production-ready RAG с anchor detection: как собрать параллельные ретриверы, агрегировать результаты и выполнить один LLM-реранкинг без потери качества

Реклама
cliv1

Как 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).

Подписаться на канал