ИИ-агенты и prompt engineering Продвинутый RAG: graph RAG, агентный поиск, контекстное сжатие, кэширование
0%

Продвинутый RAG: graph RAG, агентный поиск, контекстное сжатие, кэширование

Продвинутый RAG: graph RAG, агентный поиск, контекстное сжатие, кэширование

В базовой статье про RAG мы собрали работающий пайплайн: чанкинг → эмбеддинги → гибридный поиск → реранжирование. Он закрывает большую часть запросов «найди факт в документе». Эта статья — про оставшийся хвост, который на реальных данных оказывается непропорционально дорогим: запросы, где нужного ответа нет ни в одном отдельном чанке, где ответ надо собрать из десяти документов, где чанк без окружения теряет смысл, и где всё это ещё должно укладываться в бюджет по деньгам и латентности.

Где именно ломается наивный RAG

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

Тип запроса Пример Почему top-k поиск не справляется Что помогает
Фактоид «Какой таймаут по умолчанию у ретрая?» Справляется. Не усложняйте. Базовый гибридный поиск
Потерянный контекст «Сколько выросла выручка?» — в чанке написано «выросла на 3 %», но не сказано, чья и когда Чанк вырван из документа; эмбеддинг не содержит того, о чём чанк Contextual retrieval, late chunking
Многошаговый (multi-hop) «Кто руководит командой, которая владеет сервисом биллинга?» Ни один чанк не содержит цепочку целиком; сходство с запросом низкое у обоих звеньев Агентный поиск, graph RAG
Агрегирующий / глобальный «Какие главные темы в 4000 тикетов поддержки?» Ответа нет ни в каком k чанков — он про весь корпус Graph RAG (global search), RAPTOR
Сравнительный «Чем политика возвратов в EU отличается от US?» Нужны ровно две области корпуса, top-k заваливается одной из них Декомпозиция запроса, поиск по подзапросам
Отрицание / отсутствие «Есть ли в договоре пункт про форс-мажор?» Поиск всегда что-то возвращает; «нет» доказать сложнее, чем «да» Агентный цикл с оценкой релевантности (CRAG)
Терминологический разрыв «почему тормозит» ↔ в документах «p99 latency degradation» Лексика запроса и корпуса не пересекается HyDE, расширение запроса, BM25-половина гибрида

Ключевая мысль всей статьи: продвинутый RAG — это не «более умный поиск», а перераспределение работы между индексацией, поиском и генерацией. Вы можете заплатить один раз на индексации (graph RAG, contextual retrieval), либо платить на каждом запросе (агентный поиск), либо экономить на генерации (сжатие, кэш). Ниже — по одному разделу на каждый из четырёх рычагов.


1. Contextual retrieval: самый дешёвый большой выигрыш

Проблема в одну строку

Чанк «Выручка выросла на 3 % относительно предыдущего квартала» после разрезания документа не содержит ни названия компании, ни квартала. Ни эмбеддинг, ни BM25 не свяжут его с запросом «рост выручки ACME в Q2 2025».

Решение

Перед индексацией прогнать каждый чанк через дешёвую модель вместе с документом-родителем и попросить сгенерировать 1–3 предложения контекста. Этот контекст приписывается к чанку и уходит и в эмбеддинг, и в BM25-индекс. Идея опубликована Anthropic в сентябре 2024 (Contextual Retrieval) с замерами:

Конфигурация Доля неудачных извлечений (top-20) Относительное улучшение
Embeddings + BM25 (базовый гибрид) 5.7 %
Contextual Embeddings 3.7 % −35 %
Contextual Embeddings + Contextual BM25 2.9 % −49 %
То же + реранкер, top-20 1.9 % −67 %

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

Код

import anthropic

client = anthropic.Anthropic()

CONTEXT_PROMPT = """Вот чанк, который нужно разместить внутри документа выше.
<chunk>
{chunk}
</chunk>
Дай короткий (1–3 предложения) контекст: о чём этот чанк, к какой сущности,
периоду и разделу документа он относится. Отвечай только контекстом,
без вступлений и без пересказа самого чанка."""


