Coffee bytes

Blog de desarrollo web con Python y Javascript

Búsquedas de texto con Django y Postgres

El jueves, 6 de mayo de 2021

por Eduardo Zepeda

Tiempo de lectura: 8 minutos

Algunas veces cuando tecleamos nos equivocamos en una letra, podemos repetirla, omitirla o cambiarla por otra. Ese error puede arrojar una serie de resultados diferentes en una búsqueda web, o incluso carecer de resultados. Quizás para un blog no represente una amenaza nada, pero para un ecommerce puede significar la perdida de una venta, y para aquellas tiendas con un tráfico gigantesco, una búsqueda de texto exitosa en Django u otro framework puede representar la diferencia entre perdidas o ganancias enormes.

Django es un framework que abstrae la mayor parte del código que necesitas para realizar búsquedas de texto en Postgres, por lo que si estás pensando en un proyecto que requiera de un buscador, usar Django combinado con Postgres son una combinación a tomar en cuenta.

Si aún estás dudando sobre si usar Django, revisa mi entrada donde te explico las ventajas que tiene Django para ofrecerle a tu proyecto.

Empecemos por lo básico.

contains e icontains con Django y Postgres

Django tiene una serie de funciones básicas que te permiten buscar la coincidencia exacta de una cadena de texto.

from videogame.models import Videogame
Videogame.objects.filter(name__contains="NIER")
<QuerySet []>
# ...WHERE "videogame_videogame"."name"::text LIKE %NIER%

Pero esto nos va a excluir las palabras «nier», «Nier» y cualquier otra diferencia causada por mayúsculas o minúsculas. Por lo que nos convendría realizar una búsqueda insensible a estas diferencias. Ahora no importa si el usuario uso mayúsculas o minúsculas. Observa como, internamente, la consulta SQL vuelve todo a mayúsculas.

Videogame.objects.filter(name__icontains="nier") # nota la i, antes de contains
<QuerySet [<Videogame: Nier automata>]>
#...WHERE UPPER("videogame_videogame"."name"::text) LIKE UPPER(%nier%)

¿Pero y si nuestro cadena a buscar tiene acentos? Una búsqueda para «nier» (sin acento) va a darnos resultados diferentes que «niér» (acentuada). Normalmente la gente en internet no cuida la correcta acentuación de las palabras. Por lo que lo que, para devolver lo que ellos están buscando, es necesario crear una búsqueda en la que la acentuación correcta sea irrelevante.

Videogame.objects.filter(name__icontains="tekkén")
<QuerySet []>
Videogame.objects.filter(name__unaccent__icontains="tekkén") # Ahora no importa que la palabra esté acentuada
<QuerySet [<Videogame: Tekken>]>
#...WHERE UPPER(UNACCENT("videogame_videogame"."name")::text) LIKE '%' || UPPER(REPLACE(REPLACE(REPLACE(UNACCENT(tekkén), E'\\', E'\\\\'), E'%', E'\\%'), E'_', E'\\_')) || '%'

Si cuando ejecutaste la búsqueda anterior te saltó un error es porque te falta instalar la extensión unnacent. Vamos a instalarla.

¿Cómo instalar las extensiones de Postgres en Django?

Prerrequisitos

Tener instalado psycopg2 y sus dependencias en tu entorno virtual.

pipenv install psycopg2 # también sirve con pip install psycopg2

De la misma manera, asegúrate de que tu proyecto tenga la aplicación django.contrib.postgres instalada y revisa que estés usando postgres en la variable DATABASES de tu archivo de configuración:

# settings.py

INSTALLED_APPS = [
    # ...
    'django.contrib.postgres',
    # ...
]

# ...

DATABASES = {
    'default': {
        'ENGINE': 'django.db.backends.postgresql_psycopg2',
        'NAME': 'base_de_datos',
        'USER': 'usuario',
        'PASSWORD': 'contrasena',
        'HOST': 'localhost',
        'PORT': '5432',
    }
}

Instalar extensiones de postgres en Django desde una migración

Para instalar una extensión nueva creamos una migración vacía que modificaremos a continuación. Ahora abrimos el archivo e instalamos las extensiones bajo la sección de operaciones.

