Продвинутый уровень

Автоматическая проверка цитат в ответах RAG

Ссылка на документ подтверждает только то, что модель умеет печатать идентификаторы. Чтобы доверять ответу, нужно отдельно проверить существование источника, точность привязки фрагмента и достаточность доказательства.

Чтение: до 10 минут Результат: рабочий трёхступенчатый валидатор цитат

Почему одной ссылки недостаточно

RAG дополняет генерацию найденными документами, но сам по себе не гарантирует верность атрибуции. Модель может сослаться на существующий документ, выбрать в нём нерелевантный фрагмент или сделать более сильный вывод, чем позволяет источник.

Поэтому проверку полезно разделить на три независимых вопроса:

  1. Существование: документ и указанный фрагмент доступны в той версии корпуса, на которой строился ответ?
  2. Точность: приведённая цитата совпадает с текстом источника и относится к заявленному диапазону?
  3. Достаточность: содержимое фрагмента действительно поддерживает конкретное утверждение, а не просто говорит на похожую тему?

Первые два вопроса проверяются детерминированно. Третий требует семантического решения: правил, отдельной модели-классификатора или их комбинации. В производственном контуре эти уровни не следует объединять в один непрозрачный балл.

Шаг 1. Зафиксируйте контракт ответа

Свободный текст неудобен для аудита. Попросите генератор возвращать атомарные утверждения и отдельный список ссылок. Каждая ссылка должна содержать стабильный идентификатор документа, версию, символьный диапазон и точный текст фрагмента.

{
  "answer": "Сервис хранит журнал операций 30 дней.",
  "claims": [
    {
      "id": "c1",
      "text": "Сервис хранит журнал операций 30 дней.",
      "citation_ids": ["q1"]
    }
  ],
  "citations": [
    {
      "id": "q1",
      "document_id": "retention-policy",
      "document_version": "2026-01",
      "start": 24,
      "end": 79,
      "quote": "Журнал операций хранится в течение 30 календарных дней."
    }
  ]
}

Это демонстрационный формат, а не описание внешней системы. Смещения считаются по строке Unicode в Python и образуют полуинтервал [start, end). Если хранилище считает байты, токены или строки, укажите это явно и не смешивайте системы координат.

Шаг 2. Создайте минимальный воспроизводимый набор

Для локальной проверки достаточно Python 3 без сторонних пакетов. Создайте отдельный каталог и убедитесь, что не работаете в каталоге с важными файлами:

mkdir -p citation-check-demo
cd citation-check-demo
pwd

Сохраните следующий корпус как documents.json:

{
  "retention-policy": {
    "version": "2026-01",
    "text": "Правила хранения данных. Журнал операций хранится в течение 30 календарных дней. Резервные копии удаляются отдельно."
  }
}

Сохраните пример ответа как response.json:

{
  "answer": "Сервис хранит журнал операций 30 дней.",
  "claims": [
    {
      "id": "c1",
      "text": "Сервис хранит журнал операций 30 дней.",
      "citation_ids": ["q1"]
    }
  ],
  "citations": [
    {
      "id": "q1",
      "document_id": "retention-policy",
      "document_version": "2026-01",
      "start": 24,
      "end": 79,
      "quote": "Журнал операций хранится в течение 30 календарных дней."
    }
  ]
}

Значения в этих файлах являются учебным примером. Они не описывают реального клиента, продукт или политику хранения.

Шаг 3. Проверьте существование и точность

Сохраните код как verify.py. Валидатор отклоняет дубли идентификаторов, отсутствующие документы, несовпадающие версии, некорректные диапазоны и неточные цитаты.

import json
import sys
from pathlib import Path

def load_json(path):
    with Path(path).open(encoding="utf-8") as handle:
        return json.load(handle)

def normalized(text):
    return " ".join(text.split()).casefold()