def contextualize(doc_text: str, chunks: list[str]) -> list[str]:
    """Обогащает чанки контекстом. Документ кэшируется — платим за него один раз."""
    out = []
    for chunk in chunks:
        resp = client.messages.create(
            model="claude-haiku-4-5",
            max_tokens=200,
            system=[
                {
                    "type": "text",
                    "text": f"<document>\n{doc_text}\n</document>",
                    # Документ одинаков для всех чанков → кэш-хит начиная со второго
                    "cache_control": {"type": "ephemeral"},
                }
            ],
            messages=[{"role": "user", "content": CONTEXT_PROMPT.format(chunk=chunk)}],
        )
        ctx = next(b.text for b in resp.content if b.type == "text")
        # Обогащённый текст идёт И в эмбеддинг, И в BM25
        out.append(f"{ctx.strip()}\n\n{chunk}")
    return out

Про стоимость — и почему без кэша это не взлетает

Наивно вы отправляете весь документ на каждый чанк. Документ на 50 000 токенов, разрезанный на 60 чанков, — это 3 млн входных токенов на один документ. С prompt caching картина другая: первая запись стоит 1.25× базовой цены входа, последующие чтения — 0.1×.

Считаем на claude-haiku-4-5 (1 $ за 1M входных токенов, 5 $ за 1M выходных), документ 50k токенов, 60 чанков:

  • без кэша: 60 × 50 000 × 1 $/1M = 3.00 $ на документ;
  • с кэшем: 1 × 50 000 × 1.25 × 1 $/1M + 59 × 50 000 × 0.1 × 1 $/1M = 0.0625 $ + 0.295 $ = 0.36 $;
  • плюс выход: 60 × ~80 токенов × 5 $/1M ≈ $0.024.

Разница — 8×. Anthropic называет цифру порядка 1.02 $ на миллион токенов документов при использовании кэша — сходится с расчётом выше.

Три подводных камня:

  1. TTL кэша — 5 минут по умолчанию (есть вариант на 1 час, но запись стоит 2× вместо 1.25×). Обрабатывайте чанки одного документа подряд, без перемешивания документов в очереди — иначе кэш протухает и вы платите полную цену.
  2. Минимальный кэшируемый префикс зависит от модели: 4096 токенов для Opus/Haiku 4.5, 2048 для Sonnet-класса. Документ короче — кэш молча не создастся, usage.cache_read_input_tokens останется нулём. Всегда логируйте это поле; это единственный способ узнать, что кэш работает.
  3. Не более 4 точек кэширования на запрос, и кэш — это префиксное совпадение по байтам. Любой datetime.now() в системном промпте убивает весь кэш ниже по тексту.

Альтернатива без LLM: late chunking

Если генерировать контекст дорого или запрещено (закрытый контур), есть трюк: прогнать весь документ через long-context эмбеддер, а пулинг делать по границам чанков уже поверх контекстуализированных токенных векторов (Günther et al., «Late Chunking», arXiv:2409.04701). Каждый чанк получает эмбеддинг, «знающий» о соседях, ценой одного прохода эмбеддера. Выигрыш скромнее, чем у contextual retrieval, но стоимость близка к нулю и нет риска галлюцинации в контексте.


2. Graph RAG: когда ответ размазан по корпусу

Интуиция

Векторный поиск отвечает на «где в корпусе написано про X». Он принципиально не может ответить на «какие вообще темы есть в корпусе» или «через кого связаны X и Y» — потому что таких предложений в корпусе нет. Graph RAG строит промежуточное представление: граф сущностей и отношений, извлечённый LLM из текста, плюс иерархию кластеров-сообществ с готовыми саммари.

Каноническая работа — Edge et al., «From Local to Global: A Graph RAG Approach to Query-Focused Summarization», arXiv:2404.16130, реализация — microsoft/graphrag.

Как строится индекс

Кластеризация — алгоритм Лейдена (Traag et al., arXiv:1810.08473), улучшенная версия Лувена, гарантирующая связность сообществ. Он даёт иерархию: сообщества уровня 0 крупные, уровня 2–3 — узкие. Саммари генерируются снизу вверх, поэтому у вас есть готовый «конспект корпуса» на нескольких масштабах.

Локальный и глобальный поиск по графу знаний

Два режима поиска

