Учебник

3 техники RAG, которые возвращают нужный документ с первого раза

Ваш AI-ассистент часто отвечает «не знаю», хотя ответ есть в базе? Проблема не в модели, а в поиске: 30-40% нужных фрагментов теряются. Разбираем три техники (Contextual Retrieval, HyDE, расширение запроса), которые поднимают точность поиска на 49-67%. Без программиста, за вечер. Пример для стройфирмы.

Макс Космов··11 мин чтения

У ваших менеджеров есть база знаний с типовыми договорами, прайсами и регламентами. Клиент спрашивает «какой класс защиты у перфоратора XYZ-3000?», а AI-ассистент отвечает «не знаю». Хотя ответ есть на 15-й странице PDF. Знакомо?

Проблема не в модели. Проблема в поиске: стандартный RAG (Retrieval-Augmented Generation - генерация с поиском по базе) теряет 30-40% нужных фрагментов. Просто потому что каждый кусок текста хранится сам по себе, без контекста - из какого он документа, какого раздела.

В этой статье разбираем три техники, которые чинят эту дыру. Без программиста, за вечер. Разберём на примере стройфирмы с каталогом оборудования и прайсами.

Зачем это бизнесу

Провал поиска - это когда AI не находит нужный фрагмент, хотя он в базе есть. Клиент думает «бот не знает», хотя знание у компании есть. Для стройфирмы: менеджер ищет характеристики перфоратора, а AI отвечает «не найдено» - теряете продажу.

Anthropic измерил: их техника Contextual Retrieval (контекстный поиск - обогащение фрагмента смыслом перед индексацией) снижает число таких провалов на 49-67%. Для AI-ассистента поддержки или внутреннего корпоративного поиска это прямой рост довольных пользователей и снижение нагрузки на живых операторов.

Проблема потерянного контекста: почему поиск теряет треть документов

Представьте документ «Руководство по эксплуатации перфоратора XYZ-3000» из каталога стройфирмы. На 15-й странице есть раздел:

«Скорость вращения: 3200 об/мин.
Мощность потребления: 450 Вт.
Класс защиты: IP67.»

Этот фрагмент попадает в базу без знания о том, что:

  • Это характеристики именно XYZ-3000, а не любого другого устройства.
  • Они относятся к режиму максимальной нагрузки.
  • Документ - техническое руководство, а не коммерческое предложение.

Когда клиент спрашивает «каков класс защиты дрели XYZ-3000?», вектор фрагмента «Класс защиты: IP67» может быть далёк от вектора запроса. Потому что в самом фрагменте нет ни слова «дрель», ни «XYZ-3000». Поиск не находит нужный кусок.

Anthropic измерил это на реальных корпусах. При размере фрагмента 512 токенов стандартный RAG теряет 30-40% документов, которые содержат правильный ответ. Это не ошибка модели. Это провал поиска.

Anthropic Contextual Retrieval: добавляем смысл к каждому фрагменту до индексации

Contextual Retrieval - это вложение в качество базы знаний на этапе её создания. Переиндексировать нужно один раз. Дальше каждый запрос автоматически находит более точные фрагменты. Без изменений в самой системе поиска.

Идея простая: перед индексацией каждый фрагмент обогащается контекстом через вызов LLM (Large Language Model - большая языковая модель). Claude получает весь документ и один фрагмент. Генерирует короткое описание из 2-4 предложений. Это описание добавляется в начало фрагмента.

Промпт для контекстуализации:

CONTEXT_PROMPT = """\
Вот документ:
<document>
{full_document}
</document>

Вот чанк из этого документа:
<chunk>
{chunk}
</chunk>

Напиши краткий контекст (2-4 предложения) для этого чанка,
чтобы улучшить его поиск. Не используй вступлений типа
\"Этот чанк содержит...\" - сразу к сути."""

До Contextual Retrieval:

Фрагмент: «Скорость вращения: 3200 об/мин. Мощность: 450 Вт. Класс защиты: IP67.»

После Contextual Retrieval:

Фрагмент: «Технические характеристики перфоратора XYZ-3000 в режиме
максимальной нагрузки. Из раздела «Технические параметры» руководства
по эксплуатации.
Скорость вращения: 3200 об/мин. Мощность: 450 Вт. Класс защиты: IP67.»

Теперь поиск по слову «перфоратор XYZ-3000» точно найдёт нужный кусок.