def verify(payload, documents):
    errors = []
    citations = payload.get("citations", [])
    claims = payload.get("claims", [])

    citation_by_id = {}
    for citation in citations:
        citation_id = citation.get("id")
        if not citation_id:
            errors.append({
                "code": "citation_id_missing",
                "citation_id": None
            })
            continue
        if citation_id in citation_by_id:
            errors.append({
                "code": "citation_id_duplicate",
                "citation_id": citation_id
            })
            continue
        citation_by_id[citation_id] = citation

    valid_citations = set()

    for citation_id, citation in citation_by_id.items():
        document_id = citation.get("document_id")
        document = documents.get(document_id)

        if document is None:
            errors.append({
                "code": "document_missing",
                "citation_id": citation_id
            })
            continue

        if citation.get("document_version") != document.get("version"):
            errors.append({
                "code": "document_version_mismatch",
                "citation_id": citation_id
            })
            continue

        start = citation.get("start")
        end = citation.get("end")
        text = document.get("text", "")

        if (
            not isinstance(start, int)
            or isinstance(start, bool)
            or not isinstance(end, int)
            or isinstance(end, bool)
            or start < 0
            or end <= start
            or end > len(text)
        ):
            errors.append({
                "code": "range_invalid",
                "citation_id": citation_id
            })
            continue

        source_fragment = text[start:end]
        if source_fragment != citation.get("quote"):
            errors.append({
                "code": "quote_mismatch",
                "citation_id": citation_id,
                "expected": source_fragment,
                "received": citation.get("quote")
            })
            continue

        valid_citations.add(citation_id)

    claim_results = []
    for claim in claims:
        claim_id = claim.get("id")
        references = claim.get("citation_ids", [])
        missing = [
            item for item in references
            if item not in citation_by_id
        ]
        invalid = [
            item for item in references
            if item in citation_by_id and item not in valid_citations
        ]

        if not references:
            status = "unsupported"
        elif missing or invalid:
            status = "invalid_reference"
        else:
            status = "requires_entailment_check"

        claim_results.append({
            "claim_id": claim_id,
            "status": status,
            "missing_citations": missing,
            "invalid_citations": invalid
        })

    return {
        "ok": not errors and all(
            item["status"] == "requires_entailment_check"
            for item in claim_results
        ),
        "errors": errors,
        "claims": claim_results
    }

if __name__ == "__main__":
    if len(sys.argv) != 3:
        raise SystemExit(
            "usage: python3 verify.py RESPONSE DOCUMENTS"
        )

    result = verify(
        load_json(sys.argv[1]),
        load_json(sys.argv[2])
    )
    print(json.dumps(result, ensure_ascii=False, indent=2))
    raise SystemExit(0 if result["ok"] else 1)

Запуск безопасен: команда только читает два явно указанных файла и выводит результат в терминал.

python3 verify.py response.json documents.json

Для корректного примера ожидается "ok": true, а утверждение получит статус requires_entailment_check. Это принципиально: точное совпадение цитаты ещё не доказывает достаточность.

Шаг 4. Добавьте проверку достаточности

Проверяющий получает только одно атомарное утверждение и связанные с ним фрагменты. Его задача — выбрать один из классов:

  • entailed — источники непосредственно подтверждают всё утверждение;
  • contradicted — хотя бы один источник явно противоречит утверждению;
  • insufficient — тема совпадает, но доказательства неполны;
  • unknown — вход повреждён, неоднозначен или не поддаётся уверенной оценке.

Контракт семантического проверяющего можно выразить так:

{
  "claim_id": "c1",
  "verdict": "entailed",
  "evidence_citation_ids": ["q1"],
  "unsupported_parts": [],
  "confidence": 0.98
}

Значение уверенности здесь иллюстративное. Его нельзя переносить в рабочий порог без калибровки на размеченной выборке вашего домена.

Инструкция для отдельной модели-проверяющего должна запрещать использовать внешние знания:

Оцени только логическую поддержку утверждения приведёнными
фрагментами. Не дополняй доказательство знаниями вне фрагментов.
Верни один класс: entailed, contradicted, insufficient или unknown.
Для entailed должны быть подтверждены все существенные части,
включая числа, единицы, отрицания, условия и область действия.

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

  • числа и единицы в утверждении отсутствуют в цитатах;
  • отрицание есть только с одной стороны;
  • утверждение содержит «всегда», «только», «обязательно», а источник — нет;
  • цитата описывает возможность, а ответ превращает её в гарантию;
  • несколько цитат по отдельности релевантны, но между ними нет связи, необходимой для вывода.

