Как вы делаете cache invalidation для semantic cache при обновлении знаний?

Краткий тезис

cache|Semantic cache (Caching|семантический кэш) ускоряет ответы RAG-системы, сохраняя результаты для семантически похожих запросов. При обновлении знаний (документов, чанков) кэшированные ответы могут стать невалидными. Основные стратегии инвалидации: cache|versioned cache с отслеживанием зависимостей (каждый ответ хранит версию документа), TTL-based eventual consistency (кэш живёт фиксированное время) и полная инвалидация при глобальном version bump. Выбор стратегии — компромисс между консистентностью, сложностью и стоимостью.


1. Термин: Semantic Cache (семантический кэш)

Semantic cache — это кэш, который хранит ответы LLM на запросы, сгруппированные по семантической близости. В отличие от exact-match cache (кэш точных совпадений), semantic cache использует эмбеддинги запросов: если новый запрос семантически похож на один из ранее закэшированных (косинусная близость выше порога), возвращается сохранённый ответ. Это снижает latency (задержку) и нагрузку на LLM.

Зачем нужен:

  • Ускорение ответов для повторяющихся или похожих вопросов.
  • Экономия токенов и стоимости API.
  • Улучшение пользовательского опыта.

Проблема: при обновлении базы знаний (добавлении, изменении, удалении документов) старые кэшированные ответы могут ссылаться на устаревшую информацию. Если не инвалидировать кэш, пользователь получит неактуальный ответ.


2. Почему cache invalidation сложна в semantic cache

В отличие от exact-match cache, где ключ — точный текст запроса, в semantic cache ключ — эмбеддинг запроса, а ответ зависит от множества документов, найденных retrieval'ом. При обновлении одного документа нужно понять, какие кэшированные ответы его использовали. Это требует отслеживания зависимостей между запросами и документами.

Факторы сложности:

  • Семантическое перекрытие: два разных запроса могут использовать один и тот же документ.
  • Динамический retrieval: при каждом запросе retrieval может вернуть разный набор документов (из-за изменения эмбеддингов или индекса).
  • Агентные сценарии: в Agentic RAG агент может вызывать инструменты, и кэш может хранить результаты вызовов, которые тоже нужно инвалидировать.

3. Стратегия 1: Versioned Cache с отслеживанием зависимостей

Каждый кэшированный ответ хранит метаданные:

  • knowledge_version — версия документа или набора документов, на основе которых сгенерирован ответ.
  • doc_ids — список идентификаторов документов, которые были использованы при генерации.

При обновлении документа:

  1. Увеличиваем его версию (timestamp или монотонный счётчик).
  2. Находим все кэши, в doc_ids которых есть этот документ.
  3. Удаляем или помечаем их как невалидные.

Реализация:

  • Храним mapping document_id -> set of query_hashes (или ключей кэша).
  • При обновлении документа проходим по этому mapping и удаляем соответствующие записи из кэша.

Плюсы: точная инвалидация, минимальная потеря кэша. Минусы: нужно хранить и поддерживать mapping; сложность возрастает с числом документов и запросов.


4. Стратегия 2: TTL + Eventual Consistency

Устанавливаем Time-To-Live (TTL) для каждой записи кэша (например, 1 час). По истечении TTL запись считается недействительной и удаляется при следующем обращении. При обновлении знаний ничего не делаем — кэш постепенно вытесняется.

Плюсы: простота реализации, не нужно отслеживать зависимости. Минусы: в течение TTL пользователь может получать устаревшие ответы (eventual consistency). Не подходит для сценариев, где критична актуальность (например, финансовая отчётность).

Вариант: динамический TTL в зависимости от типа документа (критичные документы — короткий TTL, редко меняющиеся — длинный).


5. Стратегия 3: Version Bump (полная инвалидация)

При любом обновлении знаний увеличиваем глобальную версию кэша (например, cache_version += 1). Все старые записи считаются недействительными. Можно либо сразу очистить весь кэш, либо проверять версию при каждом get.

Плюсы: максимальная консистентность, простота логики. Минусы: дорого — после каждого обновления кэш пуст, latency возрастает до перезаполнения. Подходит для систем с редкими обновлениями (раз в день) или малым объёмом кэша.

Гибрид: version bump только для затронутых документов (групповая инвалидация). Например, если обновляется документ из категории "новости", инвалидируются все кэши этой категории.


6. Сравнение стратегий

СтратегияКонсистентностьСложность реализацииСтоимость (потеря кэша)Latency после обновления
Versioned + зависимостиСильная (точная)Высокая (нужен mapping)Низкая (инвалидируется только связанное)Низкая (большая часть кэша жива)
TTLСлабая (eventual)НизкаяСредняя (кэш умирает постепенно)Средняя (часть запросов без кэша)
Version BumpСильная (мгновенная)НизкаяВысокая (весь кэш сбрасывается)Высокая (все запросы без кэша)
Гибрид (TTL + версионирование)СредняяСредняяСредняяСредняя

7. Как определить, какие документы изменились

