============
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 <../front-end/structure-du-site.html#l-en-tete>`_, ou par la page
d'accueil, si elle est disponible.
.. figure:: ../images/design/en-tete.png
:align: center
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).
.. figure:: ../images/search/search-filters.png
:align: center
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 :
.. sourcecode:: python
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`` :
.. sourcecode:: python
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`` :
.. sourcecode:: python
"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_validated": 2.0, # s'il s'agit d'une publication validée (article ou tuto)
"if_validated_and_multipage": 2.5, # s'il s'agit d'une publication validée sur plusieurs pages (medium ou big)
"if_opinion": 1.66, # s'il s'agit d'un billet
"if_opinion_not_picked": 1.5, # s'il s'agit d'un billet non mis en avant sur la page d'accueil
# 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é <../install/extra-install-search-engine.html>`_, configuré et lancé, la commande suivante est utilisée :
.. sourcecode:: bash
python manage.py search_engine_manager
où ```` 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 :
.. sourcecode:: python
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//documents/``.
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 :
.. sourcecode:: python
@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 :
.. sourcecode:: python
@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')
où ``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 :
.. sourcecode:: python
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._get_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 ```` 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
<../back-end-code/search.html>`_.
.. 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
<../back-end-code/tutorialv2.html#zds.tutorialv2.models.database.FakeChapter>`_)
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éé.