Введение

Сегодня чат-боты и интеллектуальные ассистенты широко применяются в различных сферах: поддержка клиентов, корпоративные системы, поисковые сервисы и во многих других.  Для их разработки часто используют архитектуру Retrieval-Augmented Generation (RAG), которая объединяет генерацию ответа с поиском данных во внешних источниках. Такой подход помогает ботам и ассистентам давать более точные и актуальные ответы. Но на практике оказывается, что RAG сталкивается с проблемой повторяющихся запросов, из-за которой система многократно выполняет одни и те же вычисления, повышая нагрузку и время отклика.

Всем привет! Меня зовут Вадим, я Data Scientist в компании Raft, и в этой статье мы разберемся, что такое векторный кэш и как его использовать. Давайте начнем!

Краткий обзор RAG

Перед началом знакомства с векторным кэшем давайте кратко рассмотрим, как базово работает система RAG. В целом её можно поделить на 2 большие части:

  1. Индексация данных (Data Indexing): на этом этапе происходит сбор, обработка и преобразование документов в векторные представления (эмбеддинги), которые вносятся в векторную базу данных для дальнейшего поиска по ним.

  2. Поиск и генерация ответа (Data Retrieval & Generation): на этом этапе пользователь вводит свой запрос, система преобразует его в вектор, по которому ищет top-K наиболее релевантных фрагментов (chunks) для формирования контекста.  Далее, опираясь на запрос пользователя, заранее заготовленные инструкции и информацию из векторной базы данных, LLM формирует конечный ответ.

Базовая система RAG

Определение проблемы

Конечно, существует множество различных вариаций RAG, которые в большинстве случаев фокусируются на повышении качества результатов генерации: например, за счёт более сложных стратегий извлечения, моделей reranker-ов и так далее.

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

На практике же оказывается, что около 30% всех пользовательских запросов семантически похожи между собой, и система фактически заново извлекает одни и те же или очень близкие данные.

Давайте рассмотрим, как механизм векторного кэша может помочь избежать повторных вычислений и  повысить эффективность работы системы.

Что такое векторный кэш и как его готовить?

Как работает векторный кэш?

По сути, векторный кэш — это дополнительный уровень хранения, который позволяет сохранять уже сгенерированные ответы для запросов, которые «семантически» (то есть по смыслу) похожи между собой. Вместо того чтобы каждый раз заново искать и пересобирать ответ, система сначала проверяет: не встречался ли уже похожий запрос? Если да, то можно быстро вернуть готовый ответ из кэша, минуя лишние вычисления. В ином случае необходимо будет обратиться к векторной базе данных, провести все необходимые вычисления, а затем вставить ответ в хранилище кэша. 

На практике это работает примерно так:

  1. Преобразуем запрос пользователя в векторное представление — эмбеддинг.

  2. Ищем в кэше ответ или необходимый контекст среди уже сохранённых эмбеддингов и  находим те, которые находятся ближе всего к текущему запросу.

  3. Сравниваем семантическую близость — если запрос действительно достаточно похож, достаем готовый ответ или контекст из кэша.

  4. Если похожий запрос не найден — выполняем стандартный поиск по векторной базе и генерируем ответ, а затем добавляем его в кэш для будущих обращений.

Схема работы RAG с векторным кэшем
Схема работы RAG с векторным кэшем

Важные компоненты

Для стабильной и эффективной работы векторного кэша необходимо учитывать следующие компоненты:

  • Выбор эмбеддингов: подбираем модель, которая умеет хорошо улавливать смысл запросов для нашей задачи.

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

  • Выбор порога сходства: задаем порог, при котором два запроса считаются достаточно похожими, чтобы использовать кэш. Подбирается экспериментально, но обычно такой порог меньше 0.5. 

  • Политика наполнения и обновления кэша: определяем, как контролировать размер кэша и актуальность данных. На практике часто используется политика TTL, которая определяет время жизни кэша, но также есть и другие, такие как LRU, FIFO, LFU и так далее.

Схемы реализации

Реализовывать векторный кэш можно различными способами, давайте посмотрим на некоторые из них.

Реализация с нуля

Для понимания принципа работы векторного кэша рассмотрим пример его реализации с сохранением в json файле (также можно сохранять и в других представлениях, например в виде коллекций в векторной базе данных).

Для начала нам необходимо инициализировать класс работы с кэшем — SemanticCache и указать путь к файлу для хранения кэша, порог и максимальное числом запросов, которые мы можем хранить в файле. Здесь я использую простую стратегию вытеснения FIFO (First-In, First-Out) — старые записи будут удаляться первыми.

