DevGuideTK
← Trilha Backend

Busca textual

Índice invertido, relevância e os motores de busca como Elasticsearch e OpenSearch.

O código desta página foi executado nas cinco linguagens contra um PostgreSQL 18 com 500 mil produtos. A configuração do OpenSearch 2.19 do fim da página também foi testada com o mesmo catálogo.

Preparar a tabela

-- Uma configuração de português que também ignora acentos
CREATE EXTENSION IF NOT EXISTS unaccent;
CREATE TEXT SEARCH CONFIGURATION pt (COPY = portuguese);
ALTER TEXT SEARCH CONFIGURATION pt
  ALTER MAPPING FOR hword, hword_part, word WITH unaccent, portuguese_stem;

-- O vetor de busca, mantido pelo banco; o nome pesa mais que a descrição
ALTER TABLE produtos ADD COLUMN busca tsvector GENERATED ALWAYS AS (
  setweight(to_tsvector('pt', nome), 'A') || setweight(to_tsvector('pt', descricao), 'B')
) STORED;
CREATE INDEX produtos_busca ON produtos USING gin (busca);

A coluna busca é gerada pelo banco a cada gravação: o índice nunca fica desatualizado. O nome entra com peso A e a descrição com peso B.

A busca, em cada linguagem

A consulta usa websearch_to_tsquery, que entende o que o usuário digita numa caixa de busca: palavras soltas, frases entre aspas, OR e -palavra para excluir. O resultado vem ordenado por ts_rank, com um trecho da descrição destacado por ts_headline e o total de resultados.

import pg from "pg";

const pool = new pg.Pool({ connectionString: process.env.DATABASE_URL });

// websearch_to_tsquery aceita o que o usuário digita: aspas, OR e -palavra
const BUSCA = `
  SELECT id, nome,
         ts_headline('pt', descricao, q, 'StartSel=<mark>, StopSel=</mark>') AS trecho,
         count(*) OVER () AS total
    FROM produtos, websearch_to_tsquery('pt', $1) AS q
   WHERE busca @@ q
   ORDER BY ts_rank(busca, q) DESC, id
   LIMIT $2`;

async function buscar(termo: string, limite = 3) {
  const { rows } = await pool.query(BUSCA, [termo, limite]);
  return { total: Number(rows[0]?.total ?? 0), resultados: rows.map(({ total, ...r }) => r) };
}

const termos = ["tenis corrida", "corredores treino", "mochila -infantil", "relogio \"sem fio\""];
for (const termo of termos) {
  console.log(termo, JSON.stringify(await buscar(termo)));
}
await pool.end();

O resultado, nas cinco linguagens

Busca digitadaTotalPrimeiro resultadoTrecho destacado
tenis corrida5.000Tênis de corrida Veloz—
corredores treino100.001Camiseta de corrida AndarilhoIdeal para <mark>corredores</mark> e <mark>treinos</mark> longos.
mochila -infantil45.000Mochila de corrida Andarilho—
relogio "sem fio"5.000Relógio sem fio Andarilho—

As cinco versões devolveram os mesmos totais, os mesmos três primeiros resultados e os mesmos trechos. Repare na terceira linha: das 50 mil mochilas, as 5 mil infantis ficaram de fora. E na quarta, a frase entre aspas exigiu "sem fio" junto, sem acento em "relogio".

O que muda de uma linguagem para outra

A consulta é a mesma nas cinco; muda o jeito de passar os parâmetros e ler o resultado:

ParâmetrosLer as linhas
TypeScript$1, $2Objetos, com count(*) OVER () lido da primeira linha
Python%sdict_row, para ler por nome
Go$1, $2pgx.RowToStructByPos
Java?ResultSet lido por nome de coluna
C#$1, $2GetInt32, GetString, GetInt64 por posição

O count(*) OVER () devolve o total junto com a página, sem uma segunda consulta. Numa listagem paginada com milhões de resultados, contar tudo pode pesar; o tópico de paginação discute quando mostrar o total.

O texto do usuário vai sempre como parâmetro. O websearch_to_tsquery não falha com entrada estranha: aspas sem par ou símbolos soltos são ignorados.

O OpenSearch, quando a busca é o produto

O índice testado, com um analisador de português sem acentos e um subcampo sem radicalização para a busca aproximada:

