Implémenter un moteur de recherche avec elasticsearch et Symfony (partie 3)

Publié le 16/11/2019 • Mis à jour le 29/11/2019

Dans cette troisième et dernière partie, nous allons continuer à peaufiner notre moteur de recherche. Tout d'abord, nous allons améliorer notre stack elasticsearch en incorporant Kibana. Ensuite, nous implémenterons un autocomplete en utilisant la fonctionnalité de suggestion d'elasticsearch. C'est parti mon kiki ! 😎


English language detected! 🇬🇧

  We noticed that your browser is using English. Do you want to read this post in this language?

Read the english version 🇬🇧

» Publié dans "Une semaine Symfonique 672" (du 11 au 17 novembre 2019).

Tutoriel

Cet article est le troisième et dernier du tutoriel : "Implémenter un moteur de recherche avec Elasticsearch et Symfony"

Prérequis

Les prérequis sont les mêmes que pour les deux premières parties. Je vous recommande bien sûr de les lire (liens au dessus) avant d'attaquer celle-ci.

Configuration

Je viens juste de migrer le blog vers Symfony 4.4.0. J'ai eu quelques comportements étranges avec les tests mais tout le reste a fonctionné sans souci et sans changer la moindre ligne de code. 😉

  • PHP 7.2
  • Symfony 4.4.1
  • elasticsearch 6.8

Installation de Kibana

Tout d'abord nous allons améliorer notre stack Elasticsearch. Jusqu'à maintenant, nous avons utilisé le plugin "head" pour gérer notre cluster. Mais cet outil de développement est assez ancien et n'est plus maintenu. Donc, ajoutons Kibana à notre hub docker. Kibana est un plugin open-source de visualisation de données pour Elasticsearch. Bien sûr, il permet aussi de faire les tâches de maintenance courantes que nous avions l'habitude de faire avec head : supprimer, fermer un index, créer et supprimer un alias, vérifier un document, vérifier le mapping des index, mais il permet bien plus encore ! La liste de ce qu'il est possible de faire est assez impressionnante (regardez le menu à gauche de la capture d'écran suivante). Ajoutons l'entrée correspondante dans le fichier docker-compose.yaml :

kibana:
    container_name: sb-kibana
    image: docker.elastic.co/kibana/kibana:6.8.4
    ports:
      - "5601:5601"
    environment:
      - "ELASTICSEARCH_URL=http://sb-elasticsearch"
    depends_on:
      - elasticsearch
Cliquez ici pour voir le nouveau fichier docker-compose.yaml complet.
# ./docker-compose.yaml

# DEV docker compose file ——————————————————————————————————————————————————————
# Check out: https://docs.docker.com/compose/gettingstarted/
version: '3.7'

# docker-compose -f docker-compose.yaml up -d
services:

  # Database ———————————————————————————————————————————————————————————————————

  # MySQL server database (official image)
  # https://docs.docker.com/samples/library/mysql/
  db:
    image: mysql:5.7
    container_name: sb-db
    command: --default-authentication-plugin=mysql_native_password
    ports:
      - "3309:3306"
    environment:
      MYSQL_ROOT_PASSWORD: root

  # adminer database interface (official image)
  # https://hub.docker.com/_/adminer
  adminer:
    container_name: sb-adminer
    depends_on:
      - db
    image: adminer
    ports:
      - "8089:8080"

  # elasticsearch ——————————————————————————————————————————————————————————————

  # elasticsearch server (official image)
  # https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html
  elasticsearch:
    container_name: sb-elasticsearch
    image: docker.elastic.co/elasticsearch/elasticsearch:6.8.4
    ports:
      - "9209:9200"
    environment:
        - "discovery.type=single-node"
        - "bootstrap.memory_lock=true"
        - "ES_JAVA_OPTS=-Xms1G -Xmx1G"
        - "xpack.security.enabled=false"
        - "http.cors.enabled=true"
        - "http.cors.allow-origin=*"

  # elasticsearch head manager (fork of mobz/elasticsearch-head for elasticsearch 6)
  # /!\ it isn't an official image /!\
  # https://hub.docker.com/r/tobias74/elasticsearch-head
  elasticsearch-head:
    container_name: sb-elasticsearch-head
    depends_on:
      - elasticsearch
    image: tobias74/elasticsearch-head:6
    ports:
      - "9109:9100"

  # kibana (official image)
  # https://hub.docker.com/_/kibana
  kibana:
    container_name: sb-kibana
    image: docker.elastic.co/kibana/kibana:6.8.4
    ports:
      - "5609:5601"
    environment:
      - "ELASTICSEARCH_URL=http://sb-elasticsearch"
    depends_on:
      - elasticsearch

  # Cache ——————————————————————————————————————————————————————————————————————
  # Redis (official image)
  # https://hub.docker.com/_/redis
  redis:
    image: redis:5.0.6-alpine
    container_name: sb-redis
    ports:
      - '6389:6379'

