Jun 11, 2020

FullText Search en Django con Postgres

«Search». La empresa más exitosa en la historia de Internet se hizo famosa resolviendo eso, la búsqueda...

Aunque es un concepto simple, la búsqueda es algo en lo que muchos de nosotros (los desarrolladores) intentamos escatimar y por lo tanto, reducimos la calidad general de la experiencia del usuario, debido a resultados de búsqueda irrelevantes y una clasificación de búsqueda ilógica (¿mea culpa?).

En esta entrada voy a obviar el uso de alternativas como: Apache Solr, Elasticsearch, Whoosh, Xapian, etc. Por su parte, les voy a explicar sobre cómo hacer búsquedas con el ORM de Django usando PostgreSQL, ten en cuenta que la búsqueda de texto completo (Full Text Search) solo es compatible si utilizamos el backend de la base de datos PostgreSQL con Django 1.10 o posterior.


Introducción

Es muy probable que estes familiarizado con la búsqueda de patrones, que ha sido parte del estándar SQL desde el principio, y está disponible para todas las bases de datos basadas en SQL, de la siguiente forma:

SELECT column_name FROM table_name WHERE column_name LIKE 'pattern';

Eso devolverá las filas donde column_name coincide con el pattern, la forma mas cercana de traducir esa sentencia SQL a el ORM de Django es de la siguiente forma:

In [1]: Product.objects.filter(name__contains='Marinela')
# --> SELECT ... WHERE name LIKE '%Marinela%';
Out[2]: [<Product: Pinguinos Marinela>, <Product: Gansito Marinela>]

Si has usado Django por un buen periodo de tiempo, o entiendes los conceptos básicos del ORM, sabras que esa es la forma clásica de buscar, no hay nada nuevo aquí.

Entonces podrías comenzar a jugar con el API del ORM de Django para construir una mejor consulta, haciendo uso de las funciones avanzadas en PostgreSQL:

In [1]: Author.objects.filter(name__unaccent__icontains='Helen')
Out[2]:[<Author: Helen Mirren>, <Author: Helena Bonham Carter>, <Author: Hélène Joy>]

Hay que resaltar que las consultas que utilizan este filtro (unaccent) generalmente realizarán escaneo completos de tablas, que pueden ser lentos en tablas con una cantidad considerable de registros.

Aunque, quizás esta no sea la mejor manera de buscar, por mencionar uno de los problemas usando esta forma, sí buscas una palabra como "comida", y si está presente en su forma plural "comidas", entonces no la encontrará si intentas una búsqueda de patrón simple con LIKE, aunque la palabra de hecho allí está. Algunos de ustedes podrían estar pensando en usar expresiones regulares, y sí, se podrían usar, las expresiones regulares son increíblemente poderosas, aunque la implementación de consultas de búsqueda con expresiones regulares es una ruta peligrosa, no solo porque las expresiones regulares son notoriamente difíciles de hacer correctamente, sino porque pueden sufrir un rendimiento notoriamente lento, y conceptualmente no se traducen muy bien en los tipos de consultas de lenguaje natural que la mayoría de los usuarios esperan usar en sus búsquedas.

Búsqueda de texto completo

tl;DR:
"(...) full-text search refers to techniques for searching a single computer-stored document or a collection in a full text database; (...) distinguished from searches based on metadata or on parts of the original texts represented in databases (such as titles, abstracts, selected sections, or bibliographical references). Wikipedia"

En otras palabras, sí tienes un conjunto de documentos almacenados en una base de datos, estos documentos pueden tener metadatos como el nombre del autor, el lenguaje usado, un resumen del documento, o el documento en su totalidad, y usted desea saber si ciertas palabras (incluyendo sus variaciones) están presentes o no en ellos.

Usando formas de búsquedas como las mencionadas anteriormente (LIKE) puede ser muy complejo de ejecutar, es por ello que una forma más efectiva de abordar este problema es obtener un vector semántico para todas las palabras contenidas en un documento, es decir, una representación específica del lenguaje de tales palabras. Por lo tanto, cuando busca una palabra como "correr", coincidirá con todas las instancias de la palabra y sus tiempos, incluso si buscó "corrió" o "corre". Por otro lado, no buscará en el documento completo en si (que de por sí es lento), sino el vector (que es mucho más rápido).

La forma mas sencilla de utilizar full-text search es mediante el método <field_name>__search para buscar un solo término en una sola columna de la base de datos, por ejemplo:

In [1]: Product.objects.filter(name__search='Shiny')
Out[2]: [<Product: Shiny Shoes>, <Product: Very Shiny LED>]

