La recherche

Principe

Comment faire une recherche ?

La recherche se découpe en deux parties distinctes :

  • L’indexation des données

  • La recherche par l’utilisateur

L’indexation des données

L’indexation des données consiste à rassembler toutes les données dans lesquelles l’utilisateur va pouvoir rechercher. Elle est faite au préalable. Celle-ci est faite de telle façon qu’on puisse rechercher dans les éléments suivants :

  • Les contenus (article, tutoriels et billets) ainsi que leurs chapitres (s’il s’agit d’un moyen ou big-tuto) ;

  • Les sujets ;

  • Les réponses aux sujets.

Cette indexation est réalisée à intervalle régulier (et de manière à n’indexer que les données qui ont changé).

La recherche

L’utilisateur peut utiliser la recherche, en utilisant la recherche de l’en-tête, ou par la page d’accueil, si elle est disponible.

../_images/en-tete.png

Des critères de recherche peuvent être ajoutés sur la page de recherche. Le seul critère de recherche disponible actuellement est le type de résultat (contenu, sujet du forum ou message du forum).

../_images/search-filters.png

Quelques mots sur Typesense

Typesense est un moteur de recherche qui permet d’indexer et de rechercher des données. Typesense offre une interface de type REST pour interroger son index, mais nous utilisons plutôt le module Python dédié.

Phase d’indexation

Typesense organise les données sous forme de documents, regroupés dans des collections. On peut avoir différent types de collections (par exemple pour Zeste de Savoir : topics, posts, contenus, chapitres, etc).

La phase d’indexation est réalisée à l’aide de la commande python manage.py search_engine_manager (voir ci-dessous).

Phase de recherche

Durant la phase de recherche, les documents sont classés par text_match, valeur qui représente le score de correspondance avec le texte recherché. Ce score dépend des champs que l’on souhaite indexer, il est calculé selon plusieurs métriques :

  • Fréquence : elle correspond au nombre de fois qu’un terme apparaît dans un document ;

  • Distance d’édition : si un terme de la requête n’est pas trouvé dans les documents, Typesense recherchera des mots qui diffèrent de la requête d’un certain nombre de caractères (num_typos) en ajoutant, supprimant ou remplaçant des caractères ;

  • Proximité : si la requête est constituée de plusieurs termes et que ces termes sont proches alors le score sera plus élevé. Par exemple, si la requête est « moteur de recherche ». Le titre Typesense est un moteur de recherche aura un meilleur score que le titre La recherche d’un nouveau moteur thermique à pistons rotatifs ;

  • Ordre des champs : si on a indiqué qu’on recherche selon les champs titre et description (dans cet ordre), alors le score sera plus important si le terme est trouvé dans le champ titre ;

  • Pondération des champs : si un document possède un champ titre et un champ description, alors avec des poids supérieur pour le champ titre, le score sera plus élevé si le terme est trouvé dans le titre.

Les différents poids sont modifiables directement dans les paramètres de Zeste de Savoir (voir ci-dessous).

Il est possible de rechercher dans plusieurs collections en une seule requête, avec un mécanisme que Typesense appele le Federated Multi-Search.

En pratique

Configuration

La configuration de la connexion se fait dans le fichier settings/abstract_base/zds.py, à l’aide des deux variables suivantes :

SEARCH_ENABLED = True

SEARCH_CONNECTION = {
    "nodes": [
        {
            "host": "localhost",
            "port": "8108",
            "protocol": "http",
        }
    ],
    "api_key": "xyz",
    "connection_timeout_seconds": 2,
}

La première active le moteur de recherche, la seconde permet de configurer la connexion au moteur de recherche.

Pour indiquer, les poids associés à chacune des collections, il faut modifier les variables suivantes dans settings/abstract_base/zds.py :

global_weight_publishedcontent = 3 # contenus publiés (billets, tutoriaux, articles)
global_weight_topic = 2 # sujets de forum
global_weight_chapter = 1.5 # chapitres
global_weight_post = 1 # messages d'un sujet de forum

Il est possible de modifier les différents paramètres de la recherche dans settings/abstract_base/zds.py :

