En enero abrí las facturas de Supabase y Pinecone al mismo tiempo por primera vez. Hasta ese momento las tenía separadas mentalmente — “la base de datos” y “la parte de los vectores”. Pero al verlas juntas: $45 de Supabase Pro, $70 de Pinecone Starter. Para un knowledge base interno de una empresa de tres personas.
No es que $70 sea un número que me quite el sueño. Pero es $70 por algo que mi PostgreSQL podría hacer con una extensión que existe desde hace años. Eso fue suficiente para que me pusiera a investigar.
El proyecto y por qué terminé en Pinecone en primer lugar
Llevaba ocho meses construyendo una herramienta de RAG para el equipo de soporte de mi trabajo secundario. La idea era indexar toda la documentación técnica, tickets históricos y páginas de Notion exportadas, y dejar que el equipo hiciera preguntas en lenguaje natural. Nada espectacular en términos de escala — cerca de 45,000 embeddings en total, chunkeados a 512 tokens — pero genuinamente útil en el día a día.
Cuando empecé, en abril del año pasado, el camino más obvio era Pinecone. Todos los tutoriales de LangChain lo usaban, la documentación era clara, y el free tier me alcanzó mientras desarrollaba el prototipo. Cuando lo moví a producción, upgradear al plan Starter parecía lo natural.
Pinecone funciona bien. Eso lo tengo que decir, porque el resto de este artículo podría sonar como una queja y no lo es. En ocho meses no tuve un solo problema de disponibilidad, la API es limpia, y la latencia era predecible. Mi problema era de arquitectura, no de calidad. Estaba manteniendo coherencia entre dos sistemas separados: si borraba un documento de PostgreSQL, tenía que acordarme de borrarlo de Pinecone también. Si el job de indexado fallaba a mitad, podía quedarme con estados inconsistentes entre los dos stores. Ese tipo de complejidad accidental se acumula y genera bugs difíciles de rastrear un martes a las 10pm.
Y para colmo, ya tenía PostgreSQL corriendo de todas formas. Usuarios, logs, preferencias, historial de conversaciones — todo ahí. Agregar una columna de tipo vector conceptualmente no es tan diferente a agregar cualquier otra columna.
pgvector: la configuración que nadie explica del todo bien
La parte fácil es activar la extensión e insertar vectores. La parte que me tomó más tiempo entender fue cuándo usar IVFFlat versus HNSW, y qué parámetros importan en la práctica versus cuáles son ruido.
La estructura básica de la tabla:
-- Activar la extensión (una vez por base de datos)
CREATE EXTENSION IF NOT EXISTS vector;
-- Tabla de documentos con embeddings
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
source TEXT NOT NULL, -- ruta o identificador del documento origen
content TEXT NOT NULL, -- el chunk de texto
metadata JSONB, -- filtros: categoría, fecha, permisos, etc.
embedding vector(1536) -- dimensiones de text-embedding-3-small
);
-- Índice HNSW — mejor opción para la mayoría de casos hoy en día
-- m=16 y ef_construction=64 son buenos valores de partida
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
HNSW (Hierarchical Navigable Small World) es el que deberías usar. IVFFlat era la opción original de pgvector y tiene su nicho — puede ser más eficiente en memoria para colecciones muy grandes — pero HNSW da mejor recall, no requiere una fase de entrenamiento previa, y maneja bien la ingesta incremental. IVFFlat exige que ya tengas datos antes de construir el índice, lo cual es incómodo si estás indexando en tiempo real.
La búsqueda desde Python — okay, voy a mostrar la versión que realmente uso, no la versión simplificada de los tutoriales:
import psycopg2
from openai import OpenAI
openai_client = OpenAI()
def buscar_similares(
query: str,
conn: psycopg2.extensions.connection,
top_k: int = 5,
min_similarity: float = 0.72
) -> list[dict]:
# Usa el mismo modelo que empleaste al generar los embeddings del índice
resp = openai_client.embeddings.create(
input=query,
model="text-embedding-3-small"
)
query_vec = resp.data[0].embedding
with conn.cursor() as cur:
cur.execute("""
SELECT
content,
source,
metadata,
-- <=> es DISTANCIA coseno (0 = idéntico), no similitud
-- 1 - distancia da el rango [0,1] más intuitivo
1 - (embedding <=> %s::vector) AS similarity
FROM documents
WHERE 1 - (embedding <=> %s::vector) > %s
ORDER BY embedding <=> %s::vector
LIMIT %s
""", (query_vec, query_vec, min_similarity, query_vec, top_k))
rows = cur.fetchall()
return [
{"content": r[0], "source": r[1], "metadata": r[2], "similarity": float(r[3])}
for r in rows
]
Un detalle que me confundió al principio: <=> mide distancia coseno, donde 0 significa vectores idénticos. No similitud — distancia. Cuando mis primeros resultados devolvían scores de 0.08 y no entendía por qué el “mejor match” tenía el número más bajo, ahí estuve un rato dándole vueltas antes de darme cuenta. La expresión 1 - (embedding <=> query) convierte eso en similitud con el rango [0, 1] que tiene sentido intuitivo.
La migración un martes por la noche, con una sorpresa desagradable
Exportar los vectores de Pinecone fue más tedioso de lo que esperaba. No hay un endpoint de “dame todo” — tienes que usar su API de fetch() paginando por IDs, o hacer queries con vectores dummy, lo cual es una mecánica un poco rara. Por suerte yo había guardado los IDs de Pinecone en mi propia tabla de PostgreSQL desde el principio (algo que hice instintivamente y que resultó ser clave).
El proceso fue:
1. Obtener todos los IDs desde mi tabla documents en Postgres
2. Hacer requests en batches de 100 a index.fetch() de Pinecone
3. Insertar los vectores recuperados directamente en la tabla con pgvector
Para 45,000 vectores la importación tardó unos 14 minutos. Sin drama aparente.
Entonces llegó la sorpresa. Construí el índice HNSW y empecé a correr queries de prueba. Los resultados se veían bien en general… pero cuando probé queries específicas donde yo sabía exactamente qué documento debería aparecer primero, el recall se sentía ligeramente peor que Pinecone. No dramáticamente, pero sí perceptible. El tipo de cosa que no levantaría una alarma si no tuvieras baseline, pero yo lo tenía.
Tardé casi dos horas en identificar el problema. La cosa es que — y esto no está documentado en ningún lugar obvio — PostgreSQL tiene un parámetro work_mem que limita la memoria disponible por operación de sort o hash. Para búsquedas vectoriales con HNSW, si ese valor es demasiado bajo, el planner interno puede hacer elecciones subóptimas.
-- Para una sesión específica durante pruebas
SET work_mem = '256MB';
-- Para hacerlo permanente, en postgresql.conf:
-- work_mem = 256MB
Subir work_mem a 256MB resolvió el problema de recall casi por completo. No estoy 100% seguro de que este sea el culpable en todos los setups — el parámetro ef_search también influye mucho en cuántos candidatos considera el algoritmo durante la búsqueda — pero en mi caso fue esto. Curiosamente, empujé la migración completa a producción esa misma noche, lo cual no recomiendo como práctica general, pero a veces uno aprende esas cosas a su propio ritmo.
Cuatro meses en producción: los números reales
Mira, no voy a decir que pgvector es idéntico a Pinecone en rendimiento. No lo es, al menos no con mi setup en Supabase Pro (plan de 4GB RAM). Pero la diferencia práctica para mi caso de uso es irrelevante.
Métricas con ~45k vectores, promediadas sobre las últimas semanas:
| Métrica | Pinecone | pgvector |
|---|---|---|
| Latencia p50 | ~32ms | ~51ms |
| Latencia p99 | ~115ms | ~190ms |
| Disponibilidad | 100% | 100% |
| Costo mensual incremental | $70 | $0 |
La diferencia de ~19ms en p50 no cambia nada en una aplicación donde el request posterior a la API de generación de texto tarda entre 800ms y 3 segundos. La búsqueda vectorial no es el cuello de botella.
Algo que noté en las primeras semanas fue que ajustar ef_search a 100 (el default es 40) mejoró el recall perceptiblemente a cambio de unos 15ms adicionales de latencia — un tradeoff que en mi caso valió la pena. Hice un benchmark manual con 80 queries de prueba comparando los top-5 resultados de cada sistema, y después del tuning las diferencias no fueron sistemáticas en ninguna dirección. El equipo de soporte no reportó ningún cambio en la utilidad de las respuestas.
Lo que sí mejoró claramente fue la operación. Todo vive en una sola base de datos. Los filtros por metadata son queries SQL normales. Si necesito hacer un JOIN entre los resultados de búsqueda vectorial y la tabla de usuarios para aplicar permisos, es una sola query, no dos roundtrips a servicios distintos.
Cuándo tiene sentido cada opción — sin rodeos
Hay situaciones donde no haría esta migración. Si estuvieras manejando decenas de millones de vectores — un catálogo de e-commerce grande, un motor de recomendaciones a escala —, pgvector tiene limitaciones reales. El índice HNSW consume memoria proporcional al número de vectores, y en algún punto eso se convierte en un problema de hardware antes de convertirse en un problema de software. No sé exactamente dónde está ese límite en producción; en teoría pgvector aguanta bien hasta algunos cientos de millones con el hardware adecuado, pero yo no he llegado a probarlo.
Tampoco haría la migración si el recall fuera absolutamente crítico y no quisiera dedicarle tiempo al tuning de parámetros. Pinecone abstrae mucha de esa complejidad.
Pero si estás en el escenario más común — RAG sobre documentación interna, búsqueda semántica sobre un corpus de tamaño razonable, chatbot de soporte — pgvector con HNSW hace el trabajo, y probablemente ya tienes PostgreSQL corriendo. Todo queda en el mismo sitio: sin el overhead de mantener dos sistemas en sync, sin otra factura mensual que justificar.
Si ya usas PostgreSQL y tienes menos de 2-3 millones de vectores, empieza con pgvector. Mide el recall con tus queries reales, ajusta work_mem y ef_search si notas problemas, y decide desde ahí. Y si en algún momento la escala no llega, migrar a Pinecone no es ningún drama — los embeddings son los mismos, solo cambias el store y la capa de acceso.
Yo tardé una tarde en hacer el camino contrario. Si estás pagando $70 al mes por esto, probablemente es una tarde bien invertida.