- Главная
- Блог
- Семантический кэш на pgvector: как мы научили ИИ-агента не переспрашивать LLM по одному и тому же
Семантический кэш на pgvector: как мы научили ИИ-агента не переспрашивать LLM по одному и тому же

Обычное утро понедельника, открываю дашборд биллинга. За выходные наш ИИ-агент в компании отработал прилично диалогов, счёт за токены — сильно выше, чем хотелось бы. Лезу в логи и уже минут через десять становится грустно: больше половины запросов — почти одинаковые вопросы менеджеров. «Какой статус по заявке 15234?», «Когда отгрузка по 15234?», «А по 15234 что?». Каждый раз система честно вызывает LLM, тратит токены, ждёт пару секунд. Каждый раз получает один и тот же ответ.
Классический кэш по ключу тут не спасает: тексты у людей всегда разные — опечатки, перестановка слов, синонимы, лишние «пожалуйста». А по смыслу это один и тот же вопрос. Так родилась задача: научить ИИ-агента узнавать «такой запрос уже был» без жёсткого совпадения строк. Собрали на pgvector за два вечера — и оно окупилось быстрее, чем я успел написать по этому поводу отчёт.
Зачем вообще нужен семантический кэш
Кэширование вызовов LLM — тема, вокруг которой много снобизма. На вопрос «а как вы кэшируете?» часто отвечают: «Просто пиши хэш промпта в Redis». Работает, если у вас три заскриптованных сценария и три пользователя. А когда мы делаем ИИ-агента в компанию, где им пользуются десятки живых людей, каждый формулирует по-своему, добавляет контекст, переспрашивает, уточняет — хэш промпта совпадает раз в жизни.
Семантический кэш ловит совпадения по смыслу. Запрос превращается в эмбеддинг, ищется ближайший вектор в базе предыдущих запросов, и если косинусная близость выше порога — отдаётся сохранённый ответ. LLM вообще не дёргается, пользователь получает ответ почти мгновенно вместо привычных двух-трёх секунд.
Базовая выгода — меньше токенов и меньше задержка. Не базовая, но важная — ниже нагрузка на провайдера. Особенно ощутимо это в момент, когда квоты GigaChat Pro заканчиваются ровно во время демо клиенту (проверено на себе, ощущение специфическое).
Архитектура за 200 строк
Берём PostgreSQL 16, расширение pgvector 0.7, эмбеддинги — российская модель типа e5-large-ru (768 измерений) или эмбеддинги GigaChat. Сам кэш — одна таблица:
CREATE TABLE llm_cache (
id BIGSERIAL PRIMARY KEY,
query_embedding vector(768) NOT NULL,
query_text TEXT NOT NULL,
response_text TEXT NOT NULL,
model_version TEXT NOT NULL,
prompt_template_hash TEXT NOT NULL,
tenant_id BIGINT NOT NULL,
hit_count INT DEFAULT 0,
created_at TIMESTAMPTZ DEFAULT now(),
last_hit_at TIMESTAMPTZ DEFAULT now()
);
CREATE INDEX ON llm_cache USING hnsw (query_embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
CREATE INDEX ON llm_cache (tenant_id, prompt_template_hash, model_version);
Логика поиска — обычный SQL с фильтрацией по тенанту и хэшу промпт-шаблона:
async def lookup_cache(query: str, tenant_id: int, template_hash: str,
threshold: float = 0.93) -> str | None:
emb = await embed(query)
row = await db.fetchrow("""
SELECT response_text, id, 1 - (query_embedding <=> $1) AS similarity
FROM llm_cache
WHERE tenant_id = $2
AND prompt_template_hash = $3
AND model_version = $4
AND created_at > now() - interval '7 days'
ORDER BY query_embedding <=> $1
LIMIT 1
""", emb, tenant_id, template_hash, MODEL_VERSION)
if row and row["similarity"] >= threshold:
await db.execute(
"UPDATE llm_cache SET hit_count = hit_count + 1, "
"last_hit_at = now() WHERE id = $1", row["id"]
)
return row["response_text"]
return None
Если cache miss — обычный путь: вызываем LLM, сохраняем ответ вместе с эмбеддингом запроса. Если cache hit — отвечаем практически мгновенно.
Где порог и почему 0.93
Главный параметр — порог косинусной близости. Слишком низкий — отдаём чужие ответы, и это страшно. Слишком высокий — кэш почти не работает и вся затея бессмысленна. Мы вручную разметили большую пачку пар запросов из реальных логов «синонимично / нет» и построили ROC-кривую. Не самое весёлое занятие, но за один вечер можно.
Итог: для российской e5-large на наших корпоративных запросах оптимум оказался в районе 0.93. Ниже — начинает путать «статус по заявке 15234» с «статус по заявке 15235»: номера разные, тексты почти одинаковые. Выше 0.96 — кэш ловит только опечатки, и всё.
Важный нюанс: пороги для разных типов задач разные. Для FAQ-вопросов и 0.91 работает отлично. Для запросов с конкретными ID, датами, суммами — поднимаем до 0.97 либо вообще выключаем кэш для таких запросов через простую эвристику: «есть ли в тексте число длиннее четырёх цифр». Это одна из тех штук, которую понимаешь только в бою: пока не увидишь, как ИИ-агент самостоятельно отдал ответ по чужому номеру заявки, не поверишь, что 0.94 может быть опасно.
Подводные камни, которые стоили нам недели
Первая ошибка: один кэш на всех тенантов. Запустили, обрадовались высокому hit rate, ушли пить кофе. Через пару дней приходит клиент и объясняет, максимально спокойно, что его ИИ-агент в компании только что рассказал сотруднику про чужую отгрузку. Эмбеддинги-то одинаковые, а данные разные. Исправляли уже без кофе — через tenant_id в фильтре и в составном индексе. Урок очевидный, но в эйфории от первых цифр про изоляцию арендаторов забыли. Не повторяйте.
Вторая ошибка: кэш переживает смену промпта. Поменяли системный промпт у ИИ-агента — стиль ответов изменился, тональность стала другой, а пользователи продолжали получать старые формулировки из кэша и удивляться, почему «наш ассистент опять стал сухим». Завели prompt_template_hash — SHA-256 от полного шаблона промпта. Меняешь промпт — старые записи автоматически перестают подходить, кэш прогревается заново.
Третья ошибка: вечный кэш. Данные в CRM меняются. Статус заявки вчера был «согласовано», сегодня «отгружено». Если ответ закэширован — клиент получает устаревшую информацию, а ИИ-агент в его глазах превращается в тормоза, который «ничего не знает». Завели TTL семь дней по умолчанию и отдельный механизм инвалидации: при изменении сущности в базе ставим в очередь задачу «снести из кэша всё, где query_text ILIKE '%15234%'». Не самое элегантное решение, но работает. Для запросов, явно связанных со статусами и числами, TTL уменьшили до часа.
Четвёртая ошибка: HNSW индекс холодный после рестарта. Знакомая многим боль — первый запрос после деплоя занимает несколько секунд, пока pgvector прогревает индекс. Пользователь в этот момент думает, что всё сломалось. Решили прогревом через pg_prewarm после миграций и фоновым селектом самых горячих векторов сразу после старта.
Как это выглядит в бою
Внедряли в ИИ-агенте у одного из заказчиков — дистрибьютора стройматериалов. До кэша ответ приходил через пару-тройку секунд, счёт за токены рос вместе с числом менеджеров, посаженных на систему. После двух недель работы кэша картина изменилась заметно.
Значительная доля запросов вообще перестала доходить до LLM — их закрывает кэш. Для пользователя это означает, что ИИ-агент отвечает почти мгновенно, а не «думает» пару секунд, — разницу такого порядка чувствуешь интерфейсом сразу, без всяких графиков. Для бизнеса — счёт за токены заметно просел, нагрузка на квоты упала, а система стала устойчивее к деградации провайдера: если GigaChat начинает отвечать медленно или с ошибками, кэш продолжает закрывать большую часть трафика, и снаружи ничего не горит.
По объёму: таблица кэша за месяц спокойно умещается в сотни мегабайт, HNSW-индекс поверх неё — ещё скромнее. Для PostgreSQL это не нагрузка вообще.
Когда семантический кэш не работает
Не панацея. Списком, без иллюзий:
- Генеративные задачи, где важна вариативность ответа: «придумай три варианта письма». Кэш убивает разнообразие, и это сразу видно.
- Запросы с уникальным контекстом: «суммаризируй вот это письмо» — текст каждый раз новый, кэш бесполезен.
- Цепочки рассуждений у ИИ-агентов, которые действуют самостоятельно и где промежуточные ответы зависят от истории. Тут проще кэшировать на уровне отдельных tool-вызовов, а не всей цепочки.
- Высокая частота инвалидации: если данные меняются каждую минуту, TTL обнуляет смысл кэша.
Есть ещё один класс — ИИ-агенты для кода. Там подсказки и генерации сильно завязаны на конкретный файл, конкретную позицию, конкретный контекст проекта. Семантический кэш на таких запросах либо мажет мимо, либо портит выдачу. Лучше кэшировать эмбеддинги файлов и результаты статического анализа, а не сами ответы модели.
В этих случаях мы просто оставляем обычный путь без кэша. Эвристика «кэшировать или нет» — отдельный шаг пайплайна, и она проста: смотрим на тип задачи, наличие пользовательского контента в промпте, изменчивость источника данных.
Вывод: pgvector работает не только для RAG
pgvector часто воспринимают как «база для RAG и всё». На деле это универсальный инструмент для любых задач семантической похожести: кэш, дедупликация заявок, кластеризация обращений, поиск похожих кейсов в истории. И главное — он живёт в той же базе, что и остальные данные, без отдельного инфраструктурного зоопарка.
Семантический кэш — самый быстрый способ окупить эту инфраструктуру дважды. Один раз — на RAG, второй раз — на инференсе. Разницу между ИИ-агентом и ИИ-ассистентом здесь можно оставить за скобками: кэш одинаково хорошо помогает и тому, кто просто отвечает на вопросы, и тому, кто действует сам.
Главный вопрос, который стоит задать себе перед внедрением: сколько в вашем трафике повторяющихся по смыслу запросов? Если ощутимая доля — кэш окупится за неделю. Если почти нет — возможно, у вас другая боль, и стоит сначала разобраться, почему пользователи каждый раз спрашивают новое. Иногда это тоже полезный диагноз.
Похожие статьи
- Как мы научили ИИ-агента на GigaChat Pro не путать живого клиента с ночным батчем
- Как мы настроили ИИ-агента для поиска в 1С: MCP-сервер вместо метаний между окнами
- Один вопрос — три темы: как мы научили ИИ-агента разбирать составные запросы
- Text2SQL для 340 таблиц: как мы собирали ИИ-агента для анализа корпоративных данных на GigaChat Pro




