DEV Community

Felipe Barbosa
Felipe Barbosa

Posted on

Full-text search com PostgreSQL

Introdução

Full-text search refere-se a um mecanismo que permite buscar palavras, termos, expressões e frases em grandes volumes de documentos de maneira rápida e eficiente. Trata-se de um mecanismo bastante sofisticado, que é bem diferente de uma simples busca textual que pode ser feita no navegador ou em um documento (com o famoso atalho Ctrl + F). Para entender como esse tecnologia funciona, vamos compará-la a uma busca comum.

Como um resultado é encontrado

Em primeiro lugar, existe uma diferença fundamental na forma como resultados (match ou matches no plural) são encontrados.

Enquanto que numa busca textual simples é necessário uma correspondência exata entre as palavras, quando estamos utilizando full-text search (FTS), as diversas flexões de uma mesma palavra são consideradas uma só. Por exemplo, as flexões de um adjetivo como revolucionário, revolucionária, revolucionários, revolucionárias, revolucionaríssimo são entendidas como correspondência para a palavra revolucionário. Assim, se você estiver procurando por uma correspondência como "invenção revolucionária" no texto, independentemente de qual flexão esteja empregada você vai achar um resultado.

Isso acontece porque, quando usamos FTS, antes que consultas possam ser feitas, os documentos, textos, etc., são pré-processados de forma a normalizá-los e otimizá-los para busca. Isso significa, por exemplo, que apenas os lexemas (mais ou menos equivalente ao "radical") das palavras são mantidos. Outra transformação aplicada é a de remover coisas com artigos o, a, os, as, uns, umas, preposições de, para, etc, de forma que um título como "O Poderoso Chefão" possa ser encontrado com uma busca de "poderoso chefão".

Além dessa normalização, os lexemas são indexados, isto é, uma estrutura de dados apropriada é utilizada para indicar todos os lexemas presentes em um texto e sua correspondente posição no mesmo.

Por fim, mas não menos importante, o FTS traz a ideia de relevância de um resultado: se, por exemplo, em um texto são encontradas 10 ocorrências de um termo, pode-se dizer que esse resultado é mais relevante do que um outro em que apenas uma ocorrência é encontrada. Ou ainda: se o termo é encontrado no título de um artigo, ao invés de no corpo, é razoável imaginar que esse resultado seja mais relevante. Discutiremos melhor como isso funciona mais adiante.

Com essas e outras ideias e otimizações, FTS torna-se uma ferramenta bastante poderosa, como veremos a seguir.

Como FTS se compara a outras ferramentas

Ainda assim, poderia-se argumentar que existem ferramentas elaboradas para buscas textuais que não se limitam aos cenários simples descritos anteriormente.

Uma dessas que é bastante poderosa e bem conhecida por desenvolvedores são as chamadas*regular expressions* (regex). Para os não familiarizados, regex são um mecanismo que usa uma sintaxe específica para buscas textuais avançadas.

Por exemplo, a expressão regular

/.*\?\!$/
Enter fullscreen mode Exit fullscreen mode

Serve para procurar todas as frases de um texto que terminam em "!?".

O problema é que não é razoável pré-conceber todas as variações de termos que se pode querer procurar em documento e transformá-los em regex para futuras consultas.

Outra questão envolvida diz respeito a performance: por mais que expressões regulares se valham de algoritmos sofisticados que buscam otimizar o máximo possível a busca, esse mecanismo ainda assim precisa percorrer todo o texto em busca de correspondências, o que, para grandes volumes, não é nada eficiente.

Existe ainda o operador LIKE no mundo do SQL que serve para buscas textuais. Porém, novamente uma expressão como

SELECT body from documents WHERE body LIKE '%crescimento%';
Enter fullscreen mode Exit fullscreen mode

Só consegue achar uma correspondência exata da palavra "crescimento" (e não cresceu, crescendo, etc).

Além dessas limitações, nenhum desses mecanismos possui a ideia de relevância de um resultado discutida mais cedo.

Uma vez que entendemos o que é e por que utilizamos FTS, vamos ver como podemos usar esse mecanismo no postgresql.

Full-text search no PostgreSQL

Existem softwares especiais que são utilizados exclusivamente com o objectivo de realizar buscas do tipo FTS. Um bom exemplo é ElasticSearch.

Porém, o sistema do Postgres é perfeitamente capaz de fazer esse tipo de busca, como mostraremos a seguir. Apesar de não ter todas as funcionalidades de um sistema como o ElasticSearch, a implementação nativa de FTS é boa o suficiente para diversos casos de uso - e tem a vantagem de ser bem mais simples.

A base do FTS no Postgres: tsvector e tsquery

As estruturas de dados utilizadas no Postgres para trabalhar com FTS são basicamente duas:

  • tsvector
  • tsquery

Como já vimos, FTS não trabalha com strings comuns para fazer consultas, então essas duas estruturas de dados especiais são necessárias. Vejamos como elas funcionam.

tsvector pode ser entendido como um vetor de lexemas junto com suas correspondentes posições no textos. É contra essa estrutura de dado que iremos fazer nossas consultas.

tsquery é um tipo de estrutura de dado usado para fazer buscar contra o tsvector. Com ela podemos criar buscar de frases, termos e expressões, como veremos adiante.

Com esses dois tipos de dados, uma busca pode ser feita com a seguinte sintaxe:

SELECT ts_vector @@ ts_query;
Enter fullscreen mode Exit fullscreen mode

A consulta acima retorna um boolean que indica que a expressão indicada foi encontrada no vetor.

Utilizando ts_vector e ts_query

As duas estruturas descritas acimas são bastantes específicas e meticulosamente desenhadas. Felizmente, na prática nós não as criamos do zero, mas usamos texto convencional para interagir com elas.

Para criar uma ts_vector a partir de um documento ou texto, utilizamos a função to_tsvector. Ela recebe uma string e um configuração de linguagem e nos retorna nosso vetor normalizado. Assim, exemplo:

SELECT to_tsvector('portuguese', 'O PIB  do Brasil cresceu consideravelmente no ano passado');
Enter fullscreen mode Exit fullscreen mode

retorna

'ano':8 'brasil':4 'consider':6 'cresc':5 'pass':9 'pib':2
Enter fullscreen mode Exit fullscreen mode

que é o nosso vetor.

Para fazer uma busca em cima dessa frase, utilizamos a função to_tsquery da seguinte forma:

SELECT to_tsvector(
  'portuguese',
  'O PIB  do Brasil cresceu consideravelmente no ano passado'
) @@ to_tsquery('portuguese', 'crescer & pib');
Enter fullscreen mode Exit fullscreen mode

A consulta acima busca pelas palavras "crescer" e (&) "pib" na frase destacada, podendo estar as mesmas separas por qualquer distância (veremos como fazer consultas mais sofisticadas). O retorno dessa frase é TRUE, apesar de a palavra "crescer" não presente de forma exata, mas sim sua flexão: "cresceu".

Com esse exemplo, dá para se ter uma ideia do quão poderoso é essa mecanismo.

Fluxo de uso real em um banco de dados

Naturalmente, a ideia é que possamos fazer buscas em registros de um banco de dados.

Imagine, por exemplo, que temos um banco de dados utilizado para gerenciar notícias em um site como o G1.

Nosso objetivo final é o de poder fazer uma busca como "PIB" e achar as principais notícias que contenham esse termo.

E aí que entra um grande trunfo da pesquisa FTS: com ela, podemos definir parâmetros para determinar a relevância de uma correspondência. Como assim?

Uma notícia tem uma manchete (o título), uma sinopse ou subtítulo e o corpo da notícia em si.

Naturalmente, se encontramos a palavra "PIB" na manchete de uma notícia, esse resultado é mais relevante do que se a palavra fosse encontrada apenas no corpo da mesma.

E se encontramos a mesma palavra na sinopse, ainda assim trata-se de um resultado mais relevante do que apenas no corpo.

Agora, se corpo da notícia encontramos o termo 10 vezes, parece razoável que esse resultado seja mais relevante do que se o encontramos apenas 3 vezes.

E é exatamente esse tipo de "rankiamento" que podemos definir nas buscas: o correspondências no título ganham peso "A", na sinopse, "B" e no corpo "C", por exemplo.

Implementação

Vamos usar como exemplo prático o banco de dados utilizado nesse blog. Os "posts" têm a seguinte estrutura:

CREATE TABLE public.posts (
    id uuid NOT NULL,
    title text NOT NULL,
    excerpt text NOT NULL,
    slug text NOT NULL,
    "ogImageUrl" text NOT NULL,
    body text NOT NULL,
    "createdAt" timestamp(3) without time zone NOT NULL,
    "updatedAt" timestamp(3) without time zone,
    "authorId" uuid NOT NULL,
    note boolean NOT NULL
);
Enter fullscreen mode Exit fullscreen mode

Por trás dos panos, esse é o schema gerado pelo excelente ORM Prisma.

O primeiro passo para implementarmos FTS é criar gerar uma coluna que corresponda a chamar a função to_tsquery nos campos tile, excerpt e body, atribuindo pesos A, B e C, respectivamente.

Para isso usamos a seguinte query:

ALTER TABLE
  posts
ADD
  COLUMN textsearchableIndex tsvector
  GENERATED ALWAYS AS (
    setweight(to_tsvector('portuguese', title), 'A') ||
    setweight(to_tsvector('portuguese', coalesce(excerpt, '')), 'B') ||
    setweight(to_tsvector('portuguese', body), 'C')
  )
  STORED;
Enter fullscreen mode Exit fullscreen mode

Essa query adiciona uma nova coluna chamada textsearchableIndex na tabela posts do banco de dados. Essa coluna é um tipo especial de dado chamado tsvector que já abordamos.

A cláusula GENERATED ALWAYS AS especifica que os valores da coluna textsearchableIndex serão gerados automaticamente com base em uma expressão que combina os textos das colunas title, excerpt e body.

Essa expressão é composta por uma sequência de chamadas para a função to_tsvector, que converte o texto em um vetor de termos de busca. Cada vetor resultante é ponderado com um peso específico (A, B ou C) usando a função setweight, que indica a importância relativa de cada coluna na pesquisa.

A cláusula STORED indica que os valores da coluna textsearchableIndex serão armazenados fisicamente no banco de dados em vez de serem calculados toda vez que a consulta é executada. Isso torna as consultas de pesquisa mais eficientes, pois os valores já estarão pré-calculados e prontos para uso.

Criando um index

Para aumentar a eficiência de nossas buscas, criamos um índice para essa coluna

CREATE INDEX posts_textsearch_idx ON posts USING GIN (textsearchableIndex);
Enter fullscreen mode Exit fullscreen mode

O tipo de índice utilizado é o "GIN" (Generalized Inverted Index), que é otimizado para pesquisas FTS em grandes conjuntos de dados.

O índice GIN armazena um mapa invertido das palavras presentes nos textos da coluna "textsearchableIndex". Esse mapa é organizado em uma estrutura de árvore, que permite a pesquisa FTS de forma eficiente.

Ao criar esse índice, as consultas de pesquisa que utilizam a coluna "textsearchableIndex" serão muito mais rápidas, pois o índice pode ser usado para localizar rapidamente os documentos relevantes. Em outras palavras, o índice acelera a pesquisa de texto completo na tabela "posts".

Pronto! Tudo certo. Vamos ver como podemos usar essa coluna para fazer consultas!

Fazendo consultas

Concluída a construção e indexação da nossa coluna, estamos aptos a fazer pesquisas rápidas e eficientes pelo banco de forma bastante simples! Observe a query abaixo

SELECT
  title,
  excerpt,
  slug,
  ts_rank(
    textsearchableIndex,
    plainto_tsquery('portuguese', '<plain text input>')
  ) as rank
