Recherche textuelle avec MongoDB

mongoIl y a maintenant 2 ans nous avions choisi d’utiliser MongoDB sur HopWork à l’issu d’une petite série d’expérimentation. Cette base m’avait séduit pour ces puissantes fonctionnalités de requêtage et sa facilité de mise en oeuvre. Dans ce billet j’aimerais revenir sur les fonctionnalités de recherche textuelles de MongoDB car, si elles sont puissantes, elles comportement aussi quelques pièges.

 

Voyons d’abord notre jeu de données. Il s’agit de document JSON. J’ai pris l’ensemble des membres de www.lateral-thoughts.com comme profiles dans ma collection MongoDB :

db.profile.find()
 { "_id" : ObjectId("5315c3437fec2f001e01c882"), "firstName" : "Hugo", "lastName" : "Lassiege" }
 { "_id" : ObjectId("5315c3567fec2f001e01c883"), "firstName" : "Jean-Baptiste", "lastName" : "Lemée" }
 { "_id" : ObjectId("5315c3647fec2f001e01c884"), "firstName" : "Olivier", "lastName" : "Girardot" }
 { "_id" : ObjectId("5315c36e7fec2f001e01c885"), "firstName" : "Florent", "lastName" : "Biville" }
 { "_id" : ObjectId("5315c3777fec2f001e01c886"), "firstName" : "Nicolas", "lastName" : "Rey" }
 { "_id" : ObjectId("5315c37f7fec2f001e01c887"), "firstName" : "Vincent", "lastName" : "Doba" }
 { "_id" : ObjectId("5315c38a7fec2f001e01c888"), "firstName" : "Jonathan", "lastName" : "Dray" }
 { "_id" : ObjectId("5315c3937fec2f001e01c889"), "firstName" : "Stuart", "lastName" : "Corring" }

Faisons une première recherche simple pour trouver une personne par son nom de famille :

db.profile.find({lastName:"Lassiege"})
 { "_id" : ObjectId("5315c3437fec2f001e01c882"), "firstName" : "Hugo", "lastName" : "Lassiege" }

Fonctionnellement, cette recherche est équivalent à utiliser l’opération $in :

db.profile.find({lastName : {$in:["Lassiege"]}})
 { "_id" : ObjectId("5315c3437fec2f001e01c882"), "firstName" : "Hugo", "lastName" : "Lassiege" }

Jusqu’ici tout va bien. Regardons cependant nos deux requêtes avec un explain plan :

db.profile.find({lastName:"Lassiege"}).explain()
 {
 "cursor" : "BasicCursor",
 "isMultiKey" : false,
 "n" : 1,
 "nscannedObjects" : 8,
 "nscanned" : 8,
 "nscannedObjectsAllPlans" : 8,
 "nscannedAllPlans" : 8,
 "scanAndOrder" : false,
 "indexOnly" : false,
 "nYields" : 0,
 "nChunkSkips" : 0,
 "millis" : 0,
 "indexBounds" : {},
 "server" : "HUGO:27017"
 }

Cet explain plan nous indique que nous avons scanné nos 8 éléments pour trouver notre résultat. Pas très grave dans le cas présent, mais parcourir l’ensemble des éléments d’une collection sera rapidement problématique sur une plus grosse volumétrie.

Nous allons poser un index pour résoudre ce point et améliorer nos recherches.

db.profile.ensureIndex({lastName : 1})
db.profile.find({lastName : {$in:["Lassiege"]}}).explain()
 {
 "cursor" : "BtreeCursor lastName_1",
 "isMultiKey" : false,
 "n" : 1,
 "nscannedObjects" : 1,
 "nscanned" : 1,
 "nscannedObjectsAllPlans" : 1,
 "nscannedAllPlans" : 1,
 "scanAndOrder" : false,
 "indexOnly" : false,
 "nYields" : 0,
 "nChunkSkips" : 0,
 "millis" : 0,
 "indexBounds" : {
    "lastName" : [
       [
      "Lassiege",
      "Lassiege"
      ]
    ]
  },
 "server" : "HUGO:27017"
 }

C’est déjà mieux. Nos deux requêtes passent désormais par cet index et effectuent une seule lecture par l’index.

Jusqu’ici tout va bien.

