DEV Community

Cover image for Full-Text Search: Implementando com Postgres e Django
Eduardo Oliveira
Eduardo Oliveira

Posted on • Edited on

Full-Text Search: Implementando com Postgres e Django

Algum tempo atrás vi o texto "A powerful full-text search in PostgreSQL in less than 20 lines" do Leandro Proença [1] e quis implementar algo assim pra projetos que não demandam o poder de um Apache Lucene ou de um Elastic Search.

O django já possui, em seu core, uma aplicação com métodos que são utilizados apenas com o Postgres e, para a minha surpresa, todos os conceitos de full-text search já estavam disponíveis nesse app.

Restou, nesse caso, tentar reproduzir, por assim dizer, a query do texto original utilizando o ORM do django e os métodos do full-text search.

Esse texto tem, por objetivo, trazer explicações sobre como essa implementação foi feita. Fundamentalmente, esse texto será uma versão explicada dessa thread no twitter.

Mostre-me o código

Todo o código-fonte do projeto está disponível no GitHub, nesse repositório.

Disclaimer:

O código da versão desse texto está disponível na branch texto-1.


Adicionando configurações necessárias

Dentro do settings.py do projeto, precisamos adicionar a aplicação django.contrib.postgres dentro da variável de INSTALLED_APPS para que possamos utilizar as ferramentas do django próprias para o Postgres:

# ...  INSTALLED_APPS = [ 'django.contrib.admin', 'django.contrib.auth', 'django.contrib.contenttypes', 'django.contrib.sessions', 'django.contrib.messages', 'django.contrib.staticfiles', 'django.contrib.postgres', ] # ... 
Enter fullscreen mode Exit fullscreen mode

Criando o model

Precisamos criar um model para poder utilizar os conceitos da busca dentro dele. Para simplificar, esse caso, utilizamos um model com um único campo de texto para as buscas:

class Singer(models.Model): name = models.CharField("Cantor", max_length=150) def __str__(self): return self.name class Meta: verbose_name = "Cantor" verbose_name_plural = "Cantores" 
Enter fullscreen mode Exit fullscreen mode

Criando uma view

Para testar os conceitos de full-text search, podemos criar uma view. Antes, é necessário dizer que nesse texto estou usando views padrão do django com templates em HTML para não adicionar mais complexidade lidando com o Rest Framework.

Podemos criar uma view que recebe uma query string para fazer a busca:

from django.shortcuts import render from .models import Singer def search_singer(request): term = request.GET.get('q') if term: # TODO: fazer busca aqui  else: singers = Singer.objects.order_by("-id").all() context = { 'singers': singers, 'term': term, } return render(request, "cantor.html", context) 
Enter fullscreen mode Exit fullscreen mode

O template cantor.html que estou utilizando é bem simples apenas para permitir testes de forma mais fácil:

<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Buscando Cantores</title> </head> <body> <div> <form action=""> <input type="search" name="q" {% if term %} value="{{ term }}" {% endif %} /> <button type="submit">Pesquisar</button> </form> </div> {% if singers %} <main> {% for item in singers %} <div> <h3>{{item.name}}</h3> {% if item.rank or item.similarity %} <div> Rank: {{item.rank}}, Similaridade: {{item.similarity}} </div> {% endif %} </div> {% endfor %} </main> {% endif %} </body> </html> 
Enter fullscreen mode Exit fullscreen mode

Full-Text Search

Precisamos, primeiro, criar um SearchVector (ts_vector) e um SearchQuery (tsquery). Assim:

from django.contrib.postgres.search import SearchVector, SearchQuery # ...  vector = SearchVector("name", config="portuguese") query = SearchQuery(term, config="portuguese") # ... 
Enter fullscreen mode Exit fullscreen mode

O vector é feito assim pra utilizar a coluna "name" do model Singer. A query é feita para processar a variável term recebida no código da view acima.

O próximo ponto é criar annotations para fazer o select de campos como o to_tsvector e o ts_rank (o método .annotate do Django ORM faz o select de outros campos e agrega eles a entidade):

from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank # ...  vector = SearchVector("name", config="portuguese") query = SearchQuery(term, config="portuguese") singers = Singer.objects.annotate( search=vector, rank=SearchRank(vector, query), ).filter( search=query ).order_by("-rank").all() # ... 
Enter fullscreen mode Exit fullscreen mode

Adicionando o código dentro da view, passamos a ter:

from django.shortcuts import render from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank from .models import Singer def search_singer(request): term = request.GET.get('q') if term: vector = SearchVector("name", config="portuguese") query = SearchQuery(term, config="portuguese") singers = Singer.objects.annotate( search=vector, rank=SearchRank(vector, query), ).filter( search=query ).order_by("-rank").all() else: singers = Singer.objects.order_by("-id").all() context = { 'singers': singers, 'term': term, } return render(request, "cantor.html", context) 
Enter fullscreen mode Exit fullscreen mode

Utilizando um pequeno grupo de dados para teste:

Dados sem Busca

Podeos testar e verificar que passamos a ter uma busca funcional:

Resultado de Busca

Porém, ainda temos alguns problemas, pois, por exemplo, na busca por palavras incompletas, perdemos o ranqueamento:

Busca incompleta

Nesse ponto, entra a busca por similaridade que, combinada com o Full-Text Search nos permitirá fazer uma busca mais funcional.

Busca por Similaridade

Precisamos, primeiro, adicionar a extensão pg_trgm no banco de dados. Podemos fazer isso manualmente ou podemos criar uma migration vazia e adicionar essa extensão na migration. Vou seguir pela segunda opção. Para a primeira, basta executar o comando no banco de dados:

CREATE EXTENSION pg_trgm 
Enter fullscreen mode Exit fullscreen mode

Para a segunda abordagem, podemos executar o comando python manage.py makemigrations nome_do_app --empty e ele criará uma -migration vazia. A partir da migration vazia, podemos adicionar o import ao CreateExtension e adicionar dentro de operations:

from django.db import migrations from django.contrib.postgres.operations import CreateExtension class Migration(migrations.Migration): dependencies = [ ('texto', '0003_alter_feat_music'), ] operations = [ CreateExtension("pg_trgm") ] 
Enter fullscreen mode Exit fullscreen mode

Basta agora executar python manage.py migrate e teremos a extensão criada no banco de dados.

Agora, dentro da nossa busca, podemos fazer o uso do TrigramSimilarity para melhorar nossos resultados. Primeiro, vamos adicionar dentro do .annotate:

from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank, TrigramSimilarity # ...  singers = Singer.objects.annotate( search=vector, rank=SearchRank(vector, query), similarity=TrigramSimilarity("name", term), ) # ... 
Enter fullscreen mode Exit fullscreen mode

Precisamos, também, alterar o .filter para utilizar de um operador lógico OU. Para isso, precisamos fazer uso do Q(condição 1) | Q(condição 2) do django:

from django.contrib.postgres.search import SearchVector, SearchQuery, SearchRank, TrigramSimilarity from django.db.models import Q # ...  singers = Singer.objects.annotate( search=vector, rank=SearchRank(vector, query), similarity=TrigramSimilarity("name", term), ).filter( Q(search=query) | Q(similarity__gt=0) ).order_by("-rank", "-similarity").all() # ... 
Enter fullscreen mode Exit fullscreen mode

Aqui, o que fazemos é adicionar o campo de similarity na nossa query e filtrar pra "o full-text search encontrou" ou "a similaridade é maior que zero". A partir desse momento, fazendo a mesma busca de um dos prints acima:

Busca por Similaridade

Por fim, nossa view passa a ter o código:

from django.shortcuts import render from django.db.models import Q from django.contrib.postgres.search import ( SearchQuery, SearchRank, SearchVector, TrigramSimilarity, ) from .models import Singer def search_singer(request): term = request.GET.get('q') if term: vector = SearchVector("name", config="portuguese") query = SearchQuery(term, config="portuguese") singers = Singer.objects.annotate( search=vector, rank=SearchRank(vector, query), similarity=TrigramSimilarity("name", term), ).filter( Q(search=query) | Q(similarity__gt=0) ).order_by("-rank", "-similarity").all() else: singers = Singer.objects.order_by("-id").all() context = { 'singers': singers, 'term': term, } return render(request, "cantor.html", context) 
Enter fullscreen mode Exit fullscreen mode

É possível utilizar tanto o rank ou o similarity para cortar valores, conforme exemplos da documentação.

Por último, podemos adicionar um índice dentro do nosso model para lidar com performance das queries:

from django.db import models from django.contrib.postgres.indexes import GinIndex from django.contrib.postgres.search import SearchVector class Singer(models.Model): name = models.CharField("Cantor", max_length=150) def __str__(self): return self.name class Meta: verbose_name = "Cantor" verbose_name_plural = "Cantores" indexes = [ GinIndex( SearchVector("name", config="portuguese"), name="singer_search_vector_idx", ) ] 
Enter fullscreen mode Exit fullscreen mode

Todo o código-fonte do projeto está disponível no GitHub, nesse repositório.

Disclaimer:

O código da versão desse texto está disponível na branch texto-1.

Referências

1 - A powerful full-text search in PostgreSQL in less than 20 lines

2 - Full text search - Django Documentation


Foto de capa por Mick Haupt no Unsplash.

Top comments (4)

Collapse
 
urielsouza29 profile image
Uriel dos Santos Souza

Muito legal - to aprendendo bastante embora não entenda uma linha de Pyhton kkkk

Collapse
 
eduardojm profile image
Eduardo Oliveira

É assim mesmo kkkkkkkkk

Collapse
 
aryan3212 profile image
Aryan Rahman

Thanks for the article, really informative ! Do you think there are other solutions to using incomplete words/short length characters but still get proper search results?🫡

Collapse
 
eduardojm profile image
Eduardo Oliveira

Is possible to use the default SearchFilter of django rest framework using icontains lookup, but it's not rank-based like the Full-Text-Search or integrate with an external service, like ElasticSearch.