Typed Answer Contract: стоп галлюцинациям RAG — гайд 2026 | AiManual
AiManual Logo Ai / Manual.
05 Июл 2026 Гайд

Как предотвратить галлюцинации в RAG с помощью типизированного контракта ответа

Разбираем технику типизированного контракта ответа для RAG: почему промпты не спасают, как Pydantic режет галлюцинации, полный код и нюансы.

Галлюцинации — это не баг, а фича? Нет, это баг.

Когда твой RAG-пайплайн начинает рассказывать, что "кофеин содержится в дистиллированной воде", хочется выкинуть ноутбук в окно. Я видел десятки RAG-систем, которые на 80% выдают правду, а потом внезапно несут чушь, потому что LLM «дополнила» ответ из своего тренировочного набора. Стандартные промпты в стиле «отвечай только по контексту» ломаются при первом же сложном запросе. Почему? Потому что ты даёшь модели свободу выбора формата ответа, а свобода в мире LLM — это прямой билет к галлюцинациям.

В этой статье мы наденем на модель смирительную рубашку — типизированный контракт ответа (Typed Answer Contract). Не очередной промпт, а строгая схема, которую LLM обязана соблюдать. Я покажу код на Python с Pydantic, разберу типичные ошибки и докажу, что без контракта любой RAG — это рулетка.

Анатомия галлюцинации: почему мягкие методы не работают

Ты пишешь в system prompt: "Если в контексте нет ответа, скажи 'я не знаю'". Модель кивает, но на выходе выдаёт "по моим данным..." и придумывает факт. Это не злой умысел, а архитектура: трансформеры учатся заполнять пробелы правдоподобно. Чем больше свободы в структуре ответа, тем выше шанс, что модель использует свои внутренние знания, а не переданный контекст.

В предыдущем разборе RAG я уже касался проблемы: модель сложно заставить честно признаться в незнании. Но есть более радикальный подход — не просить, а требовать.

Идея контракта: перестань просить, начни диктовать

Типизированный контракт ответа — это JSON-схема или Pydantic-модель, которая описывает не только формат, но и обязательные поля: answer (сам ответ), confidence (уверенность), source_chunks (какие куски контекста использованы), is_supported (флаг, подтверждён ли ответ контекстом). Модель вынуждена заполнить все поля. Если она не может найти ответ в контексте, она выставит is_supported: false и может вообще отказаться от ответа. Это не «пожалуйста, не выдумывай», а «ты обязана заполнить эту структуру, иначе я не приму вывод». LLM понимает, что нарушение контракта = ошибка.

Важное отличие от простого JSON mode: контракт требует обязательных полей, валидации типов и логических связей. Например, если is_supported: true, то confidence должен быть выше 0.7. Если контекст пустой или нерелевантный — весь блок ответа может быть отклонён.

Связь с агентным RAG прямая: контракт — это первый шаг к контролируемому циклу генерации. Модель больше не вольна выбирать форму ответа, она подчиняется схеме.

Реализация на Python: от промпта к контракту

Пойдём от противного: сначала покажу, как НЕ надо делать, а потом — правильный вариант с Pydantic. Предположим, у нас есть RAG-система с ретривером (например, Chroma). Мы получаем контекст и генерируем ответ.

1 Типичная ошибка: строковый ответ без контроля

import openai

def generate(context: str, query: str) -> str:
    prompt = f"""Ответь на вопрос только на основе контекста.
    Если в контексте нет данных, скажи "не знаю".
    Контекст: {context}
    Вопрос: {query}
    Ответ:"""
    response = openai.chat.completions.create(
        model="gpt-4o",  # или gpt-5 на 2026
        messages=[{"role": "user", "content": prompt}]
    )
    return response.choices[0].message.content

Что произойдёт? Модель может ответить красиво, но если контекст пустой — она всё равно что-то напишет, потому что промпт не запрещает прямо, а просто «просит». Теперь добавим контракт.

2 Правильная реализация с Pydantic и structured output

from pydantic import BaseModel, Field
from typing import List, Optional
import openai

class AnswerContract(BaseModel):
    answer: Optional[str] = Field(
        default=None,
        description="Финальный ответ на вопрос. Если ответ не подтверждён контекстом, значение None."
    )
    is_supported: bool = Field(
        description="True, если ответ полностью подтверждён предоставленными чанками."
    )
    confidence: float = Field(
        ge=0.0, le=1.0,
        description="Уверенность в ответе на основе контекста (0-1)."
    )
    used_chunks: List[int] = Field(
        default_factory=list,
        description="Индексы чанков (из переданного списка), которые использованы для ответа."
    )

