Векторный кэш: делаем умные ответы еще быстрее. ai.. ai. cache.. ai. cache. rag.. ai. cache. rag. redis.. ai. cache. rag. redis. оптимизация.. ai. cache. rag. redis. оптимизация. Поисковая оптимизация.. ai. cache. rag. redis. оптимизация. Поисковая оптимизация. поисковые системы.
Векторный кэш: делаем умные ответы еще быстрее - 1

Введение

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

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

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

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

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

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

Векторный кэш: делаем умные ответы еще быстрее - 2

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Векторный кэш: делаем умные ответы еще быстрее - 4

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

Для понимания принципа работы векторного кэша рассмотрим пример его реализации с сохранением в 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

Векторный кэш: делаем умные ответы еще быстрее - 5

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): при повторных или схожих запросах можно избежать заново выполнения векторного поиска 

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

Минусы

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

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

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

Выводы

Векторный кэш: делаем умные ответы еще быстрее - 7

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

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

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

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

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

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

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

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

Автор: MidavNibush

Источник

Rambler's Top100