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

Publié le 28/10/2019 • Actualisé le 28/10/2019

Dans la deuxième partie de ce tutoriel, nous allons voir comment améliorer notre moteur de recherche afin de le rendre plus pertinent. Nous allons utiliser un alias, créer un fournisseur de données personnalisé afin de remplir l'index. Nous verrons comment affiner la recherche en boostant certains champs puis finalement nous ajouterons la pagination à la liste de résultats. C'est parti ! 😎


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 🇬🇧 Close

» Publié dans "Une semaine Symfonique 670" (du 28 octobre au 3 novembre 2019).

Avertissement

Cet article est obsolète. J'ai supprimé tout le code concernant Elasticsearch sur ce blog. Veuillez regarder mon nouvel article sur Meilisearch (à venir).

Tutoriel

Cet article est la deuxième partie du tutoriel: "Implémenter un moteur de recherche avec Elasticsearch et Symfony"

Prérequis

Les prérequis sont les mêmes que pour la première partie du tutoriel. Je vous conseille de le lire avant d'entamer cette partie afin de resituer le contexte.

Configuration

  • PHP 8.3
  • Symfony 6.4
  • Elasticsearch 6.8

Revue de code et débogage

Tout d'abord, nous allons vérifier le code que nous avons implémenté dans l'article précédent. En effet, c'est une bonne habitude, avant de développer quelque chose de regarder ce qui a été fait et de voir si quelque chose peut être nettoyé ou amélioré. Si on regarde la configuration que nous avons mise en place, vous aurez sûrement remarqué que nous avons mis en dur l'hôte et le port Elasticsearch. C'est une mauvaise pratique ! En effet, ceux-ci peuvent changer selon l'environnement utilisé. Déplaçons ces paramètres, ajoutez ces deux lignes dans votre fichier .env :

ES_HOST=localhost
ES_PORT=9209

Donc nous devons récupérer ces deux variables d'environnement dans les paramètres de l'application. Ajoutez les deux lignes suivantes à votre fichier config/services.yaml :

# config/posts/48.yaml (imported in config/services.yaml)
parameters:
  #es_host: '%env(ES_HOST)%'
  #es_port: '%env(ES_PORT)%'

