StackPractices
intermediate Por Mathias Paulenko

Bases de Datos Vectoriales: Embeddings y Búsqueda Semántica

Construye tu primer pipeline de búsqueda vectorial: embeddings, índices ANN, similitud con pgvector y Chroma, y cómo elegir tu base vectorial.

Visión General

Las bases de datos vectoriales almacenan embeddings (los vectores numéricos densos que producen los modelos de ML) y responden “qué vectores almacenados están más cerca de este” a velocidad de producción. Esa única capacidad impulsa la búsqueda semántica, los sistemas de recomendación, la recuperación de imágenes y el paso de recuperación de todo pipeline RAG.

Esta guía es el camino práctico: cómo funciona realmente la búsqueda vectorial, cómo elegir entre Pinecone, Weaviate, Qdrant, pgvector, Chroma y Milvus, y código funcional para tu primera tabla pgvector, operaciones de producción en los motores dedicados y un pipeline RAG completo.

Cuándo Usar

  • Necesitas búsqueda semántica — “encontrar cosas con significado similar” en lugar de coincidencia exacta de palabras clave
  • Estás construyendo RAG y necesitas recuperar los chunks que fundamentan las respuestas del LLM
  • Recomendaciones o funciones de “más como esto” sobre embeddings de usuarios o ítems
  • Recuperación de imágenes, audio o video por similitud de contenido
  • Ya tienes modelos de embedding y necesitas almacenamiento que indexe y escale

Cuándo no usarla: búsquedas por coincidencia exacta, joins y agregaciones son trabajo relacional. Las bases de datos vectoriales complementan tu base de datos principal; no la reemplazan.

Cómo Funciona la Búsqueda Vectorial

  1. Embedding: un modelo (OpenAI, BERT, CLIP) convierte texto o imágenes en un vector denso, típicamente de 768-1536 dimensiones
  2. Indexación: los vectores se organizan en un índice ANN (HNSW, IVF, PQ) para que la recuperación no recorra cada fila
  3. Consulta: la consulta se embebe con el mismo modelo y el índice devuelve sus K vecinos más cercanos
  4. Filtrado de metadata: la similitud vectorial se combina con filtros ordinarios (fecha, categoría, ID de tenant) para mantener la relevancia
flowchart diagram: docs[Documentos

Comparación

Base de datosDespliegueÍndiceMejor para
PineconeNube gestionadaHNSW, filtros de metadataRAG en producción, sin overhead de ops
WeaviateAutogestionada / nubeHNSW, híbrido BM25Multi-modal, interfaz GraphQL
pgvectorExtensión de PostgreSQLivfflat, hnswEquipos que ya usan Postgres
ChromaEmbebida / localHNSWPrototipado, RAG local a pequeña escala
Milvus/ZillizAutogestionada / nubeIVF, HNSW, GPUGran escala, alto throughput

Las secciones siguientes cubren código funcional para cada motor. Para la decisión en sí, ve directo a Cómo Elegir una Base de Datos Vectorial.

Ejemplo con pgvector

La forma más rápida de probar la búsqueda vectorial es dentro del Postgres que ya corres. Este ejemplo usa vector(3) para que los literales sean legibles; los embeddings reales son de 768-1536 dimensiones:

-- Habilitar la extensión pgvector
CREATE EXTENSION IF NOT EXISTS vector;

-- Crear tabla con columna vectorial
CREATE TABLE documents (
    id SERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    content TEXT,
    embedding vector(3)
);

-- Crear índice HNSW para búsqueda ANN rápida
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);

-- Insertar un documento (en producción este vector viene de tu modelo de embeddings)
INSERT INTO documents (title, content, embedding)
VALUES ('Guía de Bases Vectoriales', 'Una guía sobre bases de datos vectoriales', '[0.12,-0.03,0.44]');

-- Búsqueda semántica: encontrar los 5 documentos más similares
SELECT id, title, content,
    1 - (embedding <=> '[0.11,-0.02,0.43]') AS similarity
FROM documents
ORDER BY embedding <=> '[0.11,-0.02,0.43]'
LIMIT 5;

Búsqueda Híbrida (Vectorial + Palabras Clave)

La búsqueda puramente vectorial pierde términos exactos; la de palabras clave pierde sinónimos. La híbrida combina ambas: Weaviate la expone con un solo parámetro alpha:

import weaviate

client = weaviate.connect_to_local()