Y si la información que buscas no es muy compleja, o solo necesitas buscar sobre una columna, quizás esta sea la mejor opción, aunque podrías usar todas las herramientas que dispones desde PostgreSQL para ejecutar una consulta de texto completo, es por ello que tenemos que usar tres nuevas funciones de Django propias de PostgreSQL:

  • SearchVector
  • SearchQuery
  • SearchRank
  • SearchVectorField (opcional)

A continuación explicaré, desde el punto de vista de PostgreSQL que hace cada una de estas funciones, y cuál es su objetivo en el proceso de búsqueda.

SearchVector

Es la abstracción de la función to_tsvector de PostgreSQL, que se encarga de devolver un vector, donde cada palabra es traducido un lexema (unidad de significado léxico) con punteros (las posiciones en el documento), y donde las palabras que tienen poco significado, como artículos (the) y conjunciones (and, or) son omitidas:

SELECT to_tsvector('Thanks so much for cooking dinner. I really appreciate it.');

Si ejecutas la instrucción anterior en la consola de PostgreSQL:

                      to_tsvector
-------------------------------------------------------
 'appreci':9 'cook':5 'dinner':6 'much':3 'realli':8 'thank':1

Lo que obtenemos es la normalización de cada palabra a un lexema en inglés (por ejemplo, "cooking" se convierte en "cook") con sus respectivas posiciones vectoriales, en este caso el numero 9 junto al lexema appreci no es nada mas que la posición de ese lexema en la oración. Ten en cuenta que esto podría variar dependiendo de la configuración de localización de su instalación de PostgreSQL, o si trabaja con un idioma diferente al inglés, aunque PostgreSQL puede manejarlo si lo pasa como argumento.

Puedes leer más en la siguiente liga https://www.postgresql.org/docs/9.1/datatype-textsearch.html

SearchQuery

Los to_tsquery aceptan un conjunto de palabras que se deben buscar dentro del vector normalizado que creamos mediante to_tsvector, estas palabras pueden ser combinadas usando los operadores booleanos & (AND), | (OR), y ! (NOT), también se pueden usar paréntesis para imponer la agrupación de los operadores, veamos el siguiente ejemplo:

SELECT to_tsvector('Thanks so much for cooking dinner. I really appreciate it.') @@ to_tsquery('cook');

Aquí usamos el operador @@ para comprobar que nuestra consulta de búsqueda (tsquery) concuerda con nuestro texto (tsvector), en caso de que sea verdadero, retornara el valor t es decir true.

 ?column?
----------
 t

Podrías hacer mas combinaciones de búsqueda, de la siguiente manera:

SELECT to_tsvector('Thanks so much for cooking dinner. I really appreciate it.') @@ to_tsquery('cook | dinner');

Puedes leer mas en la siguiente liga https://www.postgresql.org/docs/9.1/datatype-textsearch.html

SearchRank

La clasificación (Rank) mediante ts_rank o ts_rank_cd intenta medir qué tan relevantes son los documentos para una consulta en particular, de modo que cuando hay muchas coincidencias, las más relevantes se pueden mostrar primero. Para ello se consideran con qué frecuencia aparecen los términos de la consulta en el documento, qué tan juntos están los términos en el documento y qué tan importante es la parte del documento donde aparecen. Sin embargo, el concepto de relevancia es vago y muy específico de la aplicación. Las diferentes aplicaciones pueden requerir información adicional para la clasificación, por ejemplo, el tiempo de modificación del documento.

SearchVectorField

Para realizar búsquedas de texto completo de manera eficiente, una base de datos debe pre-procesar los datos y resumirlos como vectores de búsqueda. Debido a que lleva tiempo convertir las cadenas en vectores de búsqueda, también sería mejor guardar estos vectores de búsqueda en la base de datos. Desde Django 1.10, puede agregar SearchVectorField a un modelo y guardar el vector de búsqueda en esta columna, la cual se convertirá a TSVECTOR, que es un tipo de búsqueda de texto incorporado de PostgreSQL, recuerda que debes de mantener esta columna actualizada, según la instancia que estes tratando de crear o actualizar.

Puesta a marcha

Ahora que ya has entendido los conceptos básicos sobre la búsqueda en PostgreSQL, veamos como hacerlo en Django, para ello crearemos el modelo Article de la siguiente forma:

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

class Article(models.Model):
    headline = models.TextField()
    content = models.TextField()
    search_vector = SearchVectorField(null=True)