{
  "settings": {
    "analysis": {
      "filter": {
        "pt_stem": { "type": "stemmer", "language": "light_portuguese" },
        "pt_stop": { "type": "stop", "stopwords": "_portuguese_" }
      },
      "analyzer": {
        "pt": { "tokenizer": "standard", "filter": ["lowercase", "asciifolding", "pt_stop", "pt_stem"] },
        "pt_simples": { "tokenizer": "standard", "filter": ["lowercase", "asciifolding"] }
      }
    }
  },
  "mappings": {
    "properties": {
      "nome": {
        "type": "text", "analyzer": "pt",
        "fields": { "simples": { "type": "text", "analyzer": "pt_simples" } }
      },
      "descricao": { "type": "text", "analyzer": "pt" }
    }
  }
}

E a busca que achou "Tênis de corrida" para "tenys corida", combinando o campo com radicais e o subcampo com tolerância a erros:

{
  "query": {
    "bool": {
      "should": [
        { "match": { "nome": { "query": "tenys corida", "operator": "and" } } },
        { "match": { "nome.simples": { "query": "tenys corida", "operator": "and", "fuzziness": "AUTO" } } }
      ]
    }
  }
}

No teste, a carga dos 500 mil produtos pela API _bulk, em lotes de 10 mil, levou 13,4 segundos, e o índice ocupou 14 MB. Para conferir o que o analisador faz com uma palavra, use POST /produtos/_analyze com o analisador e o texto: foi assim que o teste descobriu o tenil.

Lista de revisão

Busca

  • A busca usa um índice de texto (tsvector com GIN, ou um motor), e não ILIKE '%...%'?
  • Acentos são ignorados, e o radicalizador foi testado com as palavras do domínio?
  • Os resultados vêm ordenados por relevância, com pesos por campo?
  • O texto do usuário vai como parâmetro?

Qualidade

  • Há um conjunto de buscas de teste com resultado esperado, com acento, plural, sinônimo e erro de digitação?
  • A tolerância a erros roda num campo sem radicalização?
  • As buscas sem resultado são registradas, para descobrir o que os usuários procuram e não acham?

Índice separado

  • O índice é sincronizado por fila de saída ou CDC, e não por gravação dupla sem garantia?
  • Existe um processo para reconstruir o índice do zero?
  • As telas que gravam e logo buscam levam em conta o atraso do índice?

Prompts para aprofundar

Ao abrir no ChatGPT ou no Claude, o prompt é enviado ao serviço escolhido pela URL e também fica copiado na sua área de transferência.

  • Implementar busca no PostgreSQL

    Quero implementar a busca do meu sistema com a busca textual do PostgreSQL.
    
    Pergunte qual linguagem eu uso, quais tabelas e campos são buscados e em que idioma está o texto.
    
    Vamos em etapas:
    1. A configuração de texto com o dicionário do idioma e unaccent.
    2. A coluna tsvector gerada, com pesos por campo, e o índice GIN.
    3. A consulta com websearch_to_tsquery, ts_rank e ts_headline.
    4. A paginação e o total de resultados.
    5. Testes com acento, plural, frase, exclusão e erro de digitação.
  • Configurar um índice no OpenSearch

    Quero criar o índice de busca de um catálogo no OpenSearch (ou Elasticsearch).
    
    Pergunte o idioma, os campos, os filtros que a tela precisa e como os dados chegam.
    
    Explique, com o JSON completo:
    1. O analisador do idioma, com acentos, palavras vazias e radicalização.
    2. Um subcampo sem radicalização para busca aproximada e frases.
    3. A consulta que combina os dois, com pesos.
    4. Filtros e agregações para a tela de resultados.
    5. Como conferir a análise com a API _analyze.
  • Revisar uma implementação de busca

    Vou colar abaixo o código e a configuração da busca de uma aplicação. Revise.
    
    Verifique, e para cada item diga se está correto, incorreto ou ausente, citando o trecho:
    1. A busca usa um índice de texto, e não LIKE com % no começo?
    2. Acentos e variações das palavras são tratados?
    3. Os resultados são ordenados por relevância, com pesos por campo?
    4. O texto do usuário vai como parâmetro?
    5. A tolerância a erros roda num campo sem radicalização?
    6. Se há um motor separado, como o índice é sincronizado e reconstruído?
    
    Para cada problema, descreva o efeito para o usuário e mostre a correção.
    
    Código e configuração:
    [cole aqui]