Cauan Cabral's.

Fulltext Search – Busca Textual com Postgres – Parte 2

Cauan Cabral
Cauan Cabral
Posted underDesenvolvimento WebTutoriais

Continuando de onde paramos na parte 1, vamos ver um pouco como configurar o mínimo para ter uma busca textual através do Postgres. Se você quer se aprofundar mais nas possibilidades, recomendo os seguintes recursos:

Antes de tudo: você tem uma instalação do Postgres funcionando né? Não? Veja se esse artigo ajuda, quando terminar retome daqui.

Precisamos entender que a busca textual parte de um processamento do texto, fazendo uma análise estrutural e semântica e convertendo nosso texto em um formato padronizado. Esse processamento é dependente do idioma e portanto, uma configuração feita para inglês é diferente de outra para português.

Felizmente o Postgres já vem com suporte básico a várias línguas, incluindo português, mas para uma busca mais flexível (pegando variações de plural/singular ou conjugações de verbo) precisamos incluir arquivos de dicionário.

Nesse gist eu disponibilizei os arquivos de dicionário para português do Brasil (pt-br). Eles foram extraídos do projeto Vero do LibreOffice e convertidos para utf-8. Sabendo da origem dos arquivos, vamos usá-los:

!/bin/bash

# Caminho das extensões do Postgresql, varia de ambiente para ambiente. As vezes, de versão para versão.
# Para descobrir programaticamente, precisa instalar o pacote `postgresql-dev` e então usar o `pg_config --sharedir`
# Lugares possíveis:
# Debian/Ubuntu: /usr/share/postgresql/VERSAO
# Alpine/docker: /usr/local/share/postgresql
PGSHARE=/usr/local/share/postgresql

mkdir -p $PGSHARE/tsearch_data

curl -sSL 'https://gist.github.com/CauanCabral/5ad952e0014c1cf21a87c3731397f078/raw/f525e8d21f6697c68303300713ddb28a4bccf384/pt_br.dict' -o $PGSHARE/tsearch_data/hunspell_pt_br.dict
curl -sSL'https://gist.github.com/CauanCabral/5ad952e0014c1cf21a87c3731397f078/raw/f525e8d21f6697c68303300713ddb28a4bccf384/pt_br.affix' -o $PGSHARE/tsearch_data/hunspell_pt_br.affix

Feito isso, você terá os arquivos de dicionário para pt-br onde o Postgres irá procurar.

O próximo passo é conectar ao banco de dados (pode ser via psql, via adminer ou pgAdmin, o que você preferir). Para evitar qualquer problema nesse primeiro teste, crie um banco de dados próprio para a gente e conecte a ele. Vou chamar aqui de busca_textual.

-- Habilitamos a extensão 'unaccent' para usar na normalização dos dados
CREATE EXTENSION IF NOT EXISTS unaccent SCHEMA public;

-- Criamos nosso dicionário, usando os arquivos que pegamos no gist
CREATE TEXT SEARCH DICTIONARY portuguese_hunspell (
    TEMPLATE = ispell,
    DictFile = hunspell_pt_br,
    AffFile = hunspell_pt_br,
    Stopwords = portuguese);

-- Criamos uma nova configuração tomando como ponto de partida 'portuguese'
CREATE TEXT SEARCH CONFIGURATION pt_br ( COPY = portuguese );

-- Alteramos nossa nova configuração, para usar o dicionário e unaccent
ALTER TEXT SEARCH CONFIGURATION pt_br
    ALTER MAPPING FOR
        asciiword, asciihword, hword_asciipart,
        word, hword, hword_part
WITH portuguese_hunspell, unaccent, portuguese_stem;

Com esses comandos nós criamos uma configuração chamada pt_br que instrui o Postgres a processar um texto fazendo:

  • Quebra do texto usando regras do português
  • Substituição da palavra pela sua forma não conjugada (por exemplo voto vira votar)
  • Remoção dos acentos da palavra

Fundamental destacar que a identificação de uma palavra (token é o termo mais apropriado) só acontece uma vez. Então se o token sendo processado existe no dicionário portuguese_hunspell, ele vai ser salvo e indexado da forma como esse dicionário trabalha ele (desconjugada a palavra). Se ela for acentuada, ela nem chegará a passar pelo filtro/dicionário de desacentuação.