Local search — вход по сущности (нашли её вектором по описанию или через NER на запросе), дальше расширение на 1–2 хопа, собираем описания узлов, текст рёбер и исходные чанки-источники. Стоит примерно как обычный RAG. Именно этот режим закрывает multi-hop.

Global search — map-reduce: каждый саммари сообщества обрабатывается отдельным вызовом LLM, который извлекает частичные ответы с оценкой полезности, затем всё сводится в один финальный вызов. В статье этот режим даёт win rate 72–83 % против наивного RAG по метрикам полноты (comprehensiveness) и разнообразия (diversity) при LLM-as-judge оценке на корпусах из подкастов и новостей.

Честный разговор про стоимость

Это самая дорогая техника в статье, и её чаще всего внедряют зря.

Параметр Векторный индекс GraphRAG (полный) LazyGraphRAG
Стоимость индексации 1M токенов ~0.02 $ (эмбеддинги) 20 $–100+ (LLM-экстракция + саммари) ~0.1 % от полного GraphRAG
Время индексации 10k документов минуты часы–сутки минуты
Инкрементальное обновление тривиально болезненно (пересчёт сообществ) проще
Global-запросы не поддерживает сильная сторона поддерживает, ленивая генерация
Multi-hop слабо сильно средне

Microsoft сама признала проблему и выпустила LazyGraphRAG: граф строится статистическими методами (без LLM-экстракции), а саммари генерируются лениво, в момент запроса, по релевантным сообществам. Стоимость индексации падает до ~0.1 % от полного GraphRAG при сопоставимом качестве на global-запросах.

Практическое правило. Не начинайте с GraphRAG. Начинайте с гибридного поиска + contextual retrieval. Переходите к графу, только когда в логах видно долю global/multi-hop-запросов выше ~15 % и вы уже измерили, что базовый пайплайн на них проваливается. Для многих команд разумный компромисс — RAPTOR: рекурсивная кластеризация чанков и суммаризация в дерево, без извлечения сущностей. Даёт +20 % абсолютных на QuALITY при доле стоимости GraphRAG и заметно проще в эксплуатации.

Минимальный local search руками

Граф не обязан быть в Neo4j. Для корпуса до сотен тысяч сущностей хватает Postgres + networkx:

import networkx as nx

def local_search_context(
    G: nx.Graph, seeds: list[str], hops: int = 1, max_nodes: int = 40
) -> str:
    """Собирает текстовый контекст вокруг стартовых сущностей.

    Сложность: O(V + E) в худшем случае, на практике O(d^hops),
    где d — средняя степень узла. При hops=2 и d=50 это уже 2500 узлов —
    отсюда обязательный max_nodes и отсечение по весу ребра.
    """
    seen, frontier, parts = set(seeds), set(seeds), []
    for _ in range(hops):
        nxt = set()
        for node in frontier:
            # Соседи по убыванию веса связи: сначала самые сильные отношения
            nbrs = sorted(
                G[node].items(), key=lambda kv: kv[1].get("weight", 1.0), reverse=True
            )
            for nbr, edge in nbrs[:10]:
                if nbr in seen or len(seen) >= max_nodes:
                    continue
                seen.add(nbr)
                nxt.add(nbr)
                parts.append(f"({node}) —[{edge['relation']}]→ ({nbr})")
        frontier = nxt

    for node in seen:
        parts.append(f"{node}: {G.nodes[node]['description']}")
    return "\n".join(parts)

Две ловушки, которые съедят вам неделю:

  • Разрешение сущностей. «PostgreSQL», «Postgres», «постгрес» и «pg» — один узел или четыре? Без нормализации граф рассыпается на изолированные компоненты, и local search перестаёт работать. Минимум — приведение к нижнему регистру + словарь синонимов + слияние по косинусной близости эмбеддингов имён с порогом ~0.93.
  • Взрыв степени узла. Сущность вроде «компания» или «пользователь» окажется связана с половиной графа. Введите отсечку: узлы со степенью выше 95-го перцентиля не расширяются.

3. Агентный поиск: цикл вместо одного выстрела

Наивный RAG делает ровно один поиск и надеется на лучшее. Агентный поиск превращает извлечение в управляемый цикл: оценить запрос → искать → оценить найденное → уточнить/переспросить → ответить или признать, что данных нет.

