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

Ситуация, которую я в разных вариациях видел уже у нескольких заказчиков. ИИ-агент неделями крутится в продакшене, менеджеры к нему привыкли, семантический поиск отвечает мгновенно. Дальше — обычный деплой новой версии, контейнер пересобрался, кто-то из первой смены задаёт вопрос — и ждёт. Несколько секунд, иногда до восьми-девяти. Потом всё снова летает.
Первый раз мы с командой честно решили, что это разовая случайность. Посмотрели на графики — не случайность. Каждый деплой давал одинаковую картину: первый запрос долгий, дальше норма. Классический холодный старт pgvector. Ниже — как я это разбирал, что оказалось причиной и три подхода, которыми это лечится без замены инфраструктуры. Полезно, если вы разрабатываете автономные ИИ-агенты, где под капотом векторный поиск, а не просто оборачиваете чужой ИИ-агент API.
Где живёт задержка
PostgreSQL с расширением pgvector хранит индексы (HNSW или IVFFlat) на диске. После рестарта самого Postgres, перезагрузки сервера или вытеснения страниц из shared_buffers под давлением других запросов индекс лежит в холодном состоянии.
Первый запрос вынужден:
- Прочитать корневые узлы HNSW с диска в shared_buffers.
- Подтянуть слои графа по ходу обхода — это случайные чтения.
- Если эмбеддинг входящего запроса считается отдельным сервисом, добавить туда RTT.
- Применить пост-фильтрацию по 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». Заодно резко усложняет отладку: у вас появляется второе состояние, за которым надо следить.
Чек-лист для дежурного инженера
Когда прилетает жалоба «ИИ-агент тормозит после деплоя»:
- Снять EXPLAIN (ANALYZE, BUFFERS) на холодном запросе.
- Проверить через pg_buffercache долю индекса в shared_buffers.
- Убедиться, что в lifespan приложения есть warmup-фаза.
- Включить
pg_prewarm.autoprewarmна стороне Postgres. - Если индекс сильно перерос shared_buffers — пересмотреть параметр
mили увеличить память сервера.
Где это применимо
Описанные приёмы работают одинаково для эмбеддингов GigaChat, YandexGPT и self-hosted моделей вроде E5-large или ruBert. Векторы — это просто float-массивы, pgvector не различает, кто их посчитал. И, что приятно, эти же приёмы одинаково хорошо ложатся и на разговорных ИИ-агентов, и на ИИ-агент для кодинга: везде, где под капотом семантический поиск по векторному индексу, вы упираетесь в один и тот же холодный старт.
Что до цены вопроса — нас часто спрашивают, сколько стоит ИИ-агент такого уровня. Честный ответ: сама фича прогрева — это буквально день работы инженера. Гораздо дороже обходится не сделать её вовремя: доверие пользователя к системе ломается ровно на том утреннем запросе, который висел восемь секунд, и восстанавливать его потом сложнее, чем один раз аккуратно настроить warmup.
Главный вывод простой. Холодный старт pgvector — это не свойство технологии, а следствие того, что про прогрев забыли. Полтора часа работы инженера убирают многосекундный лаг навсегда. В корпоративном ИИ-агенте, к которому по утрам одновременно приходит вся смена, это разница между «всё работает» и «опять у вас что-то сломалось».
Похожие статьи
- Как мы научили ИИ-агента на GigaChat Pro не путать живого клиента с ночным батчем
- Как мы настроили ИИ-агента для поиска в 1С: MCP-сервер вместо метаний между окнами
- Один вопрос — три темы: как мы научили ИИ-агента разбирать составные запросы
- Text2SQL для 340 таблиц: как мы собирали ИИ-агента для анализа корпоративных данных на GigaChat Pro




