Nous effectuons une recherche d'applications Web à partir de zéro

Dans l'article « CrĂ©er une application Web moderne Ă  partir de zĂ©ro », j'ai dĂ©crit Ă  quoi ressemble l'architecture des applications Web modernes Ă  forte charge et j'ai rassemblĂ© pour dĂ©monstration l'implĂ©mentation la plus simple d'une telle architecture sur une pile de plusieurs technologies et frameworks extrĂȘmement populaires et simples. Nous avons construit une application d'une seule page avec un rendu cĂŽtĂ© serveur, qui prend en charge l'affichage de certaines "cartes" saisies dans Markdown et la navigation entre elles.



Dans cet article, je vais aborder un sujet un peu plus complexe et intĂ©ressant (du moins pour moi, le dĂ©veloppeur de l'Ă©quipe de recherche): la recherche en texte intĂ©gral. Nous ajouterons un nƓud Elasticsearch Ă  notre rĂ©gion de conteneur, apprendrons Ă  crĂ©er un index et Ă  rechercher dans le contenu, en prenant les descriptions de cinq mille films de TMDB 5000 Movie Dataset comme donnĂ©es de test.... Nous apprendrons Ă©galement Ă  crĂ©er des filtres de recherche et Ă  creuser un peu le classement.





Infrastructure: Elasticsearch



Elasticsearch est un magasin de documents populaire qui peut créer des index de texte intégral et, en rÚgle générale, est utilisé spécifiquement comme moteur de recherche. Elasticsearch ajoute au moteur Apache Lucene sur lequel il est basé, le partitionnement, la réplication, une API JSON pratique et un million de détails supplémentaires qui en ont fait l'une des solutions de recherche en texte intégral les plus populaires.



Ajoutons un nƓud Elasticsearch au nître docker-compose.yml:



services:
  ...
  elasticsearch:
    image: "elasticsearch:7.5.1"
    environment:
      - discovery.type=single-node
    ports:
      - "9200:9200"
  ...


La variable d'environnement discovery.type=single-nodeindique Ă  Elasticsearch de se prĂ©parer Ă  travailler seul, plutĂŽt que de rechercher d'autres nƓuds et de fusionner avec eux dans un cluster (c'est le comportement par dĂ©faut).



Notez que nous publions le port 9200 vers l'extĂ©rieur, mĂȘme si notre application y va Ă  l'intĂ©rieur du rĂ©seau crĂ©Ă© par docker-compose. Ceci est purement pour le dĂ©bogage: de cette façon, nous pouvons accĂ©der Ă  Elasticsearch directement Ă  partir du terminal (jusqu'Ă  ce que nous trouvions un moyen plus intelligent - plus d'informations ci-dessous).



Ajouter le client Elasticsearch dans notre cĂąblage n'est pas difficile - le bon, Elastic fournit un client Python minimaliste .



Indexage



Dans le dernier article, nous avons mis nos principales entités - "cartes" dans une collection MongoDB. Nous sommes en mesure de récupérer rapidement leur contenu à partir d'une collection par identifiant, car MongoDB a construit un index direct pour nous - il utilise des arbres B pour cela .



Nous sommes maintenant confrontés à la tùche inverse - par le contenu (ou ses fragments) pour obtenir les identifiants des cartes. Par conséquent, nous avons besoin d'un index inversé . C'est là qu'Elasticsearch est utile!



Le schéma général de construction d'un index ressemble généralement à ceci.



  1. Créez un nouvel index vide avec un nom unique, configurez-le selon vos besoins.
  2. Nous parcourons toutes nos entités dans la base de données et les mettons dans un nouvel index.
  3. Nous commutons la production afin que toutes les requĂȘtes commencent Ă  aller vers le nouvel index.
  4. Suppression de l'ancien index. Ici, Ă  volontĂ© - vous voudrez peut-ĂȘtre stocker les derniers index, de sorte que, par exemple, il serait plus pratique de dĂ©boguer certains problĂšmes.


Créons le squelette d'un indexeur, puis allons plus en détail à chaque étape.



import datetime

from elasticsearch import Elasticsearch, NotFoundError

from backend.storage.card import Card, CardDAO


class Indexer(object):

    def __init__(self, elasticsearch_client: Elasticsearch, card_dao: CardDAO, cards_index_alias: str):
        self.elasticsearch_client = elasticsearch_client
        self.card_dao = card_dao
        self.cards_index_alias = cards_index_alias

    def build_new_cards_index(self) -> str:
        #   .
        #      .
        index_name = "cards-" + datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")

        #   . 
        #        .
        self.create_empty_cards_index(index_name)

        #         .
        #        
        #       .
        for card in self.card_dao.get_all():
            self.put_card_into_index(card, index_name)
        return index_name

    def create_empty_cards_index(self, index_name):
        ... 

    def put_card_into_index(self, card: Card, index_name: str):
        ...

    def switch_current_cards_index(self, new_index_name: str):
        ... 


Indexation: création d'un index



Un index dans Elasticsearch est crĂ©Ă© par une simple requĂȘte PUT Ă  /-ou, dans le cas de l'utilisation d'un client Python (dans notre cas), en appelant



elasticsearch_client.indices.create(index_name, {
    ...
})


Le corps de la requĂȘte peut contenir trois champs.



  • Description des alias ( "aliases": ...). Le systĂšme d'alias vous permet de savoir quel index est actuellement Ă  jour du cĂŽtĂ© d'Elasticsearch; nous en parlerons ci-dessous.
  • ParamĂštres ( "settings": ...). Lorsque nous sommes de grands joueurs avec une vraie production, nous pourrons configurer la rĂ©plication, le partitionnement et d'autres joies SRE ici.
  • SchĂ©ma de donnĂ©es ( "mappings": ...). Ici, nous pouvons spĂ©cifier quel type de champs dans les documents que nous allons indexer, pour lesquels de ces champs nous avons besoin d'indices inverses, pour quelles agrĂ©gations doivent ĂȘtre prises en charge, etc.


Maintenant, nous ne sommes intéressés que par le schéma, et nous l'avons trÚs simple:



{
    "mappings": {
        "properties": {
            "name": {
                "type": "text",
                "analyzer": "english"
            },
            "text": {
                "type": "text",
                "analyzer": "english"
            },
            "tags": {
                "type": "keyword",
                "fields": {
                    "text": {
                        "type": "text",
                        "analyzer": "english"
                    }
                }
            }
        }
    }
}


Nous avons marqué le champ name, ainsi textque le texte en anglais. Un analyseur est une entité dans Elasticsearch qui traite le texte avant de le stocker dans l'index. Dans le cas de l' englishanalyseur, le texte sera divisé en jetons le long des limites des mots ( détails ), aprÚs quoi les jetons individuels seront lemmatisés selon les rÚgles de la langue anglaise (par exemple, le mot treessera simplifié en tree), les lemmes trop généraux (en quelque sorte the) seront supprimés et les lemmes restants seront placés dans l'index inversé.



Le domaine est un tagspeu plus compliquĂ©. Un typekeywordsuppose que les valeurs de ce champ sont des constantes de chaĂźne qui n'ont pas besoin d'ĂȘtre traitĂ©es par l'analyseur; l'indice inverse sera construit Ă  partir de leurs valeurs «brutes» - sans tokenisation ni lemmatisation. Mais Elasticsearch crĂ©era des structures de donnĂ©es spĂ©ciales afin que les agrĂ©gations puissent ĂȘtre lues par les valeurs de ce champ (par exemple, afin que, simultanĂ©ment Ă  la recherche, vous puissiez savoir quelles balises ont Ă©tĂ© trouvĂ©es dans les documents qui satisfont la requĂȘte de recherche, et en quelle quantitĂ©). C'est parfait pour les champs qui sont essentiellement enum; nous utiliserons cette fonctionnalitĂ© pour crĂ©er des filtres de recherche sympas.



Mais pour que le texte des balises puisse Ă©galement ĂȘtre recherchĂ© par recherche de texte, nous lui ajoutons un sous-champ "text", configurĂ© par analogie avec nameettextci-dessus - en substance, cela signifie qu'Elasticsearch crĂ©era un autre champ "virtuel" sous le nom dans tous les documents qu'il reçoit tags.text, dans lequel il copiera le contenu tags, mais l'indexera selon des rĂšgles diffĂ©rentes.



Indexation: remplissage de l'index



Pour indexer un document, il suffit de faire une requĂȘte PUT /-/_create/id-ou, lors de l'utilisation d'un client Python, d'appeler simplement la mĂ©thode requise. Notre implĂ©mentation ressemblera Ă  ceci:



    def put_card_into_index(self, card: Card, index_name: str):
        self.elasticsearch_client.create(index_name, card.id, {
            "name": card.name,
            "text": card.markdown,
            "tags": card.tags,
        })


Faites attention au terrain tags. Bien que nous l'ayons décrit comme contenant un mot-clé, nous n'envoyons pas une seule chaßne, mais une liste de chaßnes. Elasticsearch prend en charge cela; notre document sera situé à l'une des valeurs.



Indexation: changement d'index



Pour implémenter une recherche, nous devons connaßtre le nom de l'index entiÚrement construit le plus récent. Le mécanisme d'alias nous permet de conserver ces informations du cÎté d'Elasticsearch.



Un alias est un pointeur vers zéro ou plusieurs index. L'API Elasticsearch vous permet d'utiliser un nom d'alias au lieu d'un nom d'index lors de la recherche (POST /-/_searchau lieu de POST /-/_search); dans ce cas, Elasticsearch recherchera tous les index pointés par l'alias.



Nous allons créer un alias appelé cards, qui pointera toujours vers l'index actuel. En conséquence, le passage à l'indice réel aprÚs l'achÚvement de la construction ressemblera à ceci:



    def switch_current_cards_index(self, new_index_name: str):
        try:
            #      ,   .
            remove_actions = [
                {
                    "remove": {
                        "index": index_name, 
                        "alias": self.cards_index_alias,
                    }
                }
                for index_name in self.elasticsearch_client.indices.get_alias(name=self.cards_index_alias)
            ]
        except NotFoundError:
            # ,  -    .
            # ,    .
            remove_actions = []

        #        
        #     .
        self.elasticsearch_client.indices.update_aliases({
            "actions": remove_actions + [{
                "add": {
                    "index": new_index_name, 
                    "alias": self.cards_index_alias,
                }
            }]
        })


Je n'entrerai pas plus en détail sur l'API d'alias; tous les détails se trouvent dans la documentation .



Ici, il est nĂ©cessaire de faire une remarque que dans un service rĂ©el trĂšs chargĂ©, un tel commutateur peut ĂȘtre assez pĂ©nible et il peut ĂȘtre judicieux de faire un prĂ©chauffage prĂ©liminaire - chargez le nouvel index avec une sorte de pool de requĂȘtes utilisateur enregistrĂ©es.



Tout le code qui implémente l'indexation se trouve dans ce commit .



Indexation: ajout de contenu



Pour la dĂ©monstration de cet article, j'utilise les donnĂ©es du jeu de donnĂ©es TMDB 5000 Movie . Pour Ă©viter les problĂšmes de droits d'auteur, je ne fournis que le code de l'utilitaire qui les importe depuis un fichier CSV, que je vous suggĂšre de tĂ©lĂ©charger vous-mĂȘme depuis le site Web de Kaggle. AprĂšs le tĂ©lĂ©chargement, exĂ©cutez simplement la commande



docker-compose exec -T backend python -m tools.add_movies < ~/Downloads/tmdb-movie-metadata/tmdb_5000_movies.csv


pour créer cinq mille cartes de films et une équipe



docker-compose exec backend python -m tools.build_index


pour créer un index. Veuillez noter que la derniÚre commande ne construit pas réellement l'index, mais met uniquement la tùche dans la file d'attente des tùches, aprÚs quoi elle sera exécutée sur le travailleur - j'ai discuté de cette approche plus en détail dans le dernier article . docker-compose logs workervous montrer comment le travailleur a essayé!



Avant de commencer, en fait, la recherche, nous voulons voir de nos propres yeux si quelque chose est écrit dans Elasticsearch, et si oui, à quoi ça ressemble!



Le moyen le plus direct et le plus rapide de le faire est d'utiliser l'API HTTP Elasticsearch. Commençons par vĂ©rifier oĂč pointe l'alias:



$ curl -s localhost:9200/_cat/aliases
cards                cards-2020-09-20-16-14-18 - - - -


GĂ©nial, l'index existe! Regardons cela de prĂšs:



$ curl -s localhost:9200/cards-2020-09-20-16-14-18 | jq
{
  "cards-2020-09-20-16-14-18": {
    "aliases": {
      "cards": {}
    },
    "mappings": {
      ...
    },
    "settings": {
      "index": {
        "creation_date": "1600618458522",
        "number_of_shards": "1",
        "number_of_replicas": "1",
        "uuid": "iLX7A8WZQuCkRSOd7mjgMg",
        "version": {
          "created": "7050199"
        },
        "provided_name": "cards-2020-09-20-16-14-18"
      }
    }
  }
}


Enfin, jetons un coup d'Ɠil à son contenu:



$ curl -s localhost:9200/cards-2020-09-20-16-14-18/_search | jq
{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 4704,
      "relation": "eq"
    },
    "max_score": 1,
    "hits": [
      ...
    ]
  }
}


