Холодный старт pgvector: как я убрал 8-секундный тормоз после деплоя
У трёх клиентов подряд одна и та же история. AI-агент в проде работает неделями, семантический поиск летает. Деплой — и первый запрос менеджера висит 7–9 секунд. Потом всё снова нормально.
Это классический холодный старт pgvector. Расскажу, где прячется задержка и как с ней бороться, не меняя инфраструктуру.
Откуда берётся задержка
Индексы HNSW или IVFFlat лежат на диске. После рестарта Postgres, перезагрузки или вытеснения из shared_buffers они «холодные».
Первый запрос вынужден:
- читать корневые узлы HNSW с диска;
- тянуть слои графа случайными чтениями;
- иногда добавлять RTT, если эмбеддинг считается отдельно;
- делать пост-фильтрацию и ходить в heap.
На 800 тысячах векторов размерности 1024 HNSW-индекс весит ~3,5 ГБ. Если shared_buffers 2 ГБ — целиком не влезет, и каждое холодное обращение жрёт кучу 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) на холодном и горячем запросе. В холодном вижу shared read=12483, в горячем — shared hit=12483 read=0. Вот и разница в секундах.
Способ 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);
Третий вызов прогревает первые 50 тысяч страниц таблицы — туда же потом пойдёт планировщик за контентом.
Можно поставить pg_prewarm.autoprewarm = on — расширение само сохраняет горячие блоки и восстанавливает их после рестарта Postgres. Но если БД живёт отдельно от приложения, автоприм не сработает.
Способ 2: warmup в lifespan FastAPI
Добавляю в health-check реальный тестовый запрос:
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, через которые идут почти все реальные запросы. На одном проекте p99 первого запроса упал с 8,4 с до 340 мс, а сам прогрев занимает ~1,2 с — успевает до ready в Kubernetes.
Способ 3: периодический prewarm через pg_cron
Если индекс небольшой и критичен, можно «подкручивать» его по расписанию:
CREATE EXTENSION pg_cron;
SELECT cron.schedule(
'prewarm-embeddings',
'*/2 * * * *',
$$SELECT pg_prewarm('documents_embedding_hnsw_idx')$$
);
Под нагрузкой это не нужно, а в тихие часы спасает.
Параметры HNSW, которые влияют на размер и прогрев
| Параметр | Что даёт | Моя рекомендация |
|---|---|---|
| m | связей на узел | 16 для 768–1024 |
| ef_construction | качество построения | 64 (200 для критичного) |
| ef_search | глубина обхода на лету | 40 для p95 < 50 мс |
При размерности 1024 переход с m=32 на m=16 уменьшил индекс с 5,1 ГБ до 3,5 ГБ почти без потери качества.
Чего я обычно не делаю
RAM-диск звучит заманчиво, но при росте корпуса упрёшься в память. А Postgres и так хорошо держит горячие данные в shared_buffers.
Отдельная in-memory копия эмбеддингов в приложении — тоже не мой вариант: двойная память, проблемы с консистентностью и теряется «одна точка истины».
Короткий чек-лист
Когда после деплоя жалуются на тормоза:
- Снять EXPLAIN (ANALYZE, BUFFERS) на холодном запросе.
- Посмотреть через pg_buffercache, сколько индекса в shared_buffers.
- Убедиться, что в lifespan приложения есть warmup.
- Включить
pg_prewarm.autoprewarm. - Если индекс > 40 % shared_buffers — поиграть с m или добавить памяти.
Приёмы работают с GigaChat, YandexGPT и self-hosted моделями. Главное — холодный старт pgvector это не баг технологии, а просто отсутствие прогрева. Полтора часа работы — и 8-секундный лаг исчезает навсегда.