Mettons que désormais nous choisissions de chercher « lassiege » et non « Lassiege ».

db.profile.find({lastName:"lassiege"})

Aucun résultat… C’est le drame. Il s’agit d’une recherche « exacte ».

Pour le fun, on pourrait utiliser une première méthode avec l’opérateur $where :

db.profile.find( { $where: "this.lastName.toUpperCase() == 'lassiege'.toUpperCase()" } );
 { "_id" : ObjectId("5315c3437fec2f001e01c882"), "firstName" : "Hugo", "lastName" : "Lassiege" }

Cet opérateur permet d’appliquer une fonction Javascript pour évaluer si un élément match ou non.

Pour être clair, si cet opérateur est peu utilisé, il y a une raison. Il ne profite pas de l’index posé sur la collection. On va vite oublier… Il n’est pas recommandé d’utiliser cette technique.

Le lecteur malin qui a suivi le dernier lien vers la doc disséminé dans ce texte aura remarqué la présence de l’opérateur $regex que l’on pourrait utiliser pour notre recherche :

db.profile.find({lastName : {$regex: /lassiege/i}})
 { "_id" : ObjectId("5315c3437fec2f001e01c882"), "firstName" : "Hugo", "lastName" : "Lassiege" }

La regexp /lassiege/i permet en effet d’effectuer une recherche non sensible à la casse. Malheureusement, cette recherche ne profite pas non plus de notre index.

A noter d’ailleurs que les seules recherches avec une regexp qui profiteront de l’index sont les requêtes du type « Commençant par » (avec un ^) et non case insensitive.

Bon alors, on ne peut pas rechercher efficacement de façon insensible à la casse ?

En réalité il existe deux techniques utilisées avec MongoDB.

Avant la 2.4

Comme très souvent avec MongoDB, la réponse consiste à dupliquer l’information. Ainsi, une possibilité sera de stocker dans notre objet « profile » un champ lastname contenant le champ lastName mais normalisé selon notre besoin de recherche.

{ "_id" : ObjectId("5315c3437fec2f001e01c882"), "firstName" : "Hugo", "lastName" : "Lassiege", "lastname" : "lassiege" }

Ce champ pourra être utilisé lors d’une recherche avec un paramètre dont vous aurez changé la casse également.

Soyons clair, certains vont considérer que c’est « étrange » mais c’est la manière traditionnelle de le faire. Et c’est toujours la technique à privilégier aujourd’hui.

Depuis la 2.4

Nous allons tirer parti d’une nouvelle fonctionnalité qui a été introduite en 2.4 : les index full text. Et nous verrons que ce n’est pas la panacée.

Tout d’abord, il s’agit d’une fonctionnalité en statut béta, non recommandé en production. Il faut l’activer au démarrage pour pouvoir l’utiliser :

mongod –setParameter textSearchEnabled=true

Nous pouvons désormais ajouter cet index full text :

db.profile.ensureIndex({lastName : "text"})

La recherche va devoir être faite via une commande et non via la méthode find habituelle.

db.profile.runCommand("text", {search:"lassiege"})
 {
 "queryDebugString" : "lassieg||||||",
 "language" : "english",
 "results" : [
     {
        "score" : 1,
        "obj" : {
           "_id" : ObjectId("5315c3437fec2f001e01c882"),
           "firstName" : "Hugo",
           "lastName" : "Lassiege",
           "lastname" : "lassiege"
        }
     }
  ],
 "stats" : {
     "nscanned" : 1,
     "nscannedObjects" : 0,
     "n" : 1,
     "nfound" : 1,
     "timeMicros" : 285
 },
 "ok" : 1
 }

A noter que l’on retrouve la possibilité de faire des filtres, des projections, une limitation du nombre de résultat mais pas de tri. Car le tri est donné par la pertinence du résultat renvoyé par la recherche full text.

C’est un peu dommage car c’est assez peu homogène avec le reste. On aurait préféré passer par un find :

  • avec éventuellement l’utilisation d’un Hint pour forcer le passage par l’index full text. db.profile.find(…).hint({lastname: « text »})
  • ou mieux, avec un opérateur spécifique $text db.profile.find({lastName : {$text : « lassiege »}})

Et on aurait aimé contrôler le tri pour utiliser nos propres tris si besoin.

