Búsqueda de texto completo en Postgres, sin Elasticsearch
Postgres trae un motor de búsqueda de texto completo potente con tsvector, tsquery e índices GIN: para la mayoría de aplicaciones sobra Elasticsearch.

La mayoría de los equipos recurre a Elasticsearch en cuanto un product manager menciona la palabra "búsqueda". Normalmente es una decisión equivocada. Elasticsearch resulta costoso de operar, añade complejidad al despliegue y obliga a mantener los datos sincronizados con tu almacén principal. Postgres cuenta con un motor de búsqueda de texto completo capaz desde hace más de una década, y para la mayoría de las aplicaciones resulta más que suficiente.
Cómo funciona la búsqueda de texto completo en Postgres
Los elementos fundamentales son tsvector y tsquery. Un tsvector es una lista ordenada de lexemas normalizados (tokens reducidos a su raíz) extraídos de un texto. Un tsquery es una expresión de búsqueda: términos combinados con operadores booleanos y pesos opcionales.
-- Convert text to a searchable vector
SELECT to_tsvector('english', 'The quick brown fox jumps over the lazy dog');
-- 'brown':3 'dog':9 'fox':4 'jump':5 'lazi':8 'quick':2
-- Build a query
SELECT to_tsquery('english', 'quick & fox');
-- 'quick' & 'fox'
-- Match them with the @@ operator
SELECT to_tsvector('english', 'The quick brown fox') @@ to_tsquery('english', 'quick & fox');
-- truePostgres reduce las palabras a su raíz automáticamente según el diccionario del idioma configurado: jumps se convierte en jump, lazy se convierte en lazi. Buscar "jumping" encuentra documentos que contienen "jumps" o "jumped" sin ningún trabajo adicional de tu parte.
Cómo almacenar vectores con columnas generadas
Calcular tsvector al vuelo en cada consulta anula la ventaja: no se puede indexar una expresión que cambia por fila en el momento de la consulta sin materializarla antes. Las columnas generadas hacen que esto sea automático y siempre consistente.
-- ❌ Inline computation — full table scan, no index benefit
SELECT title, body
FROM articles
WHERE to_tsvector('english', title || ' ' || body) @@ to_tsquery('english', $1);
-- ✅ Persisted tsvector column — updated automatically, fully indexable
ALTER TABLE articles
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('english', coalesce(title, '')), 'A') ||
setweight(to_tsvector('english', coalesce(body, '')), 'B')
) STORED;setweight asigna niveles de importancia (de la A a la D) a distintos campos. Las coincidencias en el título puntúan más alto que las del cuerpo, que es casi siempre lo que quieres. El coalesce protege frente a columnas NULL; to_tsvector devuelve NULL si su entrada es NULL, lo que rompe las coincidencias de forma silenciosa.
Indexación con GIN
Un índice GIN (Generalized Inverted Index) sobre la columna del vector almacenado convierte la búsqueda de texto completo, que sería un recorrido secuencial, en una consulta indexada.
CREATE INDEX articles_search_idx ON articles USING GIN (search_vector);Eso es todo lo que hay que saber sobre la indexación. Con un índice GIN, Postgres resuelve un tsquery contra millones de filas en milisegundos. Para la mayoría de las aplicaciones SaaS que gestionan menos de unos pocos millones de documentos, la latencia de consulta se mantiene cómodamente por debajo de los 10 ms.
Los índices GIN son rápidos de leer pero más lentos de mantener que los
índices B-tree. En tablas con muchas escrituras, considera usar CREATE INDEX ... WITH (fastupdate = off) para evitar que las inserciones pendientes en el
búfer provoquen resultados desactualizados bajo carga de lectura concurrente.
Cómo puntuar y resaltar los resultados
La función ts_rank puntúa cada documento coincidente según la frecuencia de los términos y los pesos de los campos. ts_headline genera un extracto resaltado: localiza el fragmento más relevante y envuelve los términos coincidentes con los delimitadores que elijas.
import postgres from "postgres";
const sql = postgres(process.env.DATABASE_URL!);
interface SearchResult {
id: string;
title: string;
excerpt: string;
rank: number;
}
async function searchArticles(
query: string,
limit = 20,
offset = 0,
): Promise<SearchResult[]> {
// plainto_tsquery handles arbitrary user input without throwing on special chars.
// to_tsquery requires valid tsquery syntax — never pass raw user input to it.
return sql<SearchResult[]>`
SELECT
id,
title,
ts_headline(
'english',
body,
plainto_tsquery('english', ${query}),
'MaxWords=35, MinWords=15, StartSel=<mark>, StopSel=</mark>'
) AS excerpt,
ts_rank(search_vector, plainto_tsquery('english', ${query})) AS rank
FROM articles
WHERE search_vector @@ plainto_tsquery('english', ${query})
ORDER BY rank DESC
LIMIT ${limit}
OFFSET ${offset}
`;
}Observa el uso de plainto_tsquery en lugar de to_tsquery para la entrada proporcionada por el usuario. to_tsquery lanza un error de sintaxis si la entrada contiene operadores desbalanceados o caracteres no admitidos, una forma segura de provocar errores 500 en producción. plainto_tsquery trata su entrada como texto plano y la convierte de forma segura en una consulta unida con AND.
Autocompletado con búsqueda por prefijo y trigramas
La búsqueda de texto completo requiere palabras completas. Para el autocompletado, es decir, hacer coincidir una entrada parcial como "doc" → "docker", usa el operador de prefijo :* o un índice de trigramas independiente con pg_trgm.
-- Prefix matching with tsquery: 'dock:*' matches "docker", "docking", etc.
SELECT title FROM articles
WHERE search_vector @@ to_tsquery('english', 'dock:*')
LIMIT 10;
-- Fuzzy matching for typos (requires pg_trgm extension)
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX articles_title_trgm_idx ON articles USING GIN (title gin_trgm_ops);
-- % is the similarity threshold operator (default threshold: 0.3)
SELECT title, similarity(title, 'documetnation') AS sim
FROM articles
WHERE title % 'documetnation'
ORDER BY sim DESC
LIMIT 10;Usa la similitud de trigramas de pg_trgm para una búsqueda tolerante a errores tipográficos y el operador de prefijo :* para los desplegables de autocompletado. Ambos enfoques cubren distintos tipos de consulta y se combinan bien: texto completo para la búsqueda en el cuerpo, trigramas para completar títulos.
Cuándo Postgres no es suficiente
La búsqueda de texto completo de Postgres tiene límites reales. Conócelos antes de comprometerte con ella.
| Requisito | Postgres FTS | Elasticsearch |
|---|---|---|
| Búsqueda booleana, de frases y de proximidad | ✓ | ✓ |
| Lematización multilingüe | ✓ | ✓ |
| Búsqueda por facetas / agregaciones | Limitada | ✓ |
| Ajuste de relevancia por campo | Limitada | ✓ |
| Menos de 100 ms con más de 100M de documentos | Marginal | ✓ |
| Indexación en tiempo real con muchas escrituras | ✓ | Compleja |
| Sin sobrecarga operativa adicional | ✓ | ✗ |
Si tu búsqueda necesita facetas (filtrar por categoría, rango de precio y etiquetas al mismo tiempo), pipelines de puntuación de relevancia personalizados, o si realmente operas con cientos de millones de documentos, Elasticsearch o una alternativa gestionada justifica su costo. Para un catálogo de productos, una base de conocimiento, un blog o un almacén de documentos interno, Postgres se encarga de todo sin infraestructura adicional.
El punto de equilibrio es más alto de lo que la mayoría de los ingenieros asume. Operar un clúster dedicado de Elasticsearch significa otro servicio que monitorear, otro pipeline de despliegue, otro posible punto de fallo y una capa de sincronización de datos que tarde o temprano se desalineará. No asumas ese costo hasta que Postgres demuestre, de forma concreta, que ya no da abasto.
Puntos clave
- Almacena
tsvectorcomo columna generada: calcularlo al vuelo en cada consulta impide por completo el uso de índices - Usa
setweightpara la relevancia multicampo: las coincidencias en el título y los encabezados deben puntuar por encima de las del cuerpo - Los índices GIN son la clave: sin uno, cada búsqueda es un recorrido secuencial completo
- Usa
plainto_tsquerypara la entrada del usuario, nuncato_tsquery: maneja texto sin procesar de forma segura y sin errores de sintaxis - Añade
pg_trgmpara coincidencias difusas y por prefijo: la búsqueda de texto completo y los trigramas cubren distintos tipos de consulta y se combinan sin problemas - Recurre a Elasticsearch solo cuando necesites facetas a gran escala: el costo operativo es real; no lo asumas hasta que Postgres se quede demostrablemente corto