# alpha=0.5 pondera BM25 y similitud vectorial por igual
results = client.collections.get("Article").query.hybrid(
    query="arquitectura de bases de datos vectoriales",
    vector=embed_query("arquitectura de bases de datos vectoriales"),  # tu función de embedding
    alpha=0.5,  # 0 = puro BM25, 1 = puro vectorial
    limit=10
)

Bases de Datos Vectoriales Dedicadas

pgvector y Chroma cubren mucho terreno, pero llega un punto en que quieres un motor construido para el trabajo: escalado gestionado, miles de millones de vectores o una semántica de filtrado que Postgres no expresa. Estos son los snippets de producción para las cuatro opciones dedicadas más habituales.

Pinecone

Pinecone es la opción totalmente gestionada: sin servidores, sin ajuste de índices más allá de la métrica inicial y facturación por uso. Dos patrones importan en producción — los upserts por lotes y los namespaces para multi-tenancy:

from pinecone import Pinecone, ServerlessSpec

pc = Pinecone(api_key="your-api-key")

pc.create_index(
    name="products",
    dimension=1536,
    metric="cosine",
    spec=ServerlessSpec(cloud="aws", region="us-east-1"),
)
index = pc.Index("products")

# Upsert por lotes: 100 mantiene los payloads pequeños y los reintentos baratos
def batch_upsert(index, vectors, batch_size=100):
    for i in range(0, len(vectors), batch_size):
        index.upsert(vectors=vectors[i:i + batch_size])

# Multi-tenancy: un índice, un namespace por tenant
index.upsert(vectors=tenant_vectors, namespace="tenant_a")

results = index.query(
    vector=query_embedding,
    top_k=10,
    namespace="tenant_a",
    include_metadata=True,
    filter={"category": {"$eq": "electronics"}, "price": {"$lte": 50.0}},
)

Los namespaces mantienen los datos de cada tenant aislados dentro de un mismo índice — más barato que un índice por tenant y un límite estricto que los filtros de metadata no pueden darte.

Qdrant

Qdrant es open source (escrito en Rust), destaca en filtrado de payload y está disponible self-hosted o como Qdrant Cloud. Ojo: client.search() se eliminó en qdrant-client 1.16 — usa query_points en su lugar:

from qdrant_client import QdrantClient
from qdrant_client.models import (
    Distance, VectorParams, PointStruct, Filter, FieldCondition, MatchValue,
)

client = QdrantClient(host="localhost", port=6333)

client.create_collection(
    collection_name="documents",
    vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
)

client.upsert(
    collection_name="documents",
    points=[
        PointStruct(id=i, vector=vec,
                    payload={"text": f"document {i}", "category": "tech"})
        for i, vec in enumerate(embeddings)
    ],
)

results = client.query_points(
    collection_name="documents",
    query=query_embedding,
    query_filter=Filter(
        must=[FieldCondition(key="category", match=MatchValue(value="tech"))]
    ),
    limit=10,
).points

for result in results:
    print(result.score, result.payload)

Milvus

Milvus está construido para la escala — miles de millones de vectores, indexación por GPU, despliegues distribuidos — con Zilliz como su nube gestionada. El create_collection rápido solo acepta dimension y metric_type; para un índice explícito, construye primero IndexParams:

from pymilvus import MilvusClient

client = MilvusClient(uri="http://localhost:19530")

# Índice HNSW explícito mediante prepare_index_params
index_params = client.prepare_index_params()
index_params.add_index(
    field_name="vector",
    index_type="HNSW",
    metric_type="COSINE",
    params={"M": 16, "efConstruction": 64},
)
client.create_collection(
    collection_name="documents",
    dimension=1536,
    metric_type="COSINE",
    index_params=index_params,
)

client.insert(
    collection_name="documents",
    data=[
        {"id": i, "vector": vec, "text": f"document {i}", "category": "tech"}
        for i, vec in enumerate(embeddings)
    ],
)

results = client.search(
    collection_name="documents",
    data=[query_vector],
    limit=10,
    filter='category == "tech"',
    output_fields=["text", "category"],
)

Filtrado de Metadata entre Motores

Todos los motores filtran, pero la sintaxis varía lo suficiente como para que cualquier abstracción gotee. Un mapa rápido:

MotorEjemplo de filtro
Pinecone{"category": {"$eq": "electronics"}, "price": {"$lte": 50}}
WeaviateFilter.by_property("price").less_than(50.0)
QdrantFieldCondition(key="price", range=Range(gte=10, lte=50))
pgvectorWHERE metadata->>'category' = 'electronics'
Chromawhere={"category": {"$eq": "electronics"}}

