Обновление RAG обрывается на середине: как публиковать индекс целиком

Ночью загрузчик пересобирает базу знаний. Он уже удалил старые фрагменты, записал половину новых и упал на повреждённом PDF. Утром ассистент отвечает на часть вопросов, а на остальные сообщает, что сведений нет. Сам поиск работает; пользователю досталось промежуточное состояние обновления.

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

Отделите сборку от допуска к поиску

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

Вместе с поколением сохраните манифест ожидаемых документов, версии разбиения текста и модели эмбеддингов. Проверка «записано сто строк» слишком слаба: один документ мог загрузиться дважды, а другой пропасть. Сравните набор устойчивых ID с манифестом и отдельно проверьте ошибки парсинга, пустые тексты и доступность векторного поиска. Если часть источников разрешено пропустить, это должно быть явным решением владельца базы, отражённым в манифесте.

Воспроизведите прерванное обновление

В примере SQLite хранит тексты и указатель active. Это небольшой работающий прототип механизма публикации, который можно запустить стандартным Python. Поколение g2 сначала содержит только один из двух необходимых документов. Попытка опубликовать его завершается ошибкой; запрос продолжает видеть g1. После дозагрузки переключение разрешается.

import sqlite3

db = sqlite3.connect(":memory:")
db.executescript("""
CREATE TABLE chunks(gen TEXT, doc TEXT, body TEXT,
                    PRIMARY KEY(gen, doc));
CREATE TABLE active(id INTEGER PRIMARY KEY CHECK(id=1), gen TEXT);
INSERT INTO active VALUES(1, 'g1');
INSERT INTO chunks VALUES('g1', 'a', 'Лимит: 10');
INSERT INTO chunks VALUES('g1', 'b', 'Поддержка: почта');
""")
expected = {"a", "b"}

def read_generation(gen):
    return dict(db.execute(
        "SELECT doc, body FROM chunks WHERE gen=? ORDER BY doc", (gen,)))

def publish(candidate, previous):
    with db:
        db.execute("BEGIN IMMEDIATE")
        actual = set(read_generation(candidate))
        if actual != expected:
            raise ValueError(f"Incomplete generation: {actual}")
        changed = db.execute(
            "UPDATE active SET gen=? WHERE id=1 AND gen=?",
            (candidate, previous)).rowcount
        if changed != 1:
            raise RuntimeError("Active generation changed; review publication")

def active():
    return db.execute("SELECT gen FROM active WHERE id=1").fetchone()[0]

with db:
    db.execute("INSERT INTO chunks VALUES('g2', 'a', 'Лимит: 20')")
try:
    publish("g2", "g1")
except ValueError:
    pass
assert active() == "g1"
reader_generation = active()
with db:
    db.execute("INSERT INTO chunks VALUES('g2', 'b', 'Поддержка: чат')")
publish("g2", "g1")
assert read_generation(reader_generation)["a"] == "Лимит: 10"
assert read_generation(active())["a"] == "Лимит: 20"
print({"pinned_reader": reader_generation, "new_reader": active()})

Ожидаемый результат — pinned_reader g1 и new_reader g2. Старый запрос сохраняет согласованную картину, новый получает обновление. Условие gen=previous защищает от ещё одной неприятности: медленный загрузчик не сможет молча заменить поколение, которое уже опубликовал другой запуск. После такого конфликта следует сравнить манифесты и решить, какой выпуск действительно нужен.

Перенесите тот же порядок в векторную базу

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

Сначала дождитесь окончания записи и подтверждения доступности новых векторов для чтения. Затем выполните контрольные запросы с явным фильтром candidate generation. В ответе загрузчика должны остаться число ожидаемых и принятых документов, список отклонений и результаты проверок. Только прошедшее этот этап поколение может получить статус готового.

После публикации не удаляйте g1 немедленно. Его ещё могут читать запросы, которые начали работу до переключения. Задайте срок хранения с учётом максимальной длительности запроса или учитывайте активные читатели. Тот же запас позволяет быстро вернуть указатель назад, если проверка реальных вопросов обнаружила проблему. Удаление старой коллекции — отдельный шаг обслуживания.

Проверьте результат глазами пользователя

В журнал ответа добавьте ID поколения. Тогда жалобу «вчера лимит был другим» можно сопоставить с конкретным набором источников. Для контрольного вопроса «Какой лимит действует?» передайте модели только документы выбранного поколения и инструкцию: «Ответь по переданным источникам, укажи ID документа; если основания нет, сообщи об отсутствии данных». Это связывает срез поиска с ответом и помогает обнаружить обход фильтра в одном из дополнительных поисковых шагов.

Что сохранить в манифесте публикации

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

Повторный запуск загрузчика должен либо продолжать то же неизменяемое поколение по манифесту, либо создавать новое. Если под именем g2 незаметно меняются документы уже после публикации, сохранённый в трассе ID больше не позволяет воспроизвести ответ. Запретите запись в опубликованное поколение на уровне рабочего процесса и прав загрузчика. Для исправления содержимого выпускайте g3, затем проходите тот же допуск. Это делает журнал поколений пригодным для расследования, а не только для переключения коллекций.

Соберите полный контур обновления базы знаний

Следующая задача — связать загрузчик с поиском, цитатами и проверкой качества ответов. На курсе LangChain вы соберёте RAG по PDF, добавите гибридный поиск и реранжирование, измерите качество и задержку, затем опубликуете приложение с API и наблюдаемостью. Это позволяет оценивать обновление индекса по ответам на рабочие вопросы. Откройте программу платного курса «LangChain 1.0.x: c нуля до продакшн LLM-приложений».

Продолжить на практике

  • LangChain 1.0.x: c нуля до продакшн LLM-приложений ↗

    Практический курс по LangChain. Соберёте продакшн LLM-бота: RAG по PDF с цитатами, веб-поиск и трекинг заказов, многошаговый агент для анализа CSV и финальное веб-приложение (UI + API). Плюс профессиональные блоки: PromptOps и версионирование промптов, метрики качества RAG, гибридный поиск с реранком, observability/стоимость, надёжность (ретраи, таймауты), guardrails и отдельный модуль по LangGraph для устойчивых workflow. P.S. Полностью обновили материалы на актуальную версию LangChain