Три опорные работы

Self-RAG (Asai et al., arXiv:2310.11511) — модель дообучена генерировать «токены рефлексии»: нужен ли вообще поиск (Retrieve), релевантен ли фрагмент (ISREL), подтверждается ли утверждение фрагментом (ISSUP), полезен ли ответ (ISUSE). Ключевое наблюдение: поиск нужен не всегда, и модель, умеющая это решать, обгоняет и «всегда искать», и «никогда не искать».

CRAG (Yan et al., arXiv:2401.15884) — лёгкий оценщик релевантности (T5-класс, ~0.77B) классифицирует результат поиска в correct / incorrect / ambiguous. При correct работает decompose-then-recompose: документ режется на полоски, нерелевантные выбрасываются. При incorrect — фолбэк на веб-поиск. При ambiguous — оба пути. Важно: оценщик дешёвый и не является LLM-судьёй общего назначения, поэтому цикл не разоряет.

Query decomposition — сравнительные и multi-hop-запросы разбиваются на подзапросы, каждый ищется отдельно, результаты объединяются. Простейший и при этом самый выгодный по соотношению «польза/сложность» приём из троицы.

Жизненный цикл агентного поиска

Переход Отказ обязателен. Без жёсткого лимита итераций (2–3, максимум 4) агентный цикл в проде однажды уйдёт в 40 вызовов на одном хитром запросе и принесёт вам счёт и таймаут одновременно.

Как это выглядит на проводе

Обратите внимание на распределение моделей: декомпозиция и финальная генерация — на сильной модели (claude-opus-4-8), оценка релевантности — на дешёвой (claude-haiku-4-5) батчами. Оценщик вызывается 2–5 раз за запрос; если посадить его на топовую модель, он один съест больше, чем вся генерация.

Каркас цикла

from dataclasses import dataclass

@dataclass
class SearchBudget:
    max_iterations: int = 3
    max_llm_calls: int = 8
    max_wall_seconds: float = 12.0


def agentic_retrieve(query: str, budget: SearchBudget) -> list[Chunk]:
    """Возвращает контекст либо пустой список, если данных нет.

    Инвариант: функция ВСЕГДА завершается за budget — исчерпание бюджета
    считается нормальным исходом и приводит к честному «не нашёл»,
    а не к ответу на плохом контексте.
    """
    subqueries = decompose(query)          # 1 вызов LLM
    pool: dict[str, Chunk] = {}
    started = time.monotonic()

    for iteration in range(budget.max_iterations):
        candidates = []
        for sq in subqueries:
            candidates += hybrid_search(sq, k=50)   # BM25 + dense + RRF

        ranked = rerank(query, dedupe(candidates))[:20]
        verdict = grade_relevance(query, ranked)    # дешёвая модель, батч

        for c in ranked:
            if c.rerank_score >= 0.5:
                pool[c.id] = c

        if verdict.is_sufficient or time.monotonic() - started > budget.max_wall_seconds:
            break

        # Не повторяем тот же запрос — иначе цикл детерминированно зациклится
        subqueries = refine(query, subqueries, verdict.gaps)

    return list(pool.values())

Три правила, без которых цикл вредит:

  1. Никогда не повторяйте тот же подзапрос. Поиск детерминирован — вы получите те же документы и потратите итерацию впустую. Каждая итерация обязана менять запрос, фильтр или источник.
  2. Пул накапливается, а не заменяется. Иначе хороший результат первой итерации теряется на второй.
  3. Отказ — валидный ответ. На внутренней базе знаний доля «нет данных» в норме 5–15 %. Если у вас 0 %, значит модель галлюцинирует на нерелевантном контексте.

Слияние результатов: RRF

При нескольких подзапросах и нескольких индексах вам нужен способ объединить ранжированные списки без калибровки скоров. Reciprocal Rank Fusion (Cormack et al., SIGIR 2009) делает это одной строкой и на практике почти не проигрывает обученному слиянию:

from collections import defaultdict

