Semantic Cache

Кэш ответов модели, ключом которого служит смысл запроса, а не его текст. Входящий вопрос векторизуется, ищется ближайший из сохранённых, и при сходстве выше порога ответ отдаётся без обращения к модели. «Какие ставки по вкладам?» и «расскажи про процент по вкладу» — одна запись кэша.

Суть

Prompt Caching экономит на неизменном префиксе, но сам вызов модели всё равно происходит: платите меньше, ждёте почти столько же. Семантический кэш убирает вызов целиком — там, где этот вопрос уже задавали, пусть и другими словами.

Обычный кэш по хэшу строки для этого бесполезен: пользователи формулируют одно и то же бесконечным числом способов, и совпадение символ в символ случается редко. Замена ключа на вектор превращает «редко» в «часто».

Как работает

Три операции: положить, найти, выбросить.

def get(self, query: str, threshold: float = 0.92) -> str | None:
    query_vector = np.array(model.encode(query))
    best_sim, best_response = -1.0, None
    for item in self.storage.values():                    # полное сканирование
        sim = self._cosine_similarity(query_vector, np.array(item.vector))
        if sim > best_sim:
            best_sim, best_response = sim, item.response
    return best_response if best_sim >= threshold else None   # ниже порога — промах, идём в модель

Сходство считается косинусом между векторами (Embeddings): скалярное произведение, делённое на произведение норм. Нулевой вектор обрабатывается отдельно — иначе деление на ноль.

Порог решает, чем вы рискуете. Ошибки здесь асимметричны. Промах стоит одного лишнего вызова модели — неприятно, но безобидно. Ложное попадание отдаёт пользователю чужой ответ, и внешне это выглядит как уверенная галлюцинация, хотя модель ни при чём. Поэтому порог ставят высоко: в учебной реализации 0.92, и на этом уровне перефразировка вопроса про вклады дала сходство 0.94 и попадание, а вопрос про блокировку кредитки — уверенный промах.

Инвалидация по тегу

Главная опасность кэша не в промахах, а в том, что он живёт дольше правды. Ставка по вкладу поменялась, ответ в кэше — нет, и агент месяцами уверенно выдаёт вчерашние условия.

Решение — привязать к каждой записи тег предметной области и чистить кэш группами, а не целиком:

cache.set("какие ставки по вкладам?", "Ставка 16% годовых на 6 месяцев", tag="deposits")
...
cache.invalidate_by_tag("deposits")   # обновились условия по вкладам — гасим только их

Смысл в том, что событие «изменился источник» почти никогда не касается всего кэша. Сброс целиком стоит холодного старта на всех темах разом, а точечная чистка сохраняет попадания по картам, отделениям и тарифам, пока меняются только вклады. Цена такой избирательности — дисциплина проставления тегов: запись без тега невозможно инвалидировать иначе как полным сбросом.

Это отличает семантический кэш от KV-кэша в CAG, где любое изменение источника означает дорогой пересчёт целиком.

Границы применимости

Линейное сканирование — только для небольших объёмов. Реализация выше сравнивает запрос с каждой записью, что при росте кэша упирается в стену; в продакшене на этом месте стоит индекс или Vector Databases. Локальная структура на массивах оправдана в тестовых окружениях, где внешняя векторная база — лишняя зависимость ради десятка записей.

Не всё вообще кэшируется. Персонализированный ответ («какой у меня баланс») одинаково звучит у разных пользователей и обязан различаться — такой запрос либо не кэшируется, либо кэшируется с идентификатором пользователя в ключе. Быстро меняющиеся данные не кэшируются по той же причине, по которой их не кладут в CAG.

Экономия считается, а не предполагается. Выигрыш равен доле попаданий, помноженной на стоимость вызова, минус стоимость векторизации каждого запроса. При низкой доле попаданий кэш добавляет латентность на энкодере и не окупает себя — поэтому hit rate здесь такая же эксплуатационная метрика, как и для префиксного кэша (см. Agent CostControl).

Пороги, индекс и два скрытых риска