./manage.py makemigrations tu_app --empty

Ahora colocamos en operaciones la extensión que deseamos instalar.

from django.contrib.postgres.operations import UnaccentExtension

class Migration(migrations.Migration):

    dependencies = [
        (<snip>)
    ]

    operations = [
        UnaccentExtension(),
        # TrigramExtension() # Descomenta esta linea para instalar esta extensión también
    ]

Corramos las migraciones.

./manage.py migrate

Listo, ahora tenemos instalada la extensión unaccent y, si descomentaste la linea del archivo de migraciones, TrigramExtension también estará instalada.

Instalar extensiones desde la consola de Postgres

Otra manera de instalar las extensiones es ejecutar el comando requerido directo de la base de datos. Para este ejemplo instalamos TrigramExtension, la extensión requerida para usar búsquedas con trigramas. Trataré el tema de los trigramas en la siguiente entrada, por lo que no te preocupes por eso, solo céntrate en el proceso de instalación de las extensiones.

Para entrar en la consola de la base de datos usaré el comando dbshell que nos provee Django.

python3 manage.py dbshell
psql (9.6.20)
conexión SSL (protocolo: TLSv1.2, cifrado: ECDHE-RSA-AES256-GCM-SHA384, bits: 256, compresión: desactivado)
Digite «help» para obtener ayuda.

basededatos=# CREATE EXTENSION pg_trgm;
CREATE EXTENSION

Con todas las funciones que vimos anteriormente ya podemos buscar con mayúsculas, minúsculas, acentos y sin acentos, pero, ¿y las búsquedas más complejas?

Django full text search o búsqueda de texto completo

Al realizar una búsqueda no tendría sentido buscar artículos y preposiciones, ya que nos devolvería demasiados resultados, por lo que es mejor omitirlos. Imagínate cuantos resultados obtendrías en una tienda en linea con mil artículos si buscas el artículo «él» o la preposición «en».

Otro aspecto que estaría genial para nuestras búsquedas es devolver palabras que coinciden con la misma lexema o base. Es decir, si nuestro usuario busca «gato», probablemente querremos devolverme también aquellos datos que coincidan con derivados de esa palabra: gato, gata, gatos, gatas, gatuno o cualquier otra palabra que empiece con «gat».

Todo lo anterior es bastante común en Postgres y ya está cubierto por la funcionalidad search de Django. Django incorpora búsqueda de texto completo o full text searching.

¿Y eso que es? Pues traduciendo directo de la página de postgres significa más o menos lo siguiente.

La búsqueda de texto completo (o solo búsqueda de texto) provee la capacidad de identificar documentos en lenguaje natural que satisfacen una consulta, y opcionalmente ordenarlos por su relevancia con la consulta. El tipo de búsqueda más común es encontrar todos los documentos que contienen ciertos términos de una consulta y retornarlos ordenados por su similitud a la consulta.

https://www.postgresql.org/docs/current/textsearch-intro.html

La similitud va a tomar en cuenta el número de veces que aparece la palabra, que tan distanciadas están las consultas de una búsqueda en el texto y otros factores que podemos establecer nosotros mismos.

Videogame.objects.filter(name__search="dutchman revenge")
<QuerySet [<Videogame: Spongebob SquarePants: Revenge of the Flying Dutchman>]>
# WHERE to_tsvector(COALESCE("videogame_videogame"."name", )) @@ plainto_tsquery(dutchman revenge)

¿No es genial? Le pasamos una frase formada por dos palabras a nuestra búsqueda, las palabras no son adyacentes en nuestros datos y aún así nos devolvió un resultado.

¿Cómo funciona search en Django?

Mira la consulta SQL del último bloque de código y observa las funciones to_tsvector y plainto_tsquery

La función search ejecuta la función to_tsvector, la cual toma el campo de nuestro modelo (en este caso name) y remueve las conjunciones, artículos y deja solo los lexemas (la parte de una palabra que no cambia de una palabra con el género y el número de una palabra por ejemplo: gat sería el lexema de gato, gata, gatos, gatas, etc.) y su posición en la frase que se le pasa como argumento.

