Full-text search com PostgreSQL
Tags: postgres, sql
Published: 2023-03-05 20:34:46

### 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"](#distinção-entre-lexema-e-radical-segundo-o-chat-gpt3)) 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

```plaintext
/.*\?\!$/
```

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

```sql
SELECT body from documents WHERE body LIKE '%crescimento%';
```

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](https://www.elastic.co/what-is/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:

```sql
SELECT ts_vector @@ ts_query;
```

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:

```sql
SELECT to_tsvector('portuguese', 'O PIB  do Brasil cresceu consideravelmente no ano passado');
```

retorna

```plaintext
'ano':8 'brasil':4 'consider':6 'cresc':5 'pass':9 'pib':2
```

que é o nosso vetor.

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

```sql
SELECT to_tsvector(
  'portuguese',
  'O PIB  do Brasil cresceu consideravelmente no ano passado'
) @@ to_tsquery('portuguese', 'crescer & pib');
```

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:

```sql
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
);
```

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:

```sql
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;
```

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

```sql
CREATE INDEX posts_textsearch_idx ON posts USING GIN (textsearchableIndex);
```

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

```sql
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;
```

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:

```sql
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;
```

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](https://www.postgresql.org/docs/current/textsearch.html).

É 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."