class SemanticCache:
    def __init__(self, json_file: str = "cache_file.json", threshold: float = 0.35,
                 max_response: int = 100, eviction_policy: Optional[str] = None, nprobe: int = 8):
        """
        Инициализация семантического кэша.

        Args:
            json_file (str): Путь к JSON файлу для хранения кэша.
            threshold (float): Порог Евклидова расстояния, ниже которого считаем запросы похожими.
            max_response (int): Максимальное количество записей в кэше.
            eviction_policy (str, optional): Политика вытеснения (например, 'FIFO').
            nprobe (int): Количество кластеров для поиска в Faiss.
        """
        # Инициализируем Faiss-индекс и энкодер
        self.index, self.encoder = init_cache()

        self.threshold = threshold
        self.json_file = json_file
        self.max_response = max_response
        self.eviction_policy = eviction_policy
        self.nprobe = nprobe

        # Загружаем кэш из файла или создаём пустой, если файл не существует
        self.cache = retrieve_cache(self.json_file)

Дальше я определяю метод search_in_cache, задача которого проверить, есть ли в кэше похожий запрос. В нем кодируется новый запрос в эмбеддинг, а затем с помощью Faiss ищется ближайший вектор. Если расстояние до него меньше установленного порога, считаем, что запрос достаточно похож, и возвращаем готовый ответ из кэша, в ином случае — возвращаем None.

def _search_in_cache(self, embedding: List[float]) -> Optional[str]:
        """
        Ищет похожий запрос в Faiss-индексе.

        Args:
            embedding (List[float]): Векторное представление нового запроса.

        Returns:
            Optional[str]: Найденный ответ из кэша, либо None, если подходящего ответа нет.
        """
        # Устанавливаем число кластеров для поиска
        self.index.nprobe = self.nprobe

        # Выполняем поиск ближайшего соседа
        D, I = self.index.search(embedding, 1)
        distance = D[0][0]
        index = I[0][0]

        # Проверяем, подходит ли найденный результат под пороговое значение расстояния
        if index >= 0 and distance <= self.threshold:
            return self.cache['response_text'][index]

        # Если подходящего результата не найдено
        return None

Чтобы кэш не рос бесконечно, определяется метод _evict_if_needed, в котором реализована логика удаления старых записей. Как только число записей превышает max_response, мы просто удаляем несколько самых первых (самых старых) элементов.

def _evict_if_needed(self):
        """
        Проверяет, не превышает ли размер кэша максимальное количество записей,
        и при необходимости удаляет старые записи.
        """
        overflow = len(self.cache["questions"]) - self.max_response
        if overflow > 0:
            # Удаляем старые элементы из всех списков, чтобы кэш оставался синхронизированным
            self.cache["questions"] = self.cache["questions"][overflow:]
            self.cache["embeddings"] = self.cache["embeddings"][overflow:]
            self.cache["answers"] = self.cache["answers"][overflow:]
            self.cache["response_text"] = self.cache["response_text"][overflow:]

Наконец, основной метод ask объединяет всю логику вместе:

  • преобразует запрос пользователя в эмбеддинг;

  • ищет в кэше похожий запрос;

  • если находит, сразу возвращает сохраненный ответ;

  • если не находит — обращается к внешней базе, получает новые релевантные данные, добавляет их в кэш и возвращает ответ пользователю.

def ask(self, question: str, k: int) -> str:
        """
        Получает ответ из кэша или, если в кэше не найдено, из внешней базы (например, chromaDB).

        Args:
            question (str): Запрос пользователя.
            k (int): Количество документов для получения из базы при промахе кэша.

        Returns:
            str: Текст ответа.
        """
        try:
            # Кодируем запрос в вектор
            embedding = self.encoder.encode([question])

            # Пробуем найти ответ в кэше
            cached_response = self._search_in_cache(embedding)

            if cached_response:
                # Если нашли — возвращаем его
                return cached_response

            # Если не нашли — делаем запрос к внешней базе
            answers = query_database(question, k)

            # Склеиваем тексты документов в единый ответ
            response_text = "".join(doc.page_content for doc in answers)

            # Добавляем новый запрос, ответ и эмбеддинг в кэш
            self.cache['questions'].append(question)
            self.cache['embeddings'].append(embedding[0].tolist())
            self.cache['answers'].append(response_text)
            self.cache['response_text'].append(response_text)

            # Добавляем новый эмбеддинг в Faiss-индекс
            self.index.add(embedding)

            # Проверяем, не переполнился ли кэш, и при необходимости удаляем старые записи
            if len(self.cache["questions"]) > self.max_response:
                self._evict_if_needed()

            # Сохраняем обновленный кэш в файл
            store_cache(self.json_file, self.cache)
            return response_text

        except Exception as e:
            raise RuntimeError(f"Error in 'ask' method: {e}")