def rrf(rankings: list[list[str]], k: int = 60) -> list[str]:
    """Сливает несколько ранжированных списков id.

    k=60 — эмпирическая константа из оригинальной статьи; она гасит вклад
    хвоста и делает результат устойчивым к разной длине списков.
    Сложность: O(sum(len(r))) по времени, O(уникальных id) по памяти.
    """
    scores = defaultdict(float)
    for ranking in rankings:
        for rank, doc_id in enumerate(ranking, start=1):
            scores[doc_id] += 1.0 / (k + rank)
    return sorted(scores, key=scores.get, reverse=True)

RRF работает именно потому, что игнорирует абсолютные значения скоров: косинус от dense-модели и BM25-скор несопоставимы по шкале, а ранги — сопоставимы.


4. Контекстное сжатие: не всё найденное стоит показывать

Соблазн «у нас же 1M контекста, зальём всё» разбивается о три факта:

  • Позиционная деградация. Модели хуже используют информацию в середине длинного контекста (Liu et al., «Lost in the Middle», arXiv:2307.03172). 200k токенов мусора вокруг нужного абзаца снижают точность.
  • Цена. 200k входных токенов на claude-opus-4-8 — это 1.00 $ за один запрос. При 100k запросов в месяц это 100 $ 000.
  • Латентность. Prefill линеен по длине входа. 200k токенов — это секунды до первого токена ответа.

Поэтому между поиском и генерацией ставят каскад фильтров: дешёвые впереди, дорогие позади.

Каскад сжатия контекста

Уровень 1: реранкер (обязателен)

Cross-encoder читает пару (запрос, документ) вместе и выдаёт скор — в отличие от bi-encoder, который кодирует их независимо. Точнее на порядок, но и медленнее: скорить можно только сотни кандидатов, не миллионы. Отсюда стандартная схема: bi-encoder отбирает 100–200, cross-encoder ранжирует до 10–20.

Рабочие варианты: BGE-reranker-v2-m3 (открытый, мультиязычный, тянет русский, ~600M параметров — влезает на любой GPU), Cohere Rerank (API, без своей инфраструктуры), ColBERT с late interaction (компромисс между скоростью bi-encoder и точностью cross-encoder).

Уровень 2: прунинг предложений

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

  • Provence (ICLR 2025) — обучен одновременно ранжировать и прунить, работает как одна модель на месте реранкера; практически бесплатный прунинг поверх того, что вы и так делаете.
  • RECOMP — экстрактивный и абстрактивный компрессоры; в статье контекст сжимается до ~6 % от исходного размера с сохранением качества ответа.
  • LongLLMLingua — сжатие на уровне токенов по перплексии, обусловленной запросом; заявляет +17.1 % на multi-doc QA при 4× меньшем числе токенов. Наследник — LLMLingua-2, быстрее и лучше переносится между доменами.

Предупреждение: token-level сжатие ломает точные строки. Если из контекста должны цитироваться SKU, номера версий, суммы и идентификаторы — прунинг предложениями, не токенами.

Уровень 3: дедупликация

Реальные корпуса полны почти-дублей: черновик и финал политики, три версии одного README. Топ-10 легко оказывается пятью копиями одного текста. Лечится MMR (maximal marginal relevance) или простым порогом:

def dedupe_mmr(chunks, embeddings, lambda_: float = 0.7, top_n: int = 10):
    """Жадный MMR: баланс релевантности и новизны.

    Сложность O(n * top_n) по скалярным произведениям — при n=200 и top_n=10
    это 2000 операций, доли миллисекунды. Экономия токенов кратно больше.
    """
    selected, remaining = [], list(range(len(chunks)))
    while remaining and len(selected) < top_n:
        best, best_score = None, -1e9
        for i in remaining:
            redundancy = max(
                (cosine(embeddings[i], embeddings[j]) for j in selected), default=0.0
            )
            score = lambda_ * chunks[i].score - (1 - lambda_) * redundancy
            if score > best_score:
                best, best_score = i, score
        selected.append(best)
        remaining.remove(best)
    return [chunks[i] for i in selected]

Что мерить

Главная ошибка — оценивать сжатие по конечному ответу. Меряйте recall@k после каждого этапа каскада: если после реранкера золотой чанк исчез, никакая генерация его не вернёт. Дашборд минимум: recall@200 (после отбора) → recall@20 (после реранка) → recall@20 (после прунинга) → доля токенов от исходного.