Por exemplo: a palavra razões ao passar por esse dicionário é substituída por razão, por que ela existe no dicionário. Já a palavra inventada jasões passará despercebida nesse dicionário e cairá na regra de desacentuação e será substituída por jasoes.

Então a ordem dos dicionários/filtros na criação da configuração são determinantes para o resultado da sua busca. Se colocar a desacentuação antes do dicionário hunspell. nossos resultados anteriores seriam razoes e jasoes, respectivamente.

Tendo a configuração, agora podemos ver como ela funciona em frases.

Lembra do parágrafo mencionado na parte 1? Execute a query abaixo para ver como ele seria processado em nossa configuração:

SELECT * FROM ts_debug('pt_br', 'Algumas das principais razões da evasão escolar no Brasil atual são a pobreza, a dificuldade de acesso à escola, a necessidade de trabalho e, principalmente, o desinteresse pelos estudos. Segundo o Programa das Nações Unidas para o Desenvolvimento (PNUD), o país tem a terceira maior taxa de abandono escolar (24,3%) entre os 100 países com maior IDH (Índice de Desenvolvimento Humano), só atrás da Bósnia e Herzegovina (26,8%) e das ilhas de São Cristovão e Névis, no Caribe (26,5%). Na América Latina, só é superado pela Guatemala (35,2%) e pela Nicarágua (51,6%), não tendo sido divulgado o índice do Haiti.');

A saída deve ser tabular e conter algumas linhas como essas:

