Postgres full-text search: substitua o Elasticsearch em casos simples
1. Por que considerar Postgres FTS em vez do Elasticsearch?
Muitos projetos iniciam com Elasticsearch por medo de que o PostgreSQL não seja capaz de realizar buscas textuais eficientes. Na prática, para cenários de médio porte — blogs, sistemas internos, catálogos com até centenas de milhares de registros — o Elasticsearch é superdimensionado. Manter um cluster Elasticsearch dedicado significa custo operacional com servidores separados, configuração de rede, balanceamento e sincronização contínua entre bancos. Com o PostgreSQL, você elimina toda essa complexidade: dados e motor de busca coexistem no mesmo banco, sem necessidade de pipelines de replicação. A manutenção se reduz a ajustar índices e consultas, tudo dentro do SQL que sua equipe já conhece.
2. Fundamentos do full-text search no PostgreSQL
O PostgreSQL implementa busca textual através de dois tipos de dados: tsvector (representação tokenizada do texto) e tsquery (consulta estruturada). O processo de tokenização quebra o texto em lexemas, remove stop words e aplica stemming.
-- Criando um tsvector manualmente
SELECT to_tsvector('portuguese', 'Gatos e cachorros são animais domésticos');
-- Resultado: 'animal':5 'cachorr':3 'doméstic':6 'gato':1
Para consultar, usamos o operador @@:
SELECT * FROM artigos WHERE to_tsvector('portuguese', titulo || ' ' || corpo) @@ to_tsquery('portuguese', 'gato & cachorro');
Funções auxiliares como plainto_tsquery (converte texto livre em query) e phraseto_tsquery (busca por frase exata) simplificam consultas vindas de formulários de busca.
SELECT plainto_tsquery('portuguese', 'gato cachorro');
-- Resultado: 'gato' & 'cachorr'
Para relevância, temos ts_rank e ts_rank_cd:
SELECT titulo, ts_rank(to_tsvector('portuguese', titulo || ' ' || corpo), plainto_tsquery('portuguese', 'gato')) AS relevancia
FROM artigos
WHERE to_tsvector('portuguese', titulo || ' ' || corpo) @@ plainto_tsquery('portuguese', 'gato')
ORDER BY relevancia DESC;
3. Criando índices e colunas preparadas para busca
Para performance, o ideal é armazenar o tsvector em uma coluna gerada e criar um índice GIN:
ALTER TABLE artigos ADD COLUMN busca tsvector
GENERATED ALWAYS AS (to_tsvector('portuguese', COALESCE(titulo, '') || ' ' || COALESCE(corpo, ''))) STORED;
CREATE INDEX idx_artigos_busca ON artigos USING GIN(busca);
A configuração de dicionário define como as palavras são normalizadas. O 'portuguese' utiliza o dicionário de stemming do português brasileiro, removendo automaticamente stop words como "de", "para", "com".
-- Verificando a configuração ativa
SELECT cfgname FROM pg_ts_config;
4. Consultas avançadas com Postgres FTS
Busca por prefixo usando :*:
SELECT * FROM artigos WHERE busca @@ to_tsquery('portuguese', 'gato:*');
-- Encontra: gato, gatos, gatilho (se relevante)
Operador de proximidade <-> (distância entre termos):
SELECT * FROM artigos WHERE busca @@ phraseto_tsquery('portuguese', 'gato cachorro');
-- Palavras aparecem na ordem exata
SELECT * FROM artigos WHERE busca @@ to_tsquery('portuguese', 'gato <2> cachorro');
-- Até 2 palavras de distância entre os termos
Combinação com filtros SQL tradicionais:
SELECT a.id, a.titulo, ts_rank(a.busca, query) AS relevancia
FROM artigos a, to_tsquery('portuguese', 'gato & cachorro') AS query
WHERE a.busca @@ query
AND a.publicado BETWEEN '2024-01-01' AND '2024-12-31'
AND a.categoria_id = 5
ORDER BY relevancia DESC
LIMIT 20;
5. Estratégias de ranking e relevância
Para priorizar determinados campos, use setweight:
ALTER TABLE artigos ADD COLUMN busca_pesada tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('portuguese', COALESCE(titulo, '')), 'A') ||
setweight(to_tsvector('portuguese', COALESCE(corpo, '')), 'B')
) STORED;
CREATE INDEX idx_artigos_busca_pesada ON artigos USING GIN(busca_pesada);
Ordenação híbrida combinando relevância textual com data:
SELECT titulo, ts_rank(busca_pesada, query) * 0.7 + (EXTRACT(EPOCH FROM publicado) / 1000000) * 0.3 AS score
FROM artigos, to_tsquery('portuguese', 'gato') AS query
WHERE busca_pesada @@ query
ORDER BY score DESC;
Destaque de termos com ts_headline:
SELECT titulo, ts_headline('portuguese', corpo, query, 'MaxWords=50, MinWords=20')
FROM artigos, to_tsquery('portuguese', 'gato') AS query
WHERE busca @@ query;
6. Limitações e quando NÃO substituir o Elasticsearch
O PostgreSQL FTS tem limites claros. Acima de 10-50 milhões de documentos, o desempenho começa a degradar comparado ao Elasticsearch, especialmente em consultas com muitos termos. A busca fuzzy (tolerância a erros de digitação) não é nativa — você precisaria da extensão pg_trgm para aproximações. Sinônimos e stemming avançado exigem dicionários customizados complexos. Para análises agregadas em tempo real (facets com contagens rápidas, agregações multi-nível), o Elasticsearch ainda é superior. Se seu projeto requer busca com erros ortográficos, sugestões de autocomplete ou análise de logs em escala, mantenha o Elasticsearch.
7. Casos práticos de substituição bem-sucedida
Blog/CMS: busca em artigos com filtros por data, autor e categoria. Com 500 mil artigos, consultas retornam em milissegundos usando índice GIN.
-- Exemplo completo de busca em blog
SELECT titulo, autor, publicado,
ts_headline('portuguese', corpo, query, 'StartSel=<mark>, StopSel=</mark>') AS trecho
FROM artigos, plainto_tsquery('portuguese', 'receita bolo cenoura') AS query
WHERE busca @@ query AND publicado > NOW() - INTERVAL '1 year'
ORDER BY ts_rank(busca, query) DESC
LIMIT 10;
Catálogo de e-commerce: busca por produtos com pesos diferentes para nome, descrição e tags.
SELECT nome, preco, ts_rank(busca_pesada, query) AS relevancia
FROM produtos, to_tsquery('portuguese', 'smartphone & 5g') AS query
WHERE busca_pesada @@ query AND ativo = true
ORDER BY relevancia DESC;
SaaS interno: busca em tickets de suporte com filtros por status e prioridade.
8. Boas práticas e otimizações finais
Monitore o tamanho dos índices GIN e execute REINDEX periodicamente em tabelas com muitas atualizações:
REINDEX INDEX idx_artigos_busca;
Para busca aproximada (similaridade), instale a extensão pg_trgm:
CREATE EXTENSION IF NOT EXISTS pg_trgm;
CREATE INDEX idx_artigos_titulo_trgm ON artigos USING GIN (titulo gin_trgm_ops);
SELECT * FROM artigos WHERE titulo % 'gato'; -- similaridade > 0.3
Para tabelas muito grandes, considere particionamento por data e índices parciais:
CREATE INDEX idx_artigos_busca_2024 ON artigos USING GIN(busca) WHERE publicado >= '2024-01-01' AND publicado < '2025-01-01';
Cacheie consultas frequentes com views materializadas ou Redis para aliviar a carga do banco.
Referências
- Documentação oficial do PostgreSQL: Full Text Search — Referência completa sobre funções, operadores e configurações de busca textual no PostgreSQL.
- PostgreSQL Full-Text Search: A Comprehensive Guide (freeCodeCamp) — Tutorial prático cobrindo desde conceitos básicos até consultas avançadas com exemplos reais.
- Using Full-Text Search in PostgreSQL (DigitalOcean) — Guia passo a passo para implementar busca textual, incluindo configuração de dicionários e índices.
- PostgreSQL Full-Text Search vs Elasticsearch (Severalnines) — Comparação técnica detalhada entre as duas soluções, com benchmarks e cenários recomendados.
- PostgreSQL FTS: tsvector, tsquery e Índices GIN (Dev.to) — Artigo em português com exemplos práticos de implementação e otimização.