Búsqueda vectorial en PostgreSQL con pgvector: búsqueda semántica sin servicios externos en 2026
Introducción: por qué necesitas búsqueda semántica y cuándo no hace falta un servicio externo
La búsqueda de texto completo clásica funciona por coincidencia exacta de palabras. Una consulta como «comprar portátil» no encontrará un documento con las palabras «adquirir laptop», aunque el significado sea idéntico. La búsqueda semántica resuelve este problema comparando no las palabras, sino representaciones vectoriales del significado: los embeddings.
La respuesta tradicional a este reto es levantar Elasticsearch con el plugin kNN, Pinecone, Weaviate o Qdrant. Pero para la mayoría de los productos esto implica nueva infraestructura, costes adicionales, sincronización de datos y una nueva fuente de fallos. Si ya tienes PostgreSQL, la extensión pgvector te permite añadir búsqueda vectorial completa directamente en tu base de datos existente, sin servicios externos.
En 2026, pgvector ha alcanzado la madurez: soporte de índices HNSW, consultas paralelas y compatibilidad con PostgreSQL gestionado en la nube (Amazon RDS, Supabase, Neon, Google Cloud SQL). Este artículo está dirigido a desarrolladores backend y arquitectos que buscan una solución pragmática sin inflar el stack tecnológico.
Qué es pgvector: instalación y tipos soportados
pgvector es una extensión de código abierto para PostgreSQL que añade el tipo de dato vector, operadores de comparación de vectores e índices para búsqueda aproximada de vecinos más cercanos (ANN).
Instalación de la extensión
En Ubuntu/Debian con PostgreSQL 16:
sudo apt install postgresql-16-pgvector
-- En psql:
CREATE EXTENSION IF NOT EXISTS vector;
Con Docker (recomendado para desarrollo):
docker run -d \
--name pgvector-dev \
-e POSTGRES_PASSWORD=secret \
-p 5432:5432 \
pgvector/pgvector:pg16
Tras la instalación, comprueba la versión:
SELECT extversion FROM pg_extension WHERE extname = 'vector';
-- 0.8.0 (versión actual en 2026)
El tipo vector(N) acepta una dimensión fija N (máximo 16 000 dimensiones para HNSW, 2 000 para IVFFLAT). También se soportan halfvec (float de 16 bits, ocupa la mitad de espacio) y sparsevec para vectores dispersos.
Generación de embeddings: modelos e integración en 2026
Un embedding es un vector numérico que codifica el significado de un texto. Modelos populares en 2026:
- text-embedding-3-large (OpenAI) — 3072 dimensiones, alta calidad, de pago.
- text-embedding-3-small (OpenAI) — 1536 dimensiones, buen equilibrio entre precio y calidad.
- Mistral Embed — 1024 dimensiones, calidad competitiva para datos en lenguas europeas.
- nomic-embed-text-v2 (localmente con Ollama) — 768 dimensiones, completamente offline.
- multilingual-e5-large — excelente rendimiento con múltiples idiomas, incluido el español.
Obtención de un embedding mediante REST API en Go
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
)
type EmbeddingRequest struct {
Input string `json:"input"`
Model string `json:"model"`
}
type EmbeddingResponse struct {
Data []struct {
Embedding []float32 `json:"embedding"`
} `json:"data"`
}
func GetEmbedding(text, apiKey string) ([]float32, error) {
payload := EmbeddingRequest{
Input: text,
Model: "text-embedding-3-small",
}
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST",
"https://api.openai.com/v1/embeddings",
bytes.NewBuffer(body),
)
req.Header.Set("Authorization", "Bearer "+apiKey)
req.Header.Set("Content-Type", "application/json")
resp, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
var result EmbeddingResponse
json.NewDecoder(resp.Body).Decode(&result)
return result.Data[0].Embedding, nil
}
Obtención de un embedding en PHP (para Laravel)
<?php
use Illuminate\Support\Facades\Http;
function getEmbedding(string $text): array
{
$response = Http::withToken(config('services.openai.key'))
->post('https://api.openai.com/v1/embeddings', [
'input' => $text,
'model' => 'text-embedding-3-small',
]);
return $response->json('data.0.embedding');
}
Creación de tablas con columnas vectoriales y almacenamiento de embeddings
Creamos una tabla de documentos con una columna vectorial para almacenar embeddings en PostgreSQL:
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
embedding vector(1536), -- dimensión de text-embedding-3-small
created_at TIMESTAMPTZ DEFAULT NOW()
);
Inserción de un documento con su embedding (ejemplo en Go):
import "github.com/pgvector/pgvector-go"
embedding, _ := GetEmbedding("Portátiles para desarrollo", apiKey)
_, err = db.Exec(
`INSERT INTO documents (title, content, embedding)
VALUES ($1, $2, $3)`,
"Los mejores portátiles de 2026",
"Análisis de los mejores laptops para programadores...",
pgvector.NewVector(embedding),
)
Operadores de búsqueda: cosine similarity, distancia L2 y producto interior
pgvector soporta tres operadores de distancia. La elección depende de la tarea:
- Distancia coseno (
<=>) — mide el ángulo entre vectores, independiente de la longitud. La mejor opción para embeddings de texto. Valor 0 = idénticos, 2 = opuestos. - Distancia L2 (euclidiana) (
<->) — distancia euclidiana. Adecuada cuando importa la proximidad absoluta en el espacio, por ejemplo para imágenes. - Producto interior (
<#>) — producto escalar (con signo negativo para ordenación). Óptimo para vectores normalizados: matemáticamente equivalente a la similitud coseno, pero más rápido.
Ejemplo de consulta con cosine similarity en SQL:
-- Encontrar los 10 documentos semánticamente más cercanos a la consulta
SELECT
id,
title,
1 - (embedding <=> $1::vector) AS similarity
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 10;
Donde $1 es el embedding de la consulta del usuario.
Índices para búsqueda vectorial: IVFFLAT vs HNSW
Sin índice, PostgreSQL realiza un escaneo completo de la tabla (búsqueda kNN exacta). Con millones de registros, esto es inaceptablemente lento. pgvector ofrece dos tipos de índices de búsqueda aproximada.
IVFFLAT — archivo invertido con clústeres planos
CREATE INDEX ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
-- Antes de buscar, establece el número de clústeres a explorar:
SET ivfflat.probes = 10;
Parámetro lists: se recomienda filas / 1000 para tablas de hasta 1M de filas. probes: cuanto mayor, más preciso pero más lento. IVFFLAT se construye rápidamente, pero requiere que los datos existan antes de crear el índice.
HNSW — Hierarchical Navigable Small World
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
-- Durante la búsqueda:
SET hnsw.ef_search = 40;
El índice HNSW de pgvector supera a IVFFLAT en velocidad de búsqueda con una precisión comparable. Parámetros:
m(8–64) — número de conexiones por capa. Mayor = más preciso, más memoria.ef_construction(4–400) — calidad de construcción del índice. 64 es un valor por defecto razonable.ef_search— precisión durante la búsqueda, se puede cambiar en tiempo de ejecución.
HNSW requiere más memoria durante la construcción y tarda más en crearse, pero ofrece mejor recall con menor latencia. En 2026, HNSW es la opción predeterminada para la mayoría de los sistemas en producción.
Búsqueda híbrida: combinando búsqueda de texto completo y vectorial
La búsqueda semántica pura a veces falla con términos exactos (códigos de producto, nombres propios). La búsqueda híbrida en PostgreSQL combina la búsqueda con tsvector y la vectorial mediante RRF (Reciprocal Rank Fusion):
WITH
-- Búsqueda de texto completo
fulltext AS (
SELECT id, ts_rank(to_tsvector('spanish', content),
plainto_tsquery('spanish', $2)) AS ft_score
FROM documents
WHERE to_tsvector('spanish', content) @@ plainto_tsquery('spanish', $2)
LIMIT 60
),
-- Búsqueda vectorial
vector_search AS (
SELECT id, 1 - (embedding <=> $1::vector) AS vs_score
FROM documents
ORDER BY embedding <=> $1::vector
LIMIT 60
),
-- Combinación mediante RRF
ranked AS (
SELECT
COALESCE(f.id, v.id) AS id,
COALESCE(1.0 / (60 + ROW_NUMBER() OVER (ORDER BY f.ft_score DESC)), 0) +
COALESCE(1.0 / (60 + ROW_NUMBER() OVER (ORDER BY v.vs_score DESC)), 0) AS rrf_score
FROM fulltext f
FULL OUTER JOIN vector_search v ON f.id = v.id
)
SELECT d.id, d.title, r.rrf_score
FROM ranked r
JOIN documents d ON d.id = r.id
ORDER BY r.rrf_score DESC
LIMIT 10;
Rendimiento con grandes volúmenes: benchmarks y limitaciones
Valores de referencia de pgvector con HNSW en un servidor con 32 GB de RAM, PostgreSQL 16 y vectores de 1536 dimensiones:
- 100K registros: ~2 ms por consulta, recall 95%+
- 1M registros: ~15–30 ms por consulta, recall 90%+
- 10M registros: ~80–150 ms, se recomienda particionamiento
Para tablas con más de 5M filas, usa particionamiento por rango de fechas o categorías:
CREATE TABLE documents (
id BIGSERIAL,
category TEXT NOT NULL,
embedding vector(1536),
created_at TIMESTAMPTZ DEFAULT NOW()
) PARTITION BY LIST (category);
CREATE TABLE documents_tech
PARTITION OF documents FOR VALUES IN ('tech');
CREATE TABLE documents_finance
PARTITION OF documents FOR VALUES IN ('finance');
-- Los índices se crean por separado en cada partición
CREATE INDEX ON documents_tech
USING hnsw (embedding vector_cosine_ops);
Limitación importante: el índice HNSW se mantiene en memoria durante la búsqueda. Ajusta maintenance_work_mem para la construcción y shared_buffers para el almacenamiento en caché.
Caché de resultados de búsqueda con Redis
La búsqueda semántica es costosa: hay que obtener el embedding de la consulta mediante la API y ejecutar la consulta vectorial. Redis permite cachear los resultados usando el hash del embedding de la consulta.
Ejemplo en Go con la librería go-redis:
import (
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"time"
"github.com/redis/go-redis/v9"
)
func SearchWithCache(ctx context.Context, rdb *redis.Client, query string, embedding []float32) ([]Document, error) {
// Clave de caché basada en el hash del embedding
h := sha256.Sum256([]byte(fmt.Sprintf("%v", embedding)))
cacheKey := "search:" + hex.EncodeToString(h[:])
// Comprobar la caché
cached, err := rdb.Get(ctx, cacheKey).Result()
if err == nil {
var docs []Document
json.Unmarshal([]byte(cached), &docs)
return docs, nil
}
// Ejecutar la búsqueda en PostgreSQL
docs, err := SearchDocuments(ctx, embedding)
if err != nil {
return nil, err
}
// Guardar en Redis durante 10 minutos
data, _ := json.Marshal(docs)
rdb.Set(ctx, cacheKey, data, 10*time.Minute)
return docs, nil
}
Para consultas frecuentes (búsquedas sobre temas populares), el hit rate de Redis alcanza el 60–80%, lo que reduce significativamente la carga sobre la base de datos.
Comparativa: pgvector vs Elasticsearch kNN y bases de datos vectoriales especializadas
pgvector vs búsqueda kNN en Elasticsearch:
- Infraestructura: pgvector — cero nuevos componentes. Elasticsearch — clúster separado, Kibana, monitorización.
- Consistencia: pgvector — transacciones ACID. ES — consistencia eventual.
- Búsqueda híbrida: ambos la soportan, pero en PostgreSQL es SQL nativo sin DSL adicional.
- Rendimiento con 10M+: ES gana gracias al escalado horizontal con shards.
pgvector vs Pinecone/Weaviate/Qdrant:
- Las bases especializadas ganan en rendimiento pico con cientos de millones de vectores.
- pgvector gana en simplicidad, coste e integración con datos relacionales (JOINs, transacciones, SQL familiar).
- Para la mayoría de productos B2B y SaaS con volúmenes de hasta 10M de documentos, pgvector es la opción pragmática.
Regla práctica: empieza con pgvector. Migra a una base especializada solo cuando PostgreSQL deje de ser suficiente para cargas medidas y reales, no imaginadas.
Caso práctico: búsqueda semántica de documentos en una aplicación Laravel
Veamos cómo implementar pgvector con Laravel para buscar artículos en una base de conocimiento.
Migración
<?php
// database/migrations/2026_01_01_add_embeddings_to_articles.php
public function up(): void
{
DB::statement('CREATE EXTENSION IF NOT EXISTS vector');
Schema::table('articles', function (Blueprint $table) {
// Añadimos el tipo personalizado mediante sentencia raw
});
DB::statement('ALTER TABLE articles ADD COLUMN embedding vector(1536)');
DB::statement(
'CREATE INDEX articles_embedding_hnsw
ON articles USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64)'
);
}
Servicio de búsqueda
<?php
namespace App\Services;
use App\Models\Article;
use Illuminate\Support\Facades\Http;
use Illuminate\Support\Facades\Cache;
class SemanticSearchService
{
public function search(string $query, int $limit = 10): array
{
$embedding = $this->getEmbedding($query);
$vectorStr = '[' . implode(',', $embedding) . ']';
return Article::selectRaw(
'id, title, excerpt,
1 - (embedding <=> ?::vector) AS similarity',
[$vectorStr]
)
->whereNotNull('embedding')
->orderByRaw('embedding <=> ?::vector', [$vectorStr])
->limit($limit)
->get()
->toArray();
}
private function getEmbedding(string $text): array
{
return Cache::remember(
'embedding:' . md5($text),
now()->addDay(),
fn () => Http::withToken(config('services.openai.key'))
->post('https://api.openai.com/v1/embeddings', [
'input' => $text,
'model' => 'text-embedding-3-small',
])
->json('data.0.embedding')
);
}
}
Indexación de artículos con un comando Artisan
<?php
namespace App\Console\Commands;
use App\Models\Article;
use App\Services\SemanticSearchService;
use Illuminate\Console\Command;
class IndexArticleEmbeddings extends Command
{
protected $signature = 'articles:index-embeddings {--chunk=100}';
public function handle(SemanticSearchService $service): void
{
Article::whereNull('embedding')
->chunkById((int) $this->option('chunk'), function ($articles) use ($service) {
foreach ($articles as $article) {
$embedding = $service->getEmbeddingPublic(
$article->title . ' ' . $article->content
);
$vectorStr = '[' . implode(',', $embedding) . ']';
$article->updateQuietly(['embedding' => $vectorStr]);
$this->info("Indexed: {$article->id}");
// Respetamos el rate limit de la API
usleep(100_000); // 100ms
}
});
}
}
Conclusión
pgvector en 2026 es una solución madura y lista para producción que habilita la búsqueda semántica directamente dentro de PostgreSQL. Obtienes transacciones ACID, JOINs con datos relacionales, SQL familiar y cero infraestructura adicional.
Recomendaciones clave:
- Usa el índice HNSW por defecto: es más rápido que IVFFLAT con una precisión comparable.
- Para textos, aplica la distancia coseno (
<=>). - Combina la búsqueda vectorial con
tsvectormediante RRF — la búsqueda híbrida en PostgreSQL supera ampliamente a cada método por separado. - Cachea los embeddings de las consultas en Redis — reducirás el coste de las llamadas a la API entre 2 y 5 veces.
- Particiona las tablas cuando superes los 5M de documentos.
- Migra a bases de datos vectoriales especializadas solo cuando lo exija una necesidad real y medida.
pgvector + PostgreSQL es pragmatismo inteligente: resuelves el problema real de la búsqueda semántica con la mínima complejidad arquitectónica.
Tecnologías
Etiquetas
Ruslan Ismailov
Desarrollador Senior Web / Backend. Desarrollador senior web/backend con 9 años de experiencia. Stack: PHP, Laravel, PostgreSQL, Redis, Docker, Kubernetes, REST, microservicios, CI/CD. Más sobre mí →