1. Главная
  2. Блог
  3. Прогрев холодного старта pgvector: почему после деплоя ИИ-агент отвечает не сразу

Прогрев холодного старта pgvector: почему после деплоя ИИ-агент отвечает не сразу

Прогрев холодного старта pgvector: почему после деплоя ИИ-агент отвечает не сразу

Ситуация, которую я в разных вариациях видел уже у нескольких заказчиков. ИИ-агент неделями крутится в продакшене, менеджеры к нему привыкли, семантический поиск отвечает мгновенно. Дальше — обычный деплой новой версии, контейнер пересобрался, кто-то из первой смены задаёт вопрос — и ждёт. Несколько секунд, иногда до восьми-девяти. Потом всё снова летает.

Первый раз мы с командой честно решили, что это разовая случайность. Посмотрели на графики — не случайность. Каждый деплой давал одинаковую картину: первый запрос долгий, дальше норма. Классический холодный старт pgvector. Ниже — как я это разбирал, что оказалось причиной и три подхода, которыми это лечится без замены инфраструктуры. Полезно, если вы разрабатываете автономные ИИ-агенты, где под капотом векторный поиск, а не просто оборачиваете чужой ИИ-агент API.

Где живёт задержка

PostgreSQL с расширением pgvector хранит индексы (HNSW или IVFFlat) на диске. После рестарта самого Postgres, перезагрузки сервера или вытеснения страниц из shared_buffers под давлением других запросов индекс лежит в холодном состоянии.

Первый запрос вынужден:

  1. Прочитать корневые узлы HNSW с диска в shared_buffers.
  2. Подтянуть слои графа по ходу обхода — это случайные чтения.
  3. Если эмбеддинг входящего запроса считается отдельным сервисом, добавить туда RTT.
  4. Применить пост-фильтрацию по WHERE-условиям, часто с дополнительными походами в heap.

У одного из заказчиков корпус был под миллион векторов размерности 1024 (стандартный размер для эмбеддингов GigaChat). HNSW-индекс на таком корпусе получается несколько гигабайт. Если shared_buffers меньше индекса, целиком туда он не влезает — и каждое холодное обращение превращается в кучу случайных IOPS. Логи ведут себя честно: в момент запроса диск шуршит, потом успокаивается.

Как измерить, а не угадать

Прежде чем чинить, я всегда прошу подтвердить диагноз цифрами, а не ощущениями. Включаем расширение pg_prewarm и смотрим, сколько страниц индекса реально в памяти:

SELECT
  c.relname,
  pg_size_pretty(pg_relation_size(c.oid)) AS size,
  (SELECT count(*) FROM pg_buffercache WHERE relfilenode = c.relfilenode) AS buffers_loaded,
  pg_relation_size(c.oid) / 8192 AS total_pages
FROM pg_class c
WHERE c.relname LIKE '%embedding%idx%';

Если buffers_loaded заметно меньше total_pages — диагноз подтверждён. Дальше делаем EXPLAIN (ANALYZE, BUFFERS) на холодном и на горячем запросе:

EXPLAIN (ANALYZE, BUFFERS)
SELECT id, content
FROM documents
ORDER BY embedding <=> $1
LIMIT 10;

В холодном плане видно много shared read=..., в горячем — почти всё уходит в shared hit=.... Разница между этими двумя строчками — это ваши потерянные секунды. Причём разница именно та, которую живой человек за экраном воспринимает как «висит».

Способ 1: pg_prewarm на старте

Первое, что мы попробовали — самое прямолинейное. После рестарта явно загружаем индекс в буферы:

SELECT pg_prewarm('documents_embedding_hnsw_idx', 'buffer');
SELECT pg_prewarm('documents_pkey', 'buffer');
SELECT pg_prewarm('documents', 'buffer', 'main', 0, 50000);

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

Автоматизировать можно через pg_prewarm.autoprewarm = on в postgresql.conf — расширение само сохраняет список горячих блоков каждые пять минут и восстанавливает их после старта.

Подвох, на который мы наступили: автоприм работает только при рестарте самого Postgres. Если CI пересобирает контейнер приложения, а БД живёт отдельно и не перезапускалась — с точки зрения Postgres ничего не случилось, и прогревать он ничего не будет. Тогда нужен внешний триггер со стороны приложения.

Способ 2: warmup-эндпоинт в FastAPI

Мы добавили в сервис health-check, который не просто отвечает 200, а реально гоняет тестовый запрос. Это универсальный приём: если вы делаете ИИ-агент API поверх векторной БД, такой warmup-хук должен быть везде.

from fastapi import FastAPI
from contextlib import asynccontextmanager
import asyncpg

WARMUP_VECTOR = None  # фиксированный эмбеддинг для прогрева

