- Главная
- Блог
- Меняем шесть страниц из пятисот — а переиндексируем шесть: как diff-логика на pgvector кормит нашего ИИ-агента
Меняем шесть страниц из пятисот — а переиндексируем шесть: как diff-логика на pgvector кормит нашего ИИ-агента

Обычный будний день, ближе к обеду. Юрист крупной производственной компании открывает корпоративный портал и заливает новую версию регламента о договорной работе. Триста страниц PDF. Изменились шесть параграфов в разделе о согласовании условий с дилерами — правки после последнего совета директоров. Юрист жмёт «Обновить» и уходит за кофе.
Раньше он возвращался, а система всё ещё возилась. Долго перегоняла регламент через эмбеддинг-модель, пересчитывала больше тысячи чанков, дёргала GigaChat, писала в pgvector. На это же время у нас проседало качество поиска: старая версия уже удалена, новая ещё не готова, а менеджеры продолжают спрашивать у ИИ-агента, «можно ли дилеру давать отсрочку на 45 дней». Ответ прилетал куцый и без нужного пункта — потому что нужного пункта в индексе просто в этот момент не было.
Сегодня юрист возвращается с кофе, а работа уже сделана. Пересчитано ровно шесть чанков. Остальные никто не трогал. Расскажу, как мы к этому пришли — и почему первая, самая очевидная идея развалилась на второй неделе.
Наивный хеш файла не спасает никого
Первая мысль любого инженера: посчитать SHA-256 от исходника, сравнить с предыдущим. Совпало — ничего не делаем, разошлось — переиндексируем.
Работает ровно до момента, когда кто-то поправит одну запятую. Хеш файла меняется от лишнего пробела. Юрист добавил уточнение в один абзац — и мы снова гоним через эмбеддер пятьсот страниц. Выигрыш только на «залили и ничего не тронули», а такое почти не встречается: если человек полез в документ, он обязательно что-нибудь да поменял.
Прожили с этим подходом две недели. Метрика «сколько чанков пересчитали за неделю» стабильно показывала десятки тысяч при реальных изменениях на пару сотен. Дальше стало понятно: нужен diff на уровне чанков, а не файлов.
Стабильный chunk_id как ключ идемпотентности
Идея простая. У каждого чанка должен быть идентификатор, который зависит только от его содержимого, а не от позиции в документе или порядка обхода. Тогда при новой заливке достаточно спросить pgvector: «а такой чанк у тебя уже есть?»
Мы сделали id как SHA-256 от нормализованного текста чанка — нижний регистр, схлопнутые пробелы, убранные переносы строк в конце. Плюс namespace-префикс с document_id, чтобы одинаковые абзацы из разных регламентов не мешали друг другу.
def chunk_id(document_id: str, text: str) -> str:
norm = re.sub(r"\s+", " ", text.strip().lower())
digest = hashlib.sha256(norm.encode()).hexdigest()[:16]
return f"{document_id}:{digest}"
Дальше — процедура upsert. Разбиваем новый документ на чанки, для каждого считаем chunk_id, вытаскиваем из pgvector множество уже существующих id этого документа. Разница между множествами и есть работа:
- new — чанки, которых раньше не было: считаем эмбеддинг, вставляем;
- stale — чанки, которых больше нет: удаляем;
- unchanged — пересечение: не трогаем.
На регламенте с шестью правками получили шесть в new, шесть в stale, остальное — в unchanged. Счёт за эмбеддинги на первой же неделе после релиза этой логики просел так, что я сперва подумал на баг в биллинге. Пошёл проверять — нет, всё честно, просто модель перестала есть неизменённый текст.
Ловушка: одна вставка сдвигает всё
Прошла неделя, и прилетел тикет: «залили новую версию инструкции по возвратам, менеджер жалуется, что старые ответы всё ещё выдаются». Пошёл разбираться.
Оказалось, юрист вставил в начало документа новый пункт про маркировку — три абзаца. Три чанка добавились в начало, а дальше у нас включился chunker с overlap 200 символов. И граница каждого следующего чанка сдвинулась на пару десятков символов. Хеши поехали. Из трёхсот с лишним чанков «изменились» почти все.
Проблема классическая для скользящего окна: небольшое возмущение в начале ломает нарезку до самого конца. Мы гнали через эмбеддер почти весь документ, только теперь ещё и уверенные, что «diff работает». Первая мысль была злая: ну и зачем мы вообще всё это городили.
Пришлось переделывать сам чанкер.
Content-based chunking: границы по смыслу, а не по счётчику
Отказались от фиксированного окна в пользу структурного разбиения. У регламентов, договоров и внутренних инструкций почти всегда есть якоря: нумерация пунктов (1.1, 1.1.1, 2.3.4), заголовки разделов, маркированные списки. Мы написали простой парсер, который режет документ по этим якорям, а внутри пункта — по абзацам, если пункт длиннее 800 токенов.
Ключевое: id чанка стал зависеть не только от текста, но и от «пути» — например, contract-regulation-v14:3.2.1:a4f8b1c9. Путь стабильный, потому что нумерация в регламенте почти не меняется. А если меняется — это уже действительно другой пункт, и его правильно переиндексировать.
После второй итерации тот же кейс с тремя новыми абзацами в начале дал diff в четыре чанка: сам новый пункт как три абзаца плюс один следующий, у которого поменялся «путь». Никаких трёхсот. Тут уже стало ясно, что мы сделали правильно.
Как это выглядит в pgvector
Таблица прямолинейная:
create table doc_chunks (
chunk_id text primary key,
document_id text not null,
section_path text not null,
content text not null,
content_hash text not null,
embedding vector(1024) not null,
updated_at timestamptz not null default now()
);
create index on doc_chunks using hnsw (embedding vector_cosine_ops);
create index on doc_chunks (document_id);
Процедура апдейта — одна транзакция. Сначала считаем новые чанки в staging table, потом:
-- вставляем новые
insert into doc_chunks (chunk_id, document_id, section_path, content, content_hash, embedding)
select s.* from staging s
left join doc_chunks d on d.chunk_id = s.chunk_id
where d.chunk_id is null;
-- удаляем исчезнувшие
delete from doc_chunks
where document_id = $1
and chunk_id not in (select chunk_id from staging);
Эмбеддинги считаем только для тех, кого реально вставляем. GigaChat в этот момент получает не тысячу с лишним запросов, а горстку. Транзакция целиком укладывается примерно в полсекунды на среднем документе — против длинного ожидания в старой схеме.
Что стало видно за квартал
Раскатывали постепенно. Начали с двух заказчиков — та самая производственная компания и клиника с большим архивом внутренних инструкций. За квартал набралось достаточно, чтобы сказать честно, без придуманных процентов:
- Средний регламент у клиентов — где-то полторы-две сотни страниц.
- Средняя правка — единицы, максимум десяток чанков за загрузку.
- Подавляющее большинство чанков между версиями остаются нетронутыми.
- Расходы на эмбеддинги при переиндексации упали кратно. Не «на несколько процентов», а именно так, что счёт стал грустной тенью прежнего.
- Дырка в поиске между старой и новой версией схлопнулась с десятков минут до времени транзакции — пока едет коммит, ИИ-агент не успевает даже заметить.
Побочный, но приятный эффект — аудит стал проще. Поле updated_at показывает, какие именно куски регламента реально меняются от версии к версии. Комплаенс сказал спасибо: теперь видно, что фактически поправили юристы, без diff-tool по PDF.
Что бы сделали иначе, если бы начинали сегодня
Три вещи, о которых стоит подумать заранее.
Первое — миграционный путь для уже проиндексированных документов. У нас на момент внедрения были тысячи документов со «старыми» chunk_id по позиции. Пришлось писать одноразовый скрипт, который прогнал всё через новый chunker, пересчитал id и обновил pgvector. Ушла ночь, и было немного нервно смотреть, как процесс ползёт под утро.
Второе — версионирование самого чанкера. Если однажды поменяете правила нарезки, все хеши поедут разом. Мы держим в chunk_id префикс с версией парсера: v2:contract-regulation-v14:3.2.1:a4f8b1c9. При смене версии старые чанки не пересекаются с новыми, инкрементальная логика деградирует до полной переиндексации, но зато без «частичного диффа» между несовместимыми версиями.
Третье — фильтр по content_hash при поиске «а не поменялся ли только пробел». Иногда юрист правит только форматирование. Если нормализованный текст совпадает — не переиндексируем даже при видимых изменениях в исходнике.
Куда это встраивается
RAG у нас не сам по себе, а под ИИ-агентом. Агент ходит в базу знаний по MCP: инструмент отдаёт релевантные куски регламента, дальше уже LLM собирает ответ менеджеру. И вот тут инкрементальная переиндексация вдвойне важна — если во время правки регламента ИИ-агент по MCP получит из индекса пустоту, у пользователя на другом конце это моментально проявится: «а почему бот забыл, как работать с дилерами». С diff-логикой такое окно исчезает: старые чанки живут в pgvector до самого коммита новой версии.
Инкрементальная переиндексация — редко упоминаемая, но одна из самых дешёвых оптимизаций в RAG-стеке. Сам подход не новый, интересное начинается в деталях: как выбрать якоря для нарезки, что делать со сдвигом границ, где закладывать версионирование. Если у вас RAG на корпоративной документации, которая живёт и обновляется, — посчитайте, сколько эмбеддингов вы тратите на неизменившийся текст. Скорее всего, там сидят проценты, которые видно в счетах.
Мы в «Интеллект Технологии» этим и занимаемся: эффективная веб-разработка, инфраструктура, код, ИИ-агенты — под конкретный бизнес, от юридического поиска до сервисных ассистентов и внутренних help-desk-ботов. У нас есть готовые ИИ-агенты под типовые сценарии корпоративной работы, и если нужен свой — соберём под ваш стек, ваши документы, вашу модель. ИИ-агентов можно купить как коробочное решение или заказать проект под ключ, с интеграцией в вашу инфраструктуру и pgvector-хранилище — с той же diff-логикой, которая описана выше.
Похожие статьи
- Как мы научили ИИ-агента на GigaChat Pro не путать живого клиента с ночным батчем
- Как мы настроили ИИ-агента для поиска в 1С: MCP-сервер вместо метаний между окнами
- Один вопрос — три темы: как мы научили ИИ-агента разбирать составные запросы
- Text2SQL для 340 таблиц: как мы собирали ИИ-агента для анализа корпоративных данных на GigaChat Pro




