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
- Embedding: un modelo (OpenAI, BERT, CLIP) convierte texto o imágenes en un vector denso, típicamente de 768-1536 dimensiones
- Indexación: los vectores se organizan en un índice ANN (HNSW, IVF, PQ) para que la recuperación no recorra cada fila
- Consulta: la consulta se embebe con el mismo modelo y el índice devuelve sus K vecinos más cercanos
- Filtrado de metadata: la similitud vectorial se combina con filtros ordinarios (fecha, categoría, ID de tenant) para mantener la relevancia
Comparación
| Base de datos | Despliegue | Índice | Mejor para |
|---|---|---|---|
| Pinecone | Nube gestionada | HNSW, filtros de metadata | RAG en producción, sin overhead de ops |
| Weaviate | Autogestionada / nube | HNSW, híbrido BM25 | Multi-modal, interfaz GraphQL |
| pgvector | Extensión de PostgreSQL | ivfflat, hnsw | Equipos que ya usan Postgres |
| Chroma | Embebida / local | HNSW | Prototipado, RAG local a pequeña escala |
| Milvus/Zilliz | Autogestionada / nube | IVF, HNSW, GPU | Gran 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:
| Motor | Ejemplo de filtro |
|---|---|
| Pinecone | {"category": {"$eq": "electronics"}, "price": {"$lte": 50}} |
| Weaviate | Filter.by_property("price").less_than(50.0) |
| Qdrant | FieldCondition(key="price", range=Range(gte=10, lte=50)) |
| pgvector | WHERE metadata->>'category' = 'electronics' |
| Chroma | where={"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ón | Buen punto de partida |
|---|---|
| Menos de ~1M de vectores, ya usas Postgres | pgvector — sin infraestructura nueva, filtros SQL gratis |
| Prototipo o desarrollo local | Chroma — embebido, cero ops, auto-embeddings |
| Sin equipo de ops, quieres gestión | Pinecone, Weaviate Cloud, Qdrant Cloud, Zilliz |
| Miles de millones de vectores o índice GPU | Milvus, Qdrant |
| Vectorial + palabras clave en una query | Weaviate, Qdrant, Pinecone |
| Presupuesto cero, self-hosted | Weaviate, 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
| Algoritmo | Tipo | Velocidad | Memoria | Mejor para |
|---|---|---|---|---|
| HNSW | Basado en grafos | Rápido | Alta | Propósito general, alta recall |
| IVF | Clustering | Media | Media | Datasets grandes, memoria limitada |
| PQ | Cuantización | Rápido | Baja | Miles 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 —
myef_constructionen la construcción,ef_searchen 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_constructionymen la construcción, y aumentaef_searchen 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 fijaivfflat.probesohnsw.ef_searchexplícitamente expected 1536 dimensions, not 768— elvector(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étrica | Valor |
|---|---|
| Tiempo de embedding | 200ms |
| Tiempo de búsqueda vectorial | 15ms |
| Tiempo de LLM | 1.5s |
| Latencia total | ~1.7s |
| Recall@5 | 92% |
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_constructionen construcción yef_searchen 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
- pgvector — fuente de la extensión, opciones de índice y parámetros de ajuste
- Documentación de Pinecone — conceptos e índices de búsqueda vectorial gestionada
- Documentación de Weaviate — búsqueda híbrida y diseño de esquema
- Documentación de Chroma — almacén vectorial embebido para prototipado
- Documentación de Qdrant — filtrado, indexación y la API query_points
- Documentación de Milvus — escalado, tipos de índice e IndexParams
- Guía de embeddings de OpenAI — modelos, dimensiones y buenas prácticas
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.
Recursos Relacionados
Guía Completa de Arquitectura de Aplicaciones LLM
Construye aplicaciones LLM en producción: capas de API, gestión de prompts, streaming, caché, guardrails, observabilidad, evaluación y despliegue.
GuideGuía Completa de RAG en Producción
Lleva RAG a producción: estrategias de chunking, elección de vector store, recuperación híbrida, reranking, evaluación RAGAS y patrones de despliegue.
RecipeRAG con LangChain y bases vectoriales
Cómo construir un pipeline de Retrieval-Augmented Generation (RAG) usando LangChain y bases de datos vectoriales para búsqueda potenciada por IA
GuidePatrones de Modelado NoSQL
Guía práctica de modelado NoSQL: embebido vs referenciado, diseño basado en patrones de acceso, y patrones para MongoDB, DynamoDB, Cassandra y Redis.
GuideBases de Datos de Grafos
Guia practica de bases de datos de grafos: modelo de grafo de propiedades, lenguaje Cypher, patrones de modelado y cuando elegir Neo4j sobre bases relacionales.