Таким образом данная реализация позволяет максимально гибко настроить систему кэширования и увеличить производительность системы.

Но зачем нам писать всё с нуля? Если есть возможность использовать уже готовые решения, например Redis.

Использование Redis

Redis — это высокопроизводительное хранилище данных в памяти (in-memory database), которое поддерживает структуру «ключ–значение» и множество дополнительных типов данных: списки, множества, хеши, упорядоченные множества и другие. Благодаря работе в оперативной памяти и продуманной архитектуре он обеспечивает быструю обработку запросов с минимальной задержкой.

Сейчас Redis активно развивается и как часть экосистемы GenAI, среди его функционала есть работа с векторным (семантическим) кэшем. Для его реализации можно использовать библиотеку redisvl с необходимым функционалом. Для начала необходимо создать объект для работы с кэшем, класс SemanticCache, в котором необходимо установить следующие параметры:

  • название индекса в Redis,

  • адрес подключения,

  • модель для векторизации текста,

  • порог семантической близости (distance_threshold),

  • время жизни кэша (ttl).

from redisvl.extensions.cache.llm import SemanticCache
from redisvl.utils.vectorize import HFTextVectorizer

llmcache = SemanticCache(
   name="llmcache",                                          # название поискового индекса
   redis_url="redis://localhost:6379",                       # URL для подключения к Redis
   distance_threshold=0.1,                                   # пороговое значение семантической близости для кэша
   vectorizer=HFTextVectorizer("redis/langcache-embed-v1"),  # модель эмбеддингов
   overwrite=True,
   ttl= 60 * 60 * 24,   # время жизни записей в кэше (1 день)
)

Далее реализовываем аналогичную логику работы с векторным кэшем, используя готовое решение от Redis.

def process_query(question, k, vector_store, llmcache):

    # Проверяем, есть ли уже готовый ответ в кэше для данного запроса
    response = llmcache.check(question)

    if response:
        # Если кэш сработал, выводим найденный ответ
        print(f"Cache hit: {response}")
    else:
        # Если в кэше не найдено (cache miss)
        print(f"Cache miss: {response}")

        # Выполняем семантический поиск по векторной базе с топ-k результатами
        results = vector_store.similarity_search(query, k=k)

        # Объединяем тексты найденных документов в один контекст
        context = "\n".join([result.page_content for result in results])

        # Сохраняем в кэш: запрос и полученный контекст как ответ
        llmcache.store(
            prompt=question,
            response=context,
        )

        # Выводим сформированный контекст
        print(context)

Дополнительные возможности Redis

Кроме представленного механизма кэширования у Redis также другие AI инструменты:

Векторная база данных Redis в сравнении с другими популярными решениями
Векторная база данных Redis в сравнении с другими популярными решениями

Плюсы и минусы векторного кэша

Плюсы

  • Снижение задержек (latency): при повторных или схожих запросах можно избежать заново выполнения векторного поиска 

  • Экономия ресурсов: снижается нагрузка на векторную базу данных

Минусы

  • Увеличения объёма кэша: при большом числе уникальных запросов кэш быстро увеличивается в размерах, необходимо определить оптимальную политику вытеснения

  • Дополнительные вычисления при вставке: каждая новая запись требует генерации эмбеддинга, обновления индекса и сохранения данных

  • Потенциальная устарелость результатов: если кэш не обновляется, ответы могут стать неактуальными по сравнению с обновлённой внешней базой данных

Выводы

Векторный кэш — это отличное решение, если вы хотите, чтобы ваша система быстрее отвечала и не тратила лишние ресурсы на похожие запросы.

Главное — не забывать следить за размером кэша, периодически очищать устаревшие данные и правильно настроить параметры кэширования, тогда он действительно будет полезен и поможет сделать систему быстрее и умнее.

А приходилось ли вам использовать векторный кэш или как-нибудь оптимизировать запросы в RAG пайплайнах? Делитесь в комментариях!

Полезные материалы

  1. Статья от HuggingFace о собственной реализации векторного кэша с хранением в json файлах

  2. Видео о семантическом кэшировании на базе коллекций Qdrant

  3. Статья о семантическом кэшировании с Redis

  4. GPTCache - библиотека для работы с кэшем

Комментарии (0)