Pour conclure sur les index Full Text. Il faut bien se méfier sur leur utilisation :

  • Ils sont plus gourmands en espace disque (on stocke tous les tokens)
  • Ils sont plus coûteux à l’écriture (puisque MongoDB passe du temps à analyser vos termes et que le coût de stockage est plus lourd)
  • On ne peut pas avoir plusieurs index Full Text sur une collection. A la place on peut avoir uniquement un index Full Text qui regroupe plusieurs champs (la différence est importante)

En fait, pour ce cas d’usage, où il s’agit de gérer une recherche non sensible à la casse sur des noms de familles, la première méthode est la méthode à privilégier. Mais cela vous a peut-être permis de découvrir cette fonctionnalité.

Voilà c’est terminé. Vous venez de voir différents opérateurs pour vos recherches, les pièges relatifs à l’utilisation de certain d’entre eux, l’indexation et l’indexation full text.

Vous pouvez retourner à vos claviers.

 

6 réflexions sur “Recherche textuelle avec MongoDB

  1. Un article intéressant, comme à chaque fois 🙂

    J’ai deux questions qui viennent:
    1) Si je souhaite faire une recherche approchante sur plusieurs champs (par ex, je cherche « lass » ou « ugo »), si je reprends la technique pré-2.4, j’en déduis qu’il faut ajouter un champs « recherche », dans lesquels tous les champs recherchés sont mis en lower case, ce qui donne: { « _id » : ObjectId(« 5315c3437fec2f001e01c882 »), « firstName » : « Hugo », « lastName » : « Lassiege », « recherche » : « hugo lassiege » }
    On appliquera alors une regex, mais on ne bénéficie pas de l’index…
    Est-ce que tu confirmes que c’est la solution la plus adaptée ?
    Mais cela veut donc dire que suivant les champs sur lesquels on souhaite rechercher, on va dupliquer tous les champs concernés et potentiellement aller jusqu’à doubler l’espace disque d’une entrée ?

    2) Pour mettre en place le mécanisme de remplissage des champs dédiés à la recherche, on peut le faire dans le code appelant mongo. Mais est-il possible de déclarer des règles de transformation dans mongo pour que ce soit fait automatiquement ?
    Une règle qui dirait un truc du genre: « if update ‘lastName’ then update ‘lastname’.toLowerCase  »

    Merci

  2. Pour ta premiere question, vu qu’à la fin du passes par une regexp sans profiter de l’index, autant tout de suite utiliser ta regexp sur les champs orginaux. Une regexp ne passe effectivement pas par l’index, mais pour que ce ne soit pas trop pénalisant il faut la combiner avec d’autres critères de filtre qui réduisent la quantité de donnée et qui eux profiteraient d’un index.

    Pour ta seconde question, malheureusement il n’existe pas de système de trigger dans MongoDB. Ca a été évoqué dans une demande JIRA cependant donc ce serait envisageable de le voir arriver à un moment ou un autre.

    Et oui, on est loin des fonctionnalités de recherche d’elasticsearch 🙂

  3. Merci Hugo pour ce nouveau billet.

    Les index full text me semble beaucoup plus contraignant et moins puissant que d’utiliser un elasticsearch en parallèle pour le requêtage.
    Il me semble que tu utilisais cette architecture!
    Est ce toujours le cas ?

    Si non quel sont les avantages des index full text par rapport à une recherche elasticsearch ?

    • Sur HopWork on utilise toujours elasticsearch et c’est bien plus puissant que les index full text de Mongo sans comparaison.

      Pour ce billet, c’était plutot pour le cas ou on a pas assez de cas d’usage pour introduire elasticsearch dans la stack. Introduire un nouveau composant c’est potentiellement lourd, en terme d’infra, en terme d’admin, déploiement ou de stratégie de synchro entre les deux référentiels. Donc pour un seul besoin on pouvait être tenté par les index full text.
      Mais c’est vraiment le elasticsearch du (très) pauvre malheureusement et les résultats sont loin de ce qu’on pourrait attendre.

      Le seul truc potable de ces index c’est qu’ils sont naturellement multilangue alors que la mise en place du multilangue sur es demande un peu plus de boulot.

  4. Pingback: Recherche textuelle avec MongoDB | Eventually C...

Laisser un commentaire