Comme vous pouvez le voir, nous passons l'URL du serveur Elasticsearch dont le nom d'hôte est celui du conteneur docker (sb-elasticsearch). Nous gardons le port standard 5601. Nous utilisons aussi la même version d'image (6.8.4) que nous avons utilisée pour Elasticsearch afin qu'il n'y ait pas de problème de compatibilité. Si vous redémarrez le hub docker, vous pouvez accéder à la page de gestion des index :

Kibana en action !

Voilà pour Kibana. Je vais m'arrêter ici pour cette partie, ça demanderait bien plus qu'un article pour présenter toutes les fonctionnalités. Accédez au site officiel pour plus d'informations. Kibana est très puissant, il peut aussi être utilisé pour consulter vos logs Symfony ! À ce sujet, je vous conseille la lecture de ce très intéressant article du blog JoliCode.

Ajout d'un autocomplete dans la barre de recherche

Comme vous pouvez le voir, j'ai mis un champ de recherche dans l'entête de ce site. Ça marche, mais si nous essayions de compléter la saisie de l'utilisateur afin de lui suggérer des termes qu'il peut trouver sur ce blog ? Voyons comment nous pouvons faire cela avec Elasticsearch, nous allons construire un index qui sera dédié à cette fonctionnalité.

Configuration du mapping

Jusqu'à maintenant nous n'avons utilisé que le type par défaut "text" pour toutes les propriétés du mapping. Pour ce nouvel index, nous allons utiliser un type spécial : completion. Nous allons ajouter une nouvelle configuration pour cet index "suggest" juste après le principal "app" que nous avons utilisé dans les articles précédents :

# config/packages/fos_elastica.yaml
fos_elastica:
    clients:
        default: { host: '%es_host%', port: '%es_port%' }
    indexes:
        app:
          ###
        suggest:
            use_alias: true
            settings:
                index:
                    analysis:
                        analyzer:
                            suggest_analyzer:
                                type: custom
                                tokenizer: standard
                                filter: [lowercase, asciifolding]
            types:
                keyword:
                    properties:
                        locale:
                            type: keyword
                        suggest:
                            type: completion
                            analyzer: suggest_analyzer
                            contexts:
                                - name: locale
                                  type: category
                                  path: locale
Cliquez ici pour voir le mapping YAML complet.
# config/packages/fos_elastica.yaml
fos_elastica:
    clients:
        default: { host: '%es_host%', port: '%es_port%' }
    indexes:
        app:
            use_alias: true
            types:
                articles:
                    properties:
                        type: ~
                        keywordFr: { boost: 4 }
                        keywordEn: { boost: 4 }
                        # i18n
                        titleEn: { boost: 3 }
                        titleFr: { boost: 3 }
                        headlineEn: { boost: 2 }
                        headlineFr: { boost: 2 }
                        ContentEn: ~ # The default boost value is 1
                        ContentFr: ~
                    persistence:
                        driver: orm
                        model: App\Entity\Article
                        provider:
                            service: App\Elasticsearch\Provider\ArticleProvider
                        listener:
                            insert: false
                            update: false
                            delete: false
        suggest:
            use_alias: true
            settings:
                index:
                    analysis:
                        analyzer:
                            suggest_analyzer:
                                type: custom
                                tokenizer: standard
                                filter: [lowercase, asciifolding]
            types:
                keyword:
                    properties:
                        locale:
                            type: keyword
                        suggest:
                            type: completion
                            analyzer: suggest_analyzer
                            contexts:
                                - name: locale
                                  type: category
                                  path: locale