FROM
  posts
WHERE
  textsearchableIndex @@ plainto_tsquery('portuguese', '<plain text input>')
ORDER BY
  rank DESC
LIMIT 5;
Enter fullscreen mode Exit fullscreen mode

O destaque dessa query fica para a função ts_rank, que ainda não foi explicada.

Essa função é justamente a parte do FTS no Postgres que lida com a relevância dos resultados. Ela recebe dois parâmetros: um tsvector e uma tsquery, respectivamente, e retorna um número entre 0 e 1 indicando o grau de relevância do resultado baseado em critérios como frequência das ocorrências, proximidade entre os lexemas e, claro, os pesos atribuídos aos vetores. Esse último garante que ocorrências no título (a que atribuímos peso "A") terão uma nota maior do que ocorrências no corpo.

O valor calculado é usado para ordenar os resultados em ordem decrescente de sua relevância.

Uma outra observação é válida para a função utilitária plainto_tsquery, que simplesmente converte texto comum no tipo especial tsquery. Assim, por exemplo, “casa vermelha” vira “casa & vermelha”.

E pronto! Isso é realmente tudo que você precisa para ter um sistema de FTS integrado ao seu bando de dados Postgres.

Indicando as ocorrências no texto com ts_heading

Por fim, gostaríamos de, além de mostrar os resultados mais relevantes (baseados nos critérios discutidos), também indicar onde, nos textos, esses resultados aparecem.

Para tanto, o PostgreSQL conta com a função ts_heading, que recebe o texto a ser buscado (formato string) e uma tsquery e retorna uma ou mais strings que correspondem a fragmentos do documento onde as ocorrências aparecem.

Por padrão, as ocorrências são destacadas com o marcador <b></b>, porém pode ser configurado para qualquer outro indicador.

Um exemplo de uma query que usa a função ts_heading seria a seguinte:

SELECT
  title,
  excerpt,
  slug,
  ts_rank(
    textsearchableIndex,
    plainto_tsquery('portuguese', '<plain text input>')
  ) as rank,
  ts_headline(
    'portuguese',
    body,
    plainto_tsquery('portuguese', '<plain text input>'),
    'MaxFragments=10'
  ) as headline
FROM
  posts
WHERE
  textsearchableIndex @@ plainto_tsquery('portuguese', '<plain text input>')
ORDER BY
  rank DESC
LIMIT 5;
Enter fullscreen mode Exit fullscreen mode

A opção MaxFragments=10 especifica que devem ser retornadas no máximo 10 ocorrências destacadas no texto.

É necessário cuidado ao utilizar essa função porque, diferente das outras apresentadas até então, ela atua no formato de texto ao invés da estrutura tsvector.

Conclusão

Com os elementos discutidos nesse artigo, você tem toda a base de que precisa para implementar um sistema FTS no Postgres.

Para mais detalhes, acessa a documentação aqui.

É isso por enquanto, espero que tenha sido útil. Qualquer dúvida estou à disposição!

Happy coding!

Anexos

Distinção entre lexema e radical segundo o Chat GPT3

"Não, lexema e radical não são exatamente a mesma coisa, embora estejam relacionados.

O lexema é a parte da palavra que carrega o seu significado central e é comum a todas as suas formas. Por exemplo, o lexema da palavra "cantar" é "cant-". Ele é a base para todas as outras formas da palavra, como "canto", "cantava", "cantaremos", entre outras.

Já o radical é a parte da palavra que contém as suas informações gramaticais e varia em suas formas flexionadas. Ele geralmente é o mesmo que o lexema, mas pode sofrer alterações quando a palavra é flexionada em gênero, número, tempo, modo, pessoa, entre outros aspectos. Por exemplo, o radical da palavra "cantar" é "cant-", mas ele se altera para "cantav-" na forma "cantavam".

Resumindo, o lexema é a base semântica da palavra, enquanto o radical é a base morfológica que varia nas formas flexionadas."

Top comments (0)