@asynccontextmanager
async def lifespan(app: FastAPI):
    pool = await asyncpg.create_pool(DSN, min_size=5, max_size=20)
    app.state.pool = pool

    async with pool.acquire() as conn:
        await conn.execute("SELECT pg_prewarm('documents_embedding_hnsw_idx')")
        for _ in range(3):
            await conn.fetch(
                "SELECT id FROM documents ORDER BY embedding <=> $1 LIMIT 10",
                WARMUP_VECTOR,
            )
    yield
    await pool.close()

app = FastAPI(lifespan=lifespan)

Три прогревочных запроса с одним и тем же вектором подтягивают верхние узлы HNSW, через которые проходит большинство реальных запросов. Это работает, потому что граф HNSW всегда стартует через общие верхние слои — какой бы вектор ни пришёл, обход начинается сверху.

Первый раз мы сомневались, поможет ли фиксированный вектор для реальных, «пёстрых» запросов пользователей. Оказалось — да, помогает почти полностью. На проекте у одного из заказчиков в оптовой торговле долгий первый запрос после деплоя ушёл: раньше первый менеджер утром получал ответ через несколько секунд, теперь — практически мгновенно, как и остальные. Warmup занимает около секунды-полутора — Kubernetes успевает не пометить pod как ready раньше.

Способ 3: pinning через pg_buffercache

Если индекс относительно небольшой (условно до трети shared_buffers) и критичен для бизнеса — его можно эффективно «закрепить». Прямого pinning в pg_buffercache нет, но есть рабочий трюк: гонять prewarm по крону.

CREATE EXTENSION pg_cron;

SELECT cron.schedule(
  'prewarm-embeddings',
  '*/2 * * * *',
  $$SELECT pg_prewarm('documents_embedding_hnsw_idx')$$
);

Раз в пару минут Postgres перечитывает «упавшие» страницы. Под дневной нагрузкой это не нужно — буферы держатся сами. А вот в ночные окна, когда трафика мало, а какой-нибудь аналитический отчёт вымывает индекс, прогрев спасает. Мы это заметили ровно тогда, когда клиент пожаловался: «утром опять тормозит», — при том что деплоя не было. Оказалось, ночью прошёл тяжёлый бэкап-скрипт с большими выборками, и всё горячее ушло.

Параметры HNSW, влияющие на холодный старт

При создании индекса есть три настройки, напрямую завязанные на размер и скорость прогрева:

Параметр Эффект Рекомендация
m Связей на узел 16 для размерности 768–1024
ef_construction Качество построения 64 для prod, 200 для критичного поиска
ef_search (runtime) Глубина обхода 40 для p95 < 50 мс

Чем выше m — тем больше индекс и тем дольше его читать с диска. У одного из заказчиков переход с m=32 на m=16 заметно уменьшил размер индекса (примерно в полтора раза) почти без потери качества поиска. Recall@10 просел на несколько сотых — на практике пользователи разницы не почувствовали, а вот прогрев стал ощутимо быстрее.

Что мы не рекомендуем

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

Хуже — собственная in-memory копия эмбеддингов на стороне приложения. Это удваивает память, ломает консистентность при апдейтах и убивает главный аргумент «у нас одна точка истины в Postgres». Заодно резко усложняет отладку: у вас появляется второе состояние, за которым надо следить.

Чек-лист для дежурного инженера

Когда прилетает жалоба «ИИ-агент тормозит после деплоя»:

  1. Снять EXPLAIN (ANALYZE, BUFFERS) на холодном запросе.
  2. Проверить через pg_buffercache долю индекса в shared_buffers.
  3. Убедиться, что в lifespan приложения есть warmup-фаза.
  4. Включить pg_prewarm.autoprewarm на стороне Postgres.
  5. Если индекс сильно перерос shared_buffers — пересмотреть параметр m или увеличить память сервера.

Где это применимо

Описанные приёмы работают одинаково для эмбеддингов GigaChat, YandexGPT и self-hosted моделей вроде E5-large или ruBert. Векторы — это просто float-массивы, pgvector не различает, кто их посчитал. И, что приятно, эти же приёмы одинаково хорошо ложатся и на разговорных ИИ-агентов, и на ИИ-агент для кодинга: везде, где под капотом семантический поиск по векторному индексу, вы упираетесь в один и тот же холодный старт.

Что до цены вопроса — нас часто спрашивают, сколько стоит ИИ-агент такого уровня. Честный ответ: сама фича прогрева — это буквально день работы инженера. Гораздо дороже обходится не сделать её вовремя: доверие пользователя к системе ломается ровно на том утреннем запросе, который висел восемь секунд, и восстанавливать его потом сложнее, чем один раз аккуратно настроить warmup.

Главный вывод простой. Холодный старт pgvector — это не свойство технологии, а следствие того, что про прогрев забыли. Полтора часа работы инженера убирают многосекундный лаг навсегда. В корпоративном ИИ-агенте, к которому по утрам одновременно приходит вся смена, это разница между «всё работает» и «опять у вас что-то сломалось».

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

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

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

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