Au total, notre index est de 4704 documents, et dans le champ hits(que j'ai sautĂ© car il est trop grand), vous pouvez mĂȘme voir le contenu de certains d'entre eux. SuccĂšs!



Un moyen plus pratique de parcourir le contenu de l'index et généralement toutes sortes de soins avec Elasticsearch serait d'utiliser Kibana . Ajoutons le conteneur à docker-compose.yml:



services:
  ...
  kibana:
    image: "kibana:7.5.1"
    ports:
      - "5601:5601"
    depends_on:
      - elasticsearch
  ...


AprÚs une seconde fois, docker-compose upnous pouvons aller à Kibana à l'adresse localhost:5601(attention, le serveur risque de ne pas démarrer rapidement) et, aprÚs une courte configuration, visualiser le contenu de nos index dans une jolie interface web.







Je recommande vivement l'onglet Outils de dĂ©veloppement - pendant le dĂ©veloppement, vous devrez souvent effectuer certaines requĂȘtes dans Elasticsearch, et en mode interactif avec saisie automatique et mise en forme automatique, c'est beaucoup plus pratique.



Chercher



AprÚs toutes les préparations incroyablement ennuyeuses, il est temps pour nous d'ajouter des fonctionnalités de recherche à notre application Web!



Divisons cette tùche non triviale en trois étapes et discutons chacune séparément.



  1. Ajoutez un composant Searcherresponsable de la logique de recherche au backend . Il formera une requĂȘte Ă  Elasticsearch et convertira les rĂ©sultats en plus digestes pour notre backend.
  2. Ajoutez un point de terminaison à l'API (handle / route / comment l'appelez-vous dans votre entreprise?) Qui /cards/searcheffectue la recherche. Il appellera la méthode du composant Searcher, traitera les résultats et les renverra au client.
  3. Implémentons l'interface de recherche sur le frontend. Il contactera /cards/searchlorsque l'utilisateur aura décidé ce qu'il souhaite rechercher et affichera les résultats (et, éventuellement, des contrÎles supplémentaires).