SELECT to_tsvector('english', 'Spongebob SquarePants: Revenge of the Flying Dutchman');
                        to_tsvector                        
-----------------------------------------------------------
 'dutchman':7 'fli':6 'reveng':3 'spongebob':1 'squarep':2

Observa como se eliminaron los artículos y preposiciones (of y the) y como SquarePants se transformó en squarep, Revenge en reveng y flying en fli.

Así mismo, aprecia como especificamos el idioma. Postgres debe recibir el idioma correcto para identificar los lexemas y las proposiciones.

Por otro lado, la función plainto_tsquery transforma su argumento a un tsquery, que es la representación de las palabras de una frase de manera booleana.

SELECT plainto_tsquery('english', 'dutchman revenge');
    plainto_tsquery    
-----------------------
 'dutchman' & 'reveng'

¿Notaste que revenge se transformó en reveng?

Una vez que search tiene los resultados de cada función los compara para ver si coinciden o no.

De esta manera nuestra búsqueda será mucho más flexible y ya no será necesario que el usuario busque una cadena exacta de texto para poder devolverle los resultados que queremos.

Búsqueda de texto en múltiples campos de un modelo de Django

Buscar en un solo campo es bastante limitante, por lo que podemos usar SearchVector para buscar en múltiples campos, incluso en relaciones de llave foránea.

Basta con separar los nombres de los campos usando comas.

from django.contrib.postgres.search import SearchVector
Videogame.objects.annotate(
     search=SearchVector('name', 'description'),
 ).filter(search='Nier')

Así mismo, los objetos SearchVector pueden combinarse para una mejor legibilidad

from django.contrib.postgres.search import SearchVector
Videogame.objects.annotate(
     search=SearchVector('name') + SearchVector('description'),
 ).filter(search='Nier')

¿Te acuerdas que te dije que los lexemas y las proposiciones variaban según el idioma? Pues en el parámetro config podemos especificar el idioma sobre el cual queremos que postgres trabaje para esa consulta.

from django.contrib.postgres.search import SearchVector
from django.db import F

Videogame.objects.annotate(
     search=SearchVector('name', 'description', config=F('blog__language')), # config = 'spanish' también valia
 ).filter(search='Nier')

Repetir las llamadas a to_tsvector es ineficiente

Observa que cada vez que realizamos una consulta usando el ORM de Django, se ejecuta la función to_tsvector en el campo que nosotros le especifiquemos, pero ¿y si el campo de nuestro modelo contiene muchísima información? La función va a ejecutarse con cada búsqueda y va a devolver el mismo resultado una y otra vez, ¿no es un poco ineficiente? Pues sí, y los desarrolladores de Django ya pensaron en eso.

from django.db import models
from django.contrib.postgres.search import SearchVectorField

class Videogame(models.Model):
    name = models.CharField(max_length=256)
    created = models.DateTimeField(auto_now_add=True)
    modified = models.DateTimeField(auto_now=True)
    search_vector = SearchVectorField(blank=True, null=True)

    def __str__(self):
        return self.name

Este nuevo campo es como un campo cualquiera, al principio no tiene nada y podemos ponerle lo que sea, aunque probablemente querramos que contenga el vector de uno de los campos de nuestro modelo. Pero Django no lo hará automático, es responsabilidad nuestra mantenerlo actualizado con el contenido que nos convenga, ya sea sobreescribiendo el método save, usando signals, tareas periódicas, celery o cualquier aproximación que prefieras.

from django.contrib.postgres.search import SearchVector

Videogame.objects.update(search_vector=SearchVector('name'))
Videogame.objects.filter(search_vector='revenge')

Si te interesa profundizar más respecto a como maneja Postgres internamente estas funciones, encontré un excelente artículo donde explican en código SQL los vectores de búsqueda.

No te pierdas mi siguiente entrada, donde retomaré el tema de las búsquedas de texto en Django.

¿Te sirvió el contenido?

Recibe más contenido gratuito y en español como este en tu correo electrónico. Suscríbete, te toma unos segundos y puedes cancelar cuando quieras

* Campo obligatorio