Дальше код, который делает всю эту работу. Функция берёт весь документ и один фрагмент. Отправляет их в Claude. Получает обратно фрагмент с добавленным контекстом в начале. Вторая функция применяет это ко всем документам и собирает обогащённый индекс.

import anthropic
from langchain.text_splitter import RecursiveCharacterTextSplitter

client = anthropic.Anthropic()

def create_contextual_chunk(
 full_document: str,
 chunk: str,
 model: str = "claude-haiku-4-5"
) -> str:
 response = client.messages.create(
 model=model,
 max_tokens=200,
 system="Ты помогаешь улучшить поиск по документам.",
 messages=[{
 "role": "user",
 "content": CONTEXT_PROMPT.format(
 full_document=full_document[:50000],
 chunk=chunk
 )
 }]
 )

 context = response.content[0].text
 return f"{context}\n\n{chunk}"

def build_contextual_index(
 documents: list[str],
 splitter: RecursiveCharacterTextSplitter
) -> list[str]:
 contextual_chunks = []

 for doc in documents:
 raw_chunks = splitter.split_text(doc)
 for chunk in raw_chunks:
 contextual = create_contextual_chunk(doc, chunk)
 contextual_chunks.append(contextual)

 return contextual_chunks

Сколько даёт Contextual Retrieval на практике

Anthropic опубликовал детальные результаты в сентябре 2024 года:

Метод Провалов поиска Снижение
Только векторный поиск (базовая линия) базовая линия -
+ Contextual Retrieval -49% -49%
+ Contextual BM25 (поиск по словам) -60% -60%
+ Contextual + переранжирование -67% -67%

Contextual BM25 - это когда тем же контекстуализированным фрагментам обновляется ключевой словесный индекс. BM25 (Best Match 25 - классический алгоритм поиска по словам 1994 года) теперь находит по словам «XYZ-3000» или «перфоратор». Хотя в исходном фрагменте этих слов не было.

Дальше про стоимость. Это важный экономический аргумент. Без кэширования получается так: 1000 фрагментов из 100-страничного документа, умножаем на 50 тысяч токенов документа. Итого 50 миллионов входящих токенов и около $0.25 при модели claude-haiku. С кэшированием стоимость падает на 90%.

Следующий кусок кода использует кэширование Anthropic. Документ помечается как ephemeral (временный) и кэшируется на 5 минут. Первый вызов стоит полную цену. Следующие 999 фрагментов из того же документа - только стоимость самого фрагмента (около 512 токенов). Итого $0.0025 вместо $0.25 за 1000 фрагментов.

def create_contextual_chunk_with_cache(
 full_document: str,
 chunk: str
) -> str:
 response = client.messages.create(
 model="claude-haiku-4-5",
 max_tokens=200,
 system="Ты помогаешь улучшить поиск по документам.",
 messages=[{
 "role": "user",
 "content": [
 {
 "type": "text",
 "text": f"<document>\n{full_document}\n</document>\n\n",
 "cache_control": {"type": "ephemeral"}
 },
 {
 "type": "text",
 "text": f"<chunk>\n{chunk}\n</chunk>\n\
Напиши контекст (2-4 предложения):"
 }
 ]
 }]
 )
 return f"{response.content[0].text}\n\n{chunk}"

HyDE: ищем по выдуманному ответу

HyDE (Hypothetical Document Embeddings - векторы гипотетического документа) - это техника, которая улучшает поиск без переделки базы. База остаётся как была. HyDE надстраивается сверху. Внедрение - 1-2 часа работы менеджера. Минус - небольшая задержка (300-800 миллисекунд) на генерацию.

Зачем это бизнесу. Пользователь часто формулирует вопрос коротко и неконкретно. А в документации ответ длинный и развёрнутый. Их векторы оказываются далеко друг от друга в смысловом пространстве. HyDE подтягивает эти точки ближе. Тем самым находит ответ, который простой поиск пропустил.

Как работает. Запрос «как настроить HNSW?» - короткий и неполный. Релевантный фрагмент «HNSW настраивается через параметры M=16 и ef_construction=200...» - длинный и насыщенный.

Решение: вместо поиска по вектору запроса просим LLM сгенерировать гипотетический ответ на вопрос. Потом ищем по вектору этого ответа.

Пример: запрос «как настроить HNSW параметры в Qdrant?» превращается в сгенерированный текст «HNSW в Qdrant настраивается через hnsw_config при создании коллекции. Параметр m (количество соседей, рекомендуется 16-64) влияет на качество индекса...». Этот текст по смыслу гораздо ближе к реальным фрагментам документации. И находит их точнее.