FinOps-разбор описывает тот же механизм в продакшен-конфигурации:

User Query → Embedding Model → Vector DB (HNSW Index) → Threshold Check (≥ 0.96) → Cache Hit / Miss

Порог там ещё строже — 0.96 против 0.92–0.94 из основного разбора. Разброс объясняется ценой ошибки: чем чувствительнее домен, тем выше планка.

Ложные попадания на антонимах. Классический пример: «Как открыть счёт?» и «Как закрыть счёт?» дают высокую косинусную близость при противоположном смысле. Эмбеддинг ловит тему, а не намерение, и никакой порог полностью эту пару не разводит — нужна либо предварительная классификация интента, либо ключ кэша, включающий действие.

Два риска, о которых обычно не думают:

Риск В чём проявляется Решение
Stale data Изменились тарифы — кэш продолжает отдавать старые цены Жёсткий TTL плюс принудительная инвалидация по webhook
Data leak Ответ с персональными данными пользователя A отдаётся пользователю B Namespace по ролям: ключ кэша включает права доступа

Второй риск превращает оптимизацию в инцидент безопасности: кэш, общий для всех пользователей, — это канал утечки, и запись в него ответа с персональными данными нарушает границу доступа ещё до того, как кто-то её попросил (см. DLP for LLM).

Три эксплуатационных решения

Калибровка порога — по размеченным парам, а не по жалобам. Жалобы приходят с задержкой, покрывают только замеченные ошибки и не дают ни одного отрицательного примера, поэтому по ним нельзя построить кривую. Рабочий способ: собрать несколько десятков пар «одинаковый вопрос / разный вопрос» из своего домена, включая заведомо коварные (антонимы вроде «как открыть счёт» / «как закрыть счёт»), измерить на них близость и поставить порог выше максимума среди разных. Жалобы остаются как контрольный сигнал после запуска, а не как источник калибровки.

TTL нужен, даже когда есть теги. Теговая инвалидация точна, но зависит от того, что кто-то не забыл погасить тег при обновлении источника — а забывают всегда. TTL здесь не механизм актуальности, а страховка от пропущенного тега: он ограничивает срок, в течение которого протухший ответ может отдаваться. Срок ставится от того, как быстро меняется предметная область, и обычно заметно длиннее, чем интервал реальных обновлений.

Переход на индекс определяется не числом записей, а временем отклика на пике: линейное сканирование растёт линейно, и порог наступает там, где оно начинает съедать выигрыш, ради которого кэш и ставили. Ориентиры по объёмам, при которых линейный поиск заканчивается и начинается ANN-индекс, — в Vector Databases.

Место в четырёхуровневом каскаде

Семантический кэш редко стоит в одиночку: в высоконагруженной архитектуре он занимает третью позицию (L2) в каскаде, где перед ним идут граница сети и точный хэш-кэш, а после — кэш префикса в движке инференса. Разбор всего каскада и порядка ступеней — в HighLoad Inference.

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

Обязательная деталь уровня L2 — изоляция арендаторов через фильтр по метаданным. В мультиарендной системе семантический кэш без такого фильтра превращается в канал утечки: похожий вопрос другого клиента вернёт ответ, построенный на чужих данных. В отличие от точного кэша, где ключ обычно уже содержит идентификатор арендатора, здесь совпадение ищется по смыслу и границу арендатора не видит (Multi Tenancy AI).

Связано с

  • HighLoad Inference — весь каскад кэширования и что делать, когда он не спасает
  • Multi Tenancy AI — почему фильтр по арендатору здесь обязателен
  • Prompt Caching — кэш префикса: удешевляет вызов, но не отменяет его
  • CAG — кэш знаний в KV, где инвалидация идёт целиком, а не по тегу
  • Agent CostControl — куда попадает сэкономленное и как считать окупаемость
  • Embeddings — векторное представление, на котором стоит поиск по смыслу
  • Vector Databases — чем заменяют полное сканирование при росте объёма
  • RAG — тот же примитив поиска по близости, но для документов, а не ответов