Recuerda que necesitamos mantener actualizado nuestro campo search_vector, aquí podrías usar diferentes técnicas como sobrescribir el método save de los modelos, usar un post_save signal, crear una tarea con celery para no bloquear el hilo principal de la aplicación, o incluso usar una función junto a un trigger de SQL, para efectos demostrativos, usaremos el método save de la siguiente forma:

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


class Article(models.Model):
    ...

    def save(self, *args, **kwargs):
        self.search_vector = (
            SearchVector('headline', weight='A')
            + SearchVector('content', weight='B')
        )

De esta forma nos aseguramos que nuestro campo search_vector se mantendrá actualizado cada que nuestro objeto Article sea creado o actualizado, a como podrás notar hemos agregado el "peso" a nuestros SearchVector con el propósito de mejorar los resultados de búsqueda según la relevancia de estos una vez que sean ranqueados, cabe resaltar que el argumento que toma SearchVector es el nombre de un campo del modelo.

Model manager

En caso de que tengas que usar el modelo Article en muchos lugares, lo mejor será centralizar la búsqueda, para ello nos auxiliaremos de los managers de Django de la siguiente forma:

from django.contrib.postgres.aggregates import StringAgg
from django.contrib.postgres.search import (
    SearchQuery, SearchRank, SearchVector, TrigramSimilarity,
)
from django.db import models


class ArticleManager(models.Manager):
    def search(self, search_text):
        search_vectors = (
            SearchVector(
                'headline', weight='A', config='english'
            )
            + SearchVector(
                StringAgg('content', delimiter=' '),
                weight='B',
                config='english',
            )
        )
        search_query = SearchQuery(
            search_text, config='english'
        )
        search_rank = SearchRank(search_vectors, search_query)
        trigram_similarity = TrigramSimilarity('headline', search_text)
        qs = (
            self.get_queryset()
            .filter(search_vector=search_query)
            .annotate(rank=search_rank + trigram_similarity)
            .order_by('-rank')
        )
        return qs

Y ahora lo que tenemos que hacer es simplemente importar nuestro manager y agregarlo al modelo:

from myapp.managers import ArticleManager


class Article(models.Model):
    ....
    objects = ArticleManager()

TrigramSimilarity

Un método de búsqueda tolerante a los errores tipográficos es la similitud de triagrama. Compara el número de triagramas, o tres caracteres consecutivos, compartidos entre los términos de búsqueda y el texto de destino. A diferencia de otras características, debemos asegurarnos de que primero se active una extensión llamada pg_trgm en PostgreSQL, puedes crear esta extension mediante SQL con CREATE EXTENSION pg_trgm; o bien crear una migración en blanco:

from django.contrib.postgres.operations import TrigramExtension


class Migration(migrations.Migration):
    ...

    operations = [
        TrigramExtension(),
        ...
    ]

Buscando...

Y ahora que ya tenemos todo completado, solo nos queda usar el método search que declaramos en nuestro manager para ejecutar consultas en Django mediante vectores de búsqueda, para ello usaremos una vista basada en función de la siguiente forma:

import json
from django.http import Http404, HttpResponse
from myapp.models import Article


def search_articles(request):
    search_term = request.GET.get('q', None)
    if not search_term:
        raise Http404('Envía un termino de búsqueda')

    articles = Articles.objects.search(search_term)

    response_data = [
        {
            'rank': art.rank,
            'headline': art.headline,
            'url': art.get_absolute_url(),
        }
        for art in articles
    ]

    return HttpResponse(
        json.dumps(response_data),
        content_type='application/json',
    )

Y listo ya con eso obtendrías resultados de búsquedas, ranqueados por orden de relevancia, sin necesidad de configurar otros servicios, en caso de que quieras indagar mas sobre este tema, te recomiendo que revises la funcion TrigramDistance para ver la diferencia entre los términos a buscar, y SearchHeadline en caso de que quieras resaltar los términos que hacen match en tu búsqueda.

Conclusiones

Las funciones de búsqueda de texto completo en PostgreSQL son muy potentes y rápidas. Y aunque configurar un motor de búsqueda requerirá algo de trabajo, hay que tener en cuenta que esta es una característica bastante avanzada, y que no hace mucho tiempo solía requerir un equipo completo de programadores y una amplia base de código. PostgreSQL ya hizo el trabajo pesado, ahora solo necesita ajustar aspectos menores para adaptarlo según las necesidades.

Espero que esto te haya dado una introducción básica a la gran cantidad de funciones de búsqueda de texto completo que ofrecen PostgreSQL y Django.

¡Feliz búsqueda!


Referencias: