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

Меняем шесть страниц из пятисот — а переиндексируем шесть: как 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-логикой, которая описана выше.

Похожие статьи

Хотите так же?

Обсудим, как это внедрить у вас

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