Quelques explications à propos de cet index et de son mapping. Avant de déclarer le type, j'ajoute un analyseur dans la section "setting". Le filtre asciifolding va nous permettre d'ignorer les accents pour permettre à la suggestion de fonctionner même si ceux-ci ne sont pas utilisés. Par exemple, si on saisit "element", le mot "élément" devrait être suggéré.
Ensuite, dans la section "type", on utilise aussi un alias tout comme l'index "app". Dans le mapping nous avons deux propriétés : suggest qui est de type "completion". Nous avons besoin de ce type particulier pour utiliser le "suggester" Elasticsearch comme nous le verrons dans le chapitre suivant. Nous avons une seconde propriété locale qui va nous permettre de filtrer les suggestions en fonction de la langue courante (en ou fr). On a ajouté un contexte au champ "suggest" et celui-ci est associé à la propriété "locale" (path: locale).
Si nous relançons la commande d'indexation, le nouvel index est créé. À cette étape, nous avons désormais deux index dans le cluster Elasticsearch :

Kibana en action !

Peupler l'index de suggestion

Maintenant, nous allons peupler le nouvel index de suggestion. Comme il n'y a pas de modèle Doctrine associé, nous n'allons pas créer un fournisseur de données mais une commande Symfony. L'idée est d'extraire tous les mots qui ont été utilisés dans l'index app. Voilà la nouvelle commande Symfony : (quelques éclaircissements après le code 🤔)

<?php declare(strict_types=1);

// src/Command/PopulateSuggestCommand.php

namespace App\Command;

use Doctrine\Common\Inflector\Inflector;
use Elastica\Document;
use FOS\ElasticaBundle\Elastica\Index;
use FOS\ElasticaBundle\Finder\TransformedFinder;
use FOS\ElasticaBundle\HybridResult;
use FOS\ElasticaBundle\Paginator\FantaPaginatorAdapter;
use Pagerfanta\Pagerfanta;
use Symfony\Component\Console\Command\Command;
use Symfony\Component\Console\Input\InputInterface;
use Symfony\Component\Console\Output\OutputInterface;

/**
 * Populate the suggest elasticsearch index.
 */
class PopulateSuggestCommand extends Command
{
    public const NAMESPACE = 'strangebuzz';
    public const CMD = 'populate';
    public const DESC = 'Populate the "suggest" elasticsearch index';

    private $articlesFinder;
    private $suggestIndex;

    public function __construct(TransformedFinder $articlesFinder, Index $suggestIndex)
    {
        parent::__construct();
        $this->articlesFinder = $articlesFinder;
        $this->suggestIndex = $suggestIndex;
    }

    protected function configure(): void
    {
        $namespace = self::NAMESPACE;
        $cmd = self::CMD;
        $desc = self::DESC;
        $this->setName($namespace.':'.$cmd)
            ->setDescription(self::DESC)
            ->setHelp(
                <<<EOT
{$desc}
<info>php bin/console {$namespace}:{$cmd}</info>
EOT
            );
    }

    protected function execute(InputInterface $input, OutputInterface $output): int
    {
        $output->writeln(self::DESC);
        $pagination = $this->findHybridPaginated($this->articlesFinder, '');
        $nbPages = $pagination->getNbPages();
        $keywords = [];

        foreach (range(1, $nbPages) as $page) {
            $pagination->setCurrentPage($page);
            foreach ($pagination->getCurrentPageResults() as $result) {
                if ($result instanceof HybridResult) {
                    foreach ($result->getResult()->getSource() as $property => $text) {
                        if ($property === 'type') {
                            continue;
                        }
                        $locale = explode('_', Inflector::tableize($property))[1] ?? 'en';
                        $text = strip_tags($text ?? '');
                        $textArray = str_word_count($text, 2, 'çéâêîïôûàèùœÇÉÂÊÎÏÔÛÀÈÙŒ'); // FGS dot not remove french accents! 🙃
                        $textArray = array_filter(\is_array($textArray) ? $textArray : []);
                        $keywords[$locale] = array_merge($keywords[$locale] ?? [], $textArray);
                    }
                }
            }
        }

        // Index by locale
        foreach ($keywords as $locale => $localeKeywords) {
            // Remove small words and remaining craps (emojis) 😖
            $localeKeywords = array_unique(array_map('mb_strtolower', $localeKeywords));
            $localeKeywords = array_filter($localeKeywords, static function ($v) {
                return mb_strlen($v) > 2;
            });
            $documents = [];
            foreach ($localeKeywords as $idx => $keyword) {
                $documents[] = (new Document())
                    ->setType('keyword')
                    ->set('locale', $locale)
                    ->set('suggest', $keyword);
            }
            $responseSet = $this->suggestIndex->addDocuments($documents);

            $output->writeln(sprintf(' -> TODO: %d -> DONE: <info>%d</info>, "%s" keywords indexed.', count($documents), $responseSet->count(), $locale));
        }

        return 0;
    }

