1. Главная
  2. Блог
  3. Как мы посадили ИИ-агента на десятилетний архив писем и не уронили прод

Как мы посадили ИИ-агента на десятилетний архив писем и не уронили прод

Как мы посадили ИИ-агента на десятилетний архив писем и не уронили прод

Позвонил коммерческий директор — дистрибьютор электротехники, один из наших давних заказчиков. Голос усталый: «Мы за прошлый год отдали заметную сумму штрафов по нескольким спорам с сетевыми клиентами. В каждом споре был e-mail от нашего менеджера, где он что-то обещал ещё несколько лет назад. Никто не помнил, никто не мог найти. Сделайте так, чтобы менеджер спрашивал у ИИ-агента и получал цитату из письма трёхлетней давности за несколько секунд».

На бэкенде лежал архив почты на несколько миллионов писем, выгруженный из MS Exchange в PostgreSQL ещё при первой волне импортозамещения. Больше десятка гигабайт чистого текста после отсечения подписей и цитат. Задача звучала просто: посчитать эмбеддинги и уложить их в pgvector. На деле мы дважды чуть не положили продакшн, переписали пайплайн трижды и научились вещам, о которых в документации по векторным базам не пишут. Если вы в 2026 году только подступаетесь к созданию ИИ-агента поверх собственного архива — я бы советовал прочитать этот текст до, а не после.

Наивный подход, который сломал прод

Первый заход выглядел учебниково. Один SELECT по всей таблице писем, воркер на FastAPI, батч по 32 письма в эмбеддинг-модель GigaChat, батч-INSERT в pgvector. Запустили ночью в выходные, чтобы никого не тронуть.

Через какое-то время пришёл алерт: p95 у поисковых запросов из живого приложения улетел с сотни миллисекунд в несколько секунд. Ещё чуть позже DBA написал в чат: «Кто там autovacuum убивает?».

Разобрали по косточкам:

  • Один жадный курсор. Драйвер psycopg загрузил в память полтаблицы, воркер съел десяток гигабайт RAM и начал свопиться.
  • INSERT под нагрузкой. Батч-вставки в таблицу с уже существующим HNSW-индексом заставляли Postgres перестраивать связи в графе на каждой пачке. Индекс писался в WAL быстрее, чем реплика успевала догонять.
  • Autovacuum сдался. Таблица росла на глазах, dead tuples накапливались от параллельных обновлений, vacuum откладывал работу и в итоге просто встал.
  • Квота эмбеддингов. GigaChat стабильно отдавал 429. Мы ретраили экспоненциально, но по итогу воркер простаивал больше, чем работал.

Ближе к полуночи пришлось всё остановить, откатить частично залитые эмбеддинги и объяснять клиенту, почему поиск по CRM тормозит. Стыдно было по-настоящему.

Что мы поняли за ту ночь

Backfill — это не «прогони скрипт по всей таблице». Это отдельная инженерная задача с тремя независимыми ограничениями:

  1. Внешняя квота на эмбеддинги. У неё жёсткий потолок в токенах в минуту, и его нельзя обойти ретраем.
  2. Внутренние ресурсы Postgres: WAL, autovacuum, буферный кэш, репликация.
  3. Живой продакшн, который в это же время обслуживает менеджеров и не должен заметить ничего.

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

Архитектура, которая заработала

Мы разнесли пайплайн на три этапа с двумя буферами между ними, чтобы каждый шаг ограничивался своим ресурсом, а не хвостом соседнего.

Этап 1. Экстрактор. Читает исходную таблицу писем курсором на стороне сервера с FETCH FORWARD 1000. Никаких SELECT *. Только id, chunk_id, text_cleaned. Клиентская память держится в пределах пары сотен мегабайт независимо от размера архива. Пишет строки в буфер pending_embed — обычная таблица Postgres с триггером на LISTEN/NOTIFY.

