Autocompletion avec Elasticsearch

Lors de la saisie d’adresses dans des formulaire, une source fréquente de problèmes est la saisie des villes et codes postaux: gestion des accents, minuscules ou majuscules, code postal ne correspondant pas à la ville, etc.

Nous allons voir l’implémentation rudimentaire d’un autocomplete sur les noms et codes postaux des villes qui tient compte de ces soucis.

Comme point de départ, nous allons partir d’une entité “City” qui possède les colonnes “name” et “zipcode”.
La table correspondante est déjà alimentée avec les informations sur les communes françaises. L’importation de ces données n’étant pas le but-même de l’article, elle se sera pas détaillée ici.
De nombreuses bases sont facilement disponibles sur le web, entre autre:

Un autocomplete standard est assez trivial à gérer, mais peut devenir rapidement problématique pour des noms de villes lorsque l’on considère les accents, les tirets, ou les éventuels articles.
Pour nous aider nous allons utiliser Elasticsearch et y indexer les informations de nos villes.
Après avoir installé et lancé Elasticsearch (http://www.elasticsearch.org/download/), installons FOSElasticaBundle.
Dans composer.json :

{
    "require": {
        "friendsofsymfony/elastica-bundle": "3.0.*@dev"
    }
}

Une fois le bundle installé, déclarons notre index dans config.yml:

fos_elastica:
    clients:
        # à renseigner dans votre parameter.yml, généralement "localhost" et "9200"
        default: { host: %elasticsearch_host%, port: %elasticsearch_port% }
    indexes:
        lexik:
            client: default
            settings:
                index:
                    analysis:
                        analyzer:
                            custom_search_analyzer:
                                type: custom
                                tokenizer: standard
                                filter   : [standard, lowercase, asciifolding]
                            custom_index_analyzer:
                                type: custom
                                tokenizer: standard
                                filter   : [standard, lowercase, asciifolding, custom_filter]
                        filter:
                            custom_filter:
                                type: edgeNGram
                                side: front
                                min_gram: 1
                                max_gram: 20
            types:
                city:
                    mappings:
                        name:    { search_analyzer: custom_search_analyzer, index_analyzer: custom_index_analyzer, type: string }
                        zipcode: { type: string }
                    persistence:
                        driver: orm
                        # spécifiez votre propre entité
                        model: Lexik\Bundle\CitiesBundle\Entity\City
                        provider: ~
                        finder: ~

Beaucoup de choses intéressantes se passent dans cette configuration.

Tout d’abord nous décrivons les colonnes qui vont être indéxées dans ES.

mappings:
    name:    { search_analyzer: custom_search_analyzer, index_analyzer: custom_index_analyzer, type: string }
    zipcode: { type: string }

Dans le cas d’un code postal, une autocomplétion n’est pas intéressante (inutile de suggérer toutes les villes d’un département lorsque l’utilisateur est en train de saisir son code postal). Nous n’attribuons donc aucun analyzer et stockons juste la donnée brute. La recherche sur un code postal ne sera jamais partielle.

En revanche pour les noms de villes, nous attribuons des analyzers spécifiques pour l’indexation et pour la recherche. Voyons-les en détail:

analysis:
    analyzer:
        custom_search_analyzer:
            type: custom
            tokenizer: standard
            filter   : [standard, lowercase, asciifolding]
        custom_index_analyzer:
            type: custom
            tokenizer: standard
            filter   : [standard, lowercase, asciifolding, custom_filter]
    filter:
        custom_filter:
            type: edgeNGram
            side: front
            min_gram: 1
            max_gram: 20

Le filtre standard gère la séparation automatique des mots pour les langages de type européen.
lowercase assure que les tokens sont générés uniquement en minuscule pour avoir une recherche insensible à la casse.
asciifolding retire tous les caractères spéciaux (accents, cédilles, …) et les remplace par leur équivalent ascii, ce qui nous permet par exemple de retrouver “Béziers” à partir de la recherche “beziers”.
Enfin nous ajoutons un filtre custom de type edgeNGram qui nous permet de créer des tokens pour tous les sous-ensembles d’un mot, mais seulement à partir d’un bord (“edge”). Nous précisons que le bord souhaité est le début du mot avec front. Ainsi, la recherche “seil” va correspondre à “Seillac” mais pas à “Marseille”.

Avec ces settings, nous avons configuré un index qui peut retrouver des villes à partir soit d’un code postal exact, ou d’une chaîne correspondant au début d’un mot dans un nom de ville.
Assurons-nous que les villes sont bien indexées à l’aide d’un app/console fos:elastica:populate.

Maintenant on peut se pencher sur l’action qui va effectuer la recherche:

public function citySuggestAction(Request $request)
{
    $query = $request->get('search', null);

    // notre index est directement disponible sous forme de service
    $index = $this->container->get('fos_elastica.index.lexik.city');

    $searchQuery = new \Elastica\Query\QueryString();
    $searchQuery->setParam('query', $query);

    // nous forçons l'opérateur de recherche à AND, car on veut les résultats qui
    // correspondent à tous les mots de la recherche, plutôt qu'à au moins un
    // d'entre eux (opérateur OR)
    $searchQuery->setDefaultOperator('AND');

    // on exécute une requête de type "fields", qui portera sur les colonnes "name"
    // et "zipcode" de l'index
    $searchQuery->setParam('fields', array(
        'name', 
        'zipcode',
    ));

    // exécution de la requête, limitée aux 10 premiers résultats
    $results = $index->search($searchQuery, 10)->getResults();

    $data = array();

    // on arrange les données des résultats...
    foreach ($results as $result) {
        $source = $result->getSource();
        $data[] = array(
            'suggest'   => $source['zipcode'].' '.$source['name'],
            'zipcode'   => $source['zipcode'],
            'city'      => $source['name'],
        );
    }

    // ...avant de les retourner en json
    return new JsonResponse($data, 200, array(
        'Cache-Control' => 'no-cache',
    ));
}

Notons que tout le code est dans l’action à titre d’exemple, mais la requête serait bien mieux installée dans son propre repository/service.

Pour finir, côté client, voici un formulaire simplifié dans le but de l’article:

Nous laissons le champ “zipcode” en readonly car le code postal sera renseigné automatiquement lors de la séléction de la ville.

Côté javascript, l’autocomplete peut être facilement réalisé à l’aide du module typeahead présent dans Bootstrap 2:

var suggestUrl = $('#city').attr('data-suggest');
$('#city').typeahead({
    // suggestions pour une saisie d'au minimum 3 caractères
    minLength: 3,

    // nous configurons ici la source distante de données
    source: function(query, process) {
        // "query" est la chaîne de recherche
        // "process" est une closure qui doit recevoir la liste des suggestions à afficher

        var $this = this;
        $.ajax({
            url: suggestUrl,
            type: 'GET',
            data: {
                search: query,
            },
            success: function(data) {
                // les données que nous recevons sont de 3 types:
                //  "suggest" est la chaîne de caractères à afficher dans les suggestions
                //  "city" et "zipcode" sont les données que nous voulons utiliser pour
                //  remplir nos champs lors de la sélection d'une suggestion

                // ce tableau "reversed" conserve temporairement une relation entre chaque
                // suggestion, et ses données associées
                var reversed = {};

                // ici nous générons simplement la liste des suggestions à afficher
                var suggests = [];

                $.each(data, function(id, elem) {
                    reversed[elem.suggest] = elem;
                    suggests.push(elem.suggest);
                });

                $this.reversed = reversed;

                // affichage des suggestions
                process(suggests);
            }
        })
    },

    // cette méthode est appelée lorsque qu'une suggestion est sélectionnée depuis la liste
    updater: function(item) {

        // nous retrouvons alors les données associées
        var elem = this.reversed[item];

        // puis nous remplissons les champs "zipcode"...
        $('#zipcode').val(elem.zipcode);

        // ...et "city" du formulaire
        return elem.city;
    },

    // cette méthode permet de déterminer lesquelles des suggestions sont valides par rapport
    // à la recherche. Nous effectuons déjà tout cela côté serveur, donc ici il suffit de
    // retourner "true"
    matcher: function() {
        return true;
    }
});

Et voilà notre autocomplete opérationnel. On peut y chercher directement des code postaux:
Code postaux

Ou des noms de villes:
noms de villes

Et la sélection remplit automatiquement les deux champs:

Voir l’étude de cas
Lire l’article
Voir le témoignage
Fermer