Plein texte avec colonne générée PostgreSQL dans Django

Pour faire suite au tour d'horizon de la recherche plein texte PostgreSQL, en voici un exemple d'implémentation pour la langue française en utilisant une colonne générée PostgreSQL dans Django.

Attention, c'est à titre expérimental. Il y a du bricolage car les colonnes générées ne sont pas officiellement supportées par Django, ce qui demanderait un peu plus de taf !

J'ai testé cette méthode ici pour la recherche dans le blog, mais je conseille de rester sur des triggers pour le moment.

Configuration

Vous devez avoir une configuration de recherche personnalisée pour la langue française :

CREATE EXTENSION IF NOT EXISTS unaccent;

DROP TEXT SEARCH CONFIGURATION IF EXISTS french_unaccent;

CREATE TEXT SEARCH CONFIGURATION french_unaccent ( COPY = french );

ALTER TEXT SEARCH CONFIGURATION french_unaccent
    ALTER MAPPING FOR hword, hword_part, word
        WITH unaccent, french_stem;

Cette configuration peut être intégrée dans une migration Django.

Modèle

On ajoute au modèle :

  1. un champ SearchVectorField
  2. un GinIndex
  3. une surcharge de deux méthodes internes

La surcharge des méthodes internes est nécessaire car les modifications des colonnes générées sont interdites.

Sans ça, on pourrait tomber sur des erreurs du type column "full_text" can only be updated to DEFAULT, par exemple en passant par Model.save() qui met à jour tous les champs du modèle par défaut.

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

class Post(models.Model):

    # …

    title = models.CharField("Titre", max_length=255)
    content = models.TextField("Billet")
    full_text = SearchVectorField(null=True)

    class Meta:
        db_table = "blog_post"
        indexes = [GinIndex(fields=["full_text"], name="blog_post_full_text_gin")]

    def _do_insert(self, manager, using, fields, returning_fields, raw):
        fields = [f for f in fields if f.attname not in ["full_text"]]
        return super()._do_insert(manager, using, fields, returning_fields, raw)

    def _do_update(self, base_qs, using, pk_val, values, update_fields, forced_update):
        values = [value for value in values if value[0].attname not in ["full_text"]]
        return super()._do_update(
            base_qs, using, pk_val, values, update_fields, forced_update
        )

Migration

On édite la migration à la main avec :

  1. ajout du code SQL permettant la création d'une colonne générée
  2. ajout de state_operations pour que Django puisse générer un état correct du modèle (voir cette partie de la doc)

Un trigger peut aussi être utilisé à la place d'une colonne générée mais c'est désormais déconseillé du côté de la doc PostgreSQL :

The method described in this section has been obsoleted by the use of stored generated columns, as described in Section 12.2.2.

import django.contrib.postgres.indexes
import django.contrib.postgres.search
from django.db import migrations

class Migration(migrations.Migration):

    dependencies = []

    operations = [
        migrations.RunSQL(
            sql="""
              ALTER TABLE blog_post
                  ADD COLUMN full_text tsvector
                      GENERATED ALWAYS AS (
                          setweight(to_tsvector('french_unaccent', coalesce(title, '')), 'A')
                          || ' ' ||
                          setweight(to_tsvector('french_unaccent', coalesce(content,'')), 'B')
                      ) STORED;
            """,
            reverse_sql="""ALTER TABLE blog_post DROP COLUMN full_text;""",
            state_operations=[
                migrations.AddField(
                    model_name="post",
                    name="full_text",
                    field=django.contrib.postgres.search.SearchVectorField(null=True),
                ),
            ],
        ),
        migrations.AddIndex(
            model_name="post",
            index=django.contrib.postgres.indexes.GinIndex(
                fields=["full_text"], name="blog_post_full_text_gin"
            ),
        ),
    ]

Parmi les avantages de la colonne générée :

  • pas besoin d'écrire un custom trigger pour pouvoir pondérer un vecteur de recherche différemment en fonction des colonnes
  • pas besoin de déclencher le trigger à la main une première fois pour peupler les entrées déjà existantes

ORM

Il ne reste plus qu'à lancer des recherches plein texte grâce à l'ORM de Django :

search_term = form.cleaned_data["q"]

query = SearchQuery(
    search_term,
    search_type="websearch",
    config="french_unaccent"
)
rank = SearchRank(F("full_text"), query)

results = (
    Post.objects.annotate(rank=rank)
    .filter(full_text=query)
    .order_by("-rank")[:20]
)

Avant Redesign 2022 Après Gestion ennuyeuse des dépendances Python

Tag Kemar Joint