AliasDesc.TokenDicionáriosDicionárioLexema
wordWord, all lettersÍndice{portuguese_hunspell,unaccent,portuguese_stem}portuguese_hunspell{índice}
blankSpace symbols{}NULLNULL
asciiwordWord, all ASCIIde{portuguese_hunspell,unaccent,portuguese_stem}portuguese_hunspell{}
blankSpace symbols{}NULLNULL
asciiwordWord, all ASCIIDesenvolvimento{portuguese_hunspell,unaccent,portuguese_stem}portuguese_stem{desenvolv}
blankSpace symbols{}NULLNULL
asciiwordWord, all ASCIIHumano{portuguese_hunspell,unaccent,portuguese_stem}portuguese_hunspell{humano,humanar}
blankSpace symbols),{}NULLNULL
wordWord, all letters{portuguese_hunspell,unaccent,portuguese_stem}portuguese_hunspell{}
blankSpace symbols{}NULLNULL
wordWord, all lettersatrás{portuguese_hunspell,unaccent,portuguese_stem}unaccent{atras}
blankSpace symbols{}NULLNULL
asciiwordWord, all ASCIIda{portuguese_hunspell,unaccent,portuguese_stem}portuguese_hunspell{}
blankSpace symbols{}NULLNULL
wordWord, all lettersBósnia{portuguese_hunspell,unaccent,portuguese_stem}portuguese_hunspell{bósnio}
blankSpace symbols{}NULLNULL
asciiwordWord, all ASCIIe{portuguese_hunspell,unaccent,portuguese_stem}portuguese_hunspell{}
blankSpace symbols{}NULLNULL
asciiwordWord, all ASCIIHerzegovina{portuguese_hunspell,unaccent,portuguese_stem}portuguese_stem{herzegovin}
blankSpace symbols({}NULLNULL
uintUnsigned integer26{simple}simple{26}

Cada linha representa um token identificado no parágrafo, como ele foi identificado, por qual regra e qual o resultado final após a aplicação da regra.

Agora como salvamos no banco esse registro? Primeiro vamos criar uma tabela:

CREATE TABLE artigos (
   id SERIAL PRIMARY KEY,
   conteudo TEXT NOT NULL,
   conteudo_fts TSVECTOR NOT NULL
);

Em seguida, vamos inserir o registro (repare que conteúdo é duplicado, mas na segunda vez, usamos a função to_tsvector para converter ele):

INSERT INTO artigos (conteudo, conteudo_fts) VALUES (
    'Algumas das principais razões da evasão escolar no Brasil atual são a pobreza, a dificuldade de acesso à escola, a necessidade de trabalho e, principalmente, o desinteresse pelos estudos. Segundo o Programa das Nações Unidas para o Desenvolvimento (PNUD), o país tem a terceira maior taxa de abandono escolar (24,3%) entre os 100 países com maior IDH (Índice de Desenvolvimento Humano), só atrás da Bósnia e Herzegovina (26,8%) e das ilhas de São Cristovão e Névis, no Caribe (26,5%). Na América Latina, só é superado pela Guatemala (35,2%) e pela Nicarágua (51,6%), não tendo sido divulgado o índice do Haiti.',
    to_tsvector('pt_br', 'Algumas das principais razões da evasão escolar no Brasil atual são a pobreza, a dificuldade de acesso à escola, a necessidade de trabalho e, principalmente, o desinteresse pelos estudos. Segundo o Programa das Nações Unidas para o Desenvolvimento (PNUD), o país tem a terceira maior taxa de abandono escolar (24,3%) entre os 100 países com maior IDH (Índice de Desenvolvimento Humano), só atrás da Bósnia e Herzegovina (26,8%) e das ilhas de São Cristovão e Névis, no Caribe (26,5%). Na América Latina, só é superado pela Guatemala (35,2%) e pela Nicarágua (51,6%), não tendo sido divulgado o índice do Haiti.')
);

Agora sim podemos fazer uma consulta:

---- Bucansdo por 'nação unidas'
SELECT id, conteudo FROM artigos WHERE conteudo_fts @@ plainto_tsquery('pt_br', 'nação unidas');

---- A mesma busca da parte 1
SELECT id, conteudo FROM artigos WHERE conteudo_fts @@ plainto_tsquery('pt_br', 'evasão escolar do brasil');

Ambas vão encontrar nosso artigo.

Vale a pena estudar um pouco sobre as funções que o Postgres oferece na busca textual:

  • ts_debug(configuracao, texto): Ajuda a entender como sua configuração interpreta um texto.
  • ts_lexize(dicionario, palavra): Aplica a regra do dicionário na palavra/token.
  • to_tsvector(configuracao, texto): Converte seu texto para um vetor de busca usando sua configuração.
  • to_tsquery(configuracao, texto): Converte seu texto para uma query de busca de uma forma estrita. Palavras devem ser ligadas usando operadores explícitos (é uma junção? ou condicional?).
  • plainto_tsquery(configuracao, texto): Converte seu texto para uma query de busca usando uma linguagem natural, próxima ao do Google.

Da forma como está, você já consegue fazer uma busca textual básica, mas ela ainda é ineficiente porque não criamos nenhum índice. Colunas do tipo tsvector podem ser indexadas em índices dos tipos GIN e GIST – cada qual com sua vantagem. Em termos gerais, o GIN oferece uma tradeoff melhor entre tempo de escrita e otimização de busca. O GIST possui um custo consideravelmente maior para escrita mas oferece uma velocidade melhor de leitura.

Adicionando um índice em nossa tabela:

CREATE INDEX idx_artigos_conteudo_fts ON artigos USING gin(conteudo_fts);

E temos uma busca prática, sem depender de sistemas externos e que atendem vários cenários de pesquisa.

Ainda podemos trabalhar com ranqueamento do resultado, destaque do texto encontrado e dicionário de sinônimos que estão presentes em sistemas complexos e geralmente apontados como única solução para buscas de verdade.

Na próxima e última parte vou focar apenas no plugin Autopage\PgSearch, que implementa e abstrai uma parte desses recursos para aplicações CakePHP.

Taggedbuscafulltextpostgresql


pgModeler – gerando o seu binário

Cauan Cabral
Cauan Cabral

Tem algumas aplicações que são icônicas pra gente – por diversas razões – no meu caso, alguns destes são winamp, mIRC, Macromedia Flash MX, Amarok, Kompare, MySQL Workbench e Gitlab. Outro que conheci e me deixou admirado quando descobri foi o pgModeler, primeiro pela qualidade da aplicação em si que é o mais próximo do […]

Dica Rápida: usando tipos “complexos” com Migrations no Phinx

Cauan Cabral
Cauan Cabral

Essa é uma dica bem curta e realmente rápida pra registrar algo que precisei pesquisar algumas vezes nos últimos anos e sempre me esqueço. Cena: você define uma tabela no seu projeto e gostaria de usar uma coluna com o tipo tsvector (como citei em posts recentes) ou então uuid. Você quer usar as funções […]