def generate_with_contract(query: str, chunks: List[str]) -> AnswerContract:
    # ВАЖНО: передаём контекст в формате, который модель может проиндексировать
    chunk_text = "\n---\n".join([f"[{i}] {chunk}" for i, chunk in enumerate(chunks)])
    
    system_prompt = """Ты — RAG-ассистент. Твоя задача — ответить на вопрос,
    используя предоставленные фрагменты текста. Ты ОБЯЗАН заполнить структуру AnswerContract.
    Если ни один фрагмент не содержит ответа, установи is_supported=False,
    confidence=0.0, answer=None. Не придумывай информацию."""
    
    user_prompt = f"Вопрос: {query}\n\nКонтекст:\n{chunk_text}"
    
    response = openai.beta.chat.completions.parse(
        model="gpt-4o",  # уточни версию по документации 2026
        messages=[
            {"role": "system", "content": system_prompt},
            {"role": "user", "content": user_prompt}
        ],
        response_format=AnswerContract
    )
    return response.choices[0].message.parsed

Заметили response_format=AnswerContract? Это структурированный вывод (structured output), доступный в OpenAI и других провайдерах (Anthropic, Gemini). Модель не просто генерирует JSON, а следует схеме: все обязательные поля, типы, ограничения. Если модель попытается заполнить confidence строкой или пропустит is_supported — вызов упадёт с ошибкой. На этапе разработки это сразу выявляет галлюцинации.

💡
На практике я рекомендую использовать pydantic.v1 для старых моделей или instructor библиотеку — она поддерживает несколько LLM и патчит вызовы. Кстати, подход с контрактом отлично сочетается с структурированным парсингом вопросов — на входе тоже контракт, на выходе контракт.

Арбитр как второй контур: когда контракт не сработал

Даже с контрактом остаётся риск: модель может вернуть is_supported: true, хотя на самом деле ответ не подтверждён. Это редкий сценарий, но он есть. Тут в игру вступает второй LLM — арбитр. Он валидирует выполнение контракта постфактум. В статье самовосстанавливающийся RAG мы делали похожий цикл. Сейчас арбитр проверяет, что confidence адекватен и used_chunks действительно содержат ответ. Если арбитр обнаруживает несоответствие — он перезапускает генерацию с уточнённым промптом.

Типичные ошибки при внедрении контракта

Вот что я встречал в своих проектах и в коде коллег.

  • Слишком сложная схема. Если в контракте 15 полей, модель начинает выдумывать значения. Держите 3-5 полей, остальное — опционально.
  • Несоответствие типов. Например, поле confidence типа float с ограничением 0-1, но модель иногда выдаёт 0.9999 — это ок. Но если она выдаёт 2 — ошибка. Включайте strict=True в Pydantic.
  • Забыли про None. Обязательно разрешайте answer=None при отсутствии данных. Иначе модель вынуждена врать.
  • Игнорирование used_chunks. Если модель указывает чанки, нужно проверять, что в них действительно содержится ответ. Это уже задача арбитра, но можно делать простую проверку: совпадает ли ответ с текстом чанков.

Кстати, в OCC-RAG используется другой подход — компактные модели, обученные не галлюцинировать. Но контракт с ними тоже работает: вы просто добавляете response_format к вызову локальной модели, если она поддерживает structured output (например, через llama.cpp или vLLM).

FAQ: частые вопросы

ВопросОтвет
Не увеличит ли контракт latency?На 10-20% из-за структурированного вывода, но это окупается качеством. Можно кэшировать схемы.
Какие модели поддерживают response_format?OpenAI gpt-4o-2024-08-06+, Gemini 1.5 Pro, Claude 3 Sonnet (через instructor). На 2026 почти все мейнстрим-модели.
Что делать, если модель постоянно возвращает is_supported=False?Проверьте ретривер. Возможно, контекст нерелевантный. Используйте better chunking или гибридный поиск.

Вместо финала: контракт — это не панацея, но фундамент

Типизированный контракт не решит всех проблем RAG сам по себе. Он не заменит качественный ретривер, чистые данные и нормальную оценку. Но он даёт тебе контроль там, где раньше была анархия. Когда модель обязана заполнить поле confidence, ты сразу видишь, что она сомневается. И можешь решить: довериться или запросить уточнение.

Следующий шаг — внедрение контрактов в CI/CD для RAG. Мы уже тестируем это в продакшене: пайплайн генерирует ответ, арбитр проверяет, если контракт не пройден — ответ не отправляется пользователю, а логируется для анализа. Это превращает галлюцинации из проблемы в метрику.

Неочевидный совет: попробуй добавить в контракт поле ambiguous_interpretations — список альтернативных трактовок вопроса. Если модель понимает, что вопрос двусмыслен, она может явно указать это, а не выбирать одну версию наугад. Так ты превращаешь слабость (неоднозначность) в преимущество.

Подписаться на канал