5. Кэширование: три независимых уровня

Кэш в RAG — не одна штука, а три разных механизма с разными hit rate и разными последствиями промаха.

5.1 Кэш эмбеддингов

Самый скучный и самый безотказный. Ключ — хеш нормализованного текста плюс идентификатор модели. Обязателен на индексации (переиндексация после смены чанкера не должна пересчитывать неизменившиеся чанки) и полезен на запросах.

import hashlib, json, redis

r = redis.Redis()

def embed_cached(texts: list[str], model: str) -> list[list[float]]:
    keys = [
        f"emb:{model}:{hashlib.sha256(t.strip().encode()).hexdigest()}" for t in texts
    ]
    cached = r.mget(keys)
    missing = [i for i, v in enumerate(cached) if v is None]

    if missing:
        fresh = embed_api([texts[i] for i in missing], model=model)
        pipe = r.pipeline()
        for i, vec in zip(missing, fresh):
            cached[i] = json.dumps(vec).encode()
            pipe.setex(keys[i], 60 * 60 * 24 * 30, cached[i])  # 30 дней
        pipe.execute()

    return [json.loads(v) for v in cached]

Идентификатор модели в ключе обязателен. Смена эмбеддера без смены ключа даёт векторы из разных пространств в одном индексе — и поиск деградирует до случайного, причём без единой ошибки в логах.

5.2 Семантический кэш ответов

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

Это самый выгодный и самый опасный кэш. Выгодный: hit даёт ~50 мс и нулевую стоимость вместо 3 секунд и нескольких центов. Опасный: близость эмбеддингов не равна эквивалентности запросов. Классический инцидент — «как отменить подписку» и «как отменить подписку без комиссии» имеют косинус 0.94 и совершенно разные правильные ответы. Отрицания («можно ли» / «нельзя ли») эмбеддеры различают особенно плохо.

Правила безопасной эксплуатации:

Правило Почему
Порог 0.95+, не 0.85 Ниже начинаются ложные совпадения на отрицаниях и уточнениях
Ключ включает user/tenant/роль Иначе кэш становится каналом утечки данных между арендаторами
TTL 1–24 часа, не бесконечный Корпус меняется; протухший ответ хуже, чем медленный
Инвалидация по document_id ответа Изменился исходник — сбросьте все ответы, которые его цитировали
Не кэшировать персонализированные и «сейчас/сегодня» запросы Ответ по определению невоспроизводим
Логировать пары (запрос-хит, исходный запрос) Единственный способ поймать ложные совпадения до жалобы пользователя

Реалистичный hit rate на пользовательских продуктах — 10–30 %. На внутренних инструментах бывает выше: там запросы повторяются сильнее.

5.3 Prompt cache провайдера

Кэшируется префикс промпта на стороне провайдера. Экономика (Anthropic): чтение из кэша — 0.1× цены входа, запись — 1.25× при TTL 5 минут или 2× при TTL 1 час. Максимум 4 точки кэширования на запрос; порядок рендеринга — toolssystemmessages.

Точка безубыточности проста: при TTL 5 минут кэш окупается со второго запроса (1.25 + 0.1 = 1.35 против 2.0 без кэша), при TTL 1 час — с третьего (2.0 + 0.2 = 2.2 против 3.0).

Что кэшировать в RAG:

resp = client.messages.create(
    model="claude-opus-4-8",
    max_tokens=2048,
    system=[
        {"type": "text", "text": STATIC_INSTRUCTIONS},   # не меняется никогда
        {
            "type": "text",
            "text": TAXONOMY_AND_GLOSSARY,               # меняется раз в неделю
            "cache_control": {"type": "ephemeral"},      # ← точка кэша
        },
    ],
    messages=[
        {
            "role": "user",
            "content": [
                {"type": "text", "text": retrieved_context},  # свой на каждый запрос
                {"type": "text", "text": user_question},      # самый волатильный — в конце
            ],
        }
    ],
)
# Обязательно в метрики: если это ноль на повторяющихся префиксах — кэш не работает
log.info("cache_read=%s", resp.usage.cache_read_input_tokens)