Код ниже сначала генерирует гипотетический ответ через GPT-4o-mini. Потом использует этот ответ (а не исходный вопрос) как поисковый запрос к векторной базе.

from langchain.prompts import PromptTemplate
from langchain_openai import ChatOpenAI, OpenAIEmbeddings

HYDE_PROMPT = PromptTemplate(
 input_variables=["question"],
 template="""\
Напиши подробный технический абзац, который мог бы быть ответом
на следующий вопрос. Пиши как будто это выдержка из технической документации.

Вопрос: {question}

Ответ:"""
)

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

def hyde_retrieve(
 question: str,
 vectorstore,
 k: int = 5
) -> list:
 # Генерируем гипотетический документ
 hypothetical_doc = llm.invoke(
 HYDE_PROMPT.format(question=question)
 ).content

 # Ищем по вектору гипотетического документа, а не исходного вопроса
 results = vectorstore.similarity_search(
 hypothetical_doc,
 k=k
 )
 return results

HyDE хорошо работает для вопросов в форме «как?», «почему?», «что такое?». Плохо работает для поиска по точным словам («артикул E-7234», «ошибка 0xA3»). Для таких случаев нужен гибридный поиск. Про него отдельная статья.

Расширение запроса: несколько формулировок одного вопроса

Расширение запроса (query expansion) лечит проблему «неправильных слов». Пользователь написал «ускорение БД». А в документации написано «оптимизация производительности базы данных». Смысл одинаковый, слова разные. Поиск не находит.

Зачем это бизнесу. Ваши сотрудники и клиенты формулируют запросы по-разному. Менеджер пишет «подтверждение оплаты». Бухгалтер - «проводка платёжного поручения». Юрист - «акцепт платежа». Расширение запроса автоматически покрывает все варианты.

Как работает. Генерируем 3-5 перефразировок запроса. Ищем по каждой. Объединяем результаты через метод рангового слияния. Он называется RRF (Reciprocal Rank Fusion - обратное ранговое слияние, способ объединить несколько списков результатов в один).

Код ниже настраивает готовый компонент LangChain - MultiQueryRetriever. Он автоматически генерирует 3 перефразировки запроса через LLM. Ищет по каждой. Объединяет результаты без дублей.

from langchain.retrievers.multi_query import MultiQueryRetriever
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0.3)

retriever = MultiQueryRetriever.from_llm(
 retriever=vectorstore.as_retriever(search_kwargs={"k": 10}),
 llm=llm
)

# Автоматически генерирует 3 перефразировки и объединяет результаты
docs = retriever.invoke("как ускорить запросы к базе данных?")
# LLM генерирует:
# 1. "методы оптимизации производительности SQL запросов"
# 2. "снижение задержки при работе с БД"
# 3. "индексирование и настройка базы данных"

Если нужен полный контроль над процессом, есть второй вариант. Свой генератор перефразировок и асинхронный параллельный поиск. Параллельность тут важна: 4 запроса к базе выполняются одновременно, а не один за другим. Это сокращает общее время ответа в 3-4 раза.

QUERY_EXPANSION_PROMPT = """\
Генерируй {n} различных версий следующего вопроса для улучшения
поиска по документации. Версии должны:
- Использовать синонимы и альтернативные формулировки
- Рассматривать разные аспекты вопроса
- Быть конкретными и точными

Оригинальный вопрос: {question}

Отвечай только списком вопросов, по одному на строку:"""

def expand_query(question: str, n: int = 4) -> list[str]:
 response = llm.invoke(
 QUERY_EXPANSION_PROMPT.format(n=n, question=question)
 )
 expanded = [q.strip() for q in response.content.strip().split("\n") if q.strip()]
 return [question] + expanded[:n]

async def multi_query_retrieve(question: str, k: int = 5) -> list:
 queries = expand_query(question)

 # Параллельный поиск по всем запросам
 tasks = [
 vectorstore.asimilarity_search_with_score(q, k=k)
 for q in queries
 ]
 all_results = await asyncio.gather(*tasks)

 # Объединяем через RRF
 rankings = []
 for results in all_results:
 ranking = [(r.metadata["chunk_id"], s) for r, s in results]
 rankings.append(ranking)

 fused = reciprocal_rank_fusion(rankings)
 return [chunks_by_id[cid] for cid, _ in fused[:k]]

