Продвинутый 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 $ на миллион токенов документов при использовании кэша — сходится с расчётом выше.
Три подводных камня:
- TTL кэша — 5 минут по умолчанию (есть вариант на 1 час, но запись стоит 2× вместо 1.25×). Обрабатывайте чанки одного документа подряд, без перемешивания документов в очереди — иначе кэш протухает и вы платите полную цену.
- Минимальный кэшируемый префикс зависит от модели: 4096 токенов для Opus/Haiku 4.5, 2048 для Sonnet-класса. Документ короче — кэш молча не создастся,
usage.cache_read_input_tokensостанется нулём. Всегда логируйте это поле; это единственный способ узнать, что кэш работает. - Не более 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.
Как строится индекс
и отношений из каждого чанка] C --> D[Дедупликация и слияние
одинаковых сущностей] D --> E[Граф: узлы = сущности,
рёбра = отношения с весами] E --> F[Кластеризация Лейденом
иерархия сообществ C0..Cn] F --> G[LLM: саммари для каждого
сообщества снизу вверх] G --> H[(Индекс: граф + саммари
+ векторы описаний)] H --> I{Тип запроса} I -->|локальный, про сущность| J[Local search:
якорь + соседи + их описания] I -->|глобальный, про корпус| K[Global search:
map по саммари → reduce] J --> L[Ответ] K --> L
Кластеризация — алгоритм Лейдена (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 вызовов на одном хитром запросе и принесёт вам счёт и таймаут одновременно.
Как это выглядит на проводе
отличается от US?» O->>L: декомпозиция запроса L-->>O: ["политика возвратов EU", "политика возвратов US"] par Параллельный поиск O->>R: подзапрос 1, k=50 R-->>O: 50 кандидатов and O->>R: подзапрос 2, k=50 R-->>O: 50 кандидатов end O->>J: оценить релевантность (батч) J-->>O: EU: 0.91 · US: 0.34 (мало данных) Note over O: US-ветка слабая → одна итерация уточнения O->>R: подзапрос 2', фильтр region=US, k=50 R-->>O: 50 кандидатов O->>J: оценить J-->>O: US: 0.88 — достаточно O->>O: реранк, сжатие, дедуп → 6.5k токенов O->>L: промпт с обоими наборами + требование цитат L-->>O: ответ + [doc_id:span] O->>O: проверка, что все цитаты существуют O-->>U: ответ с проверенными ссылками
Обратите внимание на распределение моделей: декомпозиция и финальная генерация — на сильной модели (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())
Три правила, без которых цикл вредит:
- Никогда не повторяйте тот же подзапрос. Поиск детерминирован — вы получите те же документы и потратите итерацию впустую. Каждая итерация обязана менять запрос, фильтр или источник.
- Пул накапливается, а не заменяется. Иначе хороший результат первой итерации теряется на второй.
- Отказ — валидный ответ. На внутренней базе знаний доля «нет данных» в норме 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 и разными последствиями промаха.
кэш ответов} SC -->|hit ~10–30%| ANS[Готовый ответ
~50 мс, $0] SC -->|miss| EC{Кэш
эмбеддингов} EC -->|hit| S[Поиск] EC -->|miss| EMB[Вызов эмбеддера] --> S S --> RR[Реранк + сжатие] RR --> PC{Prompt cache
провайдера} PC -->|hit префикса| GEN[Генерация
вход ×0.1] PC -->|miss| GEN2[Генерация
вход ×1.25, запись кэша] GEN --> ANS2[Ответ] GEN2 --> ANS2 ANS2 --> W[Запись в семантический кэш]
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 точки кэширования на запрос; порядок рендеринга — tools → system → messages.
Точка безубыточности проста: при 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. Типичные ошибки
- Graph RAG как первый шаг. Самая дорогая техника внедряется раньше самой дешёвой (реранкера). Порядок должен быть обратным.
- Агентный цикл без бюджета. Один запрос на 40 итераций, таймаут у пользователя, счёт у вас. Лимит на итерации, вызовы LLM и wall-clock — все три сразу.
- Семантический кэш без изоляции по арендатору. Ответ, собранный по документам клиента A, отдаётся клиенту B. Это инцидент безопасности, а не баг производительности. Подробнее про такие утечки — в статье про безопасность.
- Порог семантического кэша 0.85. Кажется разумным, ловит «отменить подписку» ≈ «отменить подписку без комиссии». Начинайте с 0.97 и снижайте только по замерам.
- Кэш эмбеддингов без идентификатора модели в ключе. Тихая катастрофа: после смены эмбеддера индекс содержит векторы из двух пространств, поиск деградирует, ошибок нет.
- Оценка сжатия только по финальному ответу. Регрессия recall на этапе реранка маскируется тем, что модель уверенно выдумывает недостающее. Меряйте recall после каждого этапа.
- Прунинг токенами там, где нужны точные строки. Артикулы, версии, суммы и идентификаторы после LLMLingua превращаются в правдоподобный мусор.
- Отсутствие пути отказа. Если система не умеет говорить «в базе нет», она будет отвечать на нерелевантном контексте. Доля отказов — метрика первого класса.
- Забытый
cache_read_input_tokens. Кэш «включён», но каждый запрос переписывает префикс из-за таймстампа в системном промпте. Платите 1.25× вместо 0.1× и не знаете об этом месяцами. - Игнорирование инкрементальных обновлений графа. Полная переиндексация корпуса, изменившегося на 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: цикл рассуждение-действие, инструменты, планирование, память.