Порядок «стабильное → волатильное» — единственное, что здесь важно. Извлечённый контекст свой на каждый запрос, поэтому кэшировать его бессмысленно; кэшируются инструкции, глоссарий, схемы инструментов и few-shot примеры. Для типичного RAG-промпта это 2–8k токенов постоянной части — экономия скромная в процентах, но бесплатная.

5.4 Cache-augmented generation: когда RAG не нужен

Если весь корпус помещается в контекст (порядка сотен тысяч токенов — небольшой продуктовый мануал, свод политик, регламент), можно вообще отказаться от поиска: загрузить всё в кэшируемый префикс и задавать вопросы поверх (Chan et al., «Don’t Do RAG», arXiv:2412.15605). Ноль этапов извлечения — значит ноль ошибок извлечения.

Где ломается: корпус растёт, стоимость линейна по числу запросов даже при 0.1×, TTL кэша требует поддержания «горячим», а качество деградирует на длинном контексте («затерянный в середине»). Практическая граница — примерно до 200k токенов корпуса и стабильный поток запросов. Дальше возвращайтесь к RAG.


6. Сборка: роутер, а не монолит

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

ROUTES = {
    "chitchat":   lambda q: answer_directly(q),                 # без поиска вообще
    "factoid":    lambda q: simple_rag(q, k=8),                 # гибрид + реранк
    "comparison": lambda q: agentic_retrieve(q, SearchBudget(max_iterations=2)),
    "multihop":   lambda q: graph_local_search(q, hops=2),
    "global":     lambda q: graph_global_search(q),             # дорого, лимитируйте
}

def route(query: str) -> str:
    """Классификация запроса на дешёвой модели со structured output.

    Стоимость ~$0.0002 на запрос, латентность ~200 мс. Окупается тем,
    что 60–70 % трафика уходит по дешёвому маршруту 'factoid'.
    """
    resp = client.messages.create(
        model="claude-haiku-4-5",
        max_tokens=64,
        output_config={
            "format": {
                "type": "json_schema",
                "schema": {
                    "type": "object",
                    "properties": {"route": {"type": "string", "enum": list(ROUTES)}},
                    "required": ["route"],
                    "additionalProperties": False,
                },
            }
        },
        messages=[{"role": "user", "content": ROUTER_PROMPT.format(query=query)}],
    )
    return json.loads(next(b.text for b in resp.content if b.type == "text"))["route"]

Про структурированный вывод и валидацию подробнее — в статье про структурированный вывод.

Реалистичный бюджет одного запроса

Цифры для корпуса ~500k токенов, claude-opus-4-8 на генерации, claude-haiku-4-5 на служебных вызовах, реранкер на своём GPU:

Этап Токены Латентность Стоимость
Роутинг 300 вх / 20 вых 200 мс 0.0004 $
Эмбеддинг запроса (кэш-промах) 40 мс ~0 $
Гибридный поиск, k=200 60 мс ~0 $
Реранк 200 → 20 180 мс ~0 $ (амортизация GPU)
Прунинг предложений 24k вх / 7k вых 700 мс 0.06 $
Генерация (Opus, 6.5k контекста) 6.5k вх / 700 вых 4.2 с 0.05 $
Итого ~5.4 с ~0.11 $

Сравните с «залить 200k токенов в Opus»: 1.00 $ и ~12 секунд при худшем качестве из-за позиционной деградации. Каскад сжатия окупается на первом же запросе.


7. Оценка: без неё всё вышеперечисленное — гадание

Каждая техника в этой статье может ухудшить качество на вашем корпусе. Contextual retrieval добавляет галлюцинации в контекст чанка. Агентный цикл раздувает нерелевантный пул. Сжатие вырезает нужное предложение. Единственная защита — измерять поэтапно.

Разделите метрики на две группы, потому что они лечатся разными людьми и разными способами:

Качество извлечения (владелец — поисковый инженер):

  • recall@k — есть ли золотой чанк среди k. Главная метрика, всё остальное вторично.
  • nDCG@10 — насколько высоко он стоит.
  • context precision — доля релевантного среди переданного модели; напрямую переводится в деньги.

Качество генерации (владелец — прикладной инженер):

  • faithfulness — доля утверждений ответа, подтверждённых контекстом. Ловит галлюцинации.
  • answer relevancy — отвечает ли ответ на заданный вопрос.
  • refusal accuracy — правильно ли система отказывается, когда данных нет. Почти всегда забывают, и почти всегда это худшая метрика в системе.

