Продакшн-разработка ИИ-агентов · Модуль 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 ретривера.

Инкрементальная синхронизация индекса: upsert по хешу содержимого, удаление пропавших
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
}
Гибридный поиск: BM25-lite + векторные эмбеддинги, слияние через RRF
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
}
Re-ranking кандидатов через rerank-модель (cross-encoder) и сборка контекста
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
}
Цикл агентного RAG: модель сама решает, искать ли ещё (через tool use, anthropic-sdk-go)
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

Грабли продакшн-RAG
ГрабляПочему плохоКак избегать
Только векторный поиск, без 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 при старте сервиса» чисто векторный поиск стабильно не находит нужную страницу, хотя она есть в индексе и содержит ровно этот код ошибки.

Что устранит проблему наиболее прямо?

  • A Увеличить top-k векторного поиска до 200
  • B Добавить лексический BM25-ретривер и слить с вектором через RRF
  • C Перейти на более крупную модель эмбеддингов
  • D Включить HyDE для всех запросов

Команда подаёт в контекст модели сразу top-50 чанков от гибридного поиска. Ответы получаются многословными, иногда основаны на нерелевантных фрагментах, а стоимость запроса высокая.

Какой приём решает сразу и точность, и стоимость?

  • A Добавить стадию re-ranking cross-encoder и оставить только top-5
  • B Уменьшить размер чанков вдвое
  • C Отключить BM25 и оставить только вектор
  • D Поднять k в формуле RRF до 200

Вопрос пользователя: «Чем отличается политика возврата в регионе ЕС от политики в США?». Текущий RAG делает один поиск по всему вопросу и часто возвращает чанки только про один из регионов.

Какая архитектура retrieval подходит лучше всего?

  • A GraphRAG с полным графом знаний по всему корпусу
  • B Агентный (итеративный) RAG, где модель делает отдельный поиск по каждому региону
  • C Увеличить перекрытие чанков до 50%
  • D Заменить эмбеддинги на contextual retrieval

RAG-бот поддержки уверенно сообщает клиентам старую цену тарифа. Документ с ценой обновили неделю назад, но в выдаче по-прежнему появляется прежний чанк; удалённые страницы тоже иногда всплывают.

В чём корневая проблема и как её правильно решать?

  • A Индекс рассинхронизирован с источником: нужен жизненный цикл индекса — инкрементальный upsert по изменению содержимого, удаление пропавших чанков, метки версии/свежести и триггеры реиндексации
  • B Слишком маленький top-k; нужно возвращать больше чанков
  • C Нужно сменить модель эмбеддингов на более крупную
  • D Поднять порог косинусной близости