Помощник страховой компании ответил, что полис покрывает аренду автомобиля на десять дней. В найденном документе было написано «до пяти дней», но этот фрагмент не попал в контекст. Ответ звучал спокойно, ссылка внизу вела на настоящий PDF, оператор его отправил. Наличие RAG и ссылки не помешало системе выдумать условие договора.
Слово «галлюцинация» здесь мешает больше, чем помогает. Оно рисует картину модели, у которой случился припадок, тогда как произошла обычная инженерная вещь: в контексте не было нужного абзаца, и система заполнила пропуск самым правдоподобным содержимым. Ровно так же ведёт себя сотрудник, которому не дали документ, но очень настойчиво попросили ответить прямо сейчас.
Галлюцинация возникает не только внутри модели. Поиск может не найти нужный документ, chunk потеряет оговорку, индекс окажется старым, а постобработка приклеит ссылку к неподтверждённой фразе. Управлять нужно всей цепочкой и последствиями ошибки.
Сначала разделите режимы риска
Для черновика внутренней заметки допустим свободный ответ с маркировкой. Для условий полиса, цены, медицинской рекомендации или банковской операции нужны авторитетный источник, проверяемая цитата и право отказаться. Если ошибка меняет деньги, права или здоровье, одной инструкции «не выдумывай» мало.
Практический контракт ответа:
class Claim(BaseModel):
text: str
source_id: str
quote: str
class GroundedAnswer(BaseModel):
answer: str | None
claims: list[Claim]
status: Literal["answered", "insufficient_evidence", "conflict"]После генерации сервис проверяет, что каждый source_id был выдан retrieval, цитата дословно содержится в сохранённой версии документа, а существенные утверждения покрыты claims. Проверка ссылки без проверки entailment недостаточна: настоящий документ может говорить обратное.
def validate_citations(result: GroundedAnswer, docs: dict[str, str]) -> None:
for claim in result.claims:
if claim.source_id not in docs:
raise ValueError("Источник не был выдан модели")
if normalize(claim.quote) not in normalize(docs[claim.source_id]):
raise ValueError("Цитата отсутствует в зафиксированном документе")Entailment можно проверять отдельной моделью, но на критичных ответах спорные случаи должны уходить человеку. Judge является фильтром, а не нотариусом.
Разница практическая: фильтр может ошибаться в обе стороны, и вы обязаны знать, с какой частотой. Нотариус, в отличие от фильтра, отвечает за подпись, и такой роли у модели нет и не будет.
Отказ является рабочим результатом
Система должна различать отсутствие доказательств, конфликт источников и технический сбой. Во всех трёх случаях красивый ответ вреднее честного статуса. Для страхового кейса retrieval без пункта о сроке должен вернуть insufficient_evidence; два действующих документа с разными сроками дают conflict; таймаут хранилища является ошибкой сервиса.
Порог отказа калибруют по данным. Слишком низкий порог даёт уверенные выдумки, слишком высокий превращает помощника в дорогую кнопку «позовите оператора». Измеряйте отдельно unsupported claim rate, корректность отказа и coverage.
Ограничивайте blast radius
Модель не должна напрямую записывать сгенерированный факт в CRM, начислять компенсацию или менять договор. Для операций используйте типизированные аргументы, policy checks и подтверждение. В интерфейсе отделяйте цитату от пересказа, показывайте дату и версию источника. В журнале сохраняйте запрос, ids и версии найденных chunks, ответ модели и результат проверок.
Для часто меняющихся данных полезен короткий TTL или запрос к системе учёта вместо RAG. Остаток на складе из вчерашнего индекса является не галлюцинацией модели, а плохой архитектурой с тем же итогом для покупателя.
Failure modes
Источник существует, утверждение не следует из него. Проверяйте claim-level entailment, а не наличие любой ссылки.
Цитата вырвана из исключения. Chunking обязан сохранять заголовок, соседние условия и идентификатор версии документа.
Модель отвечает из памяти при пустом retrieval. Пустой результат должен программно запрещать grounded answer.
Устаревший индекс. Записывайте document_version и indexed_at, задавайте freshness SLO.
Verifier видит тот же неполный контекст. Он не обнаружит пропущенный пункт. Тестируйте retrieval отдельно и добавляйте cases, где ответ лежит за границей chunk.
Пользователь принимает черновик за решение. Маркировка нужна, но критичный процесс должен блокировать отправку непроверенного ответа, а не надеяться на серый бейдж.
Что показывать пользователю
Интерфейс — это последний рубеж, и он делает больше для снижения ущерба, чем половина промпт-инжиниринга.
- Цитата отдельно от пересказа. Пользователь должен видеть, где слова документа, а где слова модели. Смешанный текст лишает его единственной возможности проверить.
- Дата и версия источника рядом с ответом. «Согласно регламенту от 3 марта» проверяемо, «согласно регламенту» — нет.
- Отказ выглядит как результат, а не как поломка. Список того, что известно, чего не хватает, и куда идти дальше.
- Черновик не отправляется одной кнопкой. Если процесс критичный, непроверенный ответ блокируется, а не помечается серым бейджем, который через неделю перестают замечать.
Правило главы
Не обещайте устранить галлюцинации. Требуйте доказательство для каждого существенного утверждения, различайте нехватку данных и конфликт, разрешайте отказ и не давайте неподтверждённому тексту выполнять дорогие действия.
Чеклист главы
Практикум
Задача 40.1 — QA-сервис с проверкой цитат. ⭑⭑⭑
Соберите QA-сервис по десяти версиям правил возврата и возвращайте GroundedAnswer с проверкой цитат кодом.
Критерии приёмки:
Подсказки:
- Оговорка в соседнем chunk — самый ценный тип кейса: он ловит одновременно нарезку и проверку покрытия.
- Храните версию документа рядом с ответом; без неё проверка цитат через месяц перестанет работать.
Задача 40.2 — удалить факт и посмотреть, что будет. ⭑⭑
Удалите из индекса chunk с лимитом суммы и проверьте поведение системы.
Критерии приёмки:
Подсказки:
- Восстановление числа «из памяти модели» проще всего увидеть на редких значениях: круглые числа модель угадывает слишком часто.
- Freshness gate стоит навешивать на retrieval, а не на генерацию: иначе вы платите токенами за ответ, который всё равно нельзя показывать.