IA 30 abr 2026 · 7 min de lectura

Embeddings + pgvector: matching semántico en PostgreSQL

Yohangel Ramos

Yohangel Ramos

Tech Lead · Senior Fullstack Developer

Cuando arranqué el motor de matching en JXBS, la primera decisión no fue qué modelo de embeddings usar, sino dónde guardar los vectores. Y decidí no meter una base de datos vectorial dedicada. Los embeddings viven en el mismo PostgreSQL donde ya está todo lo demás: candidatos, vacantes, aplicaciones. Con la extensión pgvector, Postgres almacena, indexa y busca por similitud sin que yo tenga que sincronizar dos sistemas. Este artículo es el porqué de esa decisión y cómo la implementé de verdad.

Por qué me quedé en Postgres

La tentación de meter Pinecone, Weaviate o Qdrant es real. Pero cada datastore nuevo es otra cosa que operar, monitorear, respaldar y mantener consistente. En un producto de reclutamiento, la similitud semántica nunca vive sola: siempre la cruzo con datos estructurados. Ubicación, seniority, disponibilidad, si la vacante sigue abierta. Si los vectores están en otro sistema, cada búsqueda se convierte en dos consultas y un join hecho a mano en el código de la aplicación.

Teniéndolo todo en Postgres gano tres cosas concretas. Transacciones: cuando actualizo un candidato y su embedding, o ambos entran o ninguno. Joins: filtro por columnas estructuradas y ordeno por distancia vectorial en la misma consulta. Y simplicidad operativa: un backup, un punto de monitoreo, una migración de Prisma. Con ECS Fargate y OpenTofu, cada pieza extra de infraestructura se paga en tiempo de mantenimiento.

Cómo funcionan los embeddings, a nivel práctico

Un embedding es un vector de números que representa el significado de un texto. Textos parecidos quedan cerca en ese espacio; textos distintos, lejos. No necesito entender la geometría de alta dimensión para usarlo: le paso el texto a un modelo de embeddings, me devuelve un array de floats, y lo guardo.

Lo importante es qué texto le paso. Para un candidato no le doy el CV crudo entero: armo un resumen estructurado con experiencia, tecnologías y rol. Para una vacante, el título más los requisitos reales. Basura entra, basura sale: la calidad del embedding depende de la calidad del texto que lo genera, mucho más que del modelo.

Guardando vectores con pgvector

pgvector añade un tipo vector y operadores de distancia. La tabla se ve así:

CREATE EXTENSION IF NOT EXISTS vector;

CREATE TABLE candidate_embedding (
  candidate_id  UUID PRIMARY KEY REFERENCES candidate(id),
  embedding     vector(1536) NOT NULL,
  seniority     TEXT NOT NULL,
  location      TEXT NOT NULL,
  is_available  BOOLEAN NOT NULL DEFAULT true,
  updated_at    TIMESTAMPTZ NOT NULL DEFAULT now()
);

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

El operador <=> es distancia coseno. Menor distancia, mayor similitud. Elijo coseno porque me interesa la dirección del vector, no su magnitud.

Índice: HNSW vs IVFFlat

pgvector ofrece dos tipos de índice y la diferencia importa. IVFFlat agrupa vectores en listas y busca solo en las más cercanas: se construye rápido y ocupa poco, pero su recall depende de cuántas listas visites y sufre si los datos crecen mucho. HNSW construye un grafo navegable por capas: da mejor recall con latencia más baja en consultas, a cambio de tardar más en construirse y consumir más memoria.

Me quedé con HNSW. En reclutamiento, un candidato relevante que no aparece es un fallo silencioso que nadie reporta pero que degrada el producto. Prefiero pagar en construcción de índice y RAM a cambio de recall estable. ef_construction controla la calidad del grafo al construir; en consulta, subir ef_search mejora recall a costa de latencia. Ahí está la palanca del trade-off, y la muevo según lo que mida, no por corazonada.

💡 La distancia coseno te dice qué se parece; no te dice qué sirve. Esa diferencia es todo el producto.

Búsqueda híbrida: por qué no confío solo en el coseno

Este es el punto que más me costó aprender. La similitud semántica es buena encontrando cosas parecidas, pero "parecido" no es "correcto". Un backend senior en Caracas y uno junior en otra zona horaria pueden tener embeddings cercanos porque comparten tecnologías. El coseno no sabe que la vacante exige seniority alto y presencia local.

Por eso hago búsqueda híbrida: la similitud vectorial es una señal, no el veredicto. Filtro con WHERE sobre columnas estructuradas y combino la distancia con un score estructurado.

SELECT
  c.candidate_id,
  1 - (c.embedding <=> $1::vector)              AS similarity,
  CASE WHEN c.seniority = $2 THEN 0.3 ELSE 0 END AS seniority_boost
FROM candidate_embedding c
WHERE c.is_available = true
  AND c.location = $3
ORDER BY (c.embedding <=> $1::vector)
         - CASE WHEN c.seniority = $2 THEN 0.3 ELSE 0 END
LIMIT 20;

El WHERE recorta el espacio a candidatos elegibles antes de rankear. La similitud semántica ordena dentro de ese conjunto, y un boost estructurado ajusta el orden final. Los pesos son configurables y los afino contra lo que el equipo de reclutamiento considera un buen match, no contra una métrica abstracta de coseno.

Chunking y mantener los embeddings frescos

Los CV largos no los meto de una sola pasada. Divido en secciones con sentido (experiencia, skills, educación) y así el embedding de "experiencia backend" no queda diluido por tres párrafos de hobbies. Es chunking pragmático, guiado por la estructura del documento, no por un tamaño fijo de tokens.

Frescura: un embedding es una foto del texto en un momento. Si el candidato actualiza su perfil o la vacante cambia requisitos, el vector queda obsoleto. Guardo un hash del texto de origen; si cambia, reencolo la regeneración. Así solo recalculo lo que de verdad cambió, y no quemo llamadas al modelo por gusto.

Costos y cuándo sí sacaría una DB vectorial

Generar embeddings cuesta por token, y ese es el gasto real, no el almacenamiento. Reencolar solo ante cambios de hash mantiene la factura bajo control. HNSW pesa en RAM, así que dimensiono la instancia de Postgres pensando en el índice, no solo en las filas.

¿Cuándo dejaría Postgres? Si llegara a cientos de millones de vectores con tráfico de búsqueda muy alto y sostenido, donde el índice ya no cabe cómodo en memoria y la latencia se vuelve el cuello de botella del negocio. A esa escala, un motor vectorial dedicado con sharding se justifica. Pero es una decisión que se toma con números reales de carga, no anticipándose a un problema que quizá nunca llega. Hasta ahí, Postgres con pgvector hace el trabajo y me deja un sistema con menos piezas que puedan romperse.

Yohangel Ramos

Escrito por Yohangel Ramos

Senior Fullstack Developer y Tech Lead. Construyo con React, Next.js, Nest.js y AWS — y escribo sobre lo que aprendo en el camino.

Hablemos →

Sigue leyendo

IA

Embeddings más baratos: recortar dimensiones y cuantizar sin perder recall

IA

Qué va a pasar con el desarrollo de software: predicciones con fechas (julio 2026)