Recherche: nous implémentons



Il n'est pas si difficile d'écrire un gestionnaire de recherche que d'en concevoir un. Décrivons le résultat de la recherche et l'interface du gestionnaire et expliquons pourquoi c'est ceci et non différent.



# backend/backend/search/searcher.py

import abc
from dataclasses import dataclass
from typing import Iterable, Optional


@dataclass
class CardSearchResult:
    total_count: int
    card_ids: Iterable[str]
    next_card_offset: Optional[int]


class Searcher(metaclass=abc.ABCMeta):

    @abc.abstractmethod
    def search_cards(self, query: str = "", 
                     count: int = 20, offset: int = 0) -> CardSearchResult:
        pass


Certaines choses sont évidentes. Par exemple, la pagination. Nous sommes une jeune startup ambitieuse et tueur IMDB , et les résultats de recherche ne rentreront jamais sur une seule page!



Certains sont moins Ă©vidents. Par exemple, une liste d'identifiants, pas de cartes en consĂ©quence. Elasticsearch stocke tous nos documents par dĂ©faut et les renvoie dans les rĂ©sultats de recherche. Ce comportement peut ĂȘtre dĂ©sactivĂ© pour Ă©conomiser sur la taille de l'index de recherche, mais pour nous, il s'agit clairement d'une optimisation prĂ©maturĂ©e. Alors pourquoi ne pas retourner les cartes tout de suite? RĂ©ponse: cela violerait le principe de la responsabilitĂ© unique. Peut-ĂȘtre qu'un jour nous mettrons en place une logique complexe dans le gestionnaire de cartes qui traduit les cartes dans d'autres langues, en fonction des paramĂštres de l'utilisateur. Exactement Ă  ce moment, les donnĂ©es sur la page de la fiche et les donnĂ©es dans les rĂ©sultats de la recherche seront dispersĂ©es, car nous oublierons d'ajouter la mĂȘme logique au gestionnaire de recherche. Et ainsi de suite.