Enfin, nous pouvons utiliser ces deux nouveaux paramètres dans le fichier de configuration fos_elastica. Modifiez la ligne default pour les utiliser (on pourrait aussi récupérer directement les variables d'environnement avec %env()%) :

# config/packages/fos_elastica.yaml
fos_elastica:
    clients:
        default: { host: '%es_host%', port: '%es_port%' }

C'était la première étape. Maintenant, voyons si nous pouvons corriger un bug. En effet il y en a un très embêtant. Jusqu'à maintenant, quand nous lancions la recherche, nous utilisions directement la saisie de l'utilisateur et la passions à la fonction ->findHybrid (regardez la première partie). Le problème est qu'il existe certains caractères spéciaux utilisés par Elasticsearch comme []. Et utiliser ce genre de caractères va provoquer l'erreur 500 suivante :

Failed to parse query [doctrine[]] [index: app] [reason: all shards failed]

Pour corriger cela, nous allons utiliser la fonction d'échappage fournit par la librairie elastica : Util::escapeTerm()

Nous pouvons appeler cette fonction dans le contrôleur afin éviter le bug :

$results = !empty($q) ? $articlesFinder->findHybrid(Util::escapeTerm($q)) : [];

Maintenant que nous avons corrigé ce que nous avons développé dans la partie précédente, voyons comment améliorer l'environnement Elasticsearch.

Utilisation d'un alias Elasticsearch

Jusqu'à maintenant nous utilisions directement l'index principal pour ajouter des données. Mais que se passe t-il si le mapping change ? Si des champs sont ajoutés ou supprimés ? Ça pourrait être dangereux... Utiliser un alias nous permet d'éviter des périodes d'indisponibilité comme la bascule des index est faite uniquement quand toutes les données ont été indexées. C'est particulièrement vrai si vous avez un grand volume de données et que l'indexation prend un temps conséquent. Tout d'abord supprimons l'index "app" qui existe. On peut le faire avec une commande cURL (on peut aussi utiliser le plugin head : actions -> effacer...) :

curl -i -X DELETE 'http://localhost:9209/app'
{"acknowledged":true}

Ajouter l'option "use_alias: true" dans la configuration fos_elastica :

# config/packages/fos_elastica.yaml
fos_elastica:
    clients:
        default: { host: '%es_host%', port: '%es_port%' }
    indexes:
        app:
            use_alias: true
            types:
                articles:
                    # ...

Maintenant, lançons la commande fos:elastica:populate. Cette fois nous pouvons voir que l'index créé ne porte plus le nom "app" mais un suffixe de date a été ajouté. De plus un alias a été automatiquement ajouté à l'index, c'est cet alias qui porte le nom "app" désormais. À ce point, votre cluster Elasticsearch devrait ressembler à ça :

L'index a maintenant un alias associé

Lancez de nouveau la commande populate, mais cette fois avec l'option --no-delete. On voit qu'il y a deux index mais l'alias pointe désormais sur le plus récent. La gestion de l'alias est automatiquement prise en charge par le bundle, on n'a donc pas à le faire manuellement. L'index le plus vieux a été "fermé". ça veut dire que les données sont toujours présentes mais on ne peut plus y accéder, les opérations de lecture / écriture sont bloquées. Le cluster ressemble désormais à cela :

L'index le plus ancien a été fermé

Imaginez que vous ayez un bug critique et que le nouvel index en soit la cause. On pourrait "ouvrir" l'ancien index, supprimer l'alias en cours et l'assigner à l'ancien index afin de faire fonctionner l'application à nouveau. C'est tout pour la partie alias. Maintenant voyons comment créer un fournisseur de données personnalisé pour indexer des données.

Création d'un fournisseur de données personnalisé

Comme nous l'avons vu dans la première partie, nous n'indexons pas encore toutes les données car la plupart des textes qu'on peut lire sur ce blog sont stockés dans des fichiers de traduction, pas en base de données. Voyons comment indexer ces textes pour rendre la recherche bien plus pertinente. Nous avons besoin de créer un fournisseur d'accès personnalisé. Ce service aura besoin d'accéder à la base de données avec l'ORM et d'accéder aux fichiers de traduction à l'aide du composant i18n. En comparaison avec le fournisseur par défaut, le nôtre fera deux choses différemment. Il va ignorer les articles inactifs et il va récupérer le contenu i18n des articles. Voici le code :

<?php

declare(strict_types=1);

// src/Elasticsearch/Provider/ArticleProvider.php

namespace App\Elasticsearch\Provider;

use App\Repository\ArticleRepository;
use Doctrine\Common\Collections\ArrayCollection;
use FOS\ElasticaBundle\Provider\PagerfantaPager;
use FOS\ElasticaBundle\Provider\PagerProviderInterface;
use Pagerfanta\Doctrine\Collections\CollectionAdapter;
use Pagerfanta\Pagerfanta;
use Symfony\Component\Yaml\Yaml;
use Symfony\Contracts\Translation\TranslatorInterface;

class ArticleProvider implements PagerProviderInterface
{
    private ArticleRepository $articleRepository;
    private TranslatorInterface $translation;

    public function __construct(ArticleRepository $articleRepository, TranslatorInterface $translation)
    {
        $this->articleRepository = $articleRepository;
        $this->translation = $translation;
    }

    /**
     * @param array<mixed> $options
     */
    public function provide(array $options = []): PagerfantaPager
    {
        $articles = $this->articleRepository->findActive();
        foreach ($articles as $article) {
            $domain = $article->isArticle() ? 'blog' : 'snippet';
            foreach (['En', 'Fr'] as $locale) {
                // keywords
                $fct = 'setKeyword'.$locale;
                $keywords = [];
                foreach (explode(',', $article->getKeyword() ?? '') as $keyword) {
                    $keywords[] = $this->translation->trans($keyword, [], 'breadcrumbs', strtolower($locale));
                }
                $article->$fct(implode(',', $keywords));

                // title
                $fct = 'setTitle'.$locale;
                $article->$fct($this->translation->trans('title_'.$article->getId(), [], $domain, strtolower($locale)));

                // headline
                $fct = 'setHeadline'.$locale;
                $article->$fct($this->translation->trans('headline_'.$article->getId(), [], $domain, strtolower($locale)));

                // There is only for articles to get the full fontent stored in i18n files
                if ($article->isArticle()) {
                    $i18nFile = 'post_'.$article->getId().'.'.strtolower($locale).'.yaml';
                    $file = \dirname(__DIR__, 3).'/translations/blog/'.$i18nFile;
                    /** @var array<string,string> $translations */
                    $translations = Yaml::parse((string) file_get_contents($file));
                    $translations = array_map('strip_tags', $translations); // tags are useless, only keep texts
                    $translations = array_map('html_entity_decode', $translations);
                    $fct = 'setContent'.$locale;
                    $article->$fct(implode(' ', $translations));
                }
            }
        }

        return new PagerfantaPager(new Pagerfanta(new CollectionAdapter(new ArrayCollection($articles))));
    }
}

Quelques explications. Nous avons créé une nouvelle fonction findActive dans le dépôt doctrine afin de récupérer uniquement les articles actifs. Ensuite, pour chacun, en fonction du type, nous récupérons les traductions qui sont stockées dans les fichiers snippets.XX.yaml pour les snippets et dans les fichiers blog_ID.XX.yaml pour les articles (tous les textes des snippets sont dans le même fichier de traduction alors qu'il y a un fichier par article). Les propriétés virtuelles de l'entité sont renseignées, alors le document est prêt à être indexé. À la fin nous passons une collection Doctrine à l'adaptateur DoctrineCollectionAdapter. Nous sommes prêts à utiliser le nouveau fournisseur d'accès. Après son lancement, on constate qu'il y a un article de moins indexé. (47 au lieu de 48) car il y a un article inactif que j'utilise dans les tests fonctionnels afin de vérifier qu'on ne peut ni voir un tel article dans une liste, ni accéder à la page de détail. Examinons les nouveaux documents Elasticsearch :

Le document Elasticsearch avec les nouveaux champs

Comme vous pouvez le voir il y a bien plus de données que précédemment et les nouveaux champs sont correctement indexés. On peut vérifier que les champs *En contiennent les traductions anglaises et ceux *Fr les françaises. Maintenant, nous pouvons tester la recherche, par exemple on peut utiliser le mot-clé "interface" qui n'est ni dans les mots-clés ni dans le titre d'un article. Validez, on peut voir qu'il y a plusieurs articles qui correspondent dont celui-ci. L'un d'entre eux est la première partie de ce tutoriel. En effet le texte est bien présent dans le corps de l'article. Vous pouvez aussi rechercher "foobar" qui n'est présent que dans cet article puisqu'on vient de l'écrire !

Affiner la pertinence de recherche

Maintenant que nous avons toutes les données dont nous avons besoin nous pouvons procéder à quelques ajustements. Voyons voir comment ajouter des boosts. Parmi les champs dont nous disposons, on peut établir une hiérarchie d'importance. Le plus important semble être le champ "mots-clés" alors que le contenu est le moins important puisqu'il contient beaucoup de textes. Le boost par défaut étant 1, gardons cette valeur pour ce champ et ajoutons un peu de boost sur le résumé, le titre et les mots-clés comme ceci :

# 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

Mais quels boosts doivent être utilisés ? Et bien, il n'y a pas de réponse toute faite. Cela dépend de la recherche que vous voulez implémenter et des données contenues dans vos différents champs. Vous devrez faire de nombreux tests pour avoir quelque chose adapté à vos besoins et à vos utilisateurs. Vous trouvez un lien "Plus sur le web" en bas, pointant vers un article d'un site web ayant mis en place une procédure très complète afin d'améliorer la pertinence de leur moteur de recherche. C'est très instructif.

“Améliorer la pertinence est difficile, vraiment difficile.” — Le blog Elasticsearch

Ajout de la pagination

Maintenant voyons comment ajouter la pagination afin de pouvoir accéder à l'ensemble des articles correspondant à une recherche. Le bundle FOSelastica nous fournit déjà quelques méthodes à ce sujet. Nous allons utiliser la fonction createHybridPaginatorAdapter qui va nous retourner un adaptateur prêt à être utilisé avec la librairie de pagination Pagerfanta. Nous allons installer le bundle WhiteOctoberPagerfanta qui va nous faciliter l'intégration de cette librairie à notre projet. Regardons le code du nouveau contrôleur :

<?php

declare(strict_types=1);

// src/Controller/SearchPart2Controller.php

namespace App\Controller;

use Elastica\Util;
use FOS\ElasticaBundle\Finder\TransformedFinder;
use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpFoundation\Session\SessionInterface;
use Symfony\Component\Routing\Annotation\Route;

use function Symfony\Component\String\u;

/**
 * You know, for search.
 */
#[Route(path: '/{_locale}', name: 'search_part2_', requirements: ['_locale' => '%locales_requirements%'])]
final class SearchPart2Controller extends AbstractController
{
    public function __construct(
        private readonly TransformedFinder $articlesFinder,
    ) {
    }

    /**
     * I made a PR to have the findHybridPaginated() function in the bundle.
     *
     * @see https://github.com/FriendsOfSymfony/FOSElasticaBundle/pull/1567/files
     */
    #[Route(path: ['en' => '/part2/search', 'fr' => '/partie2/recherche'], name: 'main')]
    public function search(Request $request, SessionInterface $session): Response
    {
        $q = u($request->query->get('q', ''))->trim()->toString();
        $pagination = $this->articlesFinder->findHybridPaginated(Util::escapeTerm($q));
        $pagination->setCurrentPage($request->query->getInt('page', 1));
        $session->set('q', $q);

        return $this->render('search/search_part2.html.twig', compact('pagination', 'q'));
    }
}

Nous avons remplacé findHybrid par une nouvelle fonction findHybridPaginated qui retourne un pager à la place d'un ensemble de résultats. Ensuite on active la page courante en récupérant le paramètre page de la requête. (essayez d'accéder à une page n'existant pas pour voir ce qui arrive...🤔). Le template d'affichage des résultats a très peu changé. On itère sur l'objet pagination au lieu du tableau de résultats. Le nombre total de résultats est désormais récupéré avec pagination.nbResults et enfin on affiche le pager seulement s'il y a plus d'une page à afficher (pagination.haveToPaginate). Vous pouvez essayer avec le formulaire ci-dessous. Par exemple, une recherche avec le mot-clé "symfony" retourne plus d'une page. Le nouveau template d'affichage de la liste est disponible en cliquant ci-dessous.

Cliquez ici pour voir le nouveau template de la liste de résultats :
{% extends 'layout.html.twig' %}

{% trans_default_domain 'search' %}

{% set pagination = pagination|default(null)  %}

{% block content %}
    <div class="col-md-12">
        <div class="card">
            <div class="card-header card-header-primary">
                <p class="h3">{{ 'your_search_for'|trans}} <b>"{{ q }}"</b>, <b>{{ pagination is not null ? pagination.nbResults : ''}}</b> {{ 'results'|trans}}.</p>
            </div>
        </div>
    </div>

    {% if pagination is not null %}
        {% for article in pagination %}
            {% if article.isArticle %}
                {% set tag_route = 'blog_list_tag' %}
                {% set pathEn = path('blog_show', {'_locale': 'en','slug': article.slug|a_slug('en')}) %}
                {% set pathFr = path('blog_show', {'_locale': 'fr','slug': article.slug|a_slug('fr')}) %}
                {% set title = ('title_'~article.id)|trans({}, 'blog') %}
            {% else %}
                {% set tag_route = 'snippet_list_tag' %}
                {% set pathEn = path('snippet_show', {'_locale': 'en', 'slug': article.slug|s_slug('en') }) %}
                {% set pathFr = path('snippet_show', {'_locale': 'fr', 'slug': article.slug|s_slug('fr') }) %}
                {% set title = ('title_'~article.id)|trans({}, 'snippet') %}
            {% endif %}
            <div class="card">
                <div class="card-header">
                    <h2 class="h3">
                        [{{ ('type_'~article.type.value)|trans({}, 'blog') }}{{ is_dev() ? ' n°'~article.id : '' }}] {{ title }}</b>
                    </h2>
                </div>

                <div class="card-body">
                    <div class="blog-tags">
                        {% for tag in article.keywords %}<a class="badge badge-{{ random_class() }}" href="{{ path(tag_route, {'tag': tag}) }}"><i class="far fa-tag"></i> &nbsp;{{ tag|trans({}, 'breadcrumbs') }}</a> {% endfor %}
                    </div>
                    <br/>
                    <p class="card-text text-center">
                        <a href="{{ pathEn }}" class="btn btn-primary card-link">🇬🇧 {{ 'read_in_english'|trans({}, 'blog') }}</a>
                        {% if not strike %}
                            <a href="{{ pathFr }}" class="btn btn-primary card-link">🇫🇷 {{ 'read_in_french'|trans({}, 'blog') }}</a>
                        {% endif %}
                    </p>
                </div>
            </div>
        {% endfor %}
    {% endif %}

    <div class="col-md-12">
        {% include 'search/_form.html.twig' %}
    </div>
{% endblock %}

{% block pagination %}
    {% include '_pagination.html.twig' %}
{% endblock %}

Et le template _pagination.html.twig:

{% if pagination is not null and pagination.haveToPaginate %}
    {{ pagerfanta(pagination, {
        omitFirstPage: true,
        css_container_class: 'pagination justify-content-center',
        'prev_message': ('previous'|trans({}, 'pagination')),
        'next_message': ('next'|trans({}, 'pagination'))})
    }}
{% endif %}

Voilà c'est terminé pour la partie pagination. On peut vérifier que le premier article de la seconde page d'une recherche a bien un score inférieur au dernier article de la première page. Nous en avons fini avec la deuxième partie de ce tutoriel. À ce stade, note moteur de recherche est fonctionnel. Il est loin d'être parfait mais il est assez pertinent pour pouvoir être déployé en production. Il y a encore quelques choses qu'il serait intéressant d'ajouter ou modifier. Mais je vous propose de voir cela dans la troisième et dernière partie de ce tutoriel.

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) À tantôt ! COil. 😊

  Retour à la partie 1   Lire la 3ème partie

  Lire la doc  Plus sur le web

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

  Travaillez avec moi !


A vous de jouer !

Ces articles vous ont été utiles ? Vous pouvez m'aider à votre tour de plusieurs manières : (cf 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.
  • Cliquez sur les boutons Plus sur Stackoverflow pour me faire gagner des badges "annonceur" 🏅.

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

COil