Наивный RAG из туториалов врёт в проде. Тест на корпусе из 46 000 чанков показал recall@5 всего 35%, при этом модель уверенно выдавала правдоподобные ответы в 65% случаев. Отличить эти ответы от правильных без проверки невозможно. Решение - собственный тестовый набор из 30-50 вопросов с размеченными источниками и ловушками на галлюцинации. Он позволяет измерять retrieval и generation отдельно и проверять любые улучшения за час.
В этой статье разбираем рабочий кейс: сборка продакшн-решения на PostgreSQL и pgvector с нуля, без глубоких знаний ML. Замена эмбеддера подняла recall с 35% до 53%. Добавление контекста в чанки дало +18 пунктов на сложных вопросах. Гибридный поиск и реранкинг не окупились. Выбор модели-генератора поднял долю честных отказов с 73% до 100% при том же промпте. Итог - рабочий стек и пошаговый план для TypeScript-разработчика: от ингеста до CI-интеграции метрик.
Почему наивный RAG из туториалов врёт в проде
Стандартная схема из документации выглядит просто: разбей документы на чанки, получи эмбеддинги, сохрани в векторную базу, найди похожие по запросу, отправь их в LLM вместе с вопросом. Работает на демо. Ломается на реальных данных.
Кейс: корпус из 46 000 чанков, смешанные документы, техническая документация, статьи, внутренние регламенты. Наивный пайплайн с популярным эмбеддером дал recall@5 35%. Это значит, что в 65% случаев нужный фрагмент не попадал в топ-5 найденных чанков. Модель не получала правильный контекст, но всё равно генерировала ответ. Уверенно. Правдоподобно. Неверно.
Пример: пользователь спрашивает про лимиты API. Система находит чанки про аутентификацию, потому что там тоже встречается слово «лимит». Модель составляет ответ из этих чанков: называет лимит на количество запросов, хотя в документе речь шла про лимит на длину токена. Ответ выглядит связным. Без сверки с источником его невозможно отличить от правильного.
Проблема не в самой идее RAG. Проблема в отсутствии измеримого контура качества. Пока нет тестового набора, каждое изменение пайплайна - это гадание. Поменяли эмбеддер? Непонятно, стало ли лучше. Добавили реранкинг? Неизвестно, окупился ли он. Модель продолжает уверенно врать, а вы не знаете, в каких случаях.
Подход Context Engineering решает эту проблему на уровне каждого этапа пайплайна: парсинг, обработка вопроса, поиск, генерация. Ошибка на любом из четырёх этапов даёт уверенный неверный ответ. Наивный RAG не контролирует ни один из них. Подробнее о том, почему RAG галлюцинирует даже при идеальном промпте, читайте в разборе четырёх китов Context Engineering.
Ключевой инструмент: собственный тестовый набор из 30-50 вопросов
Тестовый набор - это 30-50 вопросов, для каждого из которых вручную размечены релевантные чанки и ожидаемое поведение системы. Набор решает две задачи: измеряет retrieval (нашёл ли поиск нужные чанки) и generation (сгенерировала ли модель правильный ответ или честно отказалась). Создание набора занимает 2-4 часа. Дальше любое изменение пайплайна проверяется за час.
Без тестового набора вы не знаете, работает ли ваша система. С ним вы получаете цифры: recall@5, precision@5, доля честных отказов, доля галлюцинаций. Цифры позволяют сравнивать эмбеддеры, модели, стратегии чанкинга. Цифры превращают улучшение RAG из искусства в инженерию.
Как разметить источники и создать ловушки на галлюцинации
Выбор вопросов - критический этап. Берите реальные запросы пользователей, если они есть. Добавляйте вопросы разной сложности: простые фактические («Какой лимит на размер файла?»), сложные аналитические («Какие ограничения действуют при одновременной загрузке нескольких файлов?»), вопросы с редкими терминами, вопросы с синонимами.
Для каждого вопроса разметьте релевантные чанки. Это чанки, которые содержат ответ. Обычно 1-3 чанка на вопрос. Если ответ размазан по пяти чанкам, это сигнал пересмотреть стратегию чанкинга.
Ловушки на галлюцинации - это вопросы, на которые в корпусе нет ответа. Система должна ответить отказом: «В документации нет информации об этом». Примеры ловушек:
- Вопрос про функцию, которой нет в документации.
- Вопрос про версию продукта, которой не существует.
- Вопрос, который звучит правдоподобно, но ответа в корпусе нет.
Ловушки проверяют главное: умеет ли система молчать, когда не знает. Модель, которая отвечает на все вопросы, в проде опаснее модели, которая иногда отказывается.
Пример разметки:
{
"question": "Какой максимальный размер файла для загрузки?",
"relevant_chunks": ["chunk_1234", "chunk_1235"],
"expected_answer": "50 МБ",
"is_trap": false
}
{
"question": "Как настроить интеграцию с Salesforce?",
"relevant_chunks": [],
"expected_answer": null,
"is_trap": true
}
30-50 вопросов достаточно для старта. Больше - лучше, но не жертвуйте качеством разметки ради количества. Каждый неправильно размеченный вопрос - это шум в метриках.
Метрики retrieval: измеряем поиск отдельно
Retrieval-метрики отвечают на вопрос: нашёл ли поиск нужные чанки? Измеряйте их отдельно от генерации. Если поиск не находит релевантный контекст, модель не сможет ответить правильно, какой бы хорошей она ни была.
Recall@k - доля вопросов, для которых хотя бы один релевантный чанк попал в топ-k результатов поиска. Основная метрика для RAG. Recall@5 35% означает, что для 65% вопросов правильный контекст не попал в топ-5. Это катастрофа.
Precision@k - доля релевантных чанков среди топ-k результатов. Показывает, сколько мусора попадает в контекст. Низкая precision раздувает промпт и сбивает модель.
MRR (Mean Reciprocal Rank) - средняя обратная позиция первого релевантного чанка. Если релевантный чанк на первой позиции, MRR = 1. На пятой - 0.2. Показывает, насколько высоко поиск поднимает нужный чанк.
Вычисление на тестовом наборе:
function calculateRecall(questions, searchResults, k) {
let hits = 0;
for (const q of questions) {
const topK = searchResults[q.id].slice(0, k);
const found = q.relevant_chunks.some(chunk => topK.includes(chunk));
if (found) hits++;
}
return hits / questions.length;
}
Запускайте расчёт после каждого изменения пайплайна. Recall упал - изменение ухудшило поиск. Recall вырос - улучшило. Просто и измеримо.
Метрики генерации: проверяем ответы на галлюцинации
Генерация оценивается по двум осям: faithfulness (верность источникам) и answer relevance (релевантность ответа вопросу). Faithfulness проверяет, что каждое утверждение в ответе подтверждается найденным контекстом. Answer relevance проверяет, что ответ вообще отвечает на вопрос.
Для оценки faithfulness используйте LLM-as-judge: дайте модели сгенерированный ответ и контекст, попросите проверить, подтверждается ли каждое утверждение контекстом. Это быстрее ручной проверки и достаточно надёжно для практических целей. О том, как оценивать качество длинных ответов с помощью LLM-as-judge и трейсинга, читайте в кейсе Similarweb.
Для ловушек метрика одна: доля правильных отказов. Система должна ответить отказом на вопрос без ответа в корпусе. Если модель отвечает на ловушку - это галлюцинация. Если отказывается - честное поведение.
Пример галлюцинации на ловушке:
Вопрос: «Как настроить интеграцию с Salesforce?»
Ответ системы: «Для настройки интеграции с Salesforce перейдите в раздел Настройки → Интеграции и выберите Salesforce из списка доступных сервисов...»
В документации нет ни слова про Salesforce. Модель выдумала ответ, потому что он звучит правдоподобно. Без ловушки вы бы никогда не узнали об этой проблеме.
Правильный отказ:
Вопрос: «Как настроить интеграцию с Salesforce?»
Ответ системы: «В предоставленной документации нет информации об интеграции с Salesforce. Проверьте, что вы используете актуальную версию документации, или уточните запрос.»
Эксперименты: что реально улучшает retrieval
Тестовый набор позволил провести серию экспериментов и получить измеримые результаты. Три изменения дали значимый эффект. Два - не окупились.
Замена эмбеддера: +18 пунктов recall
Первый эксперимент - замена эмбеддера. Наивный пайплайн использовал популярную мультиязычную модель. Замена на специализированный русский dense-эмбеддер подняла recall@5 с 35% до 53%. Это +18 пунктов без изменения остального пайплайна.
Выбор эмбеддера критичен для русского языка. Мультиязычные модели часто хуже работают на русском, чем специализированные. Если ваш корпус на русском, тестируйте русские эмбеддеры. О том, как создать лёгкий русский эмбеддер и какие ошибки стоят 14 пунктов recall, читайте в разборе оптимизации эмбеддеров.
Добавление контекста в чанки: +18 пунктов на сложных вопросах
Второй эксперимент - добавление контекста в чанки. Каждый чанк получал заголовок раздела, название документа и краткое описание родительской темы. Это дало +18 пунктов recall на сложных аналитических вопросах.
Пример чанка без контекста:
Лимит на размер файла составляет 50 МБ. При превышении лимита загрузка отклоняется.
Тот же чанк с контекстом:
[Документ: API Reference v2.3]
[Раздел: Загрузка файлов → Ограничения]
Лимит на размер файла составляет 50 МБ. При превышении лимита загрузка отклоняется.
Контекст помогает эмбеддеру лучше понимать смысл чанка и помогает модели правильно интерпретировать найденный фрагмент. На простых вопросах эффект минимален. На сложных - значителен.
Почему гибридный поиск и реранкинг не окупились
Гибридный поиск (векторный + BM25) и реранкинг - популярные техники улучшения retrieval. В этом кейсе они не дали значимого прироста. Recall остался на том же уровне, а latency выросла.
Причина - особенности данных. Корпус состоял из технической документации с чёткими терминами. Векторный поиск с хорошим эмбеддером уже находил нужные чанки. BM25 добавлял дубликаты, а реранкинг переупорядочивал уже правильные результаты.
Гибридный поиск полезен, когда в корпусе есть редкие термины, аббревиатуры, коды ошибок, которые эмбеддер плохо улавливает. Реранкинг полезен, когда топ-50 содержит нужный чанк, но топ-5 - нет. Если recall@50 уже высокий, а recall@5 низкий, реранкинг может помочь. Если recall@50 низкий, проблема в эмбеддере или чанкинге, а не в реранкинге.
Тестируйте каждую технику на своём тестовом наборе. Не внедряйте гибридный поиск только потому, что о нём пишут в туториалах.
Как научить систему честно отказываться
Галлюцинации в RAG возникают, когда модель отвечает на вопрос, на который в найденном контексте нет ответа. Решение - научить систему отказываться. Это полноценная фича, которую нужно проектировать и тестировать.
Ограничение ответов источниками: промпт-инжиниринг
Базовый промпт для ограничения ответов источниками:
Ты - ассистент, который отвечает на вопросы пользователя на основе предоставленного контекста.
Правила:
1. Используй только информацию из контекста.
2. Если в контексте нет ответа на вопрос, ответь: «В предоставленной документации нет информации об этом».
3. Не добавляй информацию, которой нет в контексте.
4. Не используй свои знания о мире.
Контекст:
{context}
Вопрос: {question}
Промпт - необходимое, но недостаточное условие. Модель может игнорировать инструкции, особенно если вопрос звучит правдоподобно. Проверяйте соблюдение ограничений на ловушках.
Тестирование отказов как полноценной фичи
Отказы - это не побочный эффект, а полноценная фича. Пользователь должен получать отказ, когда система не знает ответ, а не правдоподобную выдумку. Тестируйте отказы так же, как правильные ответы.
В тестовом наборе ловушки занимают 20-30% вопросов. Метрика - доля правильных отказов. Если система отвечает на ловушку, это баг. Если отказывается на вопрос с ответом в корпусе - тоже баг, но менее критичный.
Пример правильного отказа на ловушке и неправильного отказа на обычном вопросе:
// Правильный отказ
Вопрос: «Как настроить интеграцию с Salesforce?»
Ответ: «В предоставленной документации нет информации об интеграции с Salesforce.»
// Неправильный отказ
Вопрос: «Какой максимальный размер файла для загрузки?»
Ответ: «В предоставленной документации нет информации об этом.»
// В документации есть ответ: 50 МБ
Выбор модели-генератора: решающий фактор
При том же промпте разные модели показывают разную склонность к галлюцинациям. В кейсе замена модели-генератора подняла долю честных отказов с 73% до 100%. Промпт не менялся. Изменилась модель.
Тестируйте модели на своём тестовом наборе. Метрики: доля правильных ответов, доля честных отказов, доля галлюцинаций. Модель, которая отвечает на все вопросы, может быть хуже модели, которая честно отказывается.
О том, как модели оценивают собственную уверенность и как это использовать для сокращения затрат на инференс, читайте в разборе метода Cactus Hybrid.
Рабочий стек и пошаговый план для TypeScript-разработчика
Итоговый стек: PostgreSQL с расширением pgvector, русский dense-эмбеддер, модель-генератор с низкой склонностью к галлюцинациям, тестовый набор из 40 вопросов. Всё реализуемо на TypeScript без глубоких знаний ML.
Ингест данных: подготовка чанков с контекстом
Процесс ингеста:
- Разбейте документы на чанки по 500-1000 токенов с перекрытием 10-15%.
- Добавьте контекст в каждый чанк: заголовок раздела, название документа, описание родительской темы.
- Получите эмбеддинги для каждого чанка через API эмбеддера.
- Сохраните чанки и эмбеддинги в PostgreSQL с pgvector.
// Схема таблицы
CREATE TABLE chunks (
id SERIAL PRIMARY KEY,
document_id INTEGER NOT NULL,
content TEXT NOT NULL,
context TEXT NOT NULL,
embedding vector(768) NOT NULL
);
// Индекс для векторного поиска
CREATE INDEX ON chunks USING ivfflat (embedding vector_cosine_ops);
Реализация retrieval и генерации
Поиск по запросу:
async function retrieve(query: string, k: number = 5): Promise {
const queryEmbedding = await embed(query);
const result = await pool.query(
`SELECT id, content, context, 1 - (embedding <=> $1) AS similarity
FROM chunks
ORDER BY embedding <=> $1
LIMIT $2`,
[queryEmbedding, k]
);
return result.rows;
}
Формирование промпта и вызов LLM:
async function generateAnswer(question: string, chunks: Chunk[]): Promise {
const context = chunks.map(c => c.context + '\n' + c.content).join('\n\n');
const prompt = buildPrompt(question, context);
const answer = await callLLM(prompt);
return answer;
}
CI-интеграция метрик: автоматическая проверка качества
Тестовый набор - это код. Запускайте его в CI при каждом изменении пайплайна. Пороговые значения: recall@5 не ниже 50%, доля честных отказов не ниже 90%. Если метрики падают ниже порога, сборка не проходит.
// ci-test.ts
const results = await runTestSuite(testSet);
const recall = calculateRecall(results, 5);
const refusalRate = calculateRefusalRate(results);
if (recall < 0.5) {
throw new Error(`Recall@5 below threshold: ${recall}`);
}
if (refusalRate < 0.9) {
throw new Error(`Refusal rate below threshold: ${refusalRate}`);
}
CI-интеграция превращает качество RAG из ручной проверки в автоматический процесс. Каждое изменение проходит через тестовый набор. Регрессии видны сразу.
Заключение: главные выводы и ограничения
Главный вывод: без тестового набора вы не знаете, работает ли ваша RAG-система. Создайте 30-50 вопросов с размеченными источниками и ловушками на галлюцинации. Это займёт 2-4 часа и сэкономит недели отладки.
Второй вывод: выбор эмбеддера и модели-генератора критичен. Замена эмбеддера дала +18 пунктов recall. Замена модели подняла долю честных отказов с 73% до 100%. Эти изменения не требуют перестройки пайплайна, но дают значимый эффект.
Третий вывод: добавление контекста в чанки дёшево и эффективно. +18 пунктов на сложных вопросах за счёт заголовков и метаданных - это лучший ROI среди всех экспериментов.
Ограничения: результаты получены на конкретном корпусе из 46 000 чанков технической документации. На других данных цифры будут другими. Гибридный поиск и реранкинг могут быть полезны на корпусах с редкими терминами и кодами. Тестируйте на своих данных.
Начните с создания тестового набора. Это первый шаг к измеримому качеству RAG.