    private function findHybridPaginated(TransformedFinder $articlesFinder, string $query): Pagerfanta
    {
        $paginatorAdapter = $articlesFinder->createHybridPaginatorAdapter($query);

        return new Pagerfanta(new FantaPaginatorAdapter($paginatorAdapter));
    }
}

Quelques explications : 💡

  • On lance une recherche joker pour récupérer le nombre total de pages.
  • On itère sur chaque page pour récupérer les articles relatifs.
  • Pour chaque article, on extrait toutes les clés du document Elasticsearch.
  • Pour chaque clé, on extrait du texte tous les mots grâce à la fonction PHP str_word_count().
  • On supprime tout ce qui est vide, les doublons et les mots trop petits.
  • Pour chaque mot restant on crée un document Elasticsearch en spécifiant sa langue.
  • Finalement, on lance le processus d'indexation avec la fonction addDocuments.

À l'heure ou je vous écris, il y a environ 3500 mots indexés. Voici la sortie console de la nouvelle tâche "populate" du MakeFile :

/Users/coil/Sites/strangebuzz.com$ make populate
php bin/console fos:elastica:reset
Resetting app
Resetting suggest
php bin/console fos:elastica:populate --index=app
Resetting app
 53/53 [============================] 100%
Populating app/articlesRefreshing app
Refreshing app
php bin/console strangebuzz:populate
Populate the "suggest" elasticsearch index
 -> TODO: 2167 -> DONE: 2167, "fr" keywords indexed.
 -> TODO: 1549 -> DONE: 1549, "en" keywords indexed.

Le contenu de cette nouvelle entrée :

## —— elasticsearch 🔎 —————————————————————————————————————————————————————————
populate: ## Reset and populate the elasticsearch index
$(SYMFONY) fos:elastica:reset
$(SYMFONY) fos:elastica:populate --index=app
$(SYMFONY) strangebuzz:populate # populate the "suggest" index.

Vous pouvez trouver mon MakeFile Symfony complet dans ce snippet. Maintenant que l'index est peuplé, voyons comment l'utiliser pour l'implémentation de la fonctionnalité autocomplete.

Implémentation de l'autocomplete

Le but ici va être d'ajouter une action qui va retourner via Ajax les suggestions pour le widget autocomplete alors que l'utilisateur saisit un mot-clé. Créons un nouveau contrôleur dédié à cette tâche :

<?php declare(strict_types=1);

// src/Controller/SuggestController.php

namespace App\Controller;

use Elastica\Query;
use Elastica\Suggest;
use Elastica\Suggest\Completion;
use Elastica\Util;
use FOS\ElasticaBundle\Elastica\Index;
use Symfony\Component\HttpFoundation\JsonResponse;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\Routing\Annotation\Route;

/**
 * @Route("/{_locale}", requirements={"_locale"="%locales_requirements%"})
 */
class SuggestController extends AbstractController
{
    private const SUGGEST_NAME = 'completion';
    private const SUGGEST_FIELD = 'suggest';

    /**
     * @Route({"en": "/suggest", "fr": "/suggerer"}, name="suggest")
     */
    public function suggest(Request $request, Index $suggestIndex, string $_locale): JsonResponse
    {
        $q = (string) $request->query->get('q', '');
        $suggest = $this->getSuggest($q, $_locale);
        $query = (new Query())->setSuggest($suggest);
        $suggests = $suggestIndex->search($query)->getSuggests();
        $options = $suggests[self::SUGGEST_NAME][0]['options'] ?? [];

        return $this->json(array_column($options, 'text'));
    }

    /**
     * Check-out the links at the end of the post to get more insights about this.
     */
    protected function getSuggest(string $q, string $locale): Suggest
    {
        $completionSuggest = (new Completion(self::SUGGEST_NAME, self::SUGGEST_FIELD)) // "suggest" here is the mapping field name
            ->setPrefix(Util::escapeTerm($q)) // items starting with...
            ->setParam('context', ['locale' => $locale]) // only suggestions for current user locale
            ->setSize(10); // return 10 items (default size is 5)

        return new Suggest($completionSuggest);
    }
}