"search": {
    "mark_keywords": ["javafx", "haskell", "groovy", "powershell", "latex", "linux", "windows"],
    "results_per_page": 20,
    "search_groups": {
        "publishedcontent": (_("Contenus publiés"), ["publishedcontent", "chapter"]),
        "topic": (_("Sujets du forum"), ["topic"]),
        "post": (_("Messages du forum"), ["post"]),
    },
    "search_content_type": {
        "tutorial": (_("Tutoriels"), ["tutorial"]),
        "article": (_("Articles"), ["article"]),
        "opinion": (_("Billet"), ["opinion"]),
    },
    "search_validated_content": {
        "validated": (_("Contenus validés"), ["validated"]),
        "no_validated": (_("Contenus libres"), ["no_validated"]),
    },
    "boosts": {
        "publishedcontent": {
            "global": global_weight_publishedcontent,
            "if_article": 2.0,  # s'il s'agit d'un article
            "if_tutorial": 2.0, # s'il s'agit d'un tuto
            "if_medium_or_big_tutorial": 2.5, # s'il s'agit d'un tuto d'une taille plutôt importante
            "if_opinion": 1.66, # s'il s'agit d'un billet
            "if_opinion_not_picked": 1.5, # s'il s'agit d'un billet pas mis en avant

            # poids des différents champs :
            "title": global_weight_publishedcontent * 3,
            "description": global_weight_publishedcontent * 2,
            "categories": global_weight_publishedcontent * 1,
            "subcategories": global_weight_publishedcontent * 1,
            "tags": global_weight_publishedcontent * 1,
            "text": global_weight_publishedcontent * 2,
        },
        "topic": {
            "global": global_weight_topic,
            "if_solved": 1.1, # s'il s'agit d'un sujet résolu
            "if_sticky": 1.2, # s'il s'agit d'un sujet épinglé
            "if_locked": 0.1, # s'il s'agit d'un sujet fermé

            # poids des différents champs :
            "title": global_weight_topic * 3,
            "subtitle": global_weight_topic * 2,
            "tags": global_weight_topic * 1,
        },
        "chapter": {
            "global": global_weight_chapter,

            # poids des différents champs :
            "title": global_weight_chapter * 3,
            "text": global_weight_chapter * 2,
        },
        "post": {
            "global": global_weight_post,
            "if_first": 1.2, # s'il s'agit d'un message en première position
            "if_useful": 1.5, # s'il s'agit d'un message jugé utile
            "ld_ratio_above_1": 1.05, # si le ratio pouce vert/rouge est supérieur à 1
            "ld_ratio_below_1": 0.95, # si le ratio pouce vert/rouge est inférieur à 1
            "text_html": global_weight_post, # poids du champ
        },
    },
  • results_per_page est le nombre de résultats affichés,

  • search_groups définit les différents types de documents indexés et la manière dont ils sont groupés sur le formulaire de recherche,

  • search_content_type définit les différents types de contenus publiés et la manière dont ils sont groupés sur le formulaire de recherche,

  • search_validated_content définit les différentes validations des contenus publiés et la manière dont elles sont groupées sur le formulaire de recherche,

  • boosts contient les différents facteurs de boost appliqués aux différentes situations. Modifier ces valeurs permet de changer l’ordre des résultats retourés lors d’une recherche.

Indexer les données

Une fois Typesense installé, configuré et lancé, la commande suivante est utilisée :

python manage.py search_engine_manager <action>

<action> peut être :

  • setup : crée et configure le client Typesense (y compris la création des collections avec schémas) ;

  • clear : supprime toutes les collections du client Typesense et marque toutes les données comme « à indexer » ;

  • index_flagged : indexe les données marquées comme « à indexer » ;

  • index_all : invoque setup puis indexe toute les données (qu’elles soient marquées comme « à indexer » ou non).

La commande index_flagged peut donc être lancée de manière régulière afin d’indexer les nouvelles données ou les données modifiées.

Note

Le caractère « à indexer » est fonction des actions effectuées sur l’objet Django (par défaut, à chaque fois que la méthode save() du modèle est appelée, l’objet est marqué comme « à indexer »). Cette information est stockée dans la base de donnée MySQL.

Aspects techniques

Indexation d’un modèle

Afin d’être indexable, un modèle Django doit dériver de AbstractSearchIndexableModel (qui dérive de models.Model et de AbstractSearchIndexable). Par exemple :

class Post(Comment, AbstractSearchIndexableModel):
    # ...

Note

Le code est écrit de manière à ce que l’id utilisé par Typesense (champ id) corresponde à la pk du modèle (via la variable search_engine_id). De cette façon, si on en connait la pk d’un objet Django, il est possible de récupérer l’objet Typesense correspondant à l’aide de GET /collections/<nom de la collection>/documents/<pk>.

Différentes méthodes de la classe AbstractSearchIndexableModel peuvent ou doivent ensuite être surchargées :

  • get_search_document_schema() permet de définir le schéma d’un document, c’est à dire quels champs seront indexés avec quels types. Par exemple :

    @classmethod
    def get_search_document_schema(cls):
        search_engine_schema = super().get_search_document_schema()
    
        search_engine_schema["fields"] = [
            {"name": "topic_pk", "type": "int64"},
            {"name": "forum_pk", "type": "int64"},
            {"name": "topic_title", "type": "string", "facet": True},
        # ...
    

    Les schémas Typesense sont des dictionnaires. On indique également dans les schémas un poids de recherche qui est calculé selon différent critères, ce champ correspond au boost que reçoit le contenu lors de la phase de recherche.

  • get_indexable_objects permet de définir quels objets doivent être récupérés et indexés. Cette fonction permet également d’utiliser prefetch_related() ou select_related() pour minimiser le nombre de requêtes SQL. Par exemple :

    @classmethod
    def get_indexable_objects(cls, force_reindexing=False):
        q = super(Post, cls).get_indexable_objects(force_reindexing)\
            .prefetch_related('topic')\
            .prefetch_related('topic__forum')
    

    q est un queryset Django.

  • get_document_source() permet de gérer des cas où le champ n’est pas directement une propriété de la classe, ou si cette propriété ne peut pas être indexée directement :

    def get_document_source(self, excluded_fields=None):
        excluded_fields = excluded_fields or []
        excluded_fields.extend(["tags", "forum_pk", "forum_title", "forum_get_absolute_url", "pubdate", "weight"])
    
        data = super().get_document_source(excluded_fields=excluded_fields)
        data["tags"] = [tag.title for tag in self.tags.all()]
        data["forum_pk"] = self.forum.pk
        data["forum_title"] = self.forum.title
        data["forum_get_absolute_url"] = self.forum.get_absolute_url()
        data["pubdate"] = date_to_timestamp_int(self.pubdate)
        data["text"] = clean_html(self.text_html)
        data["weight"] = self._compute_search_weight()
    
        return data
    

    Dans cet exemple (issu de la classe Post), on voit que certains champs ne peuvent être directement indexés car ils appartiennent au topic et au forum parent. Il sont donc exclus du mécanisme par défaut (via la variable excluded_fields) et leur valeur est récupérée et définie dans la suite de la méthode.

    Cet exemple permet également de remarquer que le contenu indéxé ne contient jamais de balises HTML (c’est le rôle de la fonction clean_html()). Il est ainsi possible d’afficher de façon sûre le contenu renvoyé par Typesense (utile en particulier pour afficher les balises <mark> pour surligner les termes recherchés).

Finalement, il est important pour chaque type de document d’attraper le signal de pré-suppression en base de données, afin que le document soit également supprimé du moteur de recherche.

Plus d’informations sur les méthodes qui peuvent être surchargées sont disponibles dans la documentation technique.

Attention

À chaque fois que vous modifiez la définition d’un schéma d’une collection dans get_search_document_schema(), toutes les données doivent être réindexées.

Le cas particulier des contenus

La plupart des informations des contenus, en particulier les textes, ne sont pas stockés dans la base de données.

Il a été choisi de n’inclure dans le moteur de recherche que les chapitres de ces contenus (anciennement, les introductions et conclusions des parties étaient également incluses). Ce sont les contenus HTML qui sont indexés et non leur version écrite en Markdown, afin de rester cohérent avec ce qui se fait pour les posts. Les avantages de cette décision sont multiples :

  • Le parsing est déjà effectué et n’a pas à être refait durant l’indexation ;

  • Moins de fichiers à lire (pour rappel, les différentes parties d’un contenu sont rassemblées en un seul fichier à la publication) ;

  • Pas besoin d’utiliser Git durant le processus d’indexation ;

L’indexation des chapitres (représentés par la classe FakeChapter, voir ici) est effectuée en même temps que l’indexation des contenus publiés (PublishedContent). En particulier, c’est la méthode get_indexable() qui est surchargée, profitant du fait que cette méthode peut renvoyer n’importe quel type de document à indexer.

Le code tient aussi compte du fait que la classe PublishedContent gère le changement de slug afin de maintenir le SEO. Ainsi, la méthode save() est modifiée de manière à supprimer toute référence à elle même et aux chapitres correspondants si un objet correspondant au même contenu mais avec un nouveau slug est créé.