Шаг 5. Соберите итоговую политику

Не усредняйте ошибки. Несуществующий документ нельзя компенсировать высокой семантической оценкой другой ссылки. Пример строгой политики:

if deterministic_errors:
    final_status = "reject"
elif any(claim.verdict in {"contradicted", "unknown"} for claim in claims):
    final_status = "manual_review"
elif any(claim.verdict == "insufficient" for claim in claims):
    final_status = "regenerate_or_abstain"
elif all(claim.verdict == "entailed" for claim in claims):
    final_status = "accept"
else:
    final_status = "reject"

Для пользовательского интерфейса сохраняйте связь утверждение → цитата → версия документа → диапазон. Это позволяет подсветить доказательство, повторить проверку и расследовать расхождения после обновления корпуса.

Проверка результата

Сначала убедитесь, что успешный пример проходит:

python3 verify.py response.json documents.json
echo $?

Ожидаемый код завершения — 0. Затем временно измените document_version в копии response.json на другое значение и повторите запуск. Валидатор должен вернуть ошибку document_version_mismatch и ненулевой код завершения.

Минимальный набор проверок перед интеграцией:

  1. несуществующий document_id отклоняется;
  2. устаревшая версия документа отклоняется;
  3. отрицательный, пустой или выходящий за границы диапазон отклоняется;
  4. изменение одного символа в цитате обнаруживается;
  5. неизвестный citation_id в утверждении обнаруживается;
  6. утверждение без ссылок получает unsupported;
  7. точная, но нерелевантная цитата не принимается без семантической проверки;
  8. составное утверждение не получает entailed, если подтверждена только его часть.

Для наблюдаемости считайте долю утверждений без ссылок, долю детерминированно невалидных цитат, распределение семантических вердиктов и частоту ручной проверки. Метрики следует хранить отдельно по версии корпуса, генератора, поискового конвейера и проверяющего.

Типовые ошибки

Поиск цитаты по подстроке без версии

Одинаковая фраза может встречаться несколько раз, а документ — измениться. Храните версию и точный диапазон; для неизменяемых объектов можно дополнительно сохранять криптографический хеш содержимого.

Нормализация до проверки точности

Сведение регистра и пробелов удобно для диагностики, но скрывает изменение исходного текста. Сначала проверяйте точное совпадение, а нормализованное используйте только как объясняющий сигнал.

Одна ссылка на длинный абзац

Проверяющий не понимает, какую часть подтверждает источник. Выделяйте атомарные утверждения и разрешайте несколько ссылок на одно утверждение.

Проверяющий видит исходный ответ целиком

Лишний контекст повышает риск, что модель «достроит» доказательство. Передавайте утверждение и минимально необходимые фрагменты, а решение требуйте в структурированном формате.

Повторное использование того же генератора без независимой инструкции

Модель может повторить собственную ошибку. Разделяйте роли, промпты и журналы; для критичных решений добавляйте детерминированные правила и ручную эскалацию.

Средний балл вместо покрытия

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

Ограничения

Даже строгий конвейер не устанавливает истинность документа. Он проверяет, следует ли ответ из зафиксированного источника. Устаревший, противоречивый или ошибочный документ останется плохим основанием.

Семантическая достаточность сложна для таблиц, изображений, формул, длинных цепочек вывода и ссылок между документами. В таких случаях сохраняйте структуру исходника и применяйте специализированный разбор вместо преобразования всего содержимого в плоский текст.

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

Что перенести в production

  • неизменяемые версии документов и стабильные идентификаторы;
  • структурированный ответ с атомарными утверждениями;
  • точные диапазоны и исходный текст каждой цитаты;
  • детерминированную проверку до вызова семантической модели;
  • четырёхклассовый вердикт вместо бинарного «верно/неверно»;
  • отказ от ответа или ручную проверку при недостаточных доказательствах;
  • журнал версий, входов, вердиктов и причин отклонения;
  • набор негативных примеров для регрессионной проверки.

Дополнительные схемы проектирования агентных систем собраны в руководствах, а определения терминов — в глоссарии.