Quelques explications : 💡

  • Comme l'action de recherche, nous récupérons la saisie de l'utilisateur par le paramètre GET "q".
  • Ensuite nous créons un objet elastica Suggest avec le nom de la propriété du mapping à utiliser.
  • Juste en dessous, on ajoute un contexte qui va nous permettre de filtrer les mots retournés : dans ce cas on filtre selon la langue de la page en cours (en ou fr).
  • Ensuite, on extrait les options retournées par la réponse Elasticsearch.
  • Finalement, nous retournons une réponse de type JSON (JsonResponse) contenant un tableau simple avec les options à afficher à l'utilisateur.

Affichage des suggestions

Maintenant que l'action de suggestion est faite, nous pouvons mettre en place un widget autocomplete qui va l'utiliser. Vous pouvez essayer dans le formulaire ci-dessous. C'est exactement le formulaire qui nous avons utilisé dans les articles précédents (un peu de JavaScript a été ajouté pour récupérer les suggestions). Comme vous pouvez le voir, sur cette page, uniquement des mots français sont retournés (en fait pas tout à fait car j'utilise certains anglicismes dans les articles rédigés en français !). Si vous essayez sur la version anglaise, vous pourrez vérifier que seuls des mots anglais le sont. C'est la même action mais un filtre a été appliqué grâce au contexte que nous avons assigné à l'objet de suggestion Elasticsearch.

Le JavaScript est basique, ce n'est pas le sujet de cet article. Pas de vue.js cette fois ! C'est du bon vieux jQuery. J'aime beaucoup ce composant jQueryUI, il est facile à utiliser et à personnaliser. Juste un commentaire à propos de la route que nous utilisons : comme vous pouvez le voir nous n'avons pas à spécifier la langue : {{ path('suggest') }} (cliquez sur le lien ci-dessous pour voir le code JavaScript), car le composant de routage s'en charge et l'ajoute automatiquement pour nous (sur cette page c'est fr). Je n'ai pas encore ajouté cet autocomplete à la barre de recherche de l'entête du site, mais je l'ai mis sur la page des résultats de recherche. Tout ce que j'ai eu à faire est d'inclure le JavaScript développé pour cet article :

{% block javascripts %}
    {{ parent() }}
    {% include 'blog/posts/_51_js.html.twig' %}
{% endblock %}
Cliquez ici pour voir le code JavaScript.
{% trans_default_domain 'post_51' %}
<script>
    /*global $, console, $http */
    /*jslint browser:true */
    "use strict";
    $(document).ready(function() {
        $("#q").autocomplete({
            delay: 0,
            minLength: 2,
            source: function(request, response) {
                $.ajax({
                    url: '{{ path('suggest') }}',
                    data: {
                        q: request.term,
                    },
                    success: function(data) {
                        response(data);
                    },
                    error: function(data) {
                        alert('{{ 'form_error'|trans }}');
                    }
                });
            }
        });
    });
</script>

Conclusion

C'était la dernière partie de ce tutoriel Elasticsearch. C'était intéressant (mais très long !) de l'écrire en même temps que je développais ces fonctionnalités sur ce site web. Il y a encore beaucoup à faire, mais je suis déjà content avec ce qui a été mis en place 😊. Je me sers tous les jours de cette recherche pour retrouver rapidement certains articles ou snippets. Une bonne nouvelle : le bundle FOSElastica est en cours de mise à jour pour supporter elastica 7.0. Donc, dès que cette version sortira, je modifierai ce tutoriel pour utiliser la dernière version d'Elasticsearch, à savoir la 7.4. Rendez-vous à Amsterdam pour la SymfonyCon ! 😀

Et voilà ! J'espère que vous avez aimé. Découvrez d'autres informations en rapport à cet article avec les liens ci-dessous. Comme toujours, retours, likes et retweets sont les bienvenus. (voir la boîte ci-dessous) À la revoyure ! COil. 😊

  Retour à la partie 2

  Lire la doc  Plus sur le web  Plus sur Stackoverflow

Ils m'ont donné leurs retours et m'ont aidé à corriger des erreurs et typos dans cet article, un grand merci à : greg0ire, jmsche, Nico.F (Slack Symfony). 😊


» A vous de jouer !

Ces articles vous ont été utiles ? Vous pouvez m'aider à votre tour de plusieurs manières : (utilisez la boîte ci-dessus pour commenter ou le Tweet à droite pour me contacter )

  • Me remonter des erreurs ou typos.
  • Me remonter des choses qui pourraient être améliorées.
  • Aimez et retweetez !
  • Suivez moi sur Twitter
  • Inscrivez-vous au flux RSS.

Merci d'avoir tenu jusque ici et à très bientôt sur Strangebuzz ! 😉

COil