Для инвалидации нужно знать факт изменения документа. Способы:

  • Хэширование: хранить хэш содержимого документа; при изменении хэш меняется.
  • File system watchers: inotify, Watchdog — отслеживание изменений файлов.
  • Event-driven: при обновлении через API публикуется событие document_updated.
  • Polling: периодически проверять дату модификации.

В Agentic RAG документы могут обновляться асинхронно (например, через веб-скрапинг). Рекомендуется использовать message queue (Kafka, RabbitMQ) для уведомлений об изменениях.


8. Пример реализации на Python (упрощённый)

import hashlib
import time
from collections import defaultdict

class SemanticCache:
    def __init__(self):
        self.cache = {}  # query_hash -> (response, doc_ids, version)
        self.doc_to_queries = defaultdict(set)  # doc_id -> set of query_hashes
        self.doc_versions = {}  # doc_id -> version

    def _hash_query(self, query_embedding):
        # упрощённо: используем first 8 bytes as hex
        return hashlib.md5(str(query_embedding).encode()).hexdigest()

    def get(self, query_embedding):
        qhash = self._hash_query(query_embedding)
        if qhash not in self.cache:
            return None
        response, doc_ids, version = self.cache[qhash]
        # проверяем, что все документы актуальны
        for doc_id in doc_ids:
            if self.doc_versions.get(doc_id, 0) != version:
                self.invalidate(qhash)
                return None
        return response

    def set(self, query_embedding, response, doc_ids):
        qhash = self._hash_query(query_embedding)
        # версия = максимальная версия среди использованных документов
        version = max(self.doc_versions.get(doc_id, 0) for doc_id in doc_ids)
        self.cache[qhash] = (response, doc_ids, version)
        for doc_id in doc_ids:
            self.doc_to_queries[doc_id].add(qhash)

    def invalidate(self, qhash):
        if qhash in self.cache:
            _, doc_ids, _ = self.cache.pop(qhash)
            for doc_id in doc_ids:
                self.doc_to_queries[doc_id].discard(qhash)

    def on_document_updated(self, doc_id):
        self.doc_versions[doc_id] = self.doc_versions.get(doc_id, 0) + 1
        # инвалидируем все кэши, использующие этот документ
        for qhash in list(self.doc_to_queries.get(doc_id, [])):
            self.invalidate(qhash)

Примечание: в реальной системе query_hash должен быть на основе эмбеддинга, а не md5 от эмбеддинга (потеря точности). Лучше использовать FAISS или Annoy для поиска ближайших соседей.


9. Компромиссы и рекомендации

  • Когда использовать versioned cache: если обновления частые, но затрагивают малую часть документов, и важна высокая консистентность.
  • Когда использовать TTL: если обновления редкие или не критична актуальность (например, справочная информация).
  • Когда использовать version bump: если обновления очень редкие (раз в день) или кэш небольшой.
  • Гибридный подход: TTL для большинства запросов + versioned инвалидация для критичных документов (например, юридические тексты).

В Agentic RAG дополнительно нужно учитывать, что агент может кэшировать результаты вызовов инструментов. Инвалидация таких кэшей должна происходить при изменении состояния инструмента (например, обновлении API).


10. Связь с другими аспектами RAG

  • Chunking: размер чанков влияет на количество документов, от которых зависит ответ. Мелкие чанки -> больше зависимостей -> сложнее инвалидация.
  • Retrieval quality: если retrieval нестабилен (разные документы на один запрос), versioned cache может часто инвалидироваться.
  • Self-RAG: в Self-RAG модель сама решает, нужен ли retrieval; кэш может хранить как ответы без retrieval, так и с retrieval — инвалидация только для вторых.

Пет-проект для закрепления

Задача: Реализовать semantic cache с versioned инвалидацией для RAG-системы на синтетических данных.

Инструменты: Python, FAISS (для семантического поиска), SQLite (для хранения mapping doc->queries), Flask (простой API).

Шаги:

  1. Создать 100 синтетических документов (например, факты о городах).
  2. Реализовать эмбеддинги через sentence-transformers.
  3. Написать класс SemanticCache с методами get, set, invalidate_by_doc_id.
  4. Смоделировать запросы (50 уникальных, 50 повторяющихся).
  5. Измерить latency до и после кэширования.
  6. Обновить 10 документов и проверить, что кэш для связанных запросов инвалидирован.

Ожидаемый результат:

  • Снижение среднего времени ответа на 60-80% для повторяющихся запросов.
  • После обновления документов кэш для затронутых запросов пуст, для остальных — жив.
  • Понимание trade-off'ов между точностью инвалидации и накладными расходами на хранение mapping.

Связь с другими вопросами

ВопросТема
9Как вы обновляете документы в существующей RAG-системе?
7Как вы уменьшаете latency RAG-системы (время ответа)?
10Что такое Self-RAG и когда его использовать?
5Как вы оцениваете качество retrieval'а в RAG-системе?
3Какие стратегии chunking'а вы знаете и когда какую применяете?
1Как бы вы спроектировали RAG-систему для 10 000 документов с разной структурой?

Навигация