Практика · Средний уровень

SLO свежести для RAG: как доказать, что документ уже доступен модели

Ответ API «файл загружен» подтверждает только приём байтов. До участия актуальной версии документа в ответе остаются извлечение текста, разбиение, построение эмбеддингов, запись в индекс и обновление поисковой выдачи. Разберём, как измерять этот путь целиком.

Уровень: средний Чтение: до 8 минут Результат: измеримый SLO и контроль каждого этапа индексирования

Что считать свежестью

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

  1. файл принят системой;
  2. нужная версия находится поиском;
  3. найденный фрагмент передан в контекст модели.

Для SLO полезно выбрать второе состояние как техническую границу индексирования, а третье контролировать отдельно на уровне RAG-приложения. Иначе сбой сборки контекста будет ошибочно выглядеть как задержка индекса.

1. Запишите SLO как проверяемое утверждение

Формулировка «индекс обновляется быстро» непроверяема. Начальный пример SLO:

Не менее 99% принятых версий поддерживаемых документов становятся доступными в поиске за 10 минут в скользящем окне 30 дней.

Это пример, а не универсальная норма. Порог зависит от частоты обновлений, длины очередей, стоимости обработки и требований продукта.

Заранее зафиксируйте:

  • событие начала: версия сохранена, получила устойчивый document_id и version_id;
  • событие завершения: контрольный поиск вернул фрагмент с тем же version_id;
  • объекты учёта: только принятые файлы поддерживаемых форматов и допустимого размера;
  • окно: например, последние 30 дней;
  • цель: доля версий, уложившихся в 600 секунд;
  • исключения: явно отклонённые файлы, но не внутренние ошибки и не потерянные задания.
freshness_seconds = searchable_at - accepted_at

sli = versions_with_freshness_at_most_600
      / all_eligible_accepted_versions

slo = sli >= 0.99 over rolling 30 days

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

2. Сделайте версию сквозным идентификатором

Одного имени файла недостаточно: пользователь может несколько раз загрузить policy.pdf, а задания — завершиться не по порядку. Передавайте через все этапы неизменяемые поля:

{
  "document_id": "doc-42",
  "version_id": "sha256:EXAMPLE_DIGEST",
  "ingestion_id": "ing-202",
  "accepted_at": "2026-07-29T09:00:00Z",
  "source_modified_at": "2026-07-29T08:58:00Z",
  "access_scope": "team-example"
}

Это условный объект. В реальной системе version_id можно строить из хеша содержимого либо выдавать каждой принятой версии отдельный идентификатор. Хеш помогает распознавать повторную загрузку тех же байтов, но сам по себе не описывает права доступа или бизнес-ревизию.

Каждый фрагмент в индексе должен содержать как минимум document_id, version_id, chunk_id и область доступа. Без метаданных поиск может вернуть правильный текст, но не позволит доказать, что это актуальная версия.

3. Отмечайте завершение каждого этапа

Полезная цепочка состояний выглядит так:

accepted
  → extraction_finished
  → chunking_finished
  → embedding_finished
  → index_committed
  → search_verified
  → context_verified

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

{
  "event": "index_committed",
  "occurred_at": "2026-07-29T09:03:18Z",
  "document_id": "doc-42",
  "version_id": "sha256:EXAMPLE_DIGEST",
  "ingestion_id": "ing-202",
  "attempt": 1,
  "status": "ok",
  "chunk_count": 17
}

index_committed означает, что запись подтвердило хранилище. Это ещё не доказательство поисковой доступности: реплики, кэш или отдельный полнотекстовый индекс могут обновляться позже.

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

4. Добавьте контрольный маркер

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

RAG_FRESHNESS_PROBE_7F3A9C21

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

Проверка должна обращаться к тому же поисковому пути, которым пользуется приложение, и требовать одновременно:

  • совпадение маркера в возвращённом фрагменте;
  • совпадение document_id;
  • точное совпадение version_id;
  • корректную область доступа;
  • отсутствие более старой версии среди допустимых результатов, если политика требует немедленного замещения.

Не просите модель подтвердить наличие документа. Модель способна угадать ответ или пересказать старый контекст. Источником доказательства должен быть ответ поискового слоя с метаданными.

5. Запускайте пробу до успеха или дедлайна

Следующий пример использует только стандартную библиотеку Python. Функция search условная: замените её вызовом внутреннего поискового интерфейса. Пример не содержит адресов, клиентов или секретов.

import time
from datetime import datetime, timezone