Оптимум по числу перефразировок - 3-5. При 10 перефразировках прирост полноты поиска становится незначительным (менее 1%). А задержка и стоимость растут линейно. 3-4 перефразировки дают лучший результат на вложенный рубль.

Шаг назад: от конкретного к общему

Step-back prompting (шаг назад в запросе) - ещё одна техника расширения, придуманная в Google DeepMind. Перед поиском LLM формулирует более общий вопрос. Ответ на этот общий вопрос содержит принципы, нужные для ответа на конкретный.

Зачем это бизнесу. Часто пользователь спрашивает «как сделать X в моей ситуации?». А в документации есть только общее правило «при условиях Y делается так». Шаг назад находит это правило. Конкретный вопрос его пропустил бы.

Пример из юриспруденции. Клиент спрашивает: «могу ли я расторгнуть договор в одностороннем порядке при задержке поставки на 3 дня?». Конкретный вопрос. Шаг назад превращает его в: «при каких условиях возможно одностороннее расторжение договора поставки?». Общий вопрос находит релевантный раздел о правовых основаниях. Точный поиск его бы пропустил.

Код ниже генерирует общий вопрос через LLM. Потом ищет по обоим запросам - конкретному и общему. Объединяет результаты с дедупликацией.

STEP_BACK_PROMPT = """\
Задан конкретный вопрос: {question}

Сформулируй более общий вопрос, ответ на который содержит
принципы, необходимые для ответа на конкретный вопрос.
Только один общий вопрос:"""

def step_back_retrieve(question: str, vectorstore, k: int = 5):
 step_back_q = llm.invoke(
 STEP_BACK_PROMPT.format(question=question)
 ).content

 specific_results = vectorstore.similarity_search(question, k=k)
 general_results = vectorstore.similarity_search(step_back_q, k=k)

 seen_ids = set()
 combined = []
 for doc in specific_results + general_results:
 if doc.metadata["chunk_id"] not in seen_ids:
 combined.append(doc)
 seen_ids.add(doc.metadata["chunk_id"])

 return combined[:k]

Стоимость контекстного индексирования: реальные числа

Практический расчёт для корпуса из 10 тысяч документов по 10 страниц. Это около 5000 токенов на документ, 20 фрагментов на документ.

Подход Токены Стоимость
Без кэша, claude-haiku 10K * 20 * 5K = 1 млрд токенов $5 000
С кэшированием 10K * (5K + 20*512) = 152 млн токенов $760
С кэшем, 90% попаданий ~$75 входящих ~$150 итого

Кэширование в Anthropic API работает, когда начало запроса идентично предыдущему за последние 5 минут. При последовательной обработке всех фрагментов одного документа документ кэшируется. Платите только за фрагмент.

Важный момент. При параллельной обработке нужно группировать фрагменты по документу. А не перемешивать. Код ниже обрабатывает фрагменты каждого документа последовательно (для максимального попадания в кэш). А документы между собой - параллельно, по 10 штук одновременно.

import asyncio
from itertools import batched

async def build_contextual_corpus(documents: list[dict]) -> list[str]:
 async def process_document(doc: dict) -> list[str]:
 chunks = splitter.split_text(doc["content"])
 contextual = []
 # Фрагменты одного документа - последовательно для попадания в кэш
 for chunk in chunks:
 ctx_chunk = create_contextual_chunk_with_cache(
 doc["content"], chunk
 )
 contextual.append(ctx_chunk)
 return contextual

 # Документы обрабатываем параллельно, по 10 одновременно
 all_chunks = []
 for batch in batched(documents, 10):
 results = await asyncio.gather(*[
 process_document(doc) for doc in batch
 ])
 for doc_chunks in results:
 all_chunks.extend(doc_chunks)

 return all_chunks

Когда применять: простой алгоритм решения

Есть простой способ понять, нужны ли вам эти техники. Замерьте полноту поиска (context recall - процент запросов, где нужный фрагмент попал в топ результатов) через библиотеку Ragas. На 50-100 вопросах достаточно. Дальше по результату:

  1. Полнота выше 0.80: текущая система достаточна.
  2. Полнота 0.70-0.80: добавьте расширение запроса. Это дёшево и не требует переиндексации.
  3. Полнота ниже 0.70: нужен Contextual Retrieval. Требует переиндексации базы.
  4. После Contextual Retrieval всё ещё ниже 0.75: добавьте переранжирование.

