Продакшн-разработка ИИ-агентов · Модуль 2 · Урок 2.1
Глава 3. Продвинутый RAG
Цели главы
После главы вы сможете:
- объяснить, почему один векторный поиск (dense retrieval) проигрывает на проде, и собрать гибридный поиск (hybrid search): BM25 + эмбеддинги со слиянием через RRF (reciprocal rank fusion)
- добавить re-ranking (переранжирование) кандидатов cross-encoder/rerank-моделью и понимать, почему это поднимает precision
- выбирать стратегию чанкинга (chunking): размер, перекрытие, нарезка по структуре документа; применять contextual retrieval (контекстуализацию чанка)
- применять переписывание и расширение запроса (query rewriting/expansion, HyDE)
- строить итеративный/агентный RAG (agentic RAG), где агент сам решает, что и сколько искать
- понимать, когда нужен GraphRAG (поиск по графу знаний) для multi-hop вопросов, и когда он избыточен
Что нового (дельта к базовому курсу)
В базовом курсе RAG был «найти top-k ближайших чанков по косинусной близости и подложить в промпт». Этого хватает для демо, но на проде такой retrieval — главный источник галлюцинаций: модель уверенно отвечает на основе нерелевантного контекста.
Здесь мы разбираем RAG как поисковую систему, а не как один вызов векторной БД. Ключевые отличия:
- retrieval — это двухстадийный процесс: дешёвый recall (отобрать сотню кандидатов) + дорогой precision (переранжировать в top-5). В базовом курсе была только первая стадия, причём в усечённом виде.
- качество поиска определяется не моделью, а подготовкой данных (чанкинг, контекстуализация) и слиянием сигналов (лексический BM25 + семантический вектор).
- retrieval перестаёт быть одношаговым: агент может искать итеративно, уточняя запрос по промежуточным результатам.
Почему один вектор недостаточен: гибридный поиск и RRF
Плотный векторный поиск (dense) хорош для семантической близости («автомобиль» ≈ «машина»), но плохо ловит точные совпадения: коды ошибок (ERR_2043), имена функций, артикулы, редкие термины. Эмбеддинг «размывает» такие токены. Лексический BM25 — наоборот: ловит точные слова, но не понимает синонимы.
Решение — гибридный поиск: гоняем оба ретривера параллельно и сливаем ранжированные списки. Самый надёжный способ слияния — RRF (reciprocal rank fusion). Он работает не с сырыми скорами (которые у BM25 и у косинуса несравнимы по шкале), а с рангами:
score(d) = Σ 1 / (k + rank_i(d)) по всем ретриверам i, где k — сглаживающая константа (обычно 60).
Документ, попавший в топ обоих списков, получает высокий суммарный балл; документ, который высоко только в одном — средний. Это устойчиво к разным шкалам и не требует калибровки. Почему так: ранг — безразмерная величина, поэтому RRF не зависит от того, что у одного ретривера скоры в [0,1], а у другого — в [0, 30].
Re-ranking: вторая стадия для precision
Гибридный поиск даёт хороший recall (нужное где-то в top-100), но порядок внутри сотни всё ещё шумный. Bi-encoder (тот, что считает эмбеддинги) кодирует запрос и документ независимо — это быстро, но теряет тонкие взаимодействия.
Cross-encoder (rerank-модель) принимает пару (запрос, чанк) вместе и выдаёт один скор релевантности. Он на порядок точнее, но и на порядок дороже — поэтому его нельзя применять ко всей базе, только к кандидатам.
Канонический конвейер: retrieve (top-100, дёшево) → rerank (top-5, дорого) → в контекст модели. Это и есть разделение recall/precision. Почему так: мы тратим дорогой компьют только там, где он влияет на ответ — на финальном отборе, а не на просмотре всей базы.
Чанкинг и contextual retrieval
Качество retrieval упирается в то, как нарезаны документы. Плохой чанкинг невозможно компенсировать ни ранкером, ни моделью.
- Размер: слишком большой чанк размывает эмбеддинг (много тем в одном векторе), слишком маленький теряет контекст. Типичный диапазон — 200–500 токенов.
- Перекрытие (overlap): 10–20% между соседними чанками, чтобы факт на границе не потерялся.
- По структуре: режьте по заголовкам, абзацам, секциям (структурный/семантический чанкинг), а не по фиксированному числу символов посреди предложения.
Отдельная проблема — потеря контекста: чанк «Выручка выросла на 12%» бесполезен без «у компании X в Q3 2024». Подход Anthropic contextual retrieval: перед индексацией каждый чанк прогоняют через LLM, которая дописывает 1–2 предложения контекста из всего документа («Это фрагмент отчёта компании X за Q3 2024: …»). Контекстуализированный чанк и эмбеддят, и индексируют в BM25. Почему так: мы переносим работу по разрешению ссылок (это/она/там) с момента запроса на момент индексации, где у нас есть весь документ целиком.
Переписывание запроса и агентный RAG
Запрос пользователя редко оптимален для поиска. Техники улучшения:
- Query rewriting: переформулировать разговорный вопрос в поисковый («а что там по ценам?» → «тарифы и цены продукта X 2024»), раскрыть кореференции из истории диалога.
- Query expansion: сгенерировать несколько вариантов запроса (синонимы, разные формулировки) и искать по всем, затем слить через RRF.
- HyDE (Hypothetical Document Embeddings): попросить LLM написать гипотетический ответ на вопрос и искать по эмбеддингу этого ответа, а не вопроса. Идея: гипотетический ответ ближе в пространстве эмбеддингов к настоящим документам-ответам, чем короткий вопрос.
Агентный (итеративный) RAG — следующий шаг: вместо одного поиска агент в цикле сам решает, искать ли ещё, как переформулировать запрос, достаточно ли собрано контекста. Поиск становится инструментом (tool), а не фиксированной стадией пайплайна. Это дороже по латентности и токенам, но необходимо для multi-hop вопросов («сравни политику отпусков в филиалах A и B» — нужно минимум два независимых поиска).
GraphRAG: когда нужен граф
Векторный RAG плохо отвечает на глобальные и multi-hop вопросы вида «какие основные темы во всём корпусе?» или «как связаны сущности X и Y через посредников?» — потому что ответ не локализован в нескольких чанках, он размазан по всему корпусу или требует обхода связей.
GraphRAG (подход Microsoft Research) строит из корпуса граф знаний: LLM извлекает сущности и связи, затем строятся сообщества (community detection) и их саммари. Запрос идёт по графу/саммари, а не по плоскому списку чанков.
Когда выбирать что:
- простые факт-вопросы, ответ в 1–2 чанках → гибридный + rerank, граф избыточен
- multi-hop, рассуждение по связям → agentic RAG или GraphRAG
- глобальные вопросы «о чём весь корпус» → GraphRAG
GraphRAG дорог в построении (LLM-вызовы на извлечение графа) и в поддержке. Почему так: не платите за граф, пока вам реально не нужны связи между сущностями — для большинства продакшн-кейсов хватает гибрида с переранжированием.
Жизненный цикл индекса: переиндексация, свежесть, дрейф
RAG — это не «построил индекс один раз», а живой конвейер данных. Корпус меняется: документы добавляются, редактируются, устаревают и удаляются. Если индекс этого не отражает, retrieval начинает уверенно возвращать протухшие факты — и это незаметно, пока кто-то не получит старую цену или отменённую политику. Поэтому у индекса есть эксплуатация (data-ops):
- Инкрементальные обновления (upsert/delete). Не перестраивайте весь индекс на каждое изменение — обновляйте только затронутые чанки. Для этого нужен стабильный ключ чанка (doc_id + хеш содержимого) и операции upsert/delete, а не только insert. Удалённый документ должен исчезать и из индекса, и из BM25-части.
- Свежесть и инвалидация. Храните у чанка метку версии/времени источника. На стороне приложения — TTL и триггеры реиндексации (webhook от источника, периодический скан изменений). Для часто меняющихся данных полезно ранжировать с учётом свежести, а не только релевантности.
- Дрейф корпуса и эмбеддинг-модели. Со временем меняется и распределение запросов, и сам корпус — отслеживайте долю «пустых»/нерелевантных выдач как сигнал деградации. Отдельный, дорогой случай — смена модели эмбеддингов: новые и старые векторы несравнимы (см. урок 2.0), поэтому это полный re-embedding всего корпуса, а не частичное обновление. Делайте его как версионированную миграцию: строите новый индекс рядом, прогоняете retrieval-эвал (гл.9), затем переключаете трафик.
- Метаданные для фильтрации. Источник, дата, язык, права доступа у чанка — это то, что позволяет отфильтровать устаревшее/чужое до ранжирования и не смешивать домены.
Мысленная модель: индекс — это материализованное представление меняющегося источника. Его свежесть и согласованность с источником — такая же продакшн-метрика, как success rate ретривера.
package rag
// IndexEntry — то, что хранится про каждый чанк, чтобы синхронизировать индекс
// с источником без полной перестройки.
type IndexEntry struct {
ChunkID string // стабильный ключ: docID + порядковый номер чанка
ContentHash string // хеш текста чанка: изменился — нужно переэмбедить
Version int64 // версия/таймстамп источника для свежести
}
// Index — абстракция векторного хранилища с операциями жизненного цикла.
type Index interface {
Upsert(ctx context.Context, e IndexEntry, embedding []float32) error
Delete(ctx context.Context, chunkID string) error
ListIDs(ctx context.Context) (map[string]string, error) // chunkID -> ContentHash
}
// Sync приводит индекс в соответствие с текущим набором чанков источника:
// добавляет новые, переэмбедит изменённые, удаляет исчезнувшие. Полная
// перестройка не нужна — это и есть инкрементальная переиндексация.
func Sync(ctx context.Context, idx Index, current []IndexEntry, embed func(string) []float32, textOf func(string) string) error {
existing, err := idx.ListIDs(ctx) // что уже в индексе
if err != nil {
return err
}
seen := make(map[string]bool, len(current))
for _, e := range current {
seen[e.ChunkID] = true
// Переэмбедим только новые или изменившиеся чанки (по хешу содержимого).
if h, ok := existing[e.ChunkID]; ok && h == e.ContentHash {
continue // не изменился — пропускаем (экономим вызовы эмбеддера)
}
if err := idx.Upsert(ctx, e, embed(textOf(e.ChunkID))); err != nil {
return err
}
}
// Удаляем то, чего больше нет в источнике, — иначе вернётся протухший факт.
for id := range existing {
if !seen[id] {
if err := idx.Delete(ctx, id); err != nil {
return err
}
}
}
return nil
}package rag
import (
"context"
"sort"
)
// Doc — документ/чанк в индексе.
// (struct-теги для (де)сериализации опущены намеренно: в учебном коде
// бэктики внутри raw-литерала недопустимы; в реальном проекте добавьте
// json-теги, например для поля ID — json:"id".)
type Doc struct {
ID string
Text string
Embed []float32 // заранее посчитанный эмбеддинг чанка
}
// Retriever — общий интерфейс одной стадии поиска (recall).
type Retriever interface {
// Search возвращает кандидатов, упорядоченных по убыванию релевантности.
Search(ctx context.Context, query string, qEmbed []float32, topK int) ([]string, error)
}
// reciprocalRankFusion сливает несколько ранжированных списков ID в один.
// Работает с РАНГАМИ, а не со скорами: это устойчиво к разным шкалам
// (BM25 и косинус несравнимы напрямую). k — сглаживающая константа.
func reciprocalRankFusion(rankings [][]string, k float64) []string {
score := make(map[string]float64)
for _, ranked := range rankings {
for rank, id := range ranked {
// rank нумеруется с 0, поэтому +1: вклад = 1 / (k + позиция).
score[id] += 1.0 / (k + float64(rank+1))
}
}
ids := make([]string, 0, len(score))
for id := range score {
ids = append(ids, id)
}
// Сортируем по убыванию суммарного RRF-балла.
sort.Slice(ids, func(i, j int) bool {
return score[ids[i]] > score[ids[j]]
})
return ids
}
// HybridSearch гоняет лексический и векторный ретривер параллельно по смыслу
// (здесь — последовательно для простоты) и сливает результаты через RRF.
func HybridSearch(
ctx context.Context,
lexical, dense Retriever,
query string, qEmbed []float32, topK int,
) ([]string, error) {
bm25Hits, err := lexical.Search(ctx, query, nil, topK)
if err != nil {
return nil, err
}
denseHits, err := dense.Search(ctx, query, qEmbed, topK)
if err != nil {
return nil, err
}
// k=60 — общепринятое значение из исходной статьи по RRF.
fused := reciprocalRankFusion([][]string{bm25Hits, denseHits}, 60.0)
if len(fused) > topK {
fused = fused[:topK]
}
return fused, nil
}
package rag
import (
"context"
"sort"
)
// Reranker — обёртка над rerank-моделью (cross-encoder).
// Принимает запрос и КАНДИДАТОВ (а не всю базу): cross-encoder дорог,
// поэтому работает только на второй стадии — над top-N от гибридного поиска.
type Reranker interface {
// Score возвращает для каждого документа скор релевантности паре (query, doc).
Score(ctx context.Context, query string, docs []Doc) ([]float64, error)
}
// scored связывает документ с его скором переранжирования.
type scored struct {
doc Doc
score float64
}
// Rerank переранжирует кандидатов и возвращает top-N — финальный контекст.
// Это стадия PRECISION: дорогой компьют тратится только на отобранную сотню,
// а не на всю базу.
func Rerank(
ctx context.Context,
rr Reranker,
query string,
candidates []Doc,
topN int,
) ([]Doc, error) {
scores, err := rr.Score(ctx, query, candidates)
if err != nil {
return nil, err
}
ranked := make([]scored, len(candidates))
for i := range candidates {
ranked[i] = scored{doc: candidates[i], score: scores[i]}
}
sort.Slice(ranked, func(i, j int) bool {
return ranked[i].score > ranked[j].score
})
if topN > len(ranked) {
topN = len(ranked)
}
out := make([]Doc, topN)
for i := 0; i < topN; i++ {
out[i] = ranked[i].doc
}
return out, nil
}
package rag
import (
"context"
"encoding/json"
"fmt"
"github.com/anthropics/anthropic-sdk-go"
)
// AgenticRAG: модель получает инструмент search и в цикле сама решает,
// сколько раз искать и как переформулировать запрос. Нужно для multi-hop
// вопросов, где одного поиска недостаточно.
func AgenticRAG(
ctx context.Context,
client anthropic.Client,
userQuestion string,
runSearch func(ctx context.Context, query string) (string, error),
maxSteps int,
) (string, error) {
// Инструмент search. InputSchema — обычная map, struct-теги не нужны.
searchTool := anthropic.ToolParam{
Name: "search",
Description: anthropic.String("Поиск по базе знаний. Вызывай несколько раз для multi-hop вопросов, переформулируя запрос."),
InputSchema: anthropic.ToolInputSchemaParam{
Properties: map[string]any{
"query": map[string]any{
"type": "string",
"description": "Поисковый запрос (можно переписать вопрос пользователя)",
},
},
},
}
tools := []anthropic.ToolUnionParam{{OfTool: &searchTool}}
messages := []anthropic.MessageParam{
anthropic.NewUserMessage(anthropic.NewTextBlock(userQuestion)),
}
for step := 0; step < maxSteps; step++ {
resp, err := client.Messages.New(ctx, anthropic.MessageNewParams{
Model: anthropic.ModelClaudeOpus4_8,
MaxTokens: 16000,
Messages: messages,
Tools: tools,
})
if err != nil {
return "", err
}
// Историю дополняем ДО обработки вызовов инструментов.
messages = append(messages, resp.ToParam())
// Если модель больше не просит инструмент — это финальный ответ.
if resp.StopReason != anthropic.StopReasonToolUse {
var answer string
for _, block := range resp.Content {
if t, ok := block.AsAny().(anthropic.TextBlock); ok {
answer += t.Text
}
}
return answer, nil
}
// Выполняем все запрошенные поиски, результаты — в одно user-сообщение.
var results []anthropic.ContentBlockParamUnion
for _, block := range resp.Content {
use, ok := block.AsAny().(anthropic.ToolUseBlock)
if !ok {
continue
}
var in struct {
Query string `json:"query"`}
// Input берём как сырой JSON через JSON.Input.Raw().
if err := json.Unmarshal([]byte(use.JSON.Input.Raw()), &in); err != nil {
return "", err
}
found, err := runSearch(ctx, in.Query)
if err != nil {
// Ошибку инструмента отдаём модели как is_error=true.
results = append(results, anthropic.NewToolResultBlock(block.ID, err.Error(), true))
continue
}
results = append(results, anthropic.NewToolResultBlock(block.ID, found, false))
}
messages = append(messages, anthropic.NewUserMessage(results...))
}
return "", fmt.Errorf("исчерпан бюджет шагов (%d) без финального ответа", maxSteps)
}
Anti-patterns
| Грабля | Почему плохо | Как избегать |
|---|---|---|
| Только векторный поиск, без BM25 | Теряются точные совпадения: коды ошибок, имена функций, артикулы, редкие термины — эмбеддинг их «размывает» | Гибридный поиск BM25 + вектор со слиянием через RRF; лексический сигнал ловит то, что вектор пропускает |
| Слияние ретриверов по сырым скорам | Скоры BM25 и косинуса в разных шкалах — простое сложение даёт мусор, доминирует ретривер с большими числами | Сливать по рангам через RRF (1/(k+rank)), а не по скорам — ранг безразмерен и не требует калибровки |
| Нарезка по фиксированному числу символов | Чанк рвётся посреди предложения/таблицы, факт на границе теряется, эмбеддинг размывается несколькими темами | Резать по структуре (заголовки, абзацы) + перекрытие 10–20%; держать размер 200–500 токенов |
| Чанк без контекста документа | «Выручка выросла на 12%» бесполезна без «компании X в Q3 2024» — кореференции не разрешаются при поиске | Contextual retrieval: дописать чанку 1–2 предложения контекста через LLM ПЕРЕД индексацией |
| Подавать в модель top-100 без переранжирования | Порядок от bi-encoder шумный, нужное тонет; раздувается контекст и стоимость, растут галлюцинации | Двухстадийность: retrieve top-100 (дёшево) → rerank cross-encoder в top-5 (дорого) → в контекст |
| Один поиск на multi-hop вопрос | «Сравни A и B» требует двух независимых поисков; одного запроса не хватает, ответ неполный | Агентный RAG: поиск как инструмент, модель в цикле решает, искать ли ещё и как переформулировать |
Практическое задание (PRO-M2-RAG)
- Соберите индекс: нарежьте набор Markdown-документов по заголовкам с перекрытием 15%, прогоните каждый чанк через LLM для contextual retrieval (допишите 1–2 предложения контекста).
- Реализуйте два ретривера за общим интерфейсом Retriever: BM25-lite (можно на простом инвертированном индексе) и векторный (эмбеддинги через сторонний embeddings API).
- Реализуйте HybridSearch со слиянием через reciprocalRankFusion (k=60); покройте RRF юнит-тестом на ручном примере с двумя списками.
- Добавьте стадию Rerank: обёртка над rerank-моделью, переранжируйте top-50 кандидатов в top-5 и сравните ответы с/без ранкера на 5 вопросах.
- Реализуйте AgenticRAG: дайте модели инструмент search и проверьте на multi-hop вопросе («сравни X и Y»), что модель делает минимум два поиска.
- Замерьте на маленьком eval-наборе (10–15 вопросов): recall@10 и долю верных ответов для конфигураций dense-only / hybrid / hybrid+rerank.
Проверка знаний
Пользователи ищут по технической базе знаний. На запросы вроде «ошибка ERR_2043 при старте сервиса» чисто векторный поиск стабильно не находит нужную страницу, хотя она есть в индексе и содержит ровно этот код ошибки.
Что устранит проблему наиболее прямо?
Верный ответ: B
Точные токены вроде ERR_2043 плохо ловятся плотным вектором — эмбеддинг их размывает. Это классический кейс для гибридного поиска: лексический BM25 находит точное совпадение кода, а слияние через RRF объединяет его с семантическими кандидатами. Увеличение top-k (A) и более крупная модель (C) не решают принципиальную слабость dense-поиска на точных совпадениях; HyDE (D) помогает на размытых вопросах, а не на точных кодах.
Команда подаёт в контекст модели сразу top-50 чанков от гибридного поиска. Ответы получаются многословными, иногда основаны на нерелевантных фрагментах, а стоимость запроса высокая.
Какой приём решает сразу и точность, и стоимость?
Верный ответ: A
Порядок от bi-encoder/RRF шумный, и 50 чанков раздувают контекст. Re-ranking cross-encoder'ом — это стадия precision: он точно переранжирует кандидатов, и оставив top-5, вы одновременно повышаете релевантность и режете стоимость/галлюцинации. Размер чанков (B) и отключение BM25 (C) бьют по recall; настройка k в RRF (D) лишь сглаживает слияние, но не отбирает финальные 5.
Вопрос пользователя: «Чем отличается политика возврата в регионе ЕС от политики в США?». Текущий RAG делает один поиск по всему вопросу и часто возвращает чанки только про один из регионов.
Какая архитектура retrieval подходит лучше всего?
Верный ответ: B
Это multi-hop вопрос: нужен независимый поиск по ЕС и по США, затем сравнение. Агентный RAG даёт модели инструмент search и позволяет сделать несколько целевых запросов — ровно то, чего не хватает одношаговому пайплайну. GraphRAG (A) — тяжёлая артиллерия для глобальных вопросов «о чём весь корпус» и связей между сущностями, здесь избыточен. Перекрытие (C) и contextual retrieval (D) улучшают отдельные чанки, но не решают проблему «нужно два поиска».
RAG-бот поддержки уверенно сообщает клиентам старую цену тарифа. Документ с ценой обновили неделю назад, но в выдаче по-прежнему появляется прежний чанк; удалённые страницы тоже иногда всплывают.
В чём корневая проблема и как её правильно решать?
Верный ответ: A
A верно. Это классический сбой data-ops: индекс — материализованное представление меняющегося источника, и без upsert/delete и инвалидации он отдаёт протухшие и удалённые факты. Лечится синхронизацией (изменился хеш чанка → переэмбедить; документ исчез → удалить из индекса и BM25), метками свежести и триггерами реиндексации. B/C/D настраивают качество поиска, но не устраняют рассинхрон с источником.