Blog

RAG y búsqueda semántica con Qdrant: el pipeline que encuentra ideas, no palabras

Busca "no puedo entrar en mi cuenta" en un buscador léxico que indexe tus tickets de soporte y lo más probable es que obtengas cero resultados, aunque el índice esté lleno de tickets que dicen "acceso denegado" o "contraseña incorrecta". Ninguna palabra coincide, pero todos esos textos hablan exactamente de lo mismo. Ese hueco entre lo que la gente escribe y lo que contiene el documento es el que cubren los embeddings y la búsqueda semántica, y Qdrant es una de las formas más directas de cerrarlo. En este artículo desmonto un pipeline RAG completo con Qdrant: qué modelo de embeddings elegir, cómo trocear los documentos, cómo recuperar y qué decisiones de diseño separan un demo bonito de un sistema que funciona.

Por qué la búsqueda por palabras clave se queda corta

Ese límite se hace evidente al examinar hasta dónde llega un buscador léxico cuando la consulta y el documento no comparten vocabulario.

El techo del buscador léxico

Un buscador léxico, como el full-text search de PostgreSQL, compara términos: si la consulta no comparte vocabulario con el documento, no hay coincidencia. La derivación resuelve variantes morfológicas, "corriendo" frente a "correr", pero poco más. "Reservar mesa" y "book a table" significan lo mismo y no comparten ni un token.

Cómo atacan los embeddings el problema

Los embeddings atacan el problema desde otro ángulo. Un modelo entrenado con grandes cantidades de texto convierte cada fragmento en un vector de números en coma flotante, y los textos con significado parecido acaban cerca dentro de ese espacio vectorial. La similitud coseno entre dos vectores devuelve una puntuación de parecido semántico, no de coincidencia literal. La búsqueda semántica consiste en ordenar por esa puntuación y quedarse con los vecinos más cercanos.

Embeddings: la decisión que condiciona todo

Para entender esa decisión, conviene ver qué es un embedding y cómo la longitud de sus vectores la fija el modelo.

Qué es un embedding y por qué fija la arquitectura

Un embedding es una lista de números cuya longitud fija el modelo. all-MiniLM-L6-v2, uno de los más descargados de sentence-transformers, produce vectores de 384 dimensiones; text-embedding-3-small de OpenAI, 1536; bge-m3, 1024 dimensiones con buen soporte multilingüe.

Tres reglas para no arrepentirse

Tres reglas evitan la mayoría de los disgustos. Usa siempre el mismo modelo para indexar y para consultar, porque los vectores de modelos distintos no son comparables y el fallo es silencioso. El idioma manda: all-MiniLM-L6-v2 fue entrenado sobre todo con inglés, así que para contenido en español mira modelos multilingües como bge-m3 o multilingual-e5. Y la dimensión pesa, porque cada dimensión es un float y con millones de documentos la diferencia entre 384 y 1536 se nota en la factura de RAM.

Si el dato es sensible, los modelos locales de sentence-transformers o FastEmbed corren en tu propia máquina; las API te ahorran la GPU a cambio de enviar el texto fuera. Decide el modelo antes de indexar nada: cambiarlo más tarde obliga a recalcular y reinsertar todos los vectores de la colección.

RAG en cuatro movimientos

Estos cuatro pasos —trocear, indexar, recuperar y construir el prompt— forman un único flujo que conviene ver de punta a punta.

El flujo completo de punta a punta

RAG (Retrieval-Augmented Generation) cambia lo que le pides al LLM: en lugar de responder de memoria, el modelo recibe los fragmentos relevantes de tus documentos dentro del propio prompt. El flujo tiene cuatro pasos. Troceas los documentos en chunks, calculas el embedding de cada uno y lo guardas en Qdrant, recuperas los k fragmentos más parecidos a la pregunta y construyes un prompt con la pregunta más ese material para que el modelo responda citando fuentes.

Troceado y contexto: donde se gana o se pierde

El troceado importa más de lo que parece. Un punto de partida razonable son chunks de entre 200 y 500 tokens con un solape del 10-20 %, aunque lo que mejor funciona es respetar la estructura: corta por encabezados y párrafos, no cada N caracteres. El chunk que empieza a mitad de frase y ha perdido el título de su sección confunde al modelo tanto como un retrieval mediocre.

Qdrant por dentro: colecciones, puntos y HNSW

Para entender cómo funciona por dentro, veamos primero cómo se organiza Qdrant: colecciones, puntos y payloads.

Colecciones, puntos y payloads

Qdrant es una base de datos vectorial open source escrita en Rust, con licencia Apache 2.0. Se organiza en colecciones, y cada punto de una colección lleva un id, un vector y un payload JSON con los metadatos que quieras: sección, fecha, producto, permisos. Ese payload se convierte en tu mejor amigo en cuanto quieres filtrar.

HNSW y el despliegue local en Docker

Para no comparar la consulta contra todos los vectores del índice, Qdrant usa HNSW (Hierarchical Navigable Small World), un grafo multicapa que encuentra vecinos aproximados sin comparar todos los puntos del índice. Los parámetros m y ef_construct ajustan el equilibrio entre memoria, velocidad de construcción y calidad del grafo. Con colecciones pequeñas puedes pedir búsqueda exacta y dejar la aproximación para cuando escale.

Levantarlo en local es una línea de Docker; el puerto 6333 expone la API REST y el 6334 la gRPC:

docker run -p 6333:6333 -v $(pwd)/qdrant_storage:/qdrant/storage qdrant/qdrant

Tu primera búsqueda semántica en un puñado de líneas

Para empezar, instala el cliente de Python y obtén los embeddings.

El cliente de Python y los embeddings

