Full-text search no PostgreSQL

Para a maioria das aplicações, o full-text search nativo do PostgreSQL é mais que suficiente, e elimina a necessidade de manter um cluster Elasticsearch separado. Com tsvector, tsquery e índices GIN, você implementa busca com relevância, stemming em português e highlight de resultados no banco que já usa.

Conceitos: tsvector e tsquery

Como o PostgreSQL processa texto para busca:

sql
-- tsvector: texto processado (palavras normalizadas + posições):
SELECT to_tsvector('portuguese', 'O servidor está executando com alta disponibilidade');
-- 'alt':3 'disponibilidad':5 'execut':3 'servidor':2

-- Configuração "portuguese" faz:
-- 1. Remove stop words (o, a, de, com...)
-- 2. Aplica stemming (executando → execut, alta → alt)
-- 3. Registra posição de cada palavra no texto

-- tsquery: padrão de busca:
SELECT to_tsquery('portuguese', 'servidor & disponibilidade');
-- 'servidor' & 'disponibilid'

-- Operadores de busca:
-- &  (AND): ambas as palavras
-- |  (OR): qualquer uma
-- !  (NOT): excluir palavra
-- <-> (FOLLOWED BY): palavra A seguida por B
-- <2> (DISTANCE): palavras com distância máxima

-- Verificar se documento corresponde à query:
SELECT to_tsvector('portuguese', 'alta disponibilidade do servidor')
  @@ to_tsquery('portuguese', 'servidor & disponibilidade');
-- t (true = encontrou)

-- plainto_tsquery: aceita texto livre (não precisa de & | !):
SELECT plainto_tsquery('portuguese', 'servidor alta disponibilidade');
-- 'servidor' & 'alt' & 'disponibilid'

-- websearch_to_tsquery: sintaxe do Google (mais amigável):
SELECT websearch_to_tsquery('portuguese', '"alta disponibilidade" OR backup');

Adicionar busca a uma tabela existente

Implementar busca de texto em artigos ou produtos:

sql
-- Adicionar coluna de busca a uma tabela de artigos:
ALTER TABLE artigos ADD COLUMN busca tsvector;

-- Preencher a coluna (pesos: A=maior, B, C, D=menor):
UPDATE artigos SET busca =
  setweight(to_tsvector('portuguese', coalesce(titulo, '')), 'A') ||
  setweight(to_tsvector('portuguese', coalesce(subtitulo, '')), 'B') ||
  setweight(to_tsvector('portuguese', coalesce(conteudo, '')), 'C');

-- Criar índice GIN para buscas rápidas:
CREATE INDEX CONCURRENTLY idx_artigos_busca ON artigos USING GIN(busca);

-- Manter a coluna atualizada automaticamente com trigger:
CREATE FUNCTION atualizar_busca_artigos() RETURNS trigger AS $
BEGIN
  NEW.busca =
    setweight(to_tsvector('portuguese', coalesce(NEW.titulo, '')), 'A') ||
    setweight(to_tsvector('portuguese', coalesce(NEW.subtitulo, '')), 'B') ||
    setweight(to_tsvector('portuguese', coalesce(NEW.conteudo, '')), 'C');
  RETURN NEW;
END;
$ LANGUAGE plpgsql;

CREATE TRIGGER trigger_busca_artigos
  BEFORE INSERT OR UPDATE ON artigos
  FOR EACH ROW EXECUTE FUNCTION atualizar_busca_artigos();

-- Buscar com ranking por relevância:
SELECT
  id, titulo,
  ts_rank(busca, query) AS relevancia,
  ts_headline('portuguese', conteudo, query,
    'MaxFragments=2, MaxWords=20, MinWords=10') AS trecho
FROM artigos, websearch_to_tsquery('portuguese', 'deploy docker') query
WHERE busca @@ query
ORDER BY relevancia DESC
LIMIT 10;

Busca sem coluna pré-computada

Full-text search em múltiplas colunas na hora da query:

sql
-- Busca ad-hoc sem coluna tsvector (mais flexível, mais lento):
SELECT id, nome, descricao,
  ts_rank(
    to_tsvector('portuguese', nome || ' ' || coalesce(descricao, '')),
    plainto_tsquery('portuguese', 'configuração nginx')
  ) AS relevancia
FROM produtos
WHERE to_tsvector('portuguese', nome || ' ' || coalesce(descricao, ''))
  @@ plainto_tsquery('portuguese', 'configuração nginx')
ORDER BY relevancia DESC;

-- Índice de expressão (sem coluna extra):
CREATE INDEX CONCURRENTLY idx_produtos_fts
  ON produtos USING GIN(
    to_tsvector('portuguese', nome || ' ' || coalesce(descricao, ''))
  );

-- API Node.js — busca full-text com parâmetro:
async function buscarProdutos(termo: string, limite = 10) {
  const result = await db.query(
    `SELECT id, nome, descricao,
       ts_rank(busca, websearch_to_tsquery('portuguese', $1)) AS relevancia,
       ts_headline('portuguese', descricao, websearch_to_tsquery('portuguese', $1),
         'MaxFragments=1, MaxWords=15') AS trecho
     FROM produtos
     WHERE busca @@ websearch_to_tsquery('portuguese', $1)
     ORDER BY relevancia DESC
     LIMIT $2`,
    [termo, limite]
  )
  return result.rows
}

Configuração de dicionário português

Melhorar a qualidade da busca em português com dicionário customizado:

