Валидация ответа RAG: проверка формы, цитат и обратная связь | AiManual
AiManual Logo Ai / Manual.
06 Июл 2026 Гайд

Валидация ответа RAG: почему доверять LLM на слово — дорога в ад (и как это исправить)

Глубокое руководство по пост-генерационной валидации RAG: проверка JSON-схемы, верификация цитат, сбор обратной связи. Код, примеры, типичные ошибки.

Генерация — только полдела. Дальше начинается ад

У вас есть RAG-пайплайн. Chunk'и проиндексированы, ретривер выдёргивает топ-5, LLM генерирует ответ. Вы выкатываете в прод и... пользователи жалуются на "странные цифры", "битые JSON" или "этого нет в документации".

Знакомо? Добро пожаловать в клуб.

Проблема в том, что большинство RAG-систем заканчиваются на вызове LLM. Дальше — чёрная дыра. Ответ улетает клиенту, а если там галлюцинация, кривой JSON или неверная цитата — вы узнаёте об этом через неделю из тикета поддержки.

Единственный способ не сойти с ума — пост-генерационная валидация. Это прослойка между LLM и UI, которая проверяет:

  • Формат ответа (структура, типы, обязательные поля)
  • Цитаты (каждое утверждение подкреплено источником)
  • Обратную связь (чтобы система училась на ошибках)

В этой статье я покажу, как собрать такой валидатор на Python. Код, логи, репозиторий — всё как вы любите. А заодно разберём, почему половина решений из интернета не работает.

Три кита: форма, факт, фидбек

Представьте, что ваш RAG — это курьер. Вы заказали пиццу (запрос), курьер её приготовил (LLM сгенерировал). Но прежде чем отдать клиенту, вы проверяете:

  • Пицца в коробке? (формат)
  • Начинка соответствует заказу? (факт/цитаты)
  • Клиент доволен? (фидбек)

Без первого шага — UI падает. Без второго — юристы пишут претензии. Без третьего — вы никогда не узнаете, что курьер каждый раз роняет пиццу.

Поэтому сначала разберём валидацию формата, потом факта, а затем — как замкнуть петлю обратной связи.

1 Схема ответа как контракт

LLM может вернуть что угодно. Даже если вы попросили JSON — получите "json" с лишними пробелами, markdown-блоком или вообще текст. Единственный способ защититься — заранее объявить Pydantic-модель и парсить ответ в неё, а не на коленке.

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

class Quote(BaseModel):
    text: str = Field(..., description="Цитата из документа")
    source: str = Field(..., description="Идентификатор документа/чанка")
    page: Optional[int] = Field(None, ge=1)

class RAGResponse(BaseModel):
    answer: str = Field(..., min_length=10, description="Основной ответ")
    quotes: List[Quote] = Field(default_factory=list, max_length=5)
    confidence: float = Field(..., ge=0.0, le=1.0)
    generated_at: datetime = Field(default_factory=datetime.now)

Теперь, когда LLM выдаёт ответ, мы пытаемся распарсить. Если не выходит — это структурная галлюцинация. Тут два варианта:

  • Повторить запрос с явным примером (few-shot)
  • Вернуть fallback-ответ: "Извините, я не смог корректно обработать данные"

💡 Я предпочитаю второй вариант. Повторная генерация — это рандом: модель может снова сгенерировать кривой JSON. Лучше честно сказать пользователю, чем кормить его мусором. Подробнее про контракты ответа — в статье "Как предотвратить галлюцинации в RAG с помощью типизированного контракта ответа".

Но парсинг — только половина дела. Нужно ещё проверить, что цитаты действительно есть в индексированных документах. Иначе это та же галлюцинация, только красиво оформленная.

2 Цитаты не врут — пока их не проверят

Даже если LLM выдала список источников — не факт, что они реальны. Модель может сгенерировать "стр. 42" для документа, где всего 10 страниц. Или придумать цитату, похожую на правду.

Проверка цитат — это grounding. У нас есть оригинальные чанки (из векторной базы или поиска). Мы должны убедиться, что каждая цитата действительно содержится в соответствующем документе.

Проще всего — извлечь эмбеддинги цитат и сравнить с эмбеддингами чанков через косинусную близость. Но это дорого. Есть способ быстрее: использовать spans & overlap.

def verify_quote(quote: str, chunk_text: str) -> bool:
    """Проверяет, что цитата содержится в тексте чанка."""
    normalized_quote = quote.lower().strip()
    normalized_chunk = chunk_text.lower()
    return normalized_quote in normalized_chunk

Да, это примитивно. Но в 80% случаев работает. Для более точной проверки используйте fuzzy matching (difflib, rapidfuzz) или LLM-as-a-judge, как описано в статье "LLM-as-a-judge: как оценивать RAG-системы и находить слабые места".

Важный нюанс: если цитата не прошла проверку, не нужно молча её удалять и выдавать ответ без источников. Это снижает доверие. Лучше:

  • Пометить эту цитату как "неподтверждённую" и показать рядом оригинальный чанк
  • Снизить итоговый score доверия
  • Логировать инцидент для анализа

⚠️ Никогда не удаляйте цитату без ведома пользователя! Это считается обманом. Лучше показать честный статус "проверка не пройдена", чем скрывать.

3 Когда LLM молчит — обратная связь спасает

