Si vous utilisez MongoDB et que vous avez décidé de mettre en place une recherche textuelle dans votre application, vous avez sûrement dû vous rendre compte des limites de l'index "text" de ce dernier.

En effet, MongoDB offre la possibilité à ses utilisateurs de faire une recherche textuelle sur un index déclaré comme tel, avec quelques spécificités :

  • Possibilité de prendre en compte les accents ou non
  • Possibilité de configurer pour rechercher les variations simples des mots "rein" => ("rein", "reins","reine")
  • Possibilité d'omettre les stop-words (le, la, un, des, ...)

Mais lorsqu'il s'agit de faire une recherche partielle d'un mot, les résultats retournés sont parfois trop restrictifs, je m'explique :

Admettons des noms de villes comme ["Tours","Joué les tours", "Tournan en brie", "Vautour saint-martin"], avec un simple index textuel natif à MongoDB et une recherche sur le texte "tour", la seule ville retournée sera "Tours".

Hors, si je veux que mes utilisateurs puissent trouver toutes les villes ci-dessus, un simple index textuel ne suffit plus.

Bien sûr, j'en entends déjà qui me crieraient d'utiliser ElasticSearch, ce qui est vrai, mais pour des projets de petites ampleurs, je trouve l'outil et sa mise en place légèrement "overkill".

Voilà pourquoi je vais vous présenter une solution basée sur MongoDB (avec Mongoose), qui ne passera pas aussi bien à l'échelle qu'une instance Elastic Search mais qui fonctionne très bien sur des volumes de données raisonnables !

Attention cette méthode marche pour des recherches à un seul mot uniquement pour l'instant !

La solution

Création du nouvel index

Elastic Search base ses indexes sur ce que l'on appelle des ngrams, c'est à dire de multiples sous-ensembles de chaines de caractères. Par exemple le mot "Tours" décomposé en ngrams devient "t to tou tour tours o ou our ours u ur urs r rs s", soit toutes les possibilités de combinaisons uniques de lettres adjacentes.

C'est donc la première étape, écrire un algorithme de décomposition des mots en ngrams, ici on va prendre en entrée une liste de chaines de caractères  contenant toutes les données cherchables par l'utilisateur et retournera une chaine de caractère unique contenant tous les ngrams possibles séparés par des espaces.

Words.compute_ngrams = function (data) {
    let str_array = data;
    
    //We ensure ngrams unicity using a "set" object
    let ngrams = {};
    
    //Making sure the data is an array
    if (!Array.isArray(data)) {
        str_array = [data];
    }

    str_array.forEach(function (str) {
        if (str != null) {
        	//For each data, we make sure it's a string and split it by words
            (str+"").split(' ').forEach(function (word) {
                if (word && word != "") {
                    word = word.toLowerCase();
                    //Starting from the first letter, we add all ngrams of all lengths
                    for(let l_start=0; l_start <= word.length; l_start++){
                        for (let l_end=1; l_end <= word.length; l_end++) {
                            ngram = word.substr(l_start,l_end)
                            ngrams[ngram] = 1;
                            
                        }
                    }
                }
            });
        }
    });

    return Object.keys(ngrams).join(" ");
}

Désormais qu'il nous est possible de transformer toutes les données voulues en une seule chaine de caractère contenant tous les ngrams, il va nous falloir un index où la stocker.

let Schema = new mongoose.Schema({
    //...
    text_search: { type: String, required: true, default: ""}
});

Schema.index({ text_search: "text"});

Pour se faire nous utiliserons un index textuel classique de MongoDB pour que ce dernier puisse traiter chaque ngram de la chaine comme un mot à part entière.

Vous voyez la technique ? MongoDB ne sait traiter que des mots entiers ? Alors on lui "mâche" littéralement le travail en lui donnant des mots déjà décomposés qu'il prendra comme des mots entiers !

Ensuite il nous suffit à chaque création ou modification d'un objet de re-générer les ngrams des données voulues, ici nom de la ville, code postal et région et de le sauvegarder en base.

data.text_search = Words.compute_ngrams([
    data.city_name,
    data.post_code,
    data.region
]);

Faire une recherche

La recherche textuelle utilisée est celle de MongoDB, qui est par défaut insensible à la casse et aux accents.

let results = await Schema.find({
    $text: {
        $search: '"'+search+'"', $language: "none"
    }
});

Néanmoins il est nécessaire d'indiquer $language: "none" pour éviter que MongoDB n'enlève tous les stopwords, et comme beaucoup de nos ngrams sont des chaines de 1 à 3 caractères, ces derniers sont souvent assimilés comme des stopwords.

On encapsule aussi la requête entre double-quote pour éviter à MongoDB d'associer les ngrams de plusieurs mots différents.

Et voilà, le tour est joué, vous pouvez désormais faire des recherches de chaines de caractères partielles, en début, fin ou milieu de mot ! A noter que la taille de l'index variera beaucoup selon le nombre de documents et le nombre de données avec lesquelles vous allez générer vos ngrams !

Mais ça fonctionne !

J'espère que cet article vous a plus, et je vous dis à bientôt sur le blog !

À propos de l'auteur

Hello, je suis Nicolas Brondin-Bernard, ingénieur web indépendant depuis 2015 passionné par le partage de d'expériences et de connaissances.

Aujourd'hui je suis aussi coach pour développeurs web juniors, tu peux me contacter sur nicolas@brondin.com, sur mon site ou devenir membre de ma newsletter pour ne jamais louper le meilleur article de la semaine et être tenu au courant de mes projets !


Photo by Jason Leung on Unsplash