Этап 2. Векторизатор. Пул из четырёх воркеров, каждый берёт батч по 16 текстов. Между ними — общий rate limiter на Redis (token bucket, честный запас под потолок квоты GigaChat). Ответ пишется в pending_index. Ретраи с джиттером, максимум три попытки, потом в DLQ.

Кстати, регулярно спрашивают, почему мы не собрали оркестрацию на n8n. ИИ-агенты и мелкие интеграции на n8n мы делаем часто — там это удобно, когда шагов немного и всё синхронное. Но у бэкфилла миллионов документов свои особенности: тонкий контроль над памятью, честный token bucket, состояние между рестартами. На такое n8n натягивать больно, проще Python.

Этап 3. Индексатор. Пишет эмбеддинги в отдельную партиционированную таблицу mail_embeddings_backfill. Без индекса. Просто heap. INSERT идёт быстро и не трогает боевую таблицу вообще.

Когда партиция за конкретный год наполнилась, мы одной командой создавали HNSW-индекс с CONCURRENTLY, ждали окончания и переключали partition на боевую таблицу через ATTACH PARTITION. Тот момент, когда живые пользователи начинали видеть новый год архива в поиске, длился меньше секунды и не блокировал ни одного запроса.

Три штриха, которые дали основной эффект

Секционирование по годам, а не по хешу. Мы сначала пытались бить по id % 16. Красиво в теории, ужасно в бэкфилле: каждая партиция росла одновременно, autovacuum сходил с ума. По годам всё стало линейным: заполнили 2015, индексировали, забыли; поехали в 2016.

Отключили autovacuum на партициях бэкфилла через ALTER TABLE ... SET (autovacuum_enabled = false) на время наполнения и запустили VACUUM ANALYZE вручную после создания индекса. Это спорное решение, но у нас не было UPDATE/DELETE в этих партициях, только APPEND. Мёртвых строк не появлялось в принципе.

Rate limiter поставили перед сетевым вызовом, а не после. В первой версии мы отправляли запрос и ловили 429. Каждый провал стоил секунды сетевой задержки и токенов на неудачную попытку. Токен-бакет на входе снял с нас основную массу 429 — на дневном графике их стало почти не видно.

Как это выглядело в итоге

Ограничителем экстрактора оказались не мы, а диск исходной таблицы — читалось быстрее, чем мы ждали, но упёрлись именно туда. Векторизатор большую часть времени работал у самого потолка квоты GigaChat, без длинных простоев на ретраях. Индексатор потом собрал HNSW-индексы по каждой партиции с параметрами m=16, ef_construction=64 — суммарно это заняло минуты, а не часы.

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

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

Что мы бы сделали иначе на следующем проекте

Если вам предстоит первый бэкфилл на архив от полумиллиона документов — сразу планируйте его как трёхступенчатый пайплайн с изоляцией по ресурсам. Три вещи, которые сэкономят вам ночь:

  • Партиционируйте таблицу эмбеддингов заранее, даже если сегодня она умещается в одну. Пересобирать HNSW-индекс на десятках миллионов строк «одним куском» — это уже не обслуживаемая система.
  • Не индексируйте на лету. INSERT в проиндексированную HNSW-таблицу под нагрузкой в разы медленнее, чем в heap. Наполняйте, потом индексируйте.
  • Мониторьте не только воркеры, но и Postgres. Мы добавили в Grafana три графика: длину WAL до чекпоинта, задержку репликации, число dead tuples. Именно они первыми сигнализировали, что что-то идёт не так.

И главное: относитесь к бэкфиллу как к отдельному релизу, а не как к скрипту, который «просто прогнать один раз». У него есть цена ошибки, план отката и SLA. Если этого нет — вы не запустили ИИ-агента на legacy-архив, вы запустили таймер до инцидента.

Мы в «Интеллект Технологиях» такие проекты и делаем как раз в этом жанре: ИИ-агенты как сервис для компаний, которые хотят получить пользу из собственного архива, а не героическую историю про сломанный прод.

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

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

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

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