L'implémentation de cette interface est si simple que j'étais trop paresseux pour écrire cette section :-(



# backend/backend/search/searcher_impl.py

from typing import Any

from elasticsearch import Elasticsearch

from backend.search.searcher import CardSearchResult, Searcher


ElasticsearchQuery = Any  #   


class ElasticsearchSearcher(Searcher):

    def __init__(self, elasticsearch_client: Elasticsearch, cards_index_name: str):
        self.elasticsearch_client = elasticsearch_client
        self.cards_index_name = cards_index_name

    def search_cards(self, query: str = "", count: int = 20, offset: int = 0) -> CardSearchResult:
        result = self.elasticsearch_client.search(index=self.cards_index_name, body={
            "size": count,
            "from": offset,
            "query": self._make_text_query(query) if query else self._match_all_query
        })
        total_count = result["hits"]["total"]["value"]
        return CardSearchResult(
            total_count=total_count,
            card_ids=[hit["_id"] for hit in result["hits"]["hits"]],
            next_card_offset=offset + count if offset + count < total_count else None,
        )

    def _make_text_query(self, query: str) -> ElasticsearchQuery:
        return {
            # Multi-match query     
            #    (   match
            # query,     ).
            "multi_match": {
                "query": query,
                #   ^ – .   
                #    ,     .
                "fields": ["name^3", "tags.text", "text"],
            }
        }

    _match_all_query: ElasticsearchQuery = {"match_all": {}}


En fait, nous allons simplement à l'API Elasticsearch et extrayons soigneusement les identifiants des cartes trouvées du résultat.



L'implémentation du point de terminaison est également assez triviale:



# backend/backend/server.py

...

    def search_cards(self):
        request = flask.request.json
        search_result = self.wiring.searcher.search_cards(**request)
        cards = self.wiring.card_dao.get_by_ids(search_result.card_ids)
        return flask.jsonify({
            "totalCount": search_result.total_count,
            "cards": [
                {
                    "id": card.id,
                    "slug": card.slug,
                    "name": card.name,
                    #     ,    
                    #     ,   
                    #  .
                } for card in cards
            ],
            "nextCardOffset": search_result.next_card_offset,
        })

...


La mise en Ɠuvre du frontend utilisant ce point de terminaison, bien que volumineuse, est gĂ©nĂ©ralement assez simple et dans cet article je ne veux pas m'Ă©tendre dessus. Le code entier peut ĂȘtre visualisĂ© dans ce commit .







Jusqu'ici tout va bien, passons Ă  autre chose.



Recherche: ajout de filtres



La recherche dans le texte est cool, mais si vous avez déjà cherché des ressources sérieuses, vous avez probablement vu toutes sortes de goodies comme des filtres.



Nos descriptions de films de la base de données TMDB 5000 ont des balises en plus des titres et des descriptions, alors implémentons des filtres par balises pour la formation. Notre objectif est sur la capture d'écran: lorsque vous cliquez sur un tag, seuls les films avec ce tag doivent rester dans les résultats de la recherche (leur numéro est indiqué entre parenthÚses à cÎté).





Pour implémenter des filtres, nous devons résoudre deux problÚmes.



  • Apprenez Ă  comprendre sur demande quel ensemble de filtres est disponible. Nous ne voulons pas afficher toutes les valeurs de filtre possibles sur chaque Ă©cran, car il y en a beaucoup et la plupart conduiront Ă  un rĂ©sultat vide; vous devez comprendre les balises des documents trouvĂ©s sur demande et, idĂ©alement, laisser le N le plus populaire.
  • Pour apprendre, en effet, Ă  appliquer un filtre - pour ne laisser dans les rĂ©sultats de recherche que les documents avec des balises, le filtre par lequel l'utilisateur a choisi.


La seconde dans Elasticsearch est simplement implĂ©mentĂ©e via l'API de requĂȘte (voir les termes de requĂȘte ), la premiĂšre via un mĂ©canisme d'agrĂ©gation lĂ©gĂšrement moins trivial .



Nous devons donc savoir quelles balises se trouvent dans les cartes trouvĂ©es et ĂȘtre en mesure de filtrer les cartes avec les balises nĂ©cessaires. Tout d'abord, mettons Ă  jour la conception du gestionnaire de recherche:



# backend/backend/search/searcher.py

import abc
from dataclasses import dataclass
from typing import Iterable, Optional


@dataclass
class TagStats:
    tag: str
    cards_count: int


@dataclass
class CardSearchResult:
    total_count: int
    card_ids: Iterable[str]
    next_card_offset: Optional[int]
    tag_stats: Iterable[TagStats]


class Searcher(metaclass=abc.ABCMeta):

    @abc.abstractmethod
    def search_cards(self, query: str = "", 
                     count: int = 20, offset: int = 0,
                     tags: Optional[Iterable[str]] = None) -> CardSearchResult:
        pass


Passons maintenant Ă  la mise en Ɠuvre. La premiĂšre chose Ă  faire est de crĂ©er une agrĂ©gation par le champ tags:



--- a/backend/backend/search/searcher_impl.py
+++ b/backend/backend/search/searcher_impl.py
@@ -10,6 +10,8 @@ ElasticsearchQuery = Any
 
 class ElasticsearchSearcher(Searcher):
 
+    TAGS_AGGREGATION_NAME = "tags_aggregation"
+
     def __init__(self, elasticsearch_client: Elasticsearch, cards_index_name: str):
         self.elasticsearch_client = elasticsearch_client
         self.cards_index_name = cards_index_name
@@ -18,7 +20,12 @@ class ElasticsearchSearcher(Searcher):
         result = self.elasticsearch_client.search(index=self.cards_index_name, body={
             "size": count,
             "from": offset,
             "query": self._make_text_query(query) if query else self._match_all_query,
+            "aggregations": {
+                self.TAGS_AGGREGATION_NAME: {
+                    "terms": {"field": "tags"}
+                }
+            }
         })


Désormais, dans le résultat de la recherche d'Elasticsearch, un champ viendra à aggregationspartir duquel, à l'aide d'une clé, TAGS_AGGREGATION_NAMEnous pouvons obtenir des compartiments contenant des informations sur les valeurs du champ tagspour les documents trouvés et la fréquence à laquelle elles se produisent. Extrayons ces données et renvoyons-les comme prévu ci-dessus:



--- a/backend/backend/search/searcher_impl.py
+++ b/backend/backend/search/searcher_impl.py
@@ -28,10 +28,15 @@ class ElasticsearchSearcher(Searcher):
         total_count = result["hits"]["total"]["value"]
+        tag_stats = [
+            TagStats(tag=bucket["key"], cards_count=bucket["doc_count"])
+            for bucket in result["aggregations"][self.TAGS_AGGREGATION_NAME]["buckets"]
+        ]
         return CardSearchResult(
             total_count=total_count,
             card_ids=[hit["_id"] for hit in result["hits"]["hits"]],
             next_card_offset=offset + count if offset + count < total_count else None,
+            tag_stats=tag_stats,
         )


L'ajout d'une application de filtrage est la partie la plus simple:



--- a/backend/backend/search/searcher_impl.py
+++ b/backend/backend/search/searcher_impl.py
@@ -16,11 +16,17 @@ class ElasticsearchSearcher(Searcher):
         self.elasticsearch_client = elasticsearch_client
         self.cards_index_name = cards_index_name
 
-    def search_cards(self, query: str = "", count: int = 20, offset: int = 0) -> CardSearchResult:
+    def search_cards(self, query: str = "", count: int = 20, offset: int = 0,
+                     tags: Optional[Iterable[str]] = None) -> CardSearchResult:
         result = self.elasticsearch_client.search(index=self.cards_index_name, body={
             "size": count,
             "from": offset,
-            "query": self._make_text_query(query) if query else self._match_all_query,
+            "query": {
+                "bool": {
+                    "must": self._make_text_queries(query),
+                    "filter": self._make_filter_queries(tags),
+                }
+            },
             "aggregations": {


Les sous-requĂȘtes incluses dans la clause must sont obligatoires, mais elles seront Ă©galement prises en compte lors du calcul de la vitesse des documents et, en consĂ©quence, du classement; si jamais nous ajoutons des conditions supplĂ©mentaires aux textes, il vaut mieux les ajouter ici. Les sous-requĂȘtes de la clause de filtrage filtrent uniquement sans affecter la vitesse et le classement.



Il reste à mettre en Ɠuvre _make_filter_queries():



    def _make_filter_queries(self, tags: Optional[Iterable[str]] = None) -> List[ElasticsearchQuery]:
        return [] if tags is None else [{
            "term": {
                "tags": {
                    "value": tag
                }
            }
        } for tag in tags]


Encore une fois, je ne m'attarderai pas sur la partie frontale; tout le code est dans ce commit .



Variant



Ainsi, notre recherche recherche les cartes, les filtre en fonction d'une liste de balises donnĂ©e et les affiche dans un certain ordre. Mais lequel? La commande est trĂšs importante pour une recherche pratique, mais tout ce que nous avons fait lors de notre contentieux en termes de commande a Ă©tĂ© laissĂ© entendre Ă  Elasticsearch qu'il est plus rentable de trouver des mots dans l'en-tĂȘte de la carte que dans la description ou les balises en prĂ©cisant la prioritĂ© ^3dans la requĂȘte multi-match.



MalgrĂ© le fait que, par dĂ©faut, Elasticsearch classe les documents avec une formule assez dĂ©licate basĂ©e sur TF-IDF, pour notre startup imaginaire ambitieuse, cela ne suffit guĂšre. Si nos documents sont des marchandises, nous devons ĂȘtre en mesure de rendre compte de leurs ventes; s'il s'agit d'un contenu gĂ©nĂ©rĂ© par l'utilisateur, ĂȘtre capable de prendre en compte sa fraĂźcheur, etc. Mais nous ne pouvons pas simplement trier par le nombre de ventes / date d'ajout, car alors nous ne prendrons pas en compte la pertinence par rapport Ă  la requĂȘte de recherche.



Le classement est un domaine technologique vaste et dĂ©routant qui ne peut pas ĂȘtre couvert dans une section Ă  la fin de cet article. Je passe donc ici aux gros traits; Je vais essayer de vous dire dans les termes les plus gĂ©nĂ©raux comment le classement de qualitĂ© industrielle peut ĂȘtre organisĂ© dans la recherche, et je vais rĂ©vĂ©ler quelques dĂ©tails techniques sur la façon dont il peut ĂȘtre implĂ©mentĂ© avec Elasticsearch.



La tùche de classement est trÚs complexe, il n'est donc pas surprenant que l'une des principales méthodes modernes de résolution de ce problÚme soit l'apprentissage automatique. L'application des technologies d'apprentissage automatique au classement est appelée collectivement apprendre à classer .



Un processus typique ressemble Ă  ceci.



Nous dĂ©cidons de ce que nous voulons classer . Nous mettons les entitĂ©s qui nous intĂ©ressent dans l'index, apprenons Ă  obtenir un top raisonnable (par exemple, un tri et une coupure simples) de ces entitĂ©s pour une requĂȘte de recherche donnĂ©e, et maintenant nous voulons apprendre Ă  la classer de maniĂšre plus intelligente.



DĂ©terminer comment nous voulons nous classer... Nous dĂ©cidons sur quelle caractĂ©ristique nous voulons classer nos rĂ©sultats en fonction des objectifs commerciaux de notre service. Par exemple, si nos entitĂ©s sont des produits que nous vendons, nous pouvons souhaiter les trier par ordre dĂ©croissant de probabilitĂ© d'achat; si les mĂšmes - par probabilitĂ© d'aimer ou de partager, et ainsi de suite. Nous ne savons bien sĂ»r pas comment calculer ces probabilitĂ©s - au mieux nous pouvons estimer, et mĂȘme alors seulement pour les anciennes entitĂ©s pour lesquelles nous avons suffisamment de statistiques - mais nous allons essayer d'apprendre au modĂšle Ă  les prĂ©dire sur la base de signes indirects.



Extraire des signes... Nous proposons un ensemble de fonctionnalitĂ©s pour nos entitĂ©s qui pourraient nous aider Ă  Ă©valuer la pertinence des entitĂ©s pour les requĂȘtes de recherche. En plus du mĂȘme TF-IDF, qui sait dĂ©jĂ  calculer Elasticsearch pour nous, un exemple typique est le CTR (taux de clics): nous prenons les logs de notre service pendant tout le temps, pour chaque paire d'entitĂ© + requĂȘte de recherche nous comptons combien de fois l'entitĂ© est apparue dans les rĂ©sultats de recherche pour cette requĂȘte et combien de fois il a Ă©tĂ© cliquĂ©, nous divisons l'une par l'autre, et voilĂ  - l'estimation la plus simple de la probabilitĂ© de clic conditionnelle est prĂȘte. Nous pouvons Ă©galement proposer des traits spĂ©cifiques Ă  l'utilisateur et des traits associĂ©s Ă  une entitĂ© utilisateur pour personnaliser les classements. AprĂšs avoir trouvĂ© des signes, nous Ă©crivons du code qui les calcule, les met dans une sorte de stockage et sait comment les donner en temps rĂ©el pour une requĂȘte de recherche donnĂ©e, un utilisateur et un ensemble d'entitĂ©s.



Rassembler un ensemble de donnĂ©es d'entraĂźnement . Il existe de nombreuses options, mais toutes, en rĂšgle gĂ©nĂ©rale, sont formĂ©es Ă  partir des journaux d'Ă©vĂ©nements "bons" (par exemple, un clic puis un achat) et "mauvais" (par exemple, un clic et un retour au problĂšme) dans notre service. Lorsque nous avons collectĂ© un ensemble de donnĂ©es, qu'il s'agisse d'une liste d'Ă©noncĂ©s «l'Ă©valuation de la pertinence du produit X pour la requĂȘte Q est approximativement Ă©gale Ă  P», d'une liste de paires «le produit X est plus pertinent pour le produit Y pour la requĂȘte Q» ou d'un ensemble de listes «pour la requĂȘte Q, les produits P 1 , P 2 , ... se classent correctement comme -que ", nous relevons les signes correspondants Ă  toutes les lignes qui y apparaissent.



Nous formons le modÚle . Voici tous les classiques du ML: train / test, hyperparamÚtres, recyclage, perforationcartes vidéo et ainsi de suite. Il existe de nombreux modÚles qui conviennent (et sont largement utilisés) pour le classement; Je mentionnerai au moins XGBoost et CatBoost .



Nous intégrons le modÚle . Il nous reste à visser en quelque sorte le calcul du modÚle à la volée pour tout le top, pour que les résultats déjà classés parviennent à l'utilisateur. Il existe de nombreuses options; à des fins d'illustration, je vais (encore) me concentrer sur un simple plugin Elasticsearch Learning to Rank .



Classement: Elasticsearch Learning to Rank Plugin



Elasticsearch Learning to Rank est un plugin qui ajoute à Elasticsearch la possibilité de calculer un modÚle ML dans le SERP et de classer immédiatement les résultats en fonction des taux calculés. Cela nous aidera également à obtenir des fonctionnalités identiques à celles utilisées en temps réel, tout en réutilisant les capacités d'Elasticsearch (TF-IDF et autres).



Tout d'abord, nous devons connecter le plugin dans notre conteneur avec Elasticsearch. Nous avons besoin d'un simple Dockerfile



# elasticsearch/Dockerfile

FROM elasticsearch:7.5.1
RUN ./bin/elasticsearch-plugin install --batch http://es-learn-to-rank.labs.o19s.com/ltr-1.1.2-es7.5.1.zip


et les changements connexes docker-compose.yml:



--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,7 +5,8 @@ services:
   elasticsearch:
-    image: "elasticsearch:7.5.1"
+    build:
+      context: elasticsearch
     environment:
       - discovery.type=single-node


Nous avons également besoin de la prise en charge des plugins dans le client Python. Avec étonnement, j'ai trouvé que la prise en charge de Python n'est pas complÚte avec le plug-in, donc spécialement pour cet article, je l'ai lavé . Ajoutez et mettez elasticsearch_ltrà requirements.txtniveau le client dans le cùblage:



--- a/backend/backend/wiring.py
+++ b/backend/backend/wiring.py
@@ -1,5 +1,6 @@
 import os
 
+from elasticsearch_ltr import LTRClient
 from celery import Celery
 from elasticsearch import Elasticsearch
 from pymongo import MongoClient
@@ -39,5 +40,6 @@ class Wiring(object):
         self.task_manager = TaskManager(self.celery_app)
 
         self.elasticsearch_client = Elasticsearch(hosts=self.settings.ELASTICSEARCH_HOSTS)
+        LTRClient.infect_client(self.elasticsearch_client)
         self.indexer = Indexer(self.elasticsearch_client, self.card_dao, self.settings.CARDS_INDEX_ALIAS)
         self.searcher: Searcher = ElasticsearchSearcher(self.elasticsearch_client, self.settings.CARDS_INDEX_ALIAS)


Classement: signes de sciage



Chaque requĂȘte dans Elasticsearch renvoie non seulement une liste d'identifiants de documents trouvĂ©s, mais Ă©galement certains d'entre eux bientĂŽt (comment traduiriez-vous le mot score en russe?). Donc, s'il s'agit d'une requĂȘte de correspondance ou de correspondance multiple que nous utilisons, alors rapide est le rĂ©sultat du calcul de la formule trĂšs dĂ©licate impliquant TF-IDF; si la requĂȘte boolĂ©enne est une combinaison de taux de requĂȘte imbriquĂ©s; si la requĂȘte de score de fonction- le rĂ©sultat du calcul d'une fonction donnĂ©e (par exemple, la valeur d'un champ numĂ©rique dans un document), et ainsi de suite. Le plugin ELTR nous offre la possibilitĂ© d'utiliser la vitesse de n'importe quelle demande comme signe, nous permettant de combiner facilement des donnĂ©es sur la façon dont le document correspond Ă  la demande (via une requĂȘte multi-correspondance) et des statistiques prĂ©-calculĂ©es que nous mettons dans le document Ă  l'avance (via la requĂȘte de score de fonction) ...



Puisque nous avons entre nos mains une base de données TMDB 5000, qui contient des descriptions de films et, entre autres, leurs notes, prenons la note comme une caractéristique précalculée exemplaire.



Dans ce commitJ'ai ajouté une infrastructure de base pour stocker des fonctionnalités sur le backend de notre application Web et pris en charge le chargement de la note à partir du fichier vidéo. Afin de ne pas vous forcer à lire un autre paquet de code, je décrirai le plus basique.



  • Nous stockerons les fonctionnalitĂ©s dans une collection sĂ©parĂ©e et les obtiendrons par un gestionnaire distinct. DĂ©charger toutes les donnĂ©es dans une seule entitĂ© est une mauvaise pratique.
  • Nous contacterons ce responsable au stade de l'indexation et placerons tous les signes disponibles dans les documents indexĂ©s.
  • Pour connaĂźtre le schĂ©ma d'index, nous devons connaĂźtre la liste de toutes les fonctionnalitĂ©s existantes avant de commencer Ă  crĂ©er l'index. Nous allons coder en dur cette liste pour le moment.
  • Comme nous n'allons pas filtrer les documents par valeurs de caractĂ©ristiques, mais que nous allons les extraire uniquement de documents dĂ©jĂ  trouvĂ©s pour le calcul du modĂšle, nous dĂ©sactiverons la construction d'indices inverses par de nouveaux champs avec une option index: falsedans le schĂ©ma et Ă©conomiserons un peu d'espace grĂące Ă  cela.


Classement: collecte de l'ensemble de données



Comme, d'une part, nous n'avons pas de production, et d'autre part, les marges de cet article sont trop petites pour une histoire sur la tĂ©lĂ©mĂ©trie, Kafka, NiFi, Hadoop, Spark et la crĂ©ation de processus ETL, je vais simplement gĂ©nĂ©rer des vues et des clics alĂ©atoires pour nos cartes et une sorte de requĂȘtes de recherche. AprĂšs cela, vous devrez calculer les caractĂ©ristiques des paires carte-demande rĂ©sultantes.



Il est temps d'approfondir l'API du plugin ELTR. Pour calculer les fonctionnalités, nous devrons créer une entité de magasin de fonctionnalités (pour autant que je sache, il ne s'agit en fait que d'un index dans Elasticsearch dans lequel le plugin stocke toutes ses données), puis créer un ensemble de fonctionnalités - une liste de fonctionnalités avec une description de la façon de calculer chacune d'elles. AprÚs cela, il nous suffira d'aller sur Elasticsearch avec une demande spéciale pour obtenir un vecteur de valeurs de fonctionnalités pour chaque entité trouvée en conséquence.



Commençons par créer un ensemble de fonctionnalités:



# backend/backend/search/ranking.py

from typing import Iterable, List, Mapping

from elasticsearch import Elasticsearch
from elasticsearch_ltr import LTRClient

from backend.search.features import CardFeaturesManager


class SearchRankingManager:

    DEFAULT_FEATURE_SET_NAME = "card_features"

    def __init__(self, elasticsearch_client: Elasticsearch, 
                 card_features_manager: CardFeaturesManager,
                 cards_index_name: str):
        self.elasticsearch_client = elasticsearch_client
        self.card_features_manager = card_features_manager
        self.cards_index_name = cards_index_name

    def initialize_ranking(self, feature_set_name=DEFAULT_FEATURE_SET_NAME):
        ltr: LTRClient = self.elasticsearch_client.ltr
        try:
            #  feature store   ,
            #        ¯\_(ツ)_/¯
            ltr.create_feature_store()
        except Exception as exc:
            if "resource_already_exists_exception" not in str(exc):
                raise
        #  feature set    !
        ltr.create_feature_set(feature_set_name, {
            "featureset": {
                "features": [
                    #     
                    #      , 
                    #     ,  
                    #     .
                    self._make_feature("name_tf_idf", ["query"], {
                        "match": {
                            # ELTR  
                            # ,  .  
                            #  , ,   
                            # ,    
                            #  match query.
                            "name": "{{query}}"
                        }
                    }),
                    #  ,    .
                    self._make_feature("combined_tf_idf", ["query"], {
                        "multi_match": {
                            "query": "{{query}}",
                            "fields": ["name^3", "tags.text", "text"]
                        }
                    }),
                    *(
                        #    
                        #    function score.
                        #   -    
                        #   ,  0.
                        # (    
                        #   !)
                        self._make_feature(feature_name, [], {
                            "function_score": {
                                "field_value_factor": {
                                    "field": feature_name,
                                    "missing": 0

                                }
                            }
                        })
                        for feature_name in sorted(self.card_features_manager.get_all_feature_names_set())
                    )
                ]
            }
        })


    @staticmethod
    def _make_feature(name, params, query):
        return {
            "name": name,
            "params": params,
            "template_language": "mustache",
            "template": query,
        }


Maintenant - une fonction qui calcule les caractĂ©ristiques pour une requĂȘte et des cartes donnĂ©es:



    def compute_cards_features(self, query: str, card_ids: Iterable[str],
                                feature_set_name=DEFAULT_FEATURE_SET_NAME) -> Mapping[str, List[float]]:
        card_ids = list(card_ids)
        result = self.elasticsearch_client.search({
            "query": {
                "bool": {
                    #    ,   
                    #       —  , 
                    #     .
                    #      ID.
                    "filter": [
                        {
                            "terms": {
                                "_id": card_ids
                            }
                        },
                        #  —    ,
                        #   SLTR.  
                        #      
                        # feature set.
                        # (  ,      
                        # filter,     .)
                        {
                            "sltr": {
                                "_name": "logged_featureset",
                                "featureset": feature_set_name,
                                "params": {
                                    #   . 
                                    # ,  ,
                                    #   
                                    #  {{query}}.
                                    "query": query
                                }
                            }
                        }
                    ]
                }
            },
            #      
            #        .
            "ext": {
                "ltr_log": {
                    "log_specs": {
                        "name": "log_entry1",
                        "named_query": "logged_featureset"
                    }
                }
            },
            "size": len(card_ids),
        })
        #      (
        # )  .
        # ( ,       
        # ,       Kibana.)
        return {
            hit["_id"]: [feature.get("value", float("nan")) for feature in hit["fields"]["_ltrlog"][0]["log_entry1"]]
            for hit in result["hits"]["hits"]
        }


Un script simple qui accepte CSV avec des demandes et des cartes d'identité en entrée et en sortie CSV avec les fonctionnalités suivantes:



# backend/tools/compute_movie_features.py

import csv
import itertools
import sys

import tqdm

from backend.wiring import Wiring

if __name__ == "__main__":
    wiring = Wiring()

    reader = iter(csv.reader(sys.stdin))
    header = next(reader)

    feature_names = wiring.search_ranking_manager.get_feature_names()
    writer = csv.writer(sys.stdout)
    writer.writerow(["query", "card_id"] + feature_names)

    query_index = header.index("query")
    card_id_index = header.index("card_id")

    chunks = itertools.groupby(reader, lambda row: row[query_index])
    for query, rows in tqdm.tqdm(chunks):
        card_ids = [row[card_id_index] for row in rows]
        features = wiring.search_ranking_manager.compute_cards_features(query, card_ids)
        for card_id in card_ids:
            writer.writerow((query, card_id, *features[card_id]))


Enfin, vous pouvez tout exécuter!



#  feature set
docker-compose exec backend python -m tools.initialize_search_ranking

#  
docker-compose exec -T backend \
    python -m tools.generate_movie_events \
    < ~/Downloads/tmdb-movie-metadata/tmdb_5000_movies.csv \
    > ~/Downloads/habr-app-demo-dataset-events.csv

#  
docker-compose exec -T backend \
    python -m tools.compute_features \
    < ~/Downloads/habr-app-demo-dataset-events.csv \
    > ~/Downloads/habr-app-demo-dataset-features.csv


Maintenant, nous avons deux fichiers - avec des événements et des signes - et nous pouvons commencer la formation.



Classement: former et mettre en Ɠuvre le modùle



Sautons les dĂ©tails du chargement des ensembles de donnĂ©es (le script peut ĂȘtre consultĂ© en entier dans ce commit ) et allons droit au but.



# backend/tools/train_model.py

... 

if __name__ == "__main__":
    args = parser.parse_args()

    feature_names, features = read_features(args.features)
    events = read_events(args.events)

    #    train  test   4  1.
    all_queries = set(events.keys())
    train_queries = random.sample(all_queries, int(0.8 * len(all_queries)))
    test_queries = all_queries - set(train_queries)

    # DMatrix —   ,  xgboost.
    #        
    #  .      1,   , 
    #  0,    ( .  ).
    train_dmatrix = make_dmatrix(train_queries, events, feature_names, features)
    test_dmatrix = make_dmatrix(test_queries, events, feature_names, features)

    #  !
    #           
    #  ML,        
    #     XGBoost.
    param = {
        "max_depth": 2,
        "eta": 0.3,
        "objective": "binary:logistic",
        "eval_metric": "auc",
    }
    num_round = 10
    booster = xgboost.train(param, train_dmatrix, num_round, evals=((train_dmatrix, "train"), (test_dmatrix, "test")))

    #     . 
    booster.dump_model(args.output, dump_format="json")
 
    #    ,   : 
    #         ROC-.
    xgboost.plot_importance(booster)

    plt.figure()
    build_roc(test_dmatrix.get_label(), booster.predict(test_dmatrix))

    plt.show()


lancement



python backend/tools/train_search_ranking_model.py \
    --events ~/Downloads/habr-app-demo-dataset-events.csv \
    --features ~/Downloads/habr-app-demo-dataset-features.csv \
     -o ~/Downloads/habr-app-demo-model.xgb


Veuillez noter que puisque nous avons exportĂ© toutes les donnĂ©es nĂ©cessaires avec les scripts prĂ©cĂ©dents, ce script n'a plus besoin d'ĂȘtre exĂ©cutĂ© dans docker - il doit ĂȘtre exĂ©cutĂ© sur votre machine, aprĂšs avoir installĂ© xgboostet sklearn. De mĂȘme, en production rĂ©elle, les scripts prĂ©cĂ©dents devraient ĂȘtre exĂ©cutĂ©s quelque part oĂč il y a un accĂšs Ă  l'environnement de production, mais celui-ci ne l'est pas.



Si tout est fait correctement, le modÚle s'entraßnera avec succÚs et nous verrons deux belles photos. Le premier est un graphique de la signification des caractéristiques: bien







que les Ă©vĂ©nements aient Ă©tĂ© gĂ©nĂ©rĂ©s de maniĂšre alĂ©atoire,combined_tf_idfs'est avĂ©rĂ© ĂȘtre beaucoup plus significatif que d'autres - parce que j'ai fait une astuce et abaissĂ© artificiellement la probabilitĂ© d'un clic pour les cartes qui sont infĂ©rieures dans les rĂ©sultats de recherche, classĂ©es Ă  l'ancienne. Le fait que le modĂšle l'ait remarquĂ© est un bon signe et un signe que nous n'avons pas commis d'erreurs complĂštement stupides dans le processus d'apprentissage.



Le deuxiĂšme graphique est la courbe ROC :







la ligne bleue est au-dessus de la ligne rouge, ce qui signifie que notre modÚle prédit les étiquettes un peu mieux qu'un tirage au sort. (La courbe de l'ingénieur ML de l'ami de maman devrait presque toucher le coin supérieur gauche.)



La question est assez petite - nous ajoutons un script pour remplir le modĂšle , le remplissons et ajoutons un petit nouvel Ă©lĂ©ment Ă  la requĂȘte de recherche - rescoring:



--- a/backend/backend/search/searcher_impl.py
+++ b/backend/backend/search/searcher_impl.py
@@ -27,6 +30,19 @@ class ElasticsearchSearcher(Searcher):
                     "filter": list(self._make_filter_queries(tags, ids)),
                 }
             },
+            "rescore": {
+                "window_size": 1000,
+                "query": {
+                    "rescore_query": {
+                        "sltr": {
+                            "params": {
+                                "query": query
+                            },
+                            "model": self.ranking_manager.get_current_model_name()
+                        }
+                    }
+                }
+            },
             "aggregations": {
                 self.TAGS_AGGREGATION_NAME: {
                     "terms": {"field": "tags"}


Maintenant, aprÚs qu'Elasticsearch ait effectué la recherche dont nous avons besoin et classe les résultats avec son algorithme (assez rapide), nous prendrons les 1000 premiers résultats et les reclassifierons en utilisant notre formule (relativement lente) apprise par machine. SuccÚs!



Conclusion



Nous avons pris notre application Web minimaliste et sommes passés de l'absence de fonction de recherche en soi à une solution évolutive avec de nombreuses fonctionnalités avancées. Ce n'était pas si facile à faire. Mais ce n'est pas si difficile non plus! L'application finale se trouve dans le référentiel sur Github dans une branche avec un nom modeste feature/searchet nécessite Docker et Python 3 avec des bibliothÚques d'apprentissage automatique pour fonctionner.



J'ai utilisĂ© Elasticsearch pour montrer comment cela fonctionne en gĂ©nĂ©ral, quels problĂšmes sont rencontrĂ©s et comment ils peuvent ĂȘtre rĂ©solus, mais ce n'est certainement pas le seul outil Ă  choisir. Solr , les index de texte intĂ©gral PostgreSQL et d'autres moteurs mĂ©ritent Ă©galement votre attention lorsque vous choisissez sur quoi bĂątir votre entreprise de plusieurs milliards de dollars.moteur de recherche.



Et, bien sĂ»r, cette solution ne prĂ©tend pas ĂȘtre complĂšte et prĂȘte pour la production, mais est purement une illustration de la façon dont tout peut ĂȘtre fait. Vous pouvez l'amĂ©liorer presque Ă  l'infini!



  • Indexation incrĂ©mentielle. Lors de la modification de nos cartes, CardManageril serait bon de les mettre immĂ©diatement Ă  jour dans l'index. Afin de CardManagerne pas savoir que nous avons aussi une recherche dans le service, et pour se passer de dĂ©pendances cycliques, il va falloir visser l' inversion de dĂ©pendance sous une forme ou une autre.
  • Pour l'indexation dans notre cas particulier, MongoDB est fourni avec Elasticsearch, vous pouvez utiliser des solutions toutes faites comme mongo-connector .
  • , — Elasticsearch .
  • , , .
  • , , . -, -, - 
 !
  • ( , ), ( ). , .
  • , , .
  • Orchestrer un cluster de nƓuds avec partitionnement et rĂ©plication est un plaisir Ă  part.


Mais pour garder l'article lisible en taille, je m'arrĂȘterai lĂ  et vous laisserai seul avec ces dĂ©fis. Merci pour l'attention!



All Articles