El cliente oficial de Python se instala con pip install qdrant-client. Para los embeddings yo uso sentence-transformers con all-MiniLM-L6-v2, cuyas 384 dimensiones coinciden con la configuración de la colección; el vector de cada texto se obtiene con model.encode(). Si solo quieres experimentar, QdrantClient(":memory:") levanta una instancia en memoria sin Docker.

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, PointStruct, VectorParams

client = QdrantClient(url="http://localhost:6333")

client.create_collection(
    collection_name="docs",
    vectors_config=VectorParams(size=384, distance=Distance.COSINE),
)

client.upsert(
    collection_name="docs",
    points=[
        PointStruct(id=1, vector=emb_doc1, payload={"texto": "...", "version": "2.x"}),
        PointStruct(id=2, vector=emb_doc2, payload={"texto": "...", "version": "1.x"}),
    ],
)

resultados = client.query_points(
    collection_name="docs",
    query=emb_pregunta,
    limit=5,
).points

Retrieval funcionando y siguiente lectura

Con esto ya tienes retrieval funcionando: la pregunta se convierte en vector con el mismo modelo, Qdrant ordena por similitud coseno y devuelve los cinco puntos más cercanos. La distancia se elige según el modelo, y los de sentence-transformers están pensados para coseno. Para montar el RAG encima, extrae el campo texto de esos cinco resultados, pégalo en el prompt junto a la pregunta y pásalo a tu LLM, sea una API comercial o un modelo local con Ollama.

Si prefieres un recorrido guiado con más detalle, publiqué una guía paso a paso para crear tu primera colección: RAG y búsqueda semántica con Qdrant: de la teoría a tu primera colección. Ahí está el flujo de indexación explicado con calma, incluida la carga de documentos.

Filtros por metadatos: donde la similitud sola se queda corta

Para ver dónde encajan los filtros, primero hay que entender por qué la similitud vectorial no conoce tu contexto de negocio.

Por qué la similitud no conoce tu negocio

La similitud vectorial no conoce tu contexto de negocio. Si buscas en la documentación de la versión 2.x, no quieres resultados de la 1.x aunque el texto sea casi idéntico, y si trabajas con datos por cliente, un tenant no debe ver los documentos de otro. Los filtros de Qdrant combinan la búsqueda vectorial con condiciones must, should y must_not sobre el payload, apoyados en índices de payload para que esas condiciones no destrocen la latencia.

Diseñar el payload en el momento de indexar

Diseña el esquema del payload en el momento de indexar, no seis meses después. Pregúntate ahora por qué campos filtrarás: producto, versión, idioma, fecha, cliente. Un índice por campo ocupa poco y se paga solo la primera vez que un filtro salva una consulta.

Híbrida: denso y disperso en la misma consulta

El hueco de los vectores densos

Disperso tipo BM25 con FastEmbed

Con FastEmbed puedes generar el vector disperso tipo BM25 sin salir del cliente de Python: indexas cada chunk con su vector denso y su disperso, y en la consulta pides los dos con fusión en modo rrf. En bases de conocimiento técnicas, llenas de siglas y referencias exactas, la combinación suele superar al denso solo.

Cuatro errores que hunden un RAG

Estos fallos se repiten en casi todos los proyectos de RAG que llegan a producción cojeando, así que conviene revisarlos uno a uno.

Los fallos que se repiten

Los mismos fallos se repiten en casi todos los proyectos de RAG que llegan a producción cojeando:

  1. Chunks ciegos. Cortar cada N caracteres rompe tablas, código y párrafos a mitad de idea. Corta por encabezados y añade el título de la sección al texto del chunk.
  2. Modelos mezclados. Indexar con un modelo y consultar con otro no lanza ningún error: devuelve resultados inútiles en silencio. Fija el nombre del modelo en una constante y no lo toques.
  3. Cero evaluación. Sin preguntas con fragmento esperado, cada cambio es una apuesta. Veinte o treinta preguntas reales bastan para medir recall@k y dejar de adivinar.
  4. Payload pobre. Si no guardas la ruta del documento y la sección en los metadatos, el LLM no puede citar fuentes y tú no puedes depurar por qué respondió mal.

La raíz común: retrieval sobre generador

Los cuatro comparten raíz: el retrieval manda sobre el generador. Un LLM grande sobre fragmentos mal recuperados produce respuestas convincentes y equivocadas, la peor combinación posible.

El siguiente paso concreto

Pasemos a la acción con un plan concreto para esta semana: empezar por lo básico y medir antes de complicarlo.

Plan para esta semana

Plan para esta semana: levanta Qdrant con Docker, elige un modelo de embeddings adecuado a tu idioma, indexa cincuenta documentos tuyos y escribe treinta preguntas con el fragmento que debería recuperarlas. Mide recall, ajusta el tamaño de chunk y solo entonces enchufa el LLM.

El componente que decide si tu RAG sirve

Si quieres todo self-hosted, incluido el modelo generador, monté ese stack con Ollama y Qdrant sirviendo a un WordPress: IA local en WordPress con Ollama y Qdrant: monta tu stack self-hosted. Ahí verás la otra mitad del sistema, la generación local, funcionando sobre la misma base vectorial.

Una nota final que cambia la manera de mirar el proyecto: el componente que decide si tu RAG sirve no es Qdrant ni el LLM, sino el bucle de evaluación sobre tus propios datos. Con ese bucle montado, cambiar de modelo de embeddings pasa a ser un experimento de una tarde y no una reconstrucción desde cero. Y si la colección crece y tu portátil se queda corto, un VPS para tu RAG mantiene Qdrant y el modelo generador encendidos sin tocar tu máquina.

Pruébalo en 3 minutos

Instala Ollama + Open WebUI en tu ordenador, gratis y en privado.