sql
-- Verificar configurações de texto disponíveis:
SELECT cfgname FROM pg_ts_config;
-- portuguese já vem instalado na maioria das distros

-- Testar stemming em português:
SELECT ts_lexize('portuguese_stem', 'configurações');
-- {configur}  ← stem correto

SELECT ts_lexize('portuguese_stem', 'executando');
-- {execut}

-- Adicionar sinônimos customizados (ex: abreviaturas técnicas):
-- Criar arquivo /usr/share/postgresql/16/tsearch_data/meus_sinonimos.syn:
-- nginx nginx web-server webserver
-- k8s kubernetes

-- Criar dicionário de sinônimos:
CREATE TEXT SEARCH DICTIONARY sinonimos_tech (
  TEMPLATE = synonym,
  SYNONYMS = meus_sinonimos
);

-- Criar configuração customizada baseada no português:
CREATE TEXT SEARCH CONFIGURATION pt_tech (COPY = portuguese);
ALTER TEXT SEARCH CONFIGURATION pt_tech
  ALTER MAPPING FOR asciiword, word, numword
  WITH sinonimos_tech, portuguese_stem;

-- Usar a configuração customizada:
SELECT to_tsvector('pt_tech', 'configuração nginx k8s deploy');
-- 'configur':1 'deploy':4 'kubernetes':3 'nginx':2

Quando usar PostgreSQL FTS vs Elasticsearch

Critérios para escolher a solução certa:

sql
-- PostgreSQL FTS é suficiente quando:
-- ✓ Volume de documentos < 10 milhões
-- ✓ Buscas simples (palavras, frases, pesos por campo)
-- ✓ Você já usa PostgreSQL (zero infraestrutura extra)
-- ✓ Consistência transacional é importante (busca sempre reflete o banco)
-- ✓ Time pequeno sem dedicação para manter Elasticsearch

-- Elasticsearch faz mais sentido quando:
-- ✓ > 10-50 milhões de documentos com buscas frequentes
-- ✓ Análise de texto muito sofisticada (fuzzy search, sinônimos complexos)
-- ✓ Agregações de analytics em tempo real sobre logs/eventos
-- ✓ Buscas geoespaciais complexas
-- ✓ Você tem infraestrutura e equipe para manter

-- Benchmarks típicos para buscas simples:
-- PostgreSQL FTS com GIN: 5-50ms para 1M documentos
-- Elasticsearch: 1-10ms para qualquer volume, mas +8GB RAM

-- Opção intermediária: pg_trgm (trigrams) para busca fuzzy:
CREATE EXTENSION pg_trgm;
CREATE INDEX idx_produtos_trgm ON produtos USING GIN(nome gin_trgm_ops);

-- Busca fuzzy (tolera erros de digitação):
SELECT nome, similarity(nome, 'ngnix') AS sim
FROM produtos
WHERE nome % 'ngnix'   -- % = similaridade > pg_trgm.similarity_threshold
ORDER BY sim DESC;

Não quer configurar manualmente?

Implante o VPS para APIs em menos de 5 minutos com a Runstack. Infraestrutura da OPEN Datacenter, com servidores em São Paulo.

Perguntas frequentes

GIN (Generalized Inverted Index): mais rápido para leitura (buscas), constrói um índice invertido de termos. Mais lento para escrita (INSERT/UPDATE) e ocupa mais espaço. GIST: mais rápido para escrita, menor, mas buscas mais lentas. Para full-text search em produção: GIN é quase sempre a escolha certa, buscas são muito mais frequentes que escritas. GIST pode ser útil para tabelas com altíssimo volume de inserções.

ts_rank calcula relevância baseado na frequência dos termos e seus pesos (A/B/C/D). Para melhorar: (1) Atribua pesos corretos, título (A) deve ter peso maior que corpo (C); (2) ts_rank_cd usa cobertura do documento além da frequência, melhor para documentos de tamanhos variados; (3) Considere fatores de negócio (data de criação, avaliações) multiplicando o rank: ORDER BY ts_rank(busca, query) * popularidade DESC.

O dicionário "portuguese" normaliza acentos no stemming (configuração → configur). Para buscas onde o usuário digita sem acento: use unaccent extension (CREATE EXTENSION unaccent;) e configure o dicionário para usá-la. Índice: to_tsvector("portuguese", unaccent(nome)). Query: websearch_to_tsquery("portuguese", unaccent($1)). Isso permite que "configuracao" encontre "configuração".

Sim, extraia campos do JSON para tsvector: to_tsvector("portuguese", dados->>"titulo" || " " || dados->>"conteudo"). Para busca em todas as chaves do JSONB: jsonb_to_tsvector("portuguese", dados, "["string", "numeric"]") indexa automaticamente todos os valores string e numéricos do JSONB. Crie índice de expressão: CREATE INDEX ON tabela USING GIN(jsonb_to_tsvector("portuguese", dados, "["string"]")).

pg_trgm com GIN: CREATE INDEX ON produtos USING GIN(nome gin_trgm_ops); SELECT nome FROM produtos WHERE nome ILIKE $1||'%' ORDER BY similarity(nome, $1) DESC LIMIT 10;, funciona para prefixos e fuzzy. Para autocomplete mais rápido: use prefix matching com text_pattern_ops: CREATE INDEX ON produtos(nome text_pattern_ops); SELECT nome FROM produtos WHERE nome LIKE $1||'%' ORDER BY nome LIMIT 10;, O(log n) mas só para prefixos exatos.