def wait_until_searchable(
    search,
    marker,
    document_id,
    version_id,
    timeout_seconds=600,
    interval_seconds=5,
):
    started = time.monotonic()
    deadline = started + timeout_seconds

    while time.monotonic() < deadline:
        results = search(
            query=marker,
            access_scope="team-example",
            limit=10,
        )

        for item in results:
            metadata = item.get("metadata", {})
            if (
                marker in item.get("text", "")
                and metadata.get("document_id") == document_id
                and metadata.get("version_id") == version_id
            ):
                return {
                    "status": "search_verified",
                    "freshness_seconds": round(
                        time.monotonic() - started, 3
                    ),
                    "verified_at": datetime.now(
                        timezone.utc
                    ).isoformat(),
                }

        time.sleep(interval_seconds)

    return {
        "status": "freshness_deadline_exceeded",
        "freshness_seconds": timeout_seconds,
    }

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

6. Соберите метрики и бюджет ошибок

Из событий удобно получить четыре показателя:

  • долю версий, доступных не позднее порога SLO;
  • распределение freshness_seconds, включая медиану и верхние перцентили;
  • возраст самой старой незавершённой версии;
  • число объектов в каждом состоянии конвейера.

При цели 99% бюджет ошибок равен 1% подходящих версий за окно. Если за 30 дней принято 10 000 версий, бюджет составит 100 нарушений. Это арифметический пример; он не описывает фактическую нагрузку какого-либо сервиса.

# Пример конфигурации, не привязанный к системе мониторинга
freshness_slo:
  objective: 0.99
  threshold_seconds: 600
  window_days: 30
  start_event: accepted
  success_event: search_verified
  group_by:
    - source_type
    - pipeline_version
  alerts:
    oldest_pending_seconds: 600
    queue_age_seconds: 300

Не используйте только среднее время: небольшое число зависших документов легко скрывается за множеством быстрых. Сигнал о самой старой незавершённой версии часто обнаруживает остановку очереди раньше итогового SLO.

7. Проверьте передачу фрагмента модели

После search_verified выполните отдельную интеграционную проверку сборщика контекста. Она должна подтвердить, что найденный фрагмент:

  • не отфильтрован после поиска;
  • не вытеснен лимитом контекста;
  • сохранил document_id и version_id в трассировке;
  • разрешён текущему субъекту правилами доступа.

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

Воспроизводимая проверка результата

Проведите тест в изолированной области:

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

Если события сохранены в файле JSON Lines, их синтаксис можно безопасно проверить локально:

jq -e . ingestion-events.jsonl > /dev/null

Команда только читает файл и возвращает ненулевой код при некорректной строке JSON. Для проверки конкретной версии:

jq -c 'select(
  .document_id == "doc-42"
  and .version_id == "sha256:EXAMPLE_DIGEST"
)' ingestion-events.jsonl

Имена файла и идентификаторы в командах условные. Подставляйте свои несекретные значения.

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

Считать ответ загрузки завершением индексирования

Статус 200 или 202 обычно подтверждает приём запроса, но не прохождение асинхронных этапов. Используйте отдельные события начала и поисковой доступности.

Проверять только текст

Старый документ может содержать тот же абзац. Сравнивайте version_id в метаданных результата.

Считать запись в индекс доказательством

После подтверждения записи поисковая реплика или кэш ещё могут отдавать прежнее состояние. Завершайте измерение активной поисковой пробой.

Разрешать старой задаче перезаписать новую версию

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

Повторять весь конвейер без идемпотентности

Повторная доставка задания не должна создавать дубликаты фрагментов. Используйте устойчивый ключ вроде version_id + chunk_id и фиксируйте номер попытки отдельно.

Исключать ошибки из знаменателя

Если внутренняя обработка упала, версия нарушила обещание свежести. Исключать можно только заранее определённые неподходящие входы, отклонённые до события accepted.

Проверять через привилегированную учётную запись

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

Ограничения

  • Синтетическая проба подтверждает работоспособность выбранного маршрута, но не заменяет измерение всех реальных версий.
  • Успешный поиск не гарантирует качественного разбиения документа или релевантности по естественным вопросам.
  • Совпадение байтов не доказывает смысловую актуальность исходного документа.
  • Разные регионы, реплики и области доступа могут иметь различную задержку; измеряйте там, где выполняются реальные запросы.
  • Удаление и отзыв прав требуют отдельного SLO: устаревший документ должен не только появляться вовремя, но и вовремя исчезать.
  • Для потоковых источников граница версии может быть неочевидна. Сначала задайте атомарную единицу публикации: запись, пакет или снимок.

Минимальный критерий готовности

Контроль свежести можно считать настроенным, если для любой принятой версии команда способна ответить на четыре вопроса:

  1. какой у версии идентификатор и когда начался отсчёт;
  2. какой этап завершился последним и где находится задержка;
  3. когда поиск впервые вернул именно эту версию;
  4. была ли она передана в контекст модели с корректными правами.

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