Um SaaS com alguns milhões de registros ligou a busca textual em cima do Postgres e ela funcionou por dois anos. Agora a consulta que respondia em 40 ms leva 900 ms quando o filtro por tenant entra junto, o time de produto pediu filtros facetados na lateral e o suporte reclama que buscar “nota fical” não encontra “nota fiscal”. A pergunta que chega até a arquitetura é sempre a mesma: sobe um Elasticsearch ou dá para espremer mais o banco?
A resposta não é a mesma para os dois casos que costumam ser confundidos. Buscar texto e construir um motor de relevância são problemas diferentes, e o Postgres resolve o primeiro muito melhor do que a maioria dos times imagina.
O que o Postgres entrega de fábrica
O full-text search nativo do Postgres é maduro e cobre bem mais do que LIKE '%termo%'. Ele converte texto em tsvector — a lista de lexemas normalizados com posição — e compara contra um tsquery. Com stemming em português, remoção de stopwords e um índice GIN por cima, uma busca em uma tabela de poucos milhões de linhas responde em dezenas de milissegundos.
A forma correta de montar isso em um schema novo é com coluna gerada, não com trigger:
ALTER TABLE documentos
ADD COLUMN busca tsvector
GENERATED ALWAYS AS (
setweight(to_tsvector('portuguese', coalesce(titulo, '')), 'A') ||
setweight(to_tsvector('portuguese', coalesce(corpo, '')), 'B')
) STORED;
CREATE INDEX documentos_busca_idx ON documentos USING GIN (busca);
O setweight é o detalhe que separa uma busca aceitável de uma ruim: ele permite que uma ocorrência no título pese mais do que no corpo quando o ts_rank ordena. Do lado da consulta, websearch_to_tsquery aceita a sintaxe que o usuário já conhece de buscador — aspas para expressão exata, - para exclusão, or — sem que a aplicação precise fazer parsing próprio.
Para tolerância a erro de digitação, a extensão pg_trgm resolve o caso de “nota fical” com similaridade por trigramas, e unaccent resolve o de acentuação inconsistente. As duas cobrem a maior parte das reclamações reais de usuário sem nenhuma infraestrutura nova.
EXPLAIN (ANALYZE, BUFFERS) na consulta lenta com o filtro de tenant incluído. Na maioria dos casos o gargalo não é o GIN — é o planner escolhendo varrer a tabela porque a seletividade do filtro combinado está mal estimada, o que se corrige com índice composto ou CREATE STATISTICS.Onde o full-text nativo começa a doer
Os limites aparecem em quatro frentes, e vale identificar qual delas está travando antes de trocar de ferramenta.
Relevância. O ts_rank pontua por frequência e posição do termo, sem normalizar por tamanho de documento da forma que o BM25 faz. Em corpus com documentos de tamanhos muito diferentes, textos longos sobem indevidamente. Dá para compensar com ts_rank usando os parâmetros de normalização, mas é ajuste manual e frágil.
Escrita. Índice GIN tem custo de atualização alto. Em tabela com escrita intensa, a fila de pendências do GIN cresce e as consultas passam a pagar por ela. O parâmetro fastupdate e o dimensionamento de gin_pending_list_limit ajudam, mas em cargas de escrita constante o índice vira o ponto quente da tabela.
Facetas. Contar quantos resultados existem por categoria, por autor e por período, sobre o mesmo conjunto filtrado, é agregação sobre o resultado inteiro — não sobre a página. No Postgres isso significa varrer o conjunto completo a cada consulta, e o custo cresce linearmente com o número de acertos. É aqui que a maioria dos times bate no muro.
Highlight. O ts_headline não usa o índice: ele reprocessa o texto original de cada linha retornada. Em página de 20 resultados com documentos grandes, ele sozinho pode dominar o tempo da consulta.
Há um quinto ponto que não é limitação do full-text, mas é onde o problema aparece primeiro em SaaS: a combinação de índice GIN com filtro por tenant. São dois índices independentes, e o planner precisa decidir se parte da busca textual e filtra depois, ou se parte do tenant e varre. Quando o tenant é grande, a primeira estratégia ganha; quando é pequeno, a segunda. Estimativa errada nesse ponto explica a maior parte dos casos de “a busca ficou lenta de repente para um cliente só”.
O que o Elasticsearch resolve e o que ele cobra
O Elasticsearch — e o OpenSearch, que para efeito desta decisão se comporta igual — entrega BM25 por padrão, agregações que foram desenhadas justamente para facetas, highlight que usa a estrutura invertida em vez de reprocessar o documento, e uma cadeia de analisadores em que sinônimos, tolerância a erro e normalização são configuração, não gambiarra de query.
A conta que raramente entra na proposta é a do outro lado. Um cluster separado significa JVM para dimensionar, shards para planejar, snapshot para operar e uma segunda fonte de verdade para manter sincronizada. Essa sincronização é o custo real: dual-write na aplicação cria janela de inconsistência a cada falha parcial, e captura de mudanças via replicação lógica exige um consumidor a mais em produção. Além disso, mudança de mapping em campo já indexado costuma exigir reindexação completa com alias — procedimento que precisa estar ensaiado antes de ser necessário.
Há ainda a consistência. O índice fica visível para busca depois do refresh, que por padrão é próximo de um segundo. Fluxo em que o usuário salva um registro e imediatamente busca por ele volta vazio, e a correção — forçar refresh na escrita — anula boa parte do ganho de throughput.
O critério de escolha
| Fator | Postgres full-text | Elasticsearch / OpenSearch |
|---|---|---|
| Relevância | ts_rank com pesos manuais | BM25 por padrão, ajustável por campo |
| Facetas sobre resultado grande | Agregação linear no conjunto | Agregações nativas, custo previsível |
| Sinônimos e erro de digitação | pg_trgm e unaccent, combinam mal com ranking | Cadeia de analisadores, configuração declarativa |
| Consistência com o dado transacional | Imediata, mesma transação | Eventual, janela de refresh |
| Custo operacional | Zero adicional | Cluster, sincronização e plano de reindexação |
| Isolamento por tenant | Filtro na cláusula WHERE | Filtro, roteamento ou índice por tenant |
O critério que funciona na prática é de contagem, não de tamanho de corpus: o Elasticsearch se paga quando pelo menos dois dos três requisitos abaixo são de produto, e não de preferência técnica — facetas contadas sobre o resultado inteiro, relevância como diferencial competitivo com ajuste contínuo, e busca sobre volume que já não cabe confortavelmente em cache no nó do banco.
Com um só desses requisitos, o Postgres ainda ganha. Um SaaS B2B típico, com milhões de registros mas centenas de resultados por tenant depois do filtro, quase nunca chega aos dois. Nesse caso o esforço rende muito mais aplicado a índice composto, particionamento por tenant e limite de resultado do que a um cluster novo.
A exceção clara é busca como produto — catálogo público, marketplace, base de conhecimento com tráfego de busca orgânica. Aí a relevância é o produto, o ajuste é semanal e o Postgres vira gargalo de roadmap antes de virar gargalo de performance.
Se a migração for o caminho, o que fazer primeiro
O erro comum é começar pela infraestrutura. A ordem que reduz risco é outra: primeiro instrumentar a busca atual — distribuição de latência por percentil, taxa de consulta sem resultado, taxa de clique no primeiro resultado. Sem esses três números não existe forma de saber se o cluster novo melhorou alguma coisa.
Depois, indexar em paralelo e servir do Postgres. A aplicação escreve nos dois, consulta apenas o banco, e uma tarefa de comparação registra divergência de resultado entre as duas fontes. Semanas nesse modo revelam os problemas de mapping e de sincronização enquanto ninguém depende do índice novo.
Só então trocar a leitura, e de forma progressiva por tenant, mantendo o caminho antigo alcançável por configuração. Rollback de busca precisa ser mudança de flag, não deploy.
Duas decisões de modelagem valem ser tomadas antes da primeira indexação, porque mudá-las depois custa reindexação: o que vai no índice e o que fica só no banco, e como o tenant é isolado. Indexar o documento inteiro parece conveniente, mas infla o cluster com campo que ninguém consulta — o índice deve carregar o que participa da busca, do filtro ou da exibição do resultado, e o identificador para buscar o resto no banco. Quanto ao tenant, índice por cliente só se sustenta enquanto o número de clientes for baixo; acima disso o custo de shard por índice inviabiliza, e o caminho é índice compartilhado com roteamento pelo identificador do tenant.
Deixe um comentário