Главная / Статьи / Семантический кэш для LLM-приложений на Ubuntu 24.04 с Qdrant

Семантический кэш для LLM-приложений на Ubuntu 24.04 с Qdrant

Сократите расходы на LLM API, кэшируя ответы по смыслу, а не по тексту: Qdrant на Ubuntu 24.04, изоляция арендаторов, пороги сходства по маршрутам и цена IOPS.

788 слов

Когда генеративное ИИ-приложение выходит в продакшн, счёт быстро привлекает внимание, ведь оплачивается каждый токен. Пролистайте логи промптов, и, скорее всего, окажется, что до 40% пользовательских запросов несут одно и то же намерение в разных словах. «Как сбросить пароль?» и «Не могу войти в аккаунт, нужен доступ» означают одно и то же. Классическое хранилище «ключ–значение» видит два несвязанных запроса, дважды вызывает OpenAI API и оба раза выставляет полный счёт.

Семантический кэш это исправляет. Ниже показано, как собрать его на Ubuntu 24.04 LTS с точки зрения эксплуатации, а также три ловушки, которые туториалы обычно пропускают.

Точное совпадение против смысла

Два способа поиска ведут себя очень по-разному:

  • Лексическое кэширование, например обычное хранилище «ключ–значение» в Redis, требует идентичных строк. «Где мой заказ?» закэшировано, но «Отследить посылку» промахивается, потому что символы другие.
  • Семантическое кэширование превращает промпты в многомерные векторы с помощью модели эмбеддингов. «Где мой заказ?» и «Отследить посылку» оказываются рядом в векторном пространстве, поэтому сохранённый ответ возвращается сразу. Задержка поиска падает примерно с 3000 мс до 15 мс.

Поднимаем векторный кэш на Qdrant

В качестве хранилища запустите Qdrant в Docker с постоянным томом, затем подготовьте окружение Python:

# 1. Install Qdrant via Docker on Ubuntu 24.04 (Persistent Storage)
sudo apt update && sudo apt install docker.io -y
sudo docker run -d --restart unless-stopped -p 6333:6333 -p 6334:6334 \
    -v $(pwd)/qdrant_storage:/qdrant/storage:z \
    qdrant/qdrant

# 2. Setup Python environment
python3 -m venv llm_cache_env
source llm_cache_env/bin/activate
pip install qdrant-client sentence-transformers openai

Держите арендаторов раздельно

Единый глобальный семантический кэш внутри многоарендного SaaS-продукта грозит серьёзными утечками между клиентами. Представьте, что пользователь A просит сводку последних транзакций по своему счёту. Модель пишет ответ с его личными финансовыми данными, и ответ кэшируется. Позже пользователь B просит сводку своих транзакций. Кэш видит совпадение намерения на 98% и отдаёт B ответ, написанный для A.

Решение в том, чтобы добавить в payload Qdrant строгое пространство имён, tenant_id, и выполнять каждый поиск по сходству под жёстким фильтром метаданных. Без этого фильтра поиск вообще не должен запускаться. Код ниже создаёт коллекцию с нужным размером вектора (384 для локального энкодера MiniLM) и выполняет поиск с фильтром по арендатору:

from qdrant_client import QdrantClient
from qdrant_client.http import models
from sentence_transformers import SentenceTransformer

# Initialize Local Embedding Model (Free & Fast)
encoder = SentenceTransformer("all-MiniLM-L6-v2")
client = QdrantClient(host="localhost", port=6333)
COLLECTION_NAME = "semantic_cache_prod"
# Explicitly create collection with correct Vector Dimensions (384)
if not client.collection_exists(collection_name=COLLECTION_NAME):
    client.create_collection(
        collection_name=COLLECTION_NAME,
        vectors_config=models.VectorParams(
            size=384,  # Matches 'all-MiniLM-L6-v2' output dimension
            distance=models.Distance.COSINE
        ),
    )
def check_secure_cache(user_query: str, tenant_id: str, threshold: float = 0.90):
    query_vector = encoder.encode(user_query).tolist()

    # SRE Fix: Hard Metadata Filtering by tenant_id
    hits = client.search(
        collection_name=COLLECTION_NAME,
        query_vector=query_vector,
        query_filter=models.Filter(
            must=[models.FieldCondition(
                key="tenant_id",
                match=models.MatchValue(value=tenant_id)
            )]
        ),
        limit=1,
        score_threshold=threshold
    )

    if hits:
        print(f" Secure Cache HIT! (Score: {hits[0].score:.3f})")
        return hits[0].payload["llm_response"]

    return None

Ловушка порога

Выбор порога сходства это баланс между точностью и полнотой. Многие руководства советуют одно общее значение 0,80, и это даёт неприятные неверные ответы. Близость в пространстве эмбеддингов не означает одинаковый смысл. «Назовите столицу Франции» и «Назовите столицу Германии» имеют одну структуру предложения, поэтому лежат очень близко друг к другу, с косинусным сходством выше 0,85. При пороге 0,80 человек, спросивший про Германию, получит закэшированный ответ про Францию.

Вместо этого настраивайте порог для каждого маршрута:

  • 0,95 и выше для строгих фактических, технических или финансовых вопросов
  • 0,88 для типовых разговорных FAQ

Когда узким местом становится скорость диска

Векторные базы на индексах HNSW сильно нагружают пропускную способность памяти и диска. Тяжёлый поиск по сходству в публичном облаке обычно означает блочное хранилище с выделенными IOPS, а такие счета за io2 могут быть очень высокими.

Один из обходных путей — разместить весь стек кэша на выделенных bare-metal серверах. Подключённые напрямую корпоративные NVMe-диски на PCIe дают миллионы IOPS без доплаты, а большой объём DDR5 RAM на выделенном железе позволяет держать поиск по эмбеддингам быстрее 15 мс даже при большой параллельной нагрузке. Так исчезает налог на хранилище, который облачные провайдеры добавляют сверху к вычислениям.