Cómo implementar búsqueda vectorial en Postgres
Implemente la búsqueda vectorial en Postgres con pgvector: añade embeddings, consulta por distancia coseno, indexa con HNSW o IVFFlat y combina con búsqueda de texto completo.
pgvector añade un tipo de columna vector a Postgres, y una consulta de similitud es un ORDER BY sobre un operador de distancia seguido de un LIMIT.
Esto suele surgir después de que una búsqueda en el centro de ayuda no devuelva nada para “cancelar mi suscripción” porque el artículo se titula “Finalizar tu plan”, y alguien propone levantar una base de datos vectorial gestionada junto al Postgres que ya tienes en marcha. Ese segundo servicio rara vez es necesario. El resto de este artículo es el recorrido completo en SQL: habilitar la extensión, dimensionar la columna según tu modelo de embeddings, almacenar vectores, consultar por distancia coseno, indexar con HNSW o IVFFlat, unir resultados vectoriales con tablas ordinarias, y las consultas en las que la búsqueda vectorial es la herramienta equivocada y donde la búsqueda de texto completo de Postgres debería tomar el relevo.
Puntos clave
- El número dentro de
vector(n)debe coincidir con la longitud de los vectores que produce tu modelo de embeddings, y los vectores de modelos distintos no pueden compararse de forma significativa. - El operador
<=>devuelve la distancia coseno, por lo que ordenar de forma ascendente coloca primero la coincidencia más cercana; resta de 1 cuando necesites la similitud coseno. - Tanto HNSW como IVFFlat son índices aproximados; la búsqueda exacta es lo que obtienes cuando no hay ningún índice vectorial sobre la columna.
- El planificador solo considera un índice vectorial cuando la consulta tiene un
ORDER BYdirectamente sobre un operador de distancia, en orden ascendente y con unLIMIT. - La búsqueda vectorial encuentra filas que significan lo mismo; la búsqueda de texto completo encuentra filas que contienen las mismas palabras, y la búsqueda en producción normalmente ejecuta ambas y fusiona las listas de resultados ordenadas.
¿Para qué se usa la búsqueda vectorial?
La búsqueda vectorial encuentra coincidencias por significado, de modo que una consulta de “cancelar mi suscripción” puede devolver un documento titulado “Finalizar tu plan” aunque ambos no compartan ninguna palabra. Cada fragmento de texto es convertido por un modelo de embeddings en una lista de números de longitud fija, y los textos con significados similares quedan cerca unos de otros en ese espacio. Buscar significa encontrar los vectores almacenados más próximos al vector de la consulta. Para conocer el funcionamiento de los embeddings, consulta Vector Databases Explained.
Resulta rentable en tres escenarios: búsqueda en el sitio que tolera paráfrasis, emparejamiento de tickets de soporte (encontrar tickets anteriores similares a este) y recuperación para RAG, donde los documentos más cercanos se envían a un modelo de lenguaje como contexto, tal como se explica en la introducción a RAG para aplicaciones web.
Habilitar pgvector y añadir una columna vectorial
pgvector se habilita una vez por base de datos con CREATE EXTENSION vector, y el tipo de columna es vector(n), donde n es el número de dimensiones. La versión 0.8.6 admite Postgres 13 y posteriores, aunque Postgres 13 ya no cuenta con soporte de la comunidad, por lo que 14 o superior es el mínimo práctico.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE documents (
id bigserial PRIMARY KEY,
title text NOT NULL,
body text NOT NULL,
embedding vector(1536) -- replace 1536 with your embedding model's output size
);
-- or, on a table you already have:
ALTER TABLE documents ADD COLUMN embedding vector(1536);
El número dentro de vector(n) debe ser igual a la longitud de los vectores que produce tu modelo de embeddings. Por ejemplo, text-embedding-3-small de OpenAI devuelve vectores de 1536 dimensiones por defecto. Los vectores de modelos distintos no pueden compararse de forma significativa, así que cambiar de modelo implica volver a generar los embeddings de toda la columna.
Almacenar embeddings de cualquier modelo
Los embeddings se escriben como cualquier otro valor de columna: genera el vector en el código de la aplicación y luego pásalo como parámetro vinculado con un cast a vector. pgvector no invoca ningún modelo por ti, y funciona con cualquier lenguaje que disponga de un driver de Postgres.
INSERT INTO documents (title, body, embedding)
VALUES ($1, $2, $3::vector);
-- re-embed an existing row without creating a duplicate
INSERT INTO documents (id, title, body, embedding)
VALUES ($1, $2, $3, $4::vector)
ON CONFLICT (id) DO UPDATE SET embedding = EXCLUDED.embedding;
Para una carga inicial masiva, pgvector recomienda cargar en bloque con COPY ... FROM STDIN WITH (FORMAT BINARY) y construir los índices una vez que los datos estén dentro, en lugar de antes.
¿Qué operador de distancia de pgvector deberías usar?
Usa <=> para embeddings de texto, salvo que la documentación de tu modelo indique lo contrario. Devuelve la distancia coseno, por lo que ordenar de forma ascendente coloca primero la coincidencia más cercana; resta el resultado de 1 cuando necesites la similitud coseno para mostrarla.
-- five closest documents; $1 is the query embedding
SELECT id, title, 1 - (embedding <=> $1::vector) AS similarity
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 5;
pgvector admite seis operadores de distancia en total, y cada uno necesita una clase de operador de índice correspondiente:
| Operador | Mide | Úsalo cuando | Opclass |
|---|---|---|---|
<=> | distancia coseno | embeddings de texto; el valor predeterminado habitual | vector_cosine_ops |
<-> | distancia L2 (euclídea) | la magnitud tiene significado | vector_l2_ops |
<#> | producto interno negativo | vectores ya normalizados a longitud 1 | vector_ip_ops |
<+> | distancia L1 (Manhattan) | rara vez para texto; solo HNSW | vector_l1_ops |
<#> devuelve el producto interno con el signo invertido. Postgres solo recorre los índices en orden ascendente, así que la forma negativa mantiene el número más pequeño como la coincidencia más cercana; multiplica por -1 para recuperar el producto interno normal. Si tus vectores ya están normalizados a longitud 1, el producto interno es la opción más rápida para la búsqueda exacta. <~> (Hamming) y <%> (Jaccard) existen para vectores binarios y quedan fuera del alcance de este artículo.
¿Deberías indexar con HNSW o IVFFlat?
Sin índice, pgvector compara el vector de la consulta con todas las filas y devuelve los vecinos más cercanos exactos. Añadir un índice HNSW o IVFFlat cambia eso por una búsqueda aproximada, que es mucho más rápida, encuentra la mayoría de los vecinos reales y puede devolver filas distintas a las de la consulta exacta. Ambos tipos de índice son aproximados. La búsqueda exacta es simplemente lo que ocurre cuando la columna no tiene ningún índice vectorial.
Usa HNSW por defecto, salvo que el tiempo de construcción o la memoria te obliguen a optar por IVFFlat. La opclass debe coincidir con el operador con el que consultas.
-- production: avoid blocking writes
CREATE INDEX CONCURRENTLY documents_embedding_hnsw
ON documents USING hnsw (embedding vector_cosine_ops);
-- IVFFlat alternative; create only after the table has data
CREATE INDEX ON documents USING ivfflat (embedding vector_cosine_ops) WITH (lists = 100);
| HNSW | IVFFlat | |
|---|---|---|
| Estructura | grafo con varias capas | vectores agrupados en listas |
| Compromiso velocidad-recall | mejor | peor |
| Tiempo de construcción y memoria | más lento, más memoria | más rápido, menos memoria |
| Construcción sobre tabla vacía | sí, sin paso de entrenamiento | no; los centroides provienen de los datos presentes en el momento de la construcción |
| Parámetro de consulta | hnsw.ef_search (por defecto 40) | ivfflat.probes (por defecto 1; empieza en sqrt(lists)) |
HNSW no tiene paso de entrenamiento, por lo que el índice puede construirse antes de que llegue una sola fila a la tabla. IVFFlat sí lo tiene: sus listas se derivan de los datos presentes cuando se construye el índice, así que conviene proporcionarle antes un conjunto representativo de filas. Aumentar ef_search o probes con SET LOCAL dentro de una transacción mejora el recall de una consulta a costa de la velocidad.
El planificador solo considerará un índice vectorial cuando la consulta tenga un ORDER BY directamente sobre un operador de distancia, ordenado de forma ascendente, junto con un LIMIT; ORDER BY 1 - (embedding <=> $1) DESC no lo utilizará. En una tabla pequeña, el planificador puede seguir prefiriendo un recorrido secuencial, así que compruébalo con EXPLAIN. Para medir cuánto recall te ha costado el índice, fuerza una búsqueda exacta y compara ambos conjuntos de resultados:
BEGIN;
SET LOCAL enable_indexscan = off; -- exact search for comparison
SELECT id FROM documents ORDER BY embedding <=> $1::vector LIMIT 5;
COMMIT;
¿Necesitas una base de datos vectorial independiente?
Si ya ejecutas Postgres, probablemente no necesites una base de datos vectorial aparte: pgvector te ofrece búsqueda vectorial con las mismas copias de seguridad, las mismas transacciones y la capacidad de unir resultados vectoriales con tablas normales en una sola consulta. Un documento y su embedding se escriben en un único INSERT, por lo que no hay pipeline de sincronización entre dos almacenes ni modo de fallo por vectores huérfanos. Los vectores viajan por el write-ahead log como cualquier otra columna, de modo que las réplicas y las restauraciones point-in-time los recogen sin trabajo adicional.
El join es donde esto se vuelve tangible. Encontrar los tickets de soporte más cercanos, restringidos a clientes enterprise, es una sola sentencia:
SELECT t.id, t.subject, c.plan, t.embedding <=> $1::vector AS distance
FROM tickets t
JOIN customers c ON c.id = t.customer_id
WHERE c.plan = 'enterprise'
ORDER BY t.embedding <=> $1::vector
LIMIT 10;
Los índices aproximados tienen una pega: primero se recorre el índice y después se aplica la cláusula WHERE a lo que este haya devuelto, por lo que un filtro selectivo puede dejarte con menos filas de las que pedía el LIMIT. Un índice B-tree sobre la columna del filtro suele dar resultados exactos y rápidos; los recorridos iterativos y los índices parciales cubren el resto.
Las bases de datos vectoriales dedicadas siguen justificándose con miles de millones de vectores o tasas de escritura extremas. Por debajo de eso, el servicio adicional es coste operativo sin beneficio visible para el usuario.
Dónde se queda corta la búsqueda vectorial: búsqueda híbrida con texto completo
La búsqueda vectorial encuentra filas que significan lo mismo; la búsqueda de texto completo encuentra filas que contienen las mismas palabras, y la búsqueda en producción normalmente ejecuta ambas y fusiona las dos listas ordenadas. Los identificadores exactos, los códigos de producto, las cadenas de error y los nombres de personas no tienen un “significado” útil para un modelo de embeddings, y una consulta de SKU-4471 debería devolver la fila que contiene ese token, no filas sobre productos similares. Esas consultas requieren la búsqueda de texto completo de Postgres, que la documentación de pgvector combina con la búsqueda vectorial para consultas híbridas.
Empieza con una columna tsvector generada y almacenada y un índice GIN sobre ella:
ALTER TABLE documents
ADD COLUMN body_tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || body)) STORED;
CREATE INDEX ON documents USING gin (body_tsv);
Después combina ambos rankings con Reciprocal Rank Fusion, siguiendo la estructura del propio ejemplo de RRF de pgvector:
-- $1 = query embedding, $2 = raw query text
WITH semantic AS (
SELECT id, RANK() OVER (ORDER BY embedding <=> $1::vector) AS rnk
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 20
),
lexical AS (
SELECT id, RANK() OVER (ORDER BY ts_rank_cd(body_tsv, q) DESC) AS rnk
FROM documents, plainto_tsquery('english', $2) AS q
WHERE body_tsv @@ q
ORDER BY ts_rank_cd(body_tsv, q) DESC
LIMIT 20
)
SELECT COALESCE(s.id, l.id) AS id,
COALESCE(1.0 / (60 + s.rnk), 0.0) + COALESCE(1.0 / (60 + l.rnk), 0.0) AS score
FROM semantic s
FULL OUTER JOIN lexical l ON l.id = s.id
ORDER BY score DESC
LIMIT 10;
Cada lista aporta 1 / (k + rank), de modo que un documento que aparezca en los primeros puestos de cualquiera de las dos listas obtiene una buena puntuación, y uno presente en ambas obtiene la mejor. La constante k = 60 procede de Cormack, Clarke y Büttcher, quienes la fijaron en un estudio piloto e indicaron que el valor exacto no es crítico. Considera la consulta como ilustrativa y confirma con EXPLAIN (ANALYZE, BUFFERS) que tanto el índice HNSW como el GIN se utilizan con tus datos.
Por dónde empezar
Toda la implementación consiste en una columna, un operador y un índice, todo dentro de la base de datos de la que ya haces copias de seguridad y que ya replicas. Añade la columna vector(n) dimensionada según tu modelo, escribe primero la consulta ORDER BY ... <=> ... LIMIT sin índice, después añade HNSW y comprueba la diferencia de recall con enable_indexscan = off. Una vez que los resultados semánticos tengan buen aspecto, incorpora la parte de texto completo, porque de lo contrario el primer usuario que pegue un número de pedido encontrará la carencia.
Preguntas frecuentes
¿Puede pgvector indexar embeddings de más de 2.000 dimensiones?
No solo con el tipo vector estándar. HNSW e IVFFlat indexan columnas vectoriales de hasta 2.000 dimensiones, aunque la columna en sí almacena hasta 16.000. Para embeddings mayores, haz un cast a halfvec en un índice de expresión (indexable hasta 4.000 dimensiones), usa cuantización binaria con re-ranking (hasta 64.000), indexa un subvector o solicita menos dimensiones de salida al modelo, algo que los modelos text-embedding-3 de OpenAI admiten mediante un parámetro dimensions.
¿Necesito reconstruir un índice de pgvector después de insertar nuevas filas?
Con HNSW no: las filas nuevas se añaden al grafo a medida que se insertan, y por eso el índice puede construirse sobre una tabla vacía. IVFFlat se comporta de forma distinta. Los centroides de sus listas se calculan mediante k-means una sola vez en el momento de la construcción y nunca se mueven, por lo que el recall puede degradarse a medida que la tabla crece o su distribución cambia. Reconstruye los índices IVFFlat tras cargas masivas o cambios de distribución con REINDEX INDEX CONCURRENTLY.
¿Por qué una consulta devuelve menos filas que el LIMIT después de añadir un índice HNSW?
Porque un recorrido HNSW devuelve como máximo hnsw.ef_search candidatos, 40 por defecto, y cualquier filtro WHERE se aplica después a esos candidatos. Un LIMIT superior a 40, un filtro selectivo o tuplas muertas pueden dejar el resultado corto. Aumenta hnsw.ef_search con SET LOCAL o, en pgvector 0.8.0 y posteriores, habilita los recorridos iterativos de índice estableciendo hnsw.iterative_scan en strict_order para que el recorrido continúe hasta que coincidan suficientes filas.
¿Puedo almacenar embeddings de dos modelos distintos en la misma tabla?
Sí, pero no en una única columna indexada compartida. Usa una columna vector(n) independiente por modelo, cada una dimensionada según la salida de ese modelo y cada una con su propio índice. pgvector también permite una columna vector sin tipo que contenga dimensiones mixtas, pero un índice solo puede cubrir filas de una única dimensión, construido como índice de expresión con un cast más un WHERE parcial sobre el identificador del modelo. Las distancias entre modelos distintos carecen de sentido.
Gain Debugging Superpowers
Unleash the power of session replay to reproduce bugs, track slowdowns and uncover frustrations in your app. Get complete visibility into your frontend with OpenReplay — the most advanced open-source session replay tool for developers.
Star on GitHub12k