Обновление базы знаний без переиндексации всего
Если в вашей RAG-системе любое изменение одного файла в базе знаний запускает пересчёт эмбеддингов и перестройку индекса для всего корпуса — вы сидите на бомбе с часовым механизмом. Пока документов пятьсот, полный цикл занимает пару минут и никто не замечает проблемы. Когда их пятьдесят тысяч и обновления прилетают каждый час, та же операция растягивается на часы, съедает GPU или CPU впустую и в какой-то момент просто перестаёт успевать за темпом изменений. Ниже — рабочий подход к инкрементальному обновлению индекса: что отслеживать, что удалять, что добавлять и как не потерять синхронизацию со временем.
Содержание
- Почему полная переиндексация — это тупик
- Идентификатор и хеш: как узнать, что реально изменилось
- Обновление документа: удалить старые чанки, добавить новые
- Удаление источника: почему забыть — не то же самое, что не индексировать
- Практическая схема: манифест как источник истины для оркестрации
- Сверка расхождений: почему нельзя полагаться только на события
Почему полная переиндексация — это тупик
Наивная схема выглядит просто и поначалу работает: есть папка с документами, есть скрипт, который проходит по ней целиком, режет каждый файл на чанки, считает эмбеддинги и заливает всё в векторную базу — Postgres с pgvector, Qdrant, Milvus, не важно. Изменился один файл — запускаем скрипт заново, он обрабатывает всю папку с нуля.
Проблема в масштабировании. Время работы такого скрипта линейно растёт с объёмом корпуса, а не с объёмом изменений. Если у вас 100 документов и обновился один, вы всё равно пересчитываете эмбеддинги для всех 100. При 100 документах это неважно — обработка займёт секунды. При 50 000 документов та же логика означает пересчёт всех 50 000 ради правки в одном файле README.md. На типичном CPU-инференсе модели эмбеддингов (условно, речь не о конкретных цифрах, а об порядке — у вас точно будет иначе в зависимости от модели и железа) это может означать переход от секунд к часам.
Дальше добавляются вторичные проблемы:
- Окно недоступности поиска. Пока индекс перестраивается «на месте» (drop + recreate), запросы к базе знаний либо падают, либо отдают пустой/неполный результат.
- Пустая трата ресурсов. Если у вас платный API эмбеддингов (OpenAI, Cohere, Voyage) — вы платите за пересчёт документов, которые не менялись ни байтом.
- Невозможность частых обновлений. Документация, которая меняется по несколько раз в день, база знаний тикетов поддержки, куда каждый час падают новые обращения — под такие сценарии полная переиндексация физически не подходит: пока считается предыдущий проход, накопились уже следующие изменения.
Правильная архитектура смещает единицу работы с «весь корпус» на «изменившийся документ». Это и называется инкрементальным обновлением индекса.
Идентификатор и хеш: как узнать, что реально изменилось
Фундамент инкрементального подхода — устойчивый способ понять, какие именно документы изменились с прошлого прохода, без построчного сравнения содержимого во время самого прохода. Для этого на каждый исходный документ заводится три вещи:
- doc_id — стабильный идентификатор источника: путь к файлу, ID страницы в Confluence, ID тикета, URL. Он не должен меняться, даже если поменялось содержимое — иначе система решит, что это новый документ, и вы получите дубликаты вместо обновления.
- content_hash — хеш содержимого (SHA-256 хватает с запасом), вычисляемый по нормализованному тексту документа. Если хеш не изменился — документ не менялся, трогать его не нужно вообще, даже если менялась дата модификации файла в файловой системе (mtime — ненадёжный сигнал: git checkout, rsync или бэкап-восстановление могут проставить новую mtime без изменения содержимого).
- updated_at / version — вспомогательное поле для аудита и отладки, но не источник истины для решения «менять или нет» — этим должен быть именно хеш.
Эти три поля хранятся в отдельной служебной таблице — «манифесте» источников, не в самой векторной базе. Подойдёт обычная реляционная таблица:
CREATE TABLE kb_manifest (
doc_id TEXT PRIMARY KEY,
content_hash TEXT NOT NULL,
source_path TEXT NOT NULL,
chunk_ids TEXT[] NOT NULL, -- какие чанки в индексе принадлежат этому doc_id
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
deleted BOOLEAN NOT NULL DEFAULT false
);
Цикл синхронизации на каждый проход выглядит так:
import hashlib
def content_hash(text: str) -> str:
normalized = text.strip().replace("\r\n", "\n")
return hashlib.sha256(normalized.encode("utf-8")).hexdigest()
for doc in scan_sources(): # обходим ВСЕ источники, но не считаем эмбеддинги
new_hash = content_hash(doc.text)
row = manifest.get(doc.doc_id)
if row is None:
mark_for_insert(doc) # новый документ
elif row.content_hash != new_hash:
mark_for_update(doc) # изменился — пересчитать
# else: хеш совпал — пропускаем, ничего не делаем
Ключевой момент: обход source-дерева и вычисление хешей — дешёвая операция (это просто чтение файлов и хеширование, без обращения к модели эмбеддингов). Дорогая часть — сам инференс эмбеддингов — запускается только для тех документов, у которых content_hash реально разошёлся с записью в манифесте. При корпусе в 50 000 документов, где в день меняется 30-50, вы считаете эмбеддинги для 30-50 документов, а не для 50 000.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Развернуть ИИ на сервереОбновление документа: удалить старые чанки, добавить новые
Когда документ изменился, неправильно просто добавить новые эмбеддинги поверх старых — тогда в индексе окажутся и старая, и новая версия одного и того же контента, и поиск будет вперемешку находить оба варианта, часть из которых уже неактуальна. Правильная последовательность — удалить старые чанки этого doc_id, затем вставить новые, причём именно в этом порядке операций (или атомарно, если ваша векторная база это позволяет).
Большинство векторных баз поддерживают фильтрацию по метаданным при удалении. В Qdrant это выглядит так:
from qdrant_client import QdrantClient
from qdrant_client.models import Filter, FieldCondition, MatchValue
client = QdrantClient(url="http://localhost:6333")
# 1. Удаляем все чанки, принадлежащие этому doc_id
client.delete(
collection_name="kb",
points_selector=Filter(
must=[FieldCondition(key="doc_id", match=MatchValue(value=doc.doc_id))]
),
)
# 2. Режем документ на чанки заново и считаем эмбеддинги ТОЛЬКО для него
chunks = split_into_chunks(doc.text, chunk_size=800, overlap=100)
vectors = embed_model.encode([c.text for c in chunks])
# 3. Вставляем новые точки с тем же doc_id в метаданных
client.upsert(
collection_name="kb",
points=[
{
"id": f"{doc.doc_id}::{i}",
"vector": vectors[i],
"payload": {"doc_id": doc.doc_id, "chunk_index": i, "text": chunks[i].text},
}
for i in range(len(chunks))
],
)
manifest.upsert(doc.doc_id, content_hash=new_hash, chunk_ids=[f"{doc.doc_id}::{i}" for i in range(len(chunks))])
Для pgvector в PostgreSQL логика та же, только вместо API-фильтра — обычный SQL:
-- Шаг 1: удаляем старые чанки документа
DELETE FROM kb_chunks WHERE doc_id = $1;
-- Шаг 2: вставляем новые (после пересчёта эмбеддингов в приложении)
INSERT INTO kb_chunks (id, doc_id, chunk_index, embedding, text)
VALUES ($1, $2, $3, $4, $5);
Заворачивайте оба шага в одну транзакцию (BEGIN ... COMMIT), если движок это поддерживает — так вы избегаете промежуточного состояния, когда старые чанки уже удалены, а новые ещё не вставлены, и запрос, прилетевший ровно в этот момент, вообще ничего не находит по этому документу. Если библиотека даёт upsert по составному ключу doc_id + chunk_index — используйте его вместо связки delete+insert, но проверьте отдельно, что она же чистит «хвост»: если новая версия документа стала короче и чанков в ней меньше, чем было, upsert перезапишет первые N чанков, но лишние старые (N+1, N+2...) останутся висеть мусором, если explicitly их не удалить.
Удаление источника: почему забыть — не то же самое, что не индексировать
Отдельный и более опасный случай — источник, который перестал существовать: файл удалили из репозитория, статью сняли с публикации, тикет закрыли и заархивировали вне зоны видимости системы. Если про такое исчезновение никак не сообщить индексу, его эмбеддинги остаются в векторной базе бессрочно. Для читателя это ощущается хуже, чем просто «неполная» база — это активно вводящая в заблуждение база: RAG-система найдёт и подсунет модели фрагмент из давно удалённого документа, модель ответит уверенно и со ссылкой на источник, а по факту эта информация уже недействительна, отозвана или прямо противоречит текущей реальности (классический пример — устаревшая цена тарифа, снятая с продажи функция, отменённая политика возврата).
Практическая мера — на каждом проходе синхронизации сравнивать множество doc_id, реально присутствующих в источниках сейчас, с множеством doc_id, зафиксированных в манифесте с прошлого прохода:
current_doc_ids = {doc.doc_id for doc in scan_sources()}
manifest_doc_ids = {row.doc_id for row in manifest.all() if not row.deleted}
removed_ids = manifest_doc_ids - current_doc_ids
for doc_id in removed_ids:
client.delete(
collection_name="kb",
points_selector=Filter(must=[FieldCondition(key="doc_id", match=MatchValue(value=doc_id))]),
)
manifest.mark_deleted(doc_id) # не физически стираем строку — помечаем deleted=true с датой
Строку в манифесте лучше не удалять физически сразу, а помечать deleted=true с временной меткой — это даёт вам аудиторский след («этот документ был удалён из индекса такого-то числа») и защищает от случайного повторного добавления, если источник вернётся под тем же doc_id — тогда content_hash заведомо не совпадёт с последним известным, и документ корректно переиндексируется как обновлённый, а не проигнорируется.
Отдельно стоит продумать «мягкое» versus «жёсткое» исчезновение источника. Если у вас есть explicit-сигнал об удалении (webhook от CMS, событие file_deleted от системы хранения) — обрабатывайте его сразу, не дожидаясь планового прохода синхронизации. Если такого сигнала нет и единственный способ узнать об удалении — не найти файл при очередном обходе (как в примере выше), закладывайте это как штатную часть каждого цикла синхронизации, а не как отдельную редкую операцию.
Практическая схема: манифест как источник истины для оркестрации
Собирая три предыдущих раздела вместе, полный цикл инкрементального обновления на каждый запуск выглядит так:
1. Обойти все источники → получить {doc_id: content_hash} для каждого
2. Сравнить с манифестом:
- doc_id отсутствует в источниках, но есть в манифесте (не deleted) → удалить из индекса
- doc_id есть и там, и там, хеши различаются → обновить (delete старых чанков + insert новых)
- doc_id есть в источниках, но нет в манифесте → добавить (insert новых чанков)
- doc_id есть и там, и там, хеши совпадают → пропустить
3. Обновить манифест новыми хешами и chunk_ids
Такой цикл можно запускать как cron-задачу (раз в 15 минут, раз в час — зависит от того, насколько быстро читатели вашей базы знаний должны видеть свежие данные) или триггерить событийно — по webhook от источника (git push, обновление страницы в вики, новый тикет). Событийный запуск лучше по задержке, но обязательно держите и периодический полный проход как страховку (следующий раздел) — webhook может не долететь из-за сетевого сбоя, и тогда событийная схема тихо теряет обновления, о чём вы узнаете не сразу.
Практический нюанс с чанкингом при обновлении: если вы меняете параметры разбиения на чанки (размер, overlap) для всего корпуса — это не «обновление одного документа», это изменение схемы индексации, которое требует пересчёта всех документов, потому что старые чанки были нарезаны по другим границам и не сопоставимы с новыми. Инкрементальный подход из этой статьи ускоряет типовой сценарий «один документ изменился», но не отменяет полную переиндексацию при смене модели эмбеддингов или стратегии чанкинга — это разные, куда более редкие операции, и путать их не стоит.
Сверка расхождений: почему нельзя полагаться только на события
Даже идеально написанный инкрементальный пайплайн со временем расходится с реальностью, если полагаться исключительно на явные триггеры обновления. Причины типичны и скучны: сеть моргнула во время webhook-запроса и событие потерялось, скрипт синхронизации упал посередине прохода из-за нехватки памяти и не успел обновить манифест, кто-то вручную поправил документ в обход системы, которая должна была об этом узнать. Ни одно из этих событий не выглядит как катастрофа в моменте, но за месяцы работы они накапливаются, и однажды вы обнаруживаете, что индекс на 5-10% расходится с реальным набором источников — часть документов не обновлена по содержанию, часть удалённых источников всё ещё торчит в поиске.
Практическая мера — регулярная полная сверка (reconciliation), отдельная от инкрементального цикла и не полагающаяся на события:
def reconcile():
current = {doc.doc_id: content_hash(doc.text) for doc in scan_sources()}
manifest_rows = {row.doc_id: row.content_hash for row in manifest.all() if not row.deleted}
missing_in_index = set(current) - set(manifest_rows) # есть в источниках, нет в индексе
stale_in_index = { # хеш разошёлся, но обновление пропущено
d for d in current if d in manifest_rows and current[d] != manifest_rows[d]
}
orphaned_in_index = set(manifest_rows) - set(current) # индекс хранит то, чего уже нет
log_reconciliation_report(missing_in_index, stale_in_index, orphaned_in_index)
# дальше — та же логика insert/update/delete, что и в обычном инкрементальном цикле
return missing_in_index, stale_in_index, orphaned_in_index
По сути сверка — это тот же самый инкрементальный цикл, просто запускаемый не по событию, а по расписанию, как safety net. Разница в акценте: цель сверки — не скорость, а полнота и честный отчёт о расхождении, который стоит логировать и мониторить отдельно (если orphaned_in_index или stale_in_index вдруг резко выросли — это сигнал, что событийная синхронизация где-то сломалась, и стоит разобраться, прежде чем расхождение продолжит расти). Разумная частота — раз в сутки для базы, где основная синхронизация событийная, и не реже раза в неделю даже для стабильных корпусов, которые меняются редко: content_hash дёшев в вычислении, полный обход источников для сверки почти всегда на порядки дешевле, чем полная переиндексация, потому что вы не считаете эмбеддинги заново — только хеши и метаданные.
Отдельно стоит логировать саму дату последнего успешного прохода сверки в отдельном месте (файл, строка в БД, метрика в Prometheus) — если сверка вдруг перестала запускаться (упал cron, сломался воркер), вы должны узнать об этом из мониторинга, а не через полгода, когда пользователи начнут жаловаться на устаревшие ответы. Про сам процесс развёртывания RAG-системы с нуля и выбор векторной базы под неё можно почитать в статье как поднять RAG по своим документам на сервере, а по механике pgvector конкретно — в статье pgvector: векторы в PostgreSQL.
Нужен сервер под эту задачу?
Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.
Развернуть ИИ на сервереНужны сами нейросети для контента?
Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.
Частые вопросы
Можно ли обойтись без отдельной таблицы-манифеста, храня хеш прямо в метаданных векторной базы?
Технически можно — часть векторных баз позволяет фильтровать и по произвольному payload-полю. Но тогда, чтобы узнать хеш документа, вам нужно делать запрос к самой векторной базе (обычно дороже, чем к реляционной таблице с индексом по doc_id), а для документов, которые ещё не проиндексированы вообще, такого payload попросту не существует. Отдельная лёгкая таблица-манифест решает обе проблемы и не зависит от конкретного векторного движка.
Что делать, если пересчёт эмбеддингов для изменившегося документа падает с ошибкой (например, API эмбеддингов вернул 500)?
Не обновляйте манифест для этого doc_id, пока пересчёт не завершился успешно, и старые чанки этого документа в индексе оставьте нетронутыми до успешной вставки новых (не удаляйте старое, пока не подтверждено, что новое записалось) — так при следующем проходе система снова увидит расхождение хеша и повторит попытку, а не решит, что документ уже обработан.
Насколько часто нужно запускать полную сверку, если события работают надёжно?
Даже при надёжной событийной схеме сверку стоит держать как фоновую страховку — не реже раза в неделю для стабильных корпусов, раз в сутки для активно меняющихся. Это дешёвая операция (хеширование, не пересчёт эмбеддингов), и её задача — ловить именно те редкие сбои, которые событийная схема пропускает по определению.
Помогает ли инкрементальный подход при смене модели эмбеддингов на более новую?
Нет — смена модели эмбеддингов означает, что все существующие векторы построены в другом пространстве и несопоставимы с новыми, поэтому здесь нужна полная переиндексация корпуса. Инкрементальность закрывает сценарий «контент документа изменился», а не «изменилась сама модель или схема разбиения на чанки».
Как быть с чанками одного документа, которых стало меньше после правки?
Именно поэтому обновление всегда идёт через полное удаление старых чанков документа по doc_id, а не через point-in-place перезапись по индексу чанка — если новых чанков меньше, чем было, «лишние» старые чанки не должны остаться в индексе, а delete-по-doc_id гарантирует это автоматически.
Обсудить статью, задать вопрос или начать новую тему
Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.
Перейти в сообщество →