Гибридное кэширование векторов Redis + Qdrant для LLM-пайплайнов: архитектура и оптимизация
Гибридное кэширование семантических векторов Redis + Qdrant: архитектура, реализация и оптимизация LLM-пайплайнов
Проблема дублирования вычислений эмбеддингов в продакшн-системах на базе LLM приводит к переполнению GPU и росту задержек (latency). Решение заключается в разделении ответственности: Redis выступает как L1-кэш для мгновенного O(1) доступа к хешам запросов и ссылкам на векторы, а Qdrant — как L2-хранилище для семантического поиска по миллионам записей. Этот подход позволяет снизить нагрузку на инференс-модели до 90% при наличии повторяющихся паттернов в трафике.
Технические параметры и сводная таблица
| Параметр / Инструмент | Значение / Версия | Назначение / Команда | Примечание |
|---|---|---|---|
| Redis Stack | 8.2.0 | L1-кэш для хешей и метаданных | Режим volatile-lru, TTL 5-10 мин |
| Qdrant | 1.11.0 | L2-хранилище векторов и метаданных | on_disk_payload, Cosine distance |
| LangChain | 0.3.15 | Оркестрация пайплайнов | Интеграция кастомного SemanticCache |
| ONNX Runtime | 1.20.0 | Инференс эмбеддингов | Cold-start 0.15с вместо 1.2с |
| HuggingFace Transformers | 4.48.0 | Модель эмбеддингов | Поддержка ONNX Runtime |
| Docker Compose | v2.30.0 | Оркестрация сервисов | Ограничение CPU, сеть bridge |
Архитектура гибридного кэша: Redis + Qdrant
Система строится на паттерне Write-Through с асинхронным дублированием. При поступлении запроса система сначала проверяет Redis по MD5-хешу нормализованного текста. Если хеш найден, вектор извлекается из кэша и сразу отправляется в Qdrant для поиска, минуя генерацию эмбеддингов. Если хеш отсутствует, модель (через ONNX Runtime) генерирует вектор, сохраняет его в Redis с TTL и асинхронно дублирует в Qdrant через upsert.
Кэшировать необходимо только "чистые" текстовые запросы. Промпты с переменными (например, {{user_name}}) не должны попадать в кэш, так как это приведет к промаху (miss rate) около 40% при изменении параметров пользователя.
Кэширование запросов без нормализации (lowercase, strip, remove_punct) вызывает утечку памяти из-за дублирования хешей для идентичных по смыслу, но различающихся по регистру или знакам препинания запросов.
Настройка инфраструктуры (Docker & Config)
Запуск сервисов
Используйте Docker Compose для изоляции и управления ресурсами. Ограничьте количество CPU-ядер для предотвращения перегрузки хоста.
version: '3.9'
services:
redis-cache:
image: redis:8.2-alpine
ports:
- "6379:6379"
command: redis-server --maxmemory 2gb --maxmemory-policy volatile-lru
deploy:
resources:
limits:
cpus: '1'
networks:
- ai_infra
qdrant:
image: qdrant/qdrant:1.11.0
ports:
- "6333:6333"
volumes:
- qdrant_storage:/qdrant/storage
deploy:
resources:
limits:
cpus: '2'
networks:
- ai_infra
app:
build: .
depends_on:
- redis-cache
- qdrant
networks:
- ai_infra
volumes:
qdrant_storage:
driver: local
networks:
ai_infra:
driver: bridge
Конфигурация Qdrant
Настройте коллекцию с включением on_disk_payload для хранения тяжелых метаданных вне оперативной памяти, что критично при работе с большими объемами данных.
# config/qdrant_config.yaml
collection_name: semantic_vectors
vectors:
size: 768
distance: Cosine
shard_number: 4
replication_factor: 1
on_disk_payload: true
Включите on_disk_payload в Qdrant. Это позволяет хранить метаданные (например, ID документа, дату создания) на диске, освобождая RAM для самих векторов и ускоряя загрузку коллекции при старте.
Реализация логики кэширования на Python (LangChain)
Подготовка зависимостей
Установите необходимые библиотеки, включая оптимизированную версию ONNX Runtime для трансформеров.
pip install qdrant-client redis onnxruntime-transformers==1.20.0
Логика менеджера кэша
Реализуйте кастомный класс SemanticCache, который интегрируется в цепочку LangChain. Ключевым этапом является нормализация запроса перед вычислением хеша.
import hashlib
import pickle
import base64
import time
from typing import Optional, List, Dict, Any
import redis
import qdrant_client
from qdrant_client.http import models
class SemanticCache:
def __init__(self, redis_host: str = "localhost", qdrant_host: str = "localhost"):
self.redis_client = redis.Redis(host=redis_host, port=6379, decode_responses=True)
self.qdrant_client = qdrant_client.QdrantClient(host=qdrant_host)
self.model = None # Инициализация модели эмбеддингов (например, BERT или SentenceTransformer)
self.version = "v1.0" # Версия модели для валидации хешей
def normalize_query(self, query: str) -> str:
"""Строгая нормализация: lowercase, strip, удаление пунктуации."""
import re
text = query.lower().strip()
text = re.sub(r'[^\w\s]', '', text)
return text
def get_cached_vector(self, query: str) -> Optional[List[float]]:
"""Получение вектора из кэша Redis."""
normalized = self.normalize_query(query)
hash_val = hashlib.md5(normalized.encode()).hexdigest()
key = f"vec:{hash_val}"
cached_data = self.redis_client.get(key)
if cached_data:
# Десериализация вектора
vector = pickle.loads(base64.b64decode(cached_data))
return vector
return None
def save_vector(self, query: str, vector: List[float]):
"""Сохранение вектора в Redis и асинхронная запись в Qdrant."""
normalized = self.normalize_query(query)
hash_val = hashlib.md5(normalized.encode()).hexdigest()
key = f"vec:{hash_val}"
# 1. Запись в Redis с TTL (5-10 минут)
ttl_seconds = 300
self.redis_client.setex(key, ttl_seconds, base64.b64encode(pickle.dumps(vector)).decode())
# 2. Асинхронная запись в Qdrant
import asyncio
asyncio.run(self._async_upsert_to_qdrant(hash_val, vector))
async def _async_upsert_to_qdrant(self, point_id: str, vector: List[float]):
"""Асинхронное добавление точки в Qdrant."""
self.qdrant_client.upsert(
collection_name="semantic_vectors",
points=[
models.PointStruct(
id=point_id,
vector=vector,
payload={"original_query": None, "timestamp": time.time()}
)
]
)
def search(self, query: str, top_k: int = 5) -> List[Dict[str, Any]]:
"""Поиск по вектору с проверкой кэша."""
vector = self.get_cached_vector(query)
if vector:
# Вектор найден в кэше, ищем в Qdrant
results = self.qdrant_client.search(
collection_name="semantic_vectors",
query_vector=vector,
limit=top_k
)
return [{"id": r.id, "score": r.score} for r in results]
else:
# Вектор не найден, генерируем его заново
# Здесь должна быть логика вызова модели (ONNX Runtime)
# vector = self.model.encode(query)
# self.save_vector(query, vector)
# return self.search(query, top_k) # Рекурсивный вызов после сохранения
raise Exception("Vector generation required (cache miss)")
Используйте ONNX Runtime для генерации эмбеддингов перед записью в Redis. Это снижает время cold-start с 1.2 секунды до 0.15 секунды, что критично для пользовательского опыта.
Диагностика и устранение возможных сбоев
Сбой: "Кэш не находит вектор при идентичном запросе"
Причина: Отсутствие нормализации текста. Запросы "Привет" и "привет" или "Привет!" и "Привет" имеют разные хеши.
Решение: Внедрите метод normalize_query в класс кэша, который унифицирует регистр и убирает лишнюю пунктуацию перед вычислением MD5.
Сбой: "Redis переполнен памятью"
Причина: Отсутствие политики очистки памяти или слишком большой TTL.
Решение: Установите maxmemory-policy volatile-lru в конфигурации Redis. Это гарантирует, что при достижении лимита памяти (например, 2GB) старые векторы будут автоматически удалены.
Сбой: "Неверные результаты поиска после обновления модели"
Причина: Модель эмбеддингов обновилась, но хеши в Redis остались от старой версии.
Решение: Добавьте временную метку или версию модели (self.version) в хеш ключа. При смене версии хеш изменится, и система сгенерирует новые векторы.
Часто задаваемые вопросы (FAQ)
Как часто обновлять кэш векторов в Redis?
Рекомендуемый TTL — 5-10 минут. Более длительное хранение увеличивает риск устаревания контекста в чат-ботах, а слишком короткий срок повышает нагрузку на GPU при повторных запросах.
Стоит ли кэшировать результаты поиска в Qdrant или только векторы?
Кэшировать нужно только векторы и хеш запроса. Результаты поиска (ранжированные точки) не кэшируются глобально, так как они зависят от фильтров и весов, которые могут меняться динамически.
Как обрабатывать ситуации, когда вектор в Redis устарел, но запрос идентичен?
При вычислении хеша добавляем в него временную метку или версию модели эмбеддингов. Если версия изменилась, хеш изменится, и система сгенерирует новый вектор.
Что делать при падении Redis в продакшене?
Используйте паттерн Cache-Aside. При недоступности Redis система автоматически переключается на прямой расчет векторов с флагом skip_cache=true, обеспечивая отказоустойчивость за счет небольшого роста задержки.
Как масштабировать решение при росте трафика?
Настройте Redis Cluster для горизонтального масштабирования кэша при росте трафика. Используйте Qdrant Distributed (sharding) для распределения нагрузки по узлам при хранении >10 млн векторов.
Full-stack разработчик и Digital-спасатель. Пишу о технологиях, ИИ и реальной инженерной практике без воды.
Понравился материал?
Подпишитесь на Telegram-канал: свежие инсайты, промпты и разборы первыми. Без спама, только польза.
Подписаться в Telegram