Mete los filtros dentro de la query siempre que puedas — filtrar a posteriori el top-k te deja con menos de k resultados buenos y degrada en silencio a medida que el corpus crece.

Cómo Elegir una Base de Datos Vectorial

La respuesta honesta es que el “mejor” motor depende más de tus restricciones que de los benchmarks. Recorre esto en orden:

RestricciónBuen punto de partida
Menos de ~1M de vectores, ya usas Postgrespgvector — sin infraestructura nueva, filtros SQL gratis
Prototipo o desarrollo localChroma — embebido, cero ops, auto-embeddings
Sin equipo de ops, quieres gestiónPinecone, Weaviate Cloud, Qdrant Cloud, Zilliz
Miles de millones de vectores o índice GPUMilvus, Qdrant
Vectorial + palabras clave en una queryWeaviate, Qdrant, Pinecone
Presupuesto cero, self-hostedWeaviate, Milvus, Qdrant, pgvector

Dos preguntas de seguimiento resuelven la mayoría de los empates. Primero: ¿quiere tu equipo mantener infraestructura? Alojar una base de datos vectorial significa hacerse cargo de rebuilds de índices, presión de memoria y upgrades de versión — los servicios gestionados convierten eso en una factura. Segundo: ¿los datos ya están en Postgres? pgvector sobre una base existente elimina un sistema entero de la arquitectura; es el punto de partida correcto hasta que la latencia, el recall o la escala demuestren lo contrario.

Ejemplo de Pipeline RAG

Chroma es la forma más rápida de prototipar la recuperación de punta a punta. Una versión ejecutable con backend Postgres/pgvector está en el repositorio complementario:

from openai import OpenAI
import chromadb

openai_client = OpenAI()
client = chromadb.Client()

# 1. Cargar y fragmentar documentos
chunks = load_and_chunk_documents("knowledge_base/")

# 2. Embeber y almacenar en Chroma
collection = client.create_collection("docs")
resp = openai_client.embeddings.create(input=chunks, model="text-embedding-3-small")
collection.add(
    ids=[f"chunk-{i}" for i in range(len(chunks))],
    documents=chunks,
    embeddings=[e.embedding for e in resp.data],
)

# 3. Recuperar chunks relevantes para una consulta
question = "¿Cómo funcionan los índices vectoriales?"
q_resp = openai_client.embeddings.create(input=question, model="text-embedding-3-small")
results = collection.query(query_embeddings=[q_resp.data[0].embedding], n_results=5)

# 4. Aumentar el prompt del LLM con el contexto recuperado
context = "\n".join(results["documents"][0])
prompt = f"Contexto:\n{context}\n\nPregunta: {question}"
answer = openai_client.chat.completions.create(
    model="gpt-4o", messages=[{"role": "user", "content": prompt}]
)

Para la versión de producción de este patrón (lotes, reintentos, citación de fuentes), consulta la receta de Pipeline RAG y la guía de RAG en Producción.

Algoritmos ANN

AlgoritmoTipoVelocidadMemoriaMejor para
HNSWBasado en grafosRápidoAltaPropósito general, alta recall
IVFClusteringMediaMediaDatasets grandes, memoria limitada
PQCuantizaciónRápidoBajaMiles de millones de vectores, pérdida de recall aceptable

HNSW es el default correcto en casi todos los casos. Usa IVF cuando el grafo HNSW no quepa en memoria, y PQ solo al comprimir miles de millones de vectores donde toleras la pérdida de recall.

Benchmarking de Búsqueda Vectorial

Nunca ajustes un índice a ojo. Mide dos cosas — el recall contra una referencia de fuerza bruta y los percentiles de latencia, no las medias:

import time

