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