6

Busca Full-Text com PostgreSQL e tsvector

A maioria dos projetos que precisa de busca textual começa com LIKE '%termo%'. Funciona por duas semanas. Depois alguém pede busca por sinônimos, ranking por relevância, busca ignorando acentos, e o LIKE vira um gargalo que faz full table scan em toda query.

A reação comum é subir um Elasticsearch. Mas se o seu dataset tem menos de 10 milhões de registros e você não precisa de busca facetada complexa, o PostgreSQL resolve com tsvector e tsquery sem adicionar infraestrutura extra. Você mantém busca e dados no mesmo lugar, com transações ACID, sem sincronização entre serviços.

O que são tsvector e tsquery

O PostgreSQL tem um motor de busca textual embutido desde a versão 8.3. Dois tipos de dados sustentam esse motor:

  • tsvector: representação normalizada de um documento. Cada palavra vira um lexema (forma raiz), com posição no texto original.
  • tsquery: representação de uma consulta de busca, com operadores lógicos (&, |, !) e busca por prefixo (:*).

A normalização transforma "implementações rápidas" em lexemas como implement e rapid, removendo stopwords e aplicando stemming de acordo com o dicionário configurado.

-- Visualizando como o PostgreSQL normaliza texto em português
SELECT to_tsvector('portuguese', 'As implementações rápidas nem sempre funcionam bem');
-- Resultado: 'bem':6 'funcion':5 'implement':2 'rápid':3
-- "As", "nem", "sempre" foram removidas (stopwords)
-- "implementações" virou "implement" (stemming)
-- tsquery converte a busca do usuário no mesmo formato normalizado
SELECT to_tsquery('portuguese', 'implementação & rápida');
-- Resultado: 'implement' & 'rápid'

O operador @@ faz o match entre os dois:

SELECT to_tsvector('portuguese', 'As implementações rápidas nem sempre funcionam bem')
       @@ to_tsquery('portuguese', 'implementação & rápida');
-- Resultado: true

Modelando a coluna de busca

Existem duas abordagens para armazenar o tsvector: coluna computada em tempo de query ou coluna materializada com trigger. A diferença é direta.

AbordagemPrósContrasQuando usar
to_tsvector() na queryZero manutenção, sem triggerRecalcula em toda busca, não indexável de forma eficienteTabelas com menos de 50k linhas
Coluna tsvector + triggerIndexável com GIN, busca rápidaOcupa espaço extra, precisa de trigger para manter sincronizadaTabelas com mais de 50k linhas ou busca frequente
Coluna gerada (GENERATED ALWAYS)Sem trigger, sincronizada automaticamenteDisponível só no PostgreSQL 12+, não aceita COALESCE entre colunas de tabelas diferentesColuna derivada de campos da mesma tabela

Para a maioria dos casos reais, a coluna materializada com trigger é a escolha certa. A coluna gerada funciona bem quando o vetor depende de campos da mesma linha.

-- Criando a tabela com coluna de busca materializada
CREATE TABLE articles (
    id SERIAL PRIMARY KEY,
    title TEXT NOT NULL,
    body TEXT NOT NULL,
    author_name TEXT NOT NULL,
    search_vector tsvector,
    created_at TIMESTAMPTZ DEFAULT now()
);

-- Índice GIN: estrutura invertida que mapeia cada lexema às linhas que o contêm
-- GIN é melhor que GiST para busca textual porque tem precisão exata (sem falsos positivos)
CREATE INDEX idx_articles_search ON articles USING GIN (search_vector);

Trigger para manter o vetor sincronizado

O PostgreSQL oferece a função tsvector_update_trigger, mas ela não permite pesos diferentes por campo. Para dar mais relevância ao título do que ao corpo, use um trigger customizado:

-- Função que combina campos com pesos diferentes
-- 'A' = maior relevância (título), 'B' = média (autor), 'C' = menor (corpo)
CREATE OR REPLACE FUNCTION articles_search_vector_update() RETURNS trigger AS $$
BEGIN
    NEW.search_vector :=
        setweight(to_tsvector('portuguese', COALESCE(NEW.title, '')), 'A') ||
        setweight(to_tsvector('portuguese', COALESCE(NEW.author_name, '')),

---

Leia o artigo completo em [https://www.vivodecodigo.com.br/backend/busca-full-text-postgresql-tsvector-tsquery-implementacao](https://www.vivodecodigo.com.br/backend/busca-full-text-postgresql-tsvector-tsquery-implementacao)
Carregando publicação patrocinada...
1