Генерация — только полдела. Дальше начинается ад
У вас есть 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"}
Собирайте фидбек и используйте его для двух вещей:
- Самокоррекция на лету: если пользователь поставил 1, можно перегенерировать ответ с учётом контекста (см. самовосстанавливающийся RAG)
- Мониторинг и eval: каждую неделю запускайте пайплайн оценки на накопленных фидбеках (как в статье "Как построить eval пайплайн для RAG-агента")
Главная ошибка — хранить фидбек "в стол", не анализируя. Без автоматического анализа вы просто коллекционируете боль пользователей, но не лечите причину.
Пошаговый план: от генерации до панели мониторинга
Сведём всё в единый пайплайн. Будем использовать структурированный вывод (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 сбоит.
Типичные грабли (и как на них не наступить)
За три года 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% проблем, о которых пишут в статьях про галлюцинации. Остальное — дело техники.