Inicio / Artículos / Caché semántica para apps LLM en Ubuntu 24.04 con Qdrant

Caché semántica para apps LLM en Ubuntu 24.04 con Qdrant

Reduce el gasto en APIs de LLM guardando respuestas en caché por su significado y no por el texto: Qdrant en Ubuntu 24.04, aislamiento de clientes, umbrales por ruta y coste de IOPS.

788 palabras

En cuanto una app de IA generativa entra en producción, la factura llama la atención enseguida, porque se cobra cada token. Si repasas los logs de prompts, probablemente verás que hasta el 40% de las consultas de los usuarios expresan la misma intención con otras palabras. "¿Pasos para restablecer la contraseña?" y "Me he quedado fuera de la cuenta, necesito recuperar el acceso" significan lo mismo. Un almacén clave-valor clásico ve dos peticiones sin relación, llama dos veces a la API de OpenAI y te cobra el precio completo en ambas.

Una caché semántica lo soluciona. Así se construye una en Ubuntu 24.04 LTS desde el punto de vista de operaciones, junto con las tres trampas que los tutoriales suelen pasar por alto.

Coincidencia exacta frente a significado

Dos estilos de búsqueda se comportan de forma muy distinta:

  • La caché léxica, como un almacén clave-valor normal de Redis, necesita cadenas idénticas. "¿Dónde está el pedido?" queda en caché, pero "Seguir el paquete" falla, porque los caracteres son otros.
  • La caché semántica convierte los prompts en vectores de muchas dimensiones con un modelo de embeddings. "¿Dónde está el pedido?" y "Seguir el paquete" quedan cerca en el espacio vectorial, así que la respuesta guardada vuelve al instante. La latencia de búsqueda baja de unos 3000 ms a unos 15 ms.

Montar la caché vectorial con Qdrant

Como almacén, ejecuta Qdrant en Docker con un volumen persistente y después prepara un entorno de 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

Mantén separados a los clientes

Una única caché semántica global dentro de un producto SaaS multicliente es una invitación a fugas serias entre clientes. Imagina que el usuario A pide un resumen de las últimas transacciones de su cuenta. El modelo escribe una respuesta con los datos financieros privados de A y se guarda en caché. Más tarde el usuario B pide un resumen de sus propias transacciones. La caché ve una coincidencia de intención del 98% y le entrega a B la respuesta escrita para A.

La solución es poner un espacio de nombres estricto, tenant_id, en el payload de Qdrant y ejecutar cada búsqueda de similitud bajo un filtro de metadatos obligatorio. Sin ese filtro, una búsqueda no debería ejecutarse nunca. El código siguiente crea la colección con el tamaño de vector correcto (384 para el codificador MiniLM local) y hace una búsqueda filtrada por cliente:

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

La trampa del umbral

Elegir el umbral de similitud es un equilibrio entre precisión y exhaustividad. Muchas guías recomiendan un valor general de 0,80, y eso produce respuestas erróneas muy desagradables. Estar cerca en el espacio de embeddings no significa tener el mismo significado. "Di la capital de Francia" y "Di la capital de Alemania" comparten la estructura de la frase, así que quedan muy juntas, con una similitud coseno por encima de 0,85. Con un umbral de 0,80, quien pregunte por Alemania recibiría la respuesta guardada sobre Francia.

En lugar de eso, ajusta el umbral por ruta:

  • 0,95 o más para preguntas estrictamente factuales, técnicas o financieras
  • 0,88 para preguntas frecuentes genéricas de tipo conversacional

Cuando la velocidad del disco se vuelve el cuello de botella

Las bases de datos vectoriales sobre índices HNSW exigen mucho a la memoria y al rendimiento del disco. Hacer búsquedas de similitud intensivas en una nube pública suele significar almacenamiento en bloque con IOPS aprovisionadas, y esos cargos de io2 pueden ser muy altos.

Una salida es alojar toda la pila de caché en servidores bare-metal dedicados. Las unidades NVMe empresariales conectadas directamente por PCIe ofrecen millones de IOPS sin coste extra, y una gran reserva de RAM DDR5 en hardware dedicado puede mantener las búsquedas de embeddings por debajo de 15 ms incluso con mucho tráfico simultáneo. Así desaparece el impuesto de almacenamiento que los proveedores de nube suman al cómputo.