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();import json
import os
import psycopg
from psycopg.rows import dict_row
conexao = psycopg.connect(os.environ["DATABASE_URL"], row_factory=dict_row)
# websearch_to_tsquery aceita o que o usuário digita: aspas, OR e -palavra
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', %s) AS q
WHERE busca @@ q
ORDER BY ts_rank(busca, q) DESC, id
LIMIT %s"""
def buscar(termo: str, limite: int = 3) -> dict:
linhas = conexao.execute(BUSCA, (termo, limite)).fetchall()
total = linhas[0]["total"] if linhas else 0
resultados = [{k: v for k, v in linha.items() if k != "total"} for linha in linhas]
return {"total": total, "resultados": resultados}
for termo in ["tenis corrida", "corredores treino", "mochila -infantil", 'relogio "sem fio"']:
print(termo, json.dumps(buscar(termo), ensure_ascii=False))package main
import (
"context"
"fmt"
"os"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgxpool"
)
// 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`
type Resultado struct {
ID int
Nome string
Trecho string
Total int64
}
func buscar(ctx context.Context, db *pgxpool.Pool, termo string, limite int) ([]Resultado, error) {
linhas, err := db.Query(ctx, busca, termo, limite)
if err != nil {
return nil, err
}
return pgx.CollectRows(linhas, pgx.RowToStructByPos[Resultado])
}
func main() {
ctx := context.Background()
db, err := pgxpool.New(ctx, os.Getenv("DATABASE_URL"))
if err != nil {
panic(err)
}
defer db.Close()
termos := []string{"tenis corrida", "corredores treino", "mochila -infantil", `relogio "sem fio"`}
for _, termo := range termos {
r, err := buscar(ctx, db, termo, 3)
fmt.Println(termo, r, err)
}
}import java.sql.Connection;
import java.sql.DriverManager;
import java.sql.SQLException;
import java.util.ArrayList;
import java.util.List;
public class Busca {
record Resultado(int id, String nome, String trecho, long total) {}
// websearch_to_tsquery aceita o que o usuário digita: aspas, OR e -palavra
static final String 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', ?) AS q
WHERE busca @@ q
ORDER BY ts_rank(busca, q) DESC, id
LIMIT ?""";
static List<Resultado> buscar(Connection db, String termo, int limite) throws SQLException {
try (var comando = db.prepareStatement(BUSCA)) {
comando.setString(1, termo);
comando.setInt(2, limite);
var resultados = new ArrayList<Resultado>();
try (var linhas = comando.executeQuery()) {
while (linhas.next()) {
resultados.add(new Resultado(linhas.getInt("id"), linhas.getString("nome"),
linhas.getString("trecho"), linhas.getLong("total")));
}
}
return resultados;
}
}
public static void main(String[] args) throws Exception {
try (var db = DriverManager.getConnection(System.getenv("JDBC_URL"))) {
var termos = List.of(
"tenis corrida", "corredores treino", "mochila -infantil", "relogio \"sem fio\"");
for (var termo : termos) {
System.out.println(termo + " " + buscar(db, termo, 3));
}
}
}
}using Npgsql;
await using var db = NpgsqlDataSource.Create(Environment.GetEnvironmentVariable("DATABASE_URL")!);
// websearch_to_tsquery aceita o que o usuário digita: aspas, OR e -palavra
const string 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 Task<List<Resultado>> Buscar(string termo, int limite = 3)
{
await using var comando = db.CreateCommand(Busca);
comando.Parameters.Add(new NpgsqlParameter { Value = termo });
comando.Parameters.Add(new NpgsqlParameter { Value = limite });
var resultados = new List<Resultado>();
await using var linhas = await comando.ExecuteReaderAsync();
while (await linhas.ReadAsync())
resultados.Add(new(linhas.GetInt32(0), linhas.GetString(1),
linhas.GetString(2), linhas.GetInt64(3)));
return resultados;
}
string[] termos =
["tenis corrida", "corredores treino", "mochila -infantil", "relogio \"sem fio\""];
foreach (var termo in termos)
Console.WriteLine($"{termo} [{string.Join(", ", await Buscar(termo))}]");
record Resultado(int Id, string Nome, string Trecho, long Total);O resultado, nas cinco linguagens
| Busca digitada | Total | Primeiro resultado | Trecho destacado |
|---|---|---|---|
tenis corrida | 5.000 | Tênis de corrida Veloz | — |
corredores treino | 100.001 | Camiseta de corrida Andarilho | Ideal para <mark>corredores</mark> e <mark>treinos</mark> longos. |
mochila -infantil | 45.000 | Mochila de corrida Andarilho | — |
relogio "sem fio" | 5.000 | Reló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âmetros | Ler as linhas | |
|---|---|---|
| TypeScript | $1, $2 | Objetos, com count(*) OVER () lido da primeira linha |
| Python | %s | dict_row, para ler por nome |
| Go | $1, $2 | pgx.RowToStructByPos |
| Java | ? | ResultSet lido por nome de coluna |
| C# | $1, $2 | GetInt32, 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 (
tsvectorcom GIN, ou um motor), e nãoILIKE '%...%'? - 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]