Помощник поддержки уверенно сообщил клиенту, что возврат занимает 30 дней. В параметрах модели ошибки не было. Поиск поднял регламент трёхлетней давности, генератор аккуратно пересказал найденное. RAG сработал именно так, как его построили.
Повторю отдельной строкой, иначе дальше весь разговор пойдёт мимо: модель не соврала. Она добросовестно пересказала документ, который ей принесли. Виноват не пересказчик, а библиотекарь, который снял с полки папку с надписью «Регламент» и не посмотрел на год.
RAG состоит из двух разных систем. Retrieval выбирает свидетельства, generation формулирует ответ по ним. Между ними нужен контракт: фрагмент несёт идентификатор документа, версию, дату, права доступа и оценку релевантности. Без этого в prompt приезжают безымянные абзацы, а расследование заканчивается словом «галлюцинация».
Что RAG делает и чего не делает
Не делает: не добавляет модели знаний, не обучает её, не исправляет её склонность звучать уверенно. Модель после RAG знает ровно столько же, сколько знала. Просто теперь у неё на столе лежат несколько бумажек.
Делает: приносит свидетельства, к которым можно привязать ответ и по которым можно потом проверить, откуда взялась цифра. Ценность RAG не в том, что ответ стал умнее, а в том, что он стал прослеживаемым.
Из этого следует вывод, который экономит командам месяцы: если вопрос требует не свидетельств, а вычисления (сколько осталось на счёте, свободен ли слот, разрешено ли этому клиенту), никакой RAG не поможет. Это работа кода. Модель здесь интерпретирует запрос, а не считает — граница из главы 2, просто в новом костюме.
Рабочий поток
question -> normalize -> authorize -> retrieve -> rerank
-> context policy -> generate -> validate citations -> answerСначала область поиска ограничивают tenant, продуктом, языком и действующей версией. Затем получают кандидатов, переранжируют их и только после этого тратят токены генератора.
async def answer(question: str, tenant_id: str) -> dict:
candidates = await search(
query=question,
filters={"tenant_id": tenant_id, "status": "active"},
limit=30,
)
evidence = await rerank(question, candidates, limit=6)
if not evidence or evidence[0].score < 0.62:
return {"status": "insufficient_evidence", "answer": None}
result = await generate(question=question, sources=evidence)
allowed = {item.source_id for item in evidence}
if not set(result.citations) <= allowed:
raise ValueError("model cited a source absent from context")
return result.model_dump()Порог 0.62 здесь не универсальная константа. Его выбирают на своём наборе вопросов, сравнивая полноту и шум. Для запроса «как вернуть деньги» отсутствие свидетельства лучше бодрого вымысла.
Две строки в этом коде стоят дороже остальных.
filters идёт в запрос, а не в постобработку. Отфильтровать чужие документы после поиска — значит сначала показать их ранжировщику, потом себе в логах, а при первой же ошибке в коде — и клиенту. Права проверяются до, а не после. Подробнее об этом — глава 22.
Проверка цитат ловит любимый жанр моделей: сослаться на источник, которого в контексте не было. Иногда это склеенный из двух реальных идентификатор, иногда правдоподобное имя файла, который никогда не существовал. Множество разрешённых источников известно вам точно, сравнение стоит микросекунды, и оно превращает «модель иногда придумывает ссылки» в невозможное состояние.
Отказ — это фича, а не деградация
Статус insufficient_evidence встречает сопротивление продукта: «мы же не можем ничего не ответить». Можете. Хуже того, вы обязаны — потому что альтернатива не «ответить», а «выдумать».
Формулировка отказа решает, как это воспримет человек. «Не знаю» раздражает. «В базе знаний нет действующего регламента по возвратам для вашего тарифа; вот три близких документа и кнопка на живого оператора» — нормальный продуктовый ответ. Тот же самый insufficient_evidence, только одетый по погоде.
Отказ ещё и дешёвый: он экономит токены генератора ровно там, где ответ всё равно был бы мусором.
Что проверять отдельно
Retrieval оценивают по тому, попал ли нужный источник в top-k. Generation оценивают при заранее выданном правильном контексте. Если смешать проверки, смена prompt замаскирует плохой индекс, а хороший поиск получит вину за неверный пересказ.
Практическая матрица разбора одного плохого ответа:
| Нужный документ в top-k? | Ответ верный? | Диагноз |
|---|---|---|
| нет | нет | чинить retrieval: индекс, фильтры, запрос, чанкинг |
| да | нет | чинить generation: prompt, контекст-политику, модель |
| да | да, но без ссылки | чинить контракт цитат |
| нет | да | опасная удача: модель ответила из памяти, а не по свидетельству |
Последняя строка — самая коварная. Ответ верный, метрика зелёная, а система не работает: в следующий раз она с той же уверенностью ответит из памяти неправильно. Такие случаи стоит ловить отдельно и считать отказами.
Основные отказы
Индекс отстал от публикации; фильтр выбрал чужого tenant; запрос требует нескольких документов; reranker не знает внутреннего артикула; модель ссылается на источник, которого не видела; все кандидаты слабы, но система всё равно отвечает. Для каждого отказа нужен наблюдаемый статус, а не общий 500.
Отдельная категория — вопросы, требующие сборки из нескольких документов («какой тариф выгоднее при таком объёме»). Retrieval честно приносит два регламента, генератор честно смешивает их в один правдоподобный ответ, и никто не замечает, что арифметику никто не делал. Такие вопросы либо маршрутизируются в код, либо явно разбиваются на шаги (глава 13).
Наблюдаемость
Минимальный набор, без которого расследование превращается в спиритический сеанс:
- запрос после нормализации и применённые фильтры;
- список кандидатов с оценками до и после reranking;
- итоговые
source_idс версиями — те самые, что ушли в контекст; - статус:
answered,insufficient_evidence,citation_violation,no_access; - доля отказов и её динамика.
Резкий рост insufficient_evidence означает не деградацию модели, а изменение в данных: переиндексация не доехала, кто-то поменял status у половины документов, сменился язык запросов.
Правило главы
RAG не добавляет модели знания. Он предъявляет ей несколько свидетельств. Качество системы определяется тем, нашли ли нужные свидетельства, разрешено ли их показывать и умеет ли сервис отказаться при их отсутствии.
Чеклист главы
Практикум
Задача 19.1 — раздельные метрики. ⭑⭑
Соберите 50 вопросов с известными документами-ответами, включая десять вопросов, ответа на которые в корпусе нет. Измерьте recall@5 для retrieval отдельно от точности генерации при заранее выданном правильном контексте.
Критерии приёмки:
Подсказки:
- Пятьдесят вопросов собираются за вечер из реальных тикетов; придуманные вопросы дают красивые и бесполезные метрики.
- Вопросы без ответа в корпусе — самая ценная часть набора. Именно на них видно, умеет ли система молчать.
Задача 19.2 — контракт цитат. ⭑⭑
Введите обязательные ссылки на источники в ответе и валидацию цитат по множеству фрагментов, попавших в контекст.
Критерии приёмки:
Подсказки:
- Просить модель «указывать источники» в свободной форме бесполезно: нужен идентификатор из контекста и схема, которая его требует.
- Если модель начала отказываться слишком часто, проверьте сначала чанкинг, а не промпт.
Задача 19.3 — устаревшая версия и чужой tenant. ⭑⭑⭑
Устройте своей системе два целевых отказа: подмените действующий документ устаревшей версией и попробуйте достать документ чужого tenant.
Критерии приёмки:
Подсказки:
- Задержку индексации удобно мерить синтетическим документом-канарейкой, который публикуется по расписанию.
- Проверку прав тестируйте не только на поиске, но и на прямом чтении фрагмента по
source_id: обходной путь находится именно там.