DEV Community

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

Posted on • Edited on

10 2 1

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.

Heroku

Build apps, not infrastructure.

Dealing with servers, hardware, and infrastructure can take up your valuable time. Discover the benefits of Heroku, the PaaS of choice for developers since 2007.

Visit Site

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.

Billboard image

The Next Generation Developer Platform

Coherence is the first Platform-as-a-Service you can control. Unlike "black-box" platforms that are opinionated about the infra you can deploy, Coherence is powered by CNC, the open-source IaC framework, which offers limitless customization.

Learn more

👋 Kindness is contagious

Please leave a ❤️ or a friendly comment on this post if you found it helpful!

Okay