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.
| Abordagem | Prós | Contras | Quando usar |
|---|---|---|---|
to_tsvector() na query | Zero manutenção, sem trigger | Recalcula em toda busca, não indexável de forma eficiente | Tabelas com menos de 50k linhas |
Coluna tsvector + trigger | Indexável com GIN, busca rápida | Ocupa espaço extra, precisa de trigger para manter sincronizada | Tabelas com mais de 50k linhas ou busca frequente |
| Coluna gerada (GENERATED ALWAYS) | Sem trigger, sincronizada automaticamente | Disponível só no PostgreSQL 12+, não aceita COALESCE entre colunas de tabelas diferentes | Coluna 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)