Búsqueda de texto completo en PostgreSQL: tsvector y GIN con SQL
Sí: PostgreSQL trae búsqueda de texto completo de serie y cubre la mayoría de buscadores de un SaaS pequeño o mediano. Con una columna tsvector generada, un índice GIN, websearch_to_tsquery y ts_rank tienes búsqueda por raíces, ranking y fragmentos resaltados sin servicio extra. Solo compensa un buscador externo cuando necesitas tolerancia a erratas avanzada, facetas complejas o mucho volumen.
Por qué el LIKE '%texto%' de PostgreSQL no es un buscador
Un WHERE titulo ILIKE '%factura%' obliga a PostgreSQL a recorrer toda la tabla, porque un índice B-tree normal no sirve con comodines al principio. Además, no entiende el idioma: buscar "facturas" no encuentra "facturación", y no hay forma de ordenar por relevancia. Funciona con mil filas y se vuelve un problema cuando la tabla crece.
La búsqueda de texto completo resuelve las tres cosas: normaliza palabras a su raíz, usa un índice pensado para ello y te da una puntuación para ordenar.
Cómo funcionan tsvector y tsquery en PostgreSQL
tsvector es el documento ya procesado: una lista de lexemas (raíces) con su posición. tsquery es la consulta procesada igual. El operador @@ comprueba si una casa con la otra.
SELECT to_tsvector('spanish', 'Las facturas se envían cada mes');
-- 'envi':4 'factur':2 'mes':6
SELECT to_tsvector('spanish', 'Las facturas se envían cada mes')
@@ websearch_to_tsquery('spanish', 'factura enviada');
-- true
Fíjate en dos detalles. Las palabras vacías ("las", "se", "cada") desaparecen, y "facturas" y "factura" acaban en la misma raíz. Esa es la magia del diccionario spanish.
Para la consulta del usuario, usa websearch_to_tsquery: acepta la sintaxis que la gente ya conoce (comillas para frases exactas, - para excluir, or) y no revienta con caracteres raros, a diferencia de to_tsquery, que exige operadores bien formados.
Columna generada e índice GIN para la búsqueda de texto completo
Calcular to_tsvector en cada consulta es tirar CPU. Lo correcto es guardar el vector en una columna generada y ponerle un índice GIN:
ALTER TABLE articulos
ADD COLUMN busqueda tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('spanish', coalesce(titulo, '')), 'A') ||
setweight(to_tsvector('spanish', coalesce(resumen, '')), 'B') ||
setweight(to_tsvector('spanish', coalesce(cuerpo, '')), 'C')
) STORED;
CREATE INDEX articulos_busqueda_idx
ON articulos USING GIN (busqueda);
La columna se mantiene sola en cada INSERT y UPDATE, sin triggers. El coalesce evita que un campo nulo anule el vector entero, un error clásico.
Si la tabla ya tiene muchas filas en producción, añadir una columna STORED reescribe la tabla y crear el índice bloquea escrituras. Usa CREATE INDEX CONCURRENTLY y planifica el cambio como cualquier migración delicada; tienes un repaso de esto en migraciones seguras en producción.
Ranking y fragmentos con ts_rank y ts_headline
setweight asigna pesos de A (más importante) a D. Así, una coincidencia en el título puntúa más que una en el cuerpo. La consulta completa queda así:
SELECT id, titulo,
ts_rank(busqueda, q) AS rank,
ts_headline('spanish', cuerpo, q,
'MaxWords=30, MinWords=10, StartSel=<mark>, StopSel=</mark>') AS fragmento
FROM articulos,
websearch_to_tsquery('spanish', $1) AS q
WHERE busqueda @@ q
ORDER BY rank DESC
LIMIT 20;
ts_headline genera el fragmento con los términos resaltados, como en cualquier buscador. Ojo: es caro porque reprocesa el texto original, así que aplícalo solo a las filas que vas a mostrar, nunca a todo el conjunto. Si te preocupa el rendimiento, envuelve la búsqueda en una subconsulta con el LIMIT y calcula el fragmento fuera.
Acentos y erratas en la búsqueda de texto de PostgreSQL
El diccionario spanish no elimina tildes: "cancion" y "canción" pueden dar raíces distintas. La solución es crear una configuración propia con la extensión unaccent:
CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE TEXT SEARCH CONFIGURATION es_sin_tildes (COPY = spanish);
ALTER TEXT SEARCH CONFIGURATION es_sin_tildes
ALTER MAPPING FOR hword, hword_part, word
WITH unaccent, spanish_stem;
Después sustituye 'spanish' por 'es_sin_tildes' tanto en la columna generada como en la consulta. Si usas una configuración en un sitio y otra en el otro, los resultados fallarán de forma silenciosa.
Para las erratas ("facutra" en lugar de "factura") la búsqueda de texto completo no ayuda, porque trabaja con raíces exactas. Ahí entra pg_trgm, que compara trigramas:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX articulos_titulo_trgm_idx
ON articulos USING GIN (titulo gin_trgm_ops);
SELECT titulo FROM articulos
WHERE titulo % 'facutra'
ORDER BY similarity(titulo, 'facutra') DESC
LIMIT 5;
Un patrón razonable: lanza primero la búsqueda de texto completo y, si devuelve cero resultados, ofrece un "¿quisiste decir…?" con pg_trgm sobre títulos o etiquetas. El índice trigram también acelera los ILIKE '%texto%' que no puedas eliminar, útil para autocompletar.
PostgreSQL frente a un buscador externo: comparativa
Antes de añadir otro servicio a tu infraestructura, compara qué ganas y qué pagas:
| Criterio | PostgreSQL FTS + pg_trgm | Buscador externo dedicado |
|---|---|---|
| Infraestructura extra | Ninguna, misma base de datos | Otro servicio que desplegar y vigilar |
| Sincronización de datos | Automática con columna generada | Necesitas colas, webhooks o reindexado |
| Consistencia | Transaccional, resultados siempre al día | Eventual, hay retraso tras escribir |
| Tolerancia a erratas | Básica con trigramas | Integrada y configurable |
| Facetas y filtros | SQL normal, flexible pero manual | Nativas y rápidas |
| Relevancia avanzada | ts_rank y pesos, limitada | Ajustes finos, sinónimos, boosting |
La fila que más se subestima es la de sincronización. Un buscador externo implica mantener dos fuentes de verdad, gestionar borrados, reindexar tras cambios de esquema y depurar por qué un registro no aparece. Con PostgreSQL, si la fila está en la tabla, está en el índice.
Si lo que buscas es entender intención y no solo palabras, el siguiente paso tampoco tiene por qué ser un servicio aparte: con pgvector puedes combinar texto completo y búsqueda semántica con embeddings en la misma base de datos.
5 señales de que la búsqueda en PostgreSQL ya no te basta
- Latencia sostenida alta: las consultas con
@@siguen lentas tras revisar elEXPLAIN ANALYZE, el índice GIN se usa y aun así no llegas. - Facetas complejas: necesitas contadores por categoría, precio y marca en cada búsqueda, y las agregaciones en SQL se disparan.
- Erratas como norma: tus usuarios escriben mal casi siempre (búsqueda de productos, nombres propios) y pg_trgm se queda corto.
- Relevancia de negocio: quieres sinónimos gestionados por no técnicos, promocionar resultados o personalizar el orden por usuario.
- La búsqueda compite con las escrituras: el buscador consume tanta CPU que afecta a las transacciones críticas. Antes de migrar, prueba una réplica de lectura dedicada.
Si no te reconoces en ninguna, quédate con PostgreSQL. Imagina un SaaS de documentación interna con unas decenas de miles de artículos: una columna generada, un índice GIN y un LIMIT sensato le dan respuestas rápidas sin añadir piezas. Y si alguna consulta concreta se repite mucho, cachear sus resultados suele ser más barato que cambiar de motor; tienes ideas en caching inteligente para tu SaaS.
Empieza simple, mide con datos reales y deja que sean tus usuarios, no el miedo, quienes te digan cuándo toca un buscador externo.
Preguntas frecuentes
¿La búsqueda de texto completo de PostgreSQL funciona en varios idiomas a la vez?
La búsqueda de texto completo de PostgreSQL admite varios idiomas si guardas la configuración por fila, por ejemplo en una columna idioma de tipo regconfig, y la usas en to_tsvector y en la consulta. También puedes crear una columna tsvector por idioma, aunque ocupa más espacio.
¿Qué diferencia hay entre un índice GIN y un índice GiST para tsvector?
Un índice GIN para tsvector es más rápido en lecturas y más preciso, pero más lento de actualizar y más grande. GiST ocupa menos y se actualiza antes, aunque puede dar falsos positivos que PostgreSQL debe recomprobar. Para buscadores con más lecturas que escrituras, GIN suele ser la elección.
¿Cuántas filas aguanta la búsqueda full-text de PostgreSQL?
La búsqueda full-text de PostgreSQL no tiene un límite fijo de filas: depende del tamaño de los documentos, del hardware y de cómo ordenes. Filtrar con el índice GIN escala bien; ordenar por ts_rank muchos resultados es lo que se encarece. Mide con tus datos usando EXPLAIN ANALYZE.
¿Se puede hacer búsqueda por prefijo para autocompletar en PostgreSQL?
La búsqueda por prefijo en PostgreSQL se hace con to_tsquery añadiendo :* al término, por ejemplo 'factu:*', y aprovecha el índice GIN del tsvector. Para autocompletar títulos cortos con comodines o erratas, un índice trigram de pg_trgm suele dar resultados más naturales.