def benchmark_vector_search(search_fn, query_vectors, ground_truth, top_k=10):
    """ground_truth: lista de conjuntos de ids relevantes, un conjunto por query."""
    latencies = []
    hits = 0
    for query, expected_ids in zip(query_vectors, ground_truth):
        start = time.perf_counter()
        results = search_fn(query, top_k)
        latencies.append((time.perf_counter() - start) * 1000)

        result_ids = {r["id"] for r in results[:top_k]}
        if result_ids & expected_ids:
            hits += 1

    latencies.sort()
    n = len(latencies)
    return {
        "recall@k": hits / len(query_vectors),
        "avg_latency_ms": sum(latencies) / n,
        "p50_latency_ms": latencies[n // 2],
        "p95_latency_ms": latencies[int(n * 0.95)],
    }

Obtén la referencia (ground truth) ejecutando k-NN exacto sobre una muestra representativa del corpus — la fuerza bruta sobre 10-50K vectores es barata y te da las respuestas “perfectas” contra las que puntuar. Después barre ef_search (o su equivalente en tu motor): encontrarás el punto de inflexión donde el recall se estabiliza y la latencia sigue subiendo. El p95 importa más que la media porque tus usuarios sienten la cola.

Errores Comunes

  • Métrica de distancia incorrecta — coseno para texto semántico, euclidiana para características de imagen, producto punto para embeddings normalizados; la métrica equivocada degrada la recall silenciosamente
  • Sin filtrado de metadata — la búsqueda puramente vectorial devuelve resultados cercanos en significado pero erróneos en contexto; filtra siempre por tenant, fecha o categoría
  • Dejar los defaults del índice sin tocar — m y ef_construction en la construcción, ef_search en consulta; los defaults son un punto de partida, no un ajuste
  • Almacenar vectores sin índice — el escaneo de fuerza bruta es O(n) por consulta y colapsa a escala
  • Usar una base vectorial para consultas estructuradas — las agregaciones y los joins pertenecen a tu base de datos relacional

Solución de Problemas

  • La recall cayó tras una inserción masiva — el grafo HNSW necesita reconstruirse después de backfills grandes; sube ef_construction y m en la construcción, y aumenta ef_search en consulta si los resultados se ven raros
  • Las consultas hacen un escaneo secuencial completo — el planner está ignorando el índice: revisa EXPLAIN, confirma que la tabla sea lo bastante grande para usarlo, y fija ivfflat.probes o hnsw.ef_search explícitamente
  • expected 1536 dimensions, not 768 — el vector(n) de la columna no coincide con el modelo de embeddings; vectores de modelos distintos no pueden compartir columna, así que migra a una columna nueva dimensionada para el nuevo modelo
  • La búsqueda es rápida pero los resultados son irrelevantes — suele ser un problema de filtrado, no de índice: empuja los filtros de metadata dentro de la consulta en lugar de post-filtrar el top-k
  • Las respuestas empeoraron tras actualizar el modelo — los embeddings de modelos distintos no son comparables; re-embebe todo en una columna nueva y migra las consultas (ver versionado abajo)

Temas Avanzados

Escenario Detallado: RAG sobre 50K Páginas de Documentación

Sistema: chatbot RAG sobre 50.000 páginas de documentación técnica
Stack: OpenAI text-embedding-3-small (1536 dim) + pgvector + GPT-4o
Requisitos: respuestas precisas con citas, latencia < 3s

Pipeline:
  1. Cargar documentos (PDF, Markdown, HTML)
  2. Fragmentar en chunks de ~500 tokens con overlap de 50
  3. Generar embeddings con OpenAI
  4. Almacenar en PostgreSQL con pgvector
  5. En consulta: embeber pregunta, buscar chunks similares, aumentar LLM

Esquema con un índice HNSW ajustado:

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE doc_chunks (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    doc_id VARCHAR(100) NOT NULL,
    chunk_index INT NOT NULL,
    content TEXT NOT NULL,
    embedding vector(1536) NOT NULL,
    metadata JSONB DEFAULT '{}',
    created_at TIMESTAMPTZ DEFAULT NOW()
);

CREATE INDEX idx_doc_chunks_embedding
ON doc_chunks USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

CREATE INDEX idx_doc_chunks_doc ON doc_chunks(doc_id);

Ingestión: register_vector hace que psycopg2 serialice los embeddings correctamente:

from openai import OpenAI
import psycopg2
from pgvector.psycopg2 import register_vector

client = OpenAI()
conn = psycopg2.connect("dbname=ragdb")
register_vector(conn)

def embed_text(text: str) -> list[float]:
    resp = client.embeddings.create(input=text, model="text-embedding-3-small")
    return resp.data[0].embedding

def ingest_document(doc_id: str, chunks: list[str]):
    for i, chunk in enumerate(chunks):
        emb = embed_text(chunk)
        with conn.cursor() as cur:
            cur.execute(
                "INSERT INTO doc_chunks (doc_id, chunk_index, content, embedding) "
                "VALUES (%s, %s, %s, %s)",
                (doc_id, i, chunk, emb),
            )
    conn.commit()

Consulta con citación de fuentes:

def rag_query(question: str, top_k: int = 5) -> str:
    q_emb = embed_text(question)
    with conn.cursor() as cur:
        cur.execute(
            "SELECT content, doc_id, chunk_index, "
            "1 - (embedding <=> %s) AS similarity "
            "FROM doc_chunks "
            "ORDER BY embedding <=> %s "
            "LIMIT %s",
            (q_emb, q_emb, top_k),
        )
        results = cur.fetchall()

    context = "\n\n".join(r[0] for r in results)
    sources = [f"doc:{r[1]} chunk:{r[2]} sim:{r[3]:.3f}" for r in results]
    prompt = (
        f"Contexto:\n{context}\n\n"
        f"Pregunta: {question}\n"
        f"Responde basándote en el contexto. Cita la fuente."
    )
    response = client.chat.completions.create(
        model="gpt-4o", messages=[{"role": "user", "content": prompt}]
    )
    return response.choices[0].message.content + "\n\nFuentes: " + ", ".join(sources)

Métricas observadas con esta configuración:

MétricaValor
Tiempo de embedding200ms
Tiempo de búsqueda vectorial15ms
Tiempo de LLM1.5s
Latencia total~1.7s
Recall@592%

Notas de ajuste de HNSW: m=16 equilibra recall y memoria, ef_construction=64 controla la calidad de construcción, ef_search=40 negocia recall por latencia en consulta. Con 50K vectores el índice ocupa unos 50MB y las búsquedas quedan bajo 20ms. El tamaño de chunk importa más de lo esperado: 500 tokens con 50 de overlap superó a splits menores y mayores, y filtrar por metadata antes de la comparación vectorial mejoró la relevancia de forma medible.

Re-embedding al Cambiar de Modelo

Los vectores producidos por modelos distintos viven en espacios distintos. No puedes consultar embeddings viejos con un modelo nuevo. Guarda el nombre y versión del modelo en la metadata de cada fila. Al actualizar, re-embebe cada documento en una columna o tabla nueva, mantén ambas versiones durante la transición, cambia las consultas cuando el índice nuevo esté listo, y elimina la columna vieja cuando deje de recibir tráfico. Programa el re-embedding en lotes fuera de hora pico para no disparar la latencia.

Notas de Producción

  • Dimensiona la memoria para el índice: HNSW mantiene su grafo en RAM — presupuesta ~1.5× tus datos vectoriales crudos, o pasa a IVF/PQ
  • Reconstruye tras cargas masivas: los backfills grandes degradan la calidad del índice; programa reconstrucciones fuera de hora pico
  • Monitorea la recall, no solo el uptime: sigue la latencia p95 de consulta y revisa la relevancia del top-k después de cambios de índice
  • Respalda los documentos fuente, no solo los vectores: siempre puedes re-embeber; los embeddings que no puedes reproducir se pierden

Puntos Clave

  • pgvector cubre a la mayoría de los equipos bajo ~1M de vectores — las bases dedicadas justifican su costo a escala
  • HNSW es el índice por defecto; ajusta m/ef_construction en construcción y ef_search en consulta
  • La similitud vectorial sola no basta — combínala siempre con filtrado de metadata
  • Los embeddings son específicos del modelo: versiónalos y re-embebe al cambiar de modelo

Lecturas Adicionales

Preguntas frecuentes

¿Necesito una base de datos vectorial dedicada o me quedo en Postgres?

Por debajo de ~1 millón de vectores, pgvector suele ser suficiente — tienes búsqueda vectorial sin sumar infraestructura. Más allá de eso, o si quieres escalado gestionado y búsqueda híbrida lista para usar, Pinecone o Weaviate empiezan a justificar su costo.

¿Debería indexar con HNSW o con IVF?

HNSW por defecto: mejor recall y consultas más rápidas cuando el grafo cabe en memoria. IVF tiene sentido cuando tu volumen de vectores empuja el grafo HNSW más allá de la RAM disponible, a costa de algo de recall.

¿Cómo elijo las dimensiones del embedding?

No las eliges tú — las decide el modelo (text-embedding-3-small es 1536, BERT-base es 768). No truncar dimensiones para ahorrar espacio salvo que el modelo haya sido entrenado para ello, y declara el vector(n) de la columna exactamente igual a la salida del modelo.

¿Puedo actualizar vectores en su lugar?

Sí, la mayoría de los motores lo soporta. Algunos tipos de índice necesitan reconstrucción o reajuste después, así que agrupa las actualizaciones cuando puedas y verifica la recall tras mutaciones grandes.

¿Cuándo NO debería usar una base de datos vectorial?

Para búsquedas por coincidencia exacta, joins pesados o agregaciones — eso es territorio relacional. Si tu corpus es tan pequeño que una búsqueda de fuerza bruta sobre unos miles de vectores es instantánea, quizá ni siquiera necesites indexación ANN.