Код ниже запускает автоматическое измерение полноты поиска через Ragas. Подаём вопросы, найденные фрагменты и правильные ответы. Получаем числовой показатель качества.

from ragas import evaluate
from ragas.metrics import context_recall
from datasets import Dataset

eval_data = Dataset.from_dict({
 "question": questions,
 "contexts": [[r.page_content for r in retrieve(q)] for q in questions],
 "ground_truth": ground_truths
})

result = evaluate(eval_data, metrics=[context_recall])
print(f"Context Recall: {result['context_recall']:.3f}")

# < 0.70 -> нужен Contextual Retrieval
# 0.70-0.80 -> попробуй query expansion
# >= 0.80 -> достаточно

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

Переиндексировать всю базу без измерения. Сначала замерьте полноту. Если она выше 0.80, Contextual Retrieval не даст ощутимого прироста. А затраты на переиндексацию будут серьёзные.

Применять HyDE для поиска по точным словам. HyDE работает для вопросов. Для запросов типа «артикул 7734-B» или «ошибка E-7234» не помогает. Лучше использовать условный HyDE: применяйте его только для запросов в форме предложений.

Не группировать фрагменты по документу при кэшировании. Если обрабатывать фрагменты из разных документов вперемешку, кэш не работает. Платите полную стоимость. Группируйте все фрагменты одного документа в одну очередь.

Генерировать слишком много перефразировок. 10 вместо 4 дают менее 1% прироста полноты. Но в 2.5 раза увеличивают задержку и стоимость. Оптимум - 3-4 перефразировки.

Не строить параллельную схему при переиндексации. Если переиндексировать базу «на живую», в переходный период поиск работает хуже. Правильная схема: строим новый индекс параллельно. Тестируем на 10% трафика. Переключаем.

Частые вопросы

Contextual Retrieval требует переиндексации всей базы. Как сделать без простоя?

Схема «синий/зелёный»: строим новый индекс параллельно со старым. Шаги: создаём новую коллекцию в Qdrant с суффиксом _v2. Запускаем контекстуализацию в фоне, не блокируя запросы к старой версии. После завершения тестируем на 10% трафика. Переключаем весь трафик. Удаляем старый индекс.

HyDE улучшает поиск, но добавляет задержку. Когда это оправдано?

Оправдано для вопросов «как?», «почему?», «что делать?». Для технической документации с плотным жаргоном. Когда базовая полнота поиска ниже 0.65. Не оправдано: для запросов по точным словам (артикулы, названия), когда время ответа уже на пределе договорного уровня, для простых FAQ где обычный поиск даёт полноту выше 0.85.

Как использовать кэширование Anthropic для удешевления индексации?

Ключ - группировка фрагментов одного документа в одной сессии подряд. Передавайте документ с полем cache_control: ephemeral. При последовательной обработке 20 фрагментов: первый вызов кэширует документ (полная стоимость). Следующие 19 - попадание в кэш (скидка 90% на входящие токены).

Сколько перефразировок оптимально: 3, 5 или 10?

По данным экспериментов на технических корпусах. Переход с 1 на 3 запроса даёт +8-12% полноты. С 3 на 5 - ещё +3-5%. С 5 на 10 - менее 1%. Задержка и стоимость растут линейно. Оптимум - 3-4 перефразировки. Исключение - медицина и юриспруденция, где терминология неоднозначна. Там 5 перефразировок оправданы.

Можно ли применить Contextual Retrieval с открытой LLM вместо Claude?

Можно. Llama-3.1-8B-Instruct или Qwen2.5-7B-Instruct дают сравнимое качество контекстуализации при запуске модели на своём сервере. Разница: нет кэширования как у Anthropic API. Замена: пакетная обработка через vLLM с общим префиксом (документ как системный запрос, фрагменты как пользовательские сообщения). Даёт похожую экономию.

Что дальше

Последний шаг перед выкаткой в боевую среду: как замерить достоверность ответа по источнику, полноту поиска и построить автоматическую проверку. Чтобы регрессия не пролезла незаметно. Читайте на /uchebnik/rag-evaluation.

Полная архитектура: /uchebnik/sverhpro-rag-dlya-nachinayuschih. От простого к сложному.

AI Компас (t.me/kosmoslab_ai) - канал для предпринимателей в РФ и СНГ, которые применяют AI в своём бизнесе без программиста. Разбираем инструменты и схемы - без курсов и теории.