Валидация формата и цитат — это защита от дурака. Но как понять, что ответ полезен пользователю? Единственный способ — спросить у него. Да, UX-гуру скажут, что кнопки "лайк/дизлайк" — прошлый век. Но если у вас B2B-продукт, где каждый ответ влияет на решение — обратная связь критична.

Реализуйте минимальный REST-эндпоинт:

from fastapi import APIRouter, HTTPException
from pydantic import BaseModel

router = APIRouter()

class Feedback(BaseModel):
    response_id: str
    rating: int  # 1-5
    comment: Optional[str] = None
    corrected_answer: Optional[str] = None

feedback_store = []

@router.post("/feedback")
def submit_feedback(fb: Feedback):
    feedback_store.append(fb.model_dump())
    # Тут можно отправить в Kafka или просто в лог
    return {"status": "ok"}

Собирайте фидбек и используйте его для двух вещей:

Главная ошибка — хранить фидбек "в стол", не анализируя. Без автоматического анализа вы просто коллекционируете боль пользователей, но не лечите причину.

Пошаговый план: от генерации до панели мониторинга

Сведём всё в единый пайплайн. Будем использовать структурированный вывод (Pydantic) и цепочку валидаторов.

from pydantic import ValidationError
from typing import Optional

class ValidationResult(BaseModel):
    passed: bool
    reason: Optional[str] = None
    corrected_response: Optional[RAGResponse] = None

def validate_response(
    raw_response: str,
    source_chunks: List[str]
) -> ValidationResult:
    # Шаг 1: парсинг JSON и Pydantic
    try:
        parsed = RAGResponse.model_validate_json(raw_response)
    except (ValidationError, ValueError) as e:
        return ValidationResult(passed=False, reason=f"Struct error: {e}")

    # Шаг 2: проверка цитат
    for q in parsed.quotes:
        # находим чанк по source (упрощённо)
        # на практике нужен маппинг id->текст
        if q.source not in chunk_map:
            return ValidationResult(passed=False, reason=f"Unknown source: {q.source}")
        chunk_text = chunk_map[q.source]
        if not verify_quote(q.text, chunk_text):
            return ValidationResult(passed=False, reason="Quote not found in source")
    # Шаг 3: дополнительно проверяем confidence
    if parsed.confidence < 0.5:
        return ValidationResult(passed=False, reason="Low confidence")
    return ValidationResult(passed=True)

Теперь вы можете вставить этот валидатор между LLM и UI. Если returned passed=False — сразу fallback.

Следующий шаг — подключить обратную связь к дашборду. Используйте Prometheus метрики или просто логи. Я предпочитаю OpenTelemetry — он даёт те же трассировки и позволяет понять, на каком этапе RAG сбоит.

💡
Весь код из статьи лежит в моём GitHub (ссылка в профиле). Там же — интеграционные тесты и Docker Compose для быстрого старта.

Типичные грабли (и как на них не наступить)

За три года RAG-разработки я набил кучу шишек. Вот что чаще всего идёт не так.

Грабли #1: Валидация тормозит ответ

Если вы проверяете каждую цитату через LLM (as a judge), время ответа растёт на секунды. Решение — проверять только сами цитаты быстрым fuzzy search, а deep judge запускать асинхронно в фоне для логов, не блокируя пользователя.

Грабли #2: Пользователь не хочет давать обратную связь

Никто не тыкает звёздочки, если ответ устроил. Но если ответ неправильный — кричат. Вы получаете смещённую выборку. Сбор фидбека — это искусство. Совет: показывайте кнопку "это не соответствует моим данным" только когда у ответа низкий confidence. Так вы увеличите конверсию в 2–3 раза.

Грабли #3: Слишком строгие правила

Если вы заблокируете 20% ответов из-за формальных ошибок (лишняя запятая в JSON), пользователи уйдут. Балансируйте: мягкая валидация с исправлением (например, парсинг с strict=False) и fallback только при явных галлюцинациях.

Дополнительно про то, как настраивать строгость, читайте в статье "Структурированный парсинг вопросов в RAG" — там та же логика применима к ответам.

А что если не валидировать?

Вы получите RAG, который:

  • Сыпет битые JSON (фронтенд падает)
  • Цитирует несуществующие источники (юридические риски)
  • Теряет контекст и начинает "выдумывать"

Пользователи перестанут доверять. Вы будете тратить время на поддержку вместо развития. Валидация — это не опция, а обязательный слой, как авторизация или логирование.

На рынке уже есть фреймворки, которые помогают — например, Guardrails AI (я не аффилирован, просто юзаю). Но они не закрывают все кейсы. Поэтому свой велосипед, заточенный под ваш продукт, часто надёжнее.

Резюме одним абзацем

Пост-генерационная валидация RAG — это три действия: проверить структуру (Pydantic), проверить цитаты (grounding), собрать обратную связь (фидбек-эндпоинт). Без этого вы строите систему, которая только выглядит умной, но на практике опасна. Внедрите валидатор как middleware, логируйте отказы, настраивайте пороги. И никогда не показывайте пользователю сырой ответ LLM — всегда пропускайте через валидатор.

Если у вас RAG уже в проде — срочно добавьте хотя бы проверку структуры. Это спасёт вас от 90% проблем, о которых пишут в статьях про галлюцинации. Остальное — дело техники.

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