Инструмент по умолчанию — RAGAS (Es et al., arXiv:2309.15217). Для multi-hop берите публичные датасеты как sanity check: HotpotQA, MuSiQue, BRIGHT (запросы, требующие рассуждения, где обычный dense retrieval проваливается). Но решает всё свой датасет из 100–300 реальных запросов с размеченными золотыми чанками. Детально про построение таких датасетов и LLM-as-judge — в статье про оценку и бенчмарки.

Дисциплина: одна техника — один A/B. Если внедрить contextual retrieval и графовый индекс одновременно и качество вырастет на 4 п.п., вы не узнаете, что граф на самом деле дал −2.


8. Типичные ошибки

  1. Graph RAG как первый шаг. Самая дорогая техника внедряется раньше самой дешёвой (реранкера). Порядок должен быть обратным.
  2. Агентный цикл без бюджета. Один запрос на 40 итераций, таймаут у пользователя, счёт у вас. Лимит на итерации, вызовы LLM и wall-clock — все три сразу.
  3. Семантический кэш без изоляции по арендатору. Ответ, собранный по документам клиента A, отдаётся клиенту B. Это инцидент безопасности, а не баг производительности. Подробнее про такие утечки — в статье про безопасность.
  4. Порог семантического кэша 0.85. Кажется разумным, ловит «отменить подписку» ≈ «отменить подписку без комиссии». Начинайте с 0.97 и снижайте только по замерам.
  5. Кэш эмбеддингов без идентификатора модели в ключе. Тихая катастрофа: после смены эмбеддера индекс содержит векторы из двух пространств, поиск деградирует, ошибок нет.
  6. Оценка сжатия только по финальному ответу. Регрессия recall на этапе реранка маскируется тем, что модель уверенно выдумывает недостающее. Меряйте recall после каждого этапа.
  7. Прунинг токенами там, где нужны точные строки. Артикулы, версии, суммы и идентификаторы после LLMLingua превращаются в правдоподобный мусор.
  8. Отсутствие пути отказа. Если система не умеет говорить «в базе нет», она будет отвечать на нерелевантном контексте. Доля отказов — метрика первого класса.
  9. Забытый cache_read_input_tokens. Кэш «включён», но каждый запрос переписывает префикс из-за таймстампа в системном промпте. Платите 1.25× вместо 0.1× и не знаете об этом месяцами.
  10. Игнорирование инкрементальных обновлений графа. Полная переиндексация корпуса, изменившегося на 0.5 %, потому что пересчёт сообществ Лейдена не спроектировали инкрементальным.

Мини-итог

  • Contextual retrieval — лучшее соотношение выигрыша к сложности: −49 % ошибок извлечения, с prompt caching около 1 $ на миллион токенов документов. Делайте это первым.
  • Graph RAG решает то, чего вектора не могут в принципе — глобальные и multi-hop-запросы, — но стоит на два порядка дороже на индексации. Сначала измерьте долю таких запросов; при необходимости берите LazyGraphRAG или RAPTOR.
  • Агентный поиск превращает один выстрел в цикл «искать → оценить → уточнить → отказаться». Жёсткий бюджет итераций обязателен, отказ — валидный исход.
  • Контекстное сжатие — каскад «реранк → прунинг → дедуп», дающий −95…−97 % токенов промпта при потере 1–3 п.п. recall. Меряйте recall после каждой ступени.
  • Кэш — три независимых уровня: эмбеддинги (безотказный), семантический (выгодный и опасный, порог 0.95+ и изоляция по арендатору), prompt cache провайдера (0.1× на чтение, окупается со второго запроса).
  • Роутер важнее любой отдельной техники: 60–70 % трафика должно уходить по самому дешёвому маршруту.

Что дальше

Мы довели извлечение до предела: система умеет искать в цикле, оценивать найденное и решать, когда остановиться. Это уже почти агент — не хватает только инструментов помимо поиска и явного цикла рассуждение-действие. Следующая статья: Агенты и ReAct: цикл рассуждение-действие, инструменты, планирование, память.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов