Recherche plein texte avec Hibernate Search
Selon Wikipedia, la recherche texte plein est :
une technique de recherche textuelle dans un document électronique ou une base de données, qui consiste pour le moteur de recherche à examiner tous les mots de chaque document enregistré et à essayer de les faire correspondre à ceux fournis par l’utilisateur.
C’est donc ce que font tous les moteurs de recherche du web : rechercher des occurrences de mots dans un ensemble de textes. Ce genre de recherche n’est pas adaptée à l’utilisation d’un système de gestion de base de données relationnelle, car il est très fastidieux de produire les requêtes qui permettront de faire des recherches de ce style. De plus, le SQL ne permet pas l’approximation sur les recherches. Par exemple, si je fais une recherche sur le verbe « débuter » sur un champ quelconque, une requête SQL ne va ramener que les champs qui contiennent effectivement « débuter » et non, par exemple, les champs qui contiennent des conjugaisons du verbe. Cela implique soit de se passer d’un moteur de recherche performant dans une application qui utilise une base de données SQL, soit d’en bricoler un soi-même, avec de gros risques de perdre en performances sur l’application.
C’est là qu’entre en scène Hibernate Search (pour les applications J2EE qui utilisent Hibernate). Il permet d’apporter la flexibilité aux recherches, sur les applications J2EE. Les développeurs d’Hibernate n’ont pas réinventé la roue : ils ont adapté à Hibernate un moteur de recherche existant et reconnu : Apache Lucene (encore un bon projet de la Fondation Apache). Comme la recherche plein texte n’est pas adaptée à un modèle objet, l’intégration a dû un travail assez important, avec à l’arrivée, une utilisation transparente du moteur de recherche pour le développeur.
La présentation que je vais faire d’Hibernate Search sera forcément incomplète, d’une part parce qu’il possède un nombre d’options de recherches important, et d’autre part parce que je ne les connais pas toutes.
J’utiliserai Spring, parce que je trouve ça pratique pour faire des tests, PostgreSQL comme SGBD et également Maven.
Installation des dépendances, et création des objets métiers
Pour ce qui est de la gestion des dépendances, voici le pom utilisé pour mon projet de tests : pom.xml
Il y a donc toutes les dépendances de Spring, d’Hibernate, d’Hibernate Search de Lucene, et quelques autres.
Je ne vais utiliser deux classes pour les tests : une classe « Article », toute simple, qui ne contient qu’un titre et un contenu (deux chaînes des caractères). Et une classe « Commentaire », qui contient un Article et un contenu (chaîne de caractère).
Voici la classe Article :
@Entity @Indexed @Analyzer(impl = FrenchAnalyzer.class) @Table(name = "article") public class Article { private Long identifiant; private String contenu; private String titre; @Id @DocumentId @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "identifiant", unique = true, nullable = false) public Long getIdentifiant() { return identifiant; } public void setIdentifiant(Long identifiant) { this.identifiant = identifiant; } @Column(name = "contenu") @Field(index = Index.TOKENIZED, store = Store.NO) public String getContenu() { return contenu; } public void setContenu(String contenu) { this.contenu = contenu; } @Column(name = "titre") @Field(index = Index.TOKENIZED, store = Store.NO) public String getTitre() { return titre; } public void setTitre(String titre) { this.titre = titre; } @Override public String toString() { return "Article n° " + identifiant + " : " + titre; } }
Et la classe Commentaire :
package fr.mael.search.transverse.om; import javax.persistence.Column; import javax.persistence.Entity; import javax.persistence.FetchType; import javax.persistence.GeneratedValue; import javax.persistence.GenerationType; import javax.persistence.Id; import javax.persistence.JoinColumn; import javax.persistence.ManyToOne; import javax.persistence.Table; import org.apache.lucene.analysis.fr.FrenchAnalyzer; import org.hibernate.search.annotations.Analyzer; import org.hibernate.search.annotations.DocumentId; import org.hibernate.search.annotations.Indexed; @Entity @Indexed @Analyzer(impl = FrenchAnalyzer.class) @Table(name = "commentaire") public class Commentaire { private Long identifiant; private Article article; private String contenu; @Column(name = "contenu") public String getContenu() { return contenu; } public void setContenu(String contenu) { this.contenu = contenu; } @Id @DocumentId @GeneratedValue(strategy = GenerationType.IDENTITY) @Column(name = "identifiant", unique = true, nullable = false) public Long getIdentifiant() { return identifiant; } public void setIdentifiant(Long identifiant) { this.identifiant = identifiant; } @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "id_article") public Article getArticle() { return article; } public void setArticle(Article article) { this.article = article; } }
Il y a dans ces classes deux types d’annotations :
- les annotations liées à la persistance des données en base (postgresql ici). Je ne vais pas les expliquer
- les annotations liées à l’indexation des données par Lucene, qui sont les suivantes :
- Indexed : elle est placée sur la classe et indique que certaines données de cette classes seront indexées
- Analyzer : elle permet d’indiquer quel type « d’analyzer » lucene va utiliser. Un analyzer a pour mission de découper les textes en morceaux (tokens). Ici, on utilise le FrenchAnalyzer, qui, comme son nom l’indique, est adapté à des textes en français.
- DocumentId : permet d’indiquer à Hibernate Search l’identifiant du document (un document correspond à un objet, ici, donc l’identifiant du document = la clé primaire)
- Field : permet d’indiquer quelles propriétés de l’objet seront indexées
On peut maintenant déclarer ce dont on a besoin dans une configuration spring. Voici les deux fichiers de configuration que j’ai utilisés : application-context.xml et application-context-db.xml.
Quasiment toute la configuration se situe au niveau de application-context-db.xml, et la seule chose qui change par rapport à une configuration sans Hibernate Search sont ces deux lignes :
<prop key="hibernate.search.default.directory_provider">org.hibernate.search.store.FSDirectoryProvider</prop> <prop key="hibernate.search.default.indexBase">indexes</prop>
On déclare donc deux nouvelles propriétés. La première indique quel type de « Directory » Lucene utilisera pour stocker les fichiers d’index. On a deux choix principaux : sous la forme de fichiers sur le disque dur (org.hibernate.search.store.FSDirectoryProvider), ou dans la RAM (org.hibernate.search.store.RAMDirectoryProvider). La deuxième propriété indique dans quel dossier seront stockés les dossier et fichiers d’index.
On peut maintenant commencer les tests.
Injection de données
Avant de pouvoir commencer les tests, il convient d’injecter des données dans la base. Et il est préférable d’en injecter quelques une si l’on veut tester un peu les performances de Hibernate Search. J’ai donc fait une petite classe qui permet d’injecter des articles et des commentaires :
@ContextConfiguration(locations = {"/fr/mael/search/application-context.xml"}) @TransactionConfiguration(transactionManager = "jdbcTransactionManager", defaultRollback = false) public class Injection extends AbstractTransactionalJUnit4SpringContextTests { private Random randomGenerator = new Random(); @Resource(name = "sessionFactory") private SessionFactory sessionFactory; private ArrayList<String> mots = new ArrayList<String>(); @Before public void readMots() throws Exception { System.out.println("Lecture du fichier"); File file = new File("/home/mael/Bureau/liste.de.mots.francais.frgut.txt"); FileInputStream fis; BufferedInputStream bis = null; DataInputStream dis = null; Date date = new Date(); fis = new FileInputStream(file); // Here BufferedInputStream is added for fast reading. bis = new BufferedInputStream(fis); dis = new DataInputStream(bis); while (dis.available() != 0) { mots.add(dis.readLine()); } fis.close(); bis.close(); dis.close(); System.out.println("Fichier lu en " + (new Date().getTime() - date.getTime()) + " ms"); } @Test public void creationArticles() throws Exception { Session session = sessionFactory.getCurrentSession(); Transaction tx = session.beginTransaction(); Date date = new Date(); for (int i = 0; i < 10000; i++) { StringBuffer contenu = new StringBuffer(); for (int j = 0; j < 2000; j++) { contenu.append(mots.get(randomGenerator.nextInt(336531))).append(" "); } String titre = mots.get(randomGenerator.nextInt(336531)); Article article = new Article(); article.setContenu(contenu.toString()); article.setTitre(titre); session.persist(article); System.out.println("article " + i + " créé"); } tx.commit(); System.out.println("Articles créés en " + (new Date().getTime() - date.getTime()) + " ms"); } @Test public void creationCommentaires() throws Exception { Date date = new Date(); Session session = sessionFactory.getCurrentSession(); Transaction tx = session.beginTransaction(); Query query = session.createQuery("from Article"); List<Article> articles = query.list(); for (int i = 0; i < 5000; i++) { StringBuffer contenu = new StringBuffer(); for (int j = 0; j < 100; j++) { contenu.append(mots.get(randomGenerator.nextInt(336531))).append(" "); } Commentaire commentaire = new Commentaire(); commentaire.setArticle(articles.get(randomGenerator.nextInt(articles.size()))); commentaire.setContenu(contenu.toString()); session.persist(commentaire); } tx.commit(); System.out.println("Commentaires créés en " + (new Date().getTime() - date.getTime()) + " ms"); } }
Pour injecter des données, on voit que j’utilise une liste de mots. J’ai récupéré cette liste de mots ici : liste de 336531 mots français. Je crée ensuite 10000 articles, dont je remplis le contenu de 2000 mots aléatoires. Puis je crée 5000 commentaires, dont je remplis le contenu de 100 mots aléatoires, et auxquels je lie un article au hasard.
Attention, cette classe est un peu (beaucoup ?) « bourrin », il y a des risques qu’elle crée des erreurs de mémoire à l’exécution (on crée quand même des chaînes de caractères de 20.000.000 mots, mises bout à bout), si c’est le cas, il vaut mieux exécuter les deux tests en deux fois, baisser le nombre d’insertions, ou encore faire des méthodes moins « bourrin ».
On remarque à l’exécution de ces tests que le commit des transactions est très long. Et pour cause, par défaut, Hibernate Search fonctionne en mode synchrone avec Lucene, ce qui implique que les indexations se font en même temps que les insertions en base. On peut très bien décider de fonctionner en mode asynchrone, il suffit pour ça d’ajouter la propriété hibernate suivante dans le fichier de configuration de spring :
<prop key="hibernate.search.worker.execution">async</prop>
A titre de comparaison, sur ma machine, il faut 279491 ms (quasiment 5 min) pour effectuer les insertions de 10000 articles en mode synchrone, contre 39654 ms (39 s) en mode asynchrone. Mais, en mode asynchrone, quand le commit de la transaction est terminé, tous les articles (quasiment aucun en fait) ne sont pas indexés. L’indexation est en fait lancée dans un thread à part.
Il n’y a pas de bon choix entre les deux modes, il doit être fait en fonction des besoins de l’application, en sachant que le mode synchrone dégrade les performances des opérations en base, et que le mode asynchrone ne permet pas de prévoir quand on aura accès aux documents indexés.
Autre chiffre qui peut avoir son importance : la taille des fichiers d’index sur le disque. Pour l’insertion des 10000 articles (contenant chacun 2000 mots), l’ordre de grandeur de l’espace disque utilisé par les fichiers d’indexation de Lucene est de plusieurs dizaines de Mo (67 Mo pour être exact).
Il faut bien garder une chose en tête : c’est que l’indexation des documents ne se fait que sur les opérations lancées par hibernate. Cela implique que si je supprime des lignes en base à la main, les index seront toujours là. Des recherches sur les éléments supprimés ne ramèneront rien, mais les index risquent de « s’encrasser ». Pour vérifier si l’encrassement des fichiers d’indexation a une influence, j’ai fait une expérience : j’ai inséré 10000 enregistrements que j’ai supprimés directement en base, puis j’ai calculé le temps moyen (10 mesures) d’exécution d’un requête de recherche (évidemment, comme les enregistrements sont supprimés en base, elle ne ramène rien). J’ai reproduit l’opération 10 fois. Voici le graphique que j’obtiens :
Ce graphique montre clairement que les performances se dégradent avec le temps, si on laisse les fichiers d’index s’encrasser. Et la dégradation est conséquente : on passe d’un temps d’exécution de 355 ms à quasiment 1400 ms. On peut palier le problème en programmant des réindexations (voir plus bas).
Cela m’a également permis de créer un graphique de l’évolution de la taille des fichiers d’index en fonction du nombre d’enregistrements :
Il s’agit d’une fonction linéaire, ce qui permet donc de prévoir la taille des fichiers d’indexation.
Effectuer des recherches sur les documents indexés
Ce n’est pas le tout d’indexer les documents, mais il faut bien faire des recherches dessus pour que ça soit utile. Hibernate Search offre beaucoup de manières d’effectuer des recherches, nous alors en voir quelques unes.
Recherche simple
Commençons tout d’abord par une recherche toute simple. J’ai pris un mot au hasard : « décarbonateront » (si, ça existe). Et je fais la recherche avec Hibernate Search et sans Hibernate Search. Sans hibernate Search, je dois faire une recherche « like ‘%décarbonateront% ». Puis je compte le nombre de résultats obtenus, ainsi que les temps d’exécution.
Voici la méthode de test :
@Test public void testRechercheLucene() throws ParseException, IOException, InvalidTokenOffsetsException { // exécution avec lucene Date dateDebutLucene = new Date(); // création de la FullTextSession, spécifique à Hibernate Search FullTextSession searchSession = Search.getFullTextSession(sessionFactory.getCurrentSession()); // Construction d'un QueryParser. Il permet de faire une recherche sur un champ donné en paramètre : ici, "contenu" // On peut également instancier un "MultiFieldQueryParser", auquel on fait passer un tableau de champs en paramètre // On indique également la version de lucene et l'analyzer qui sont utilisés QueryParser parser = new QueryParser(Version.LUCENE_32, "contenu", new FrenchAnalyzer(Version.LUCENE_32)); // On construit une Query lucene, en appelant parse(String), à laquelle on fait passer en paramètre // le(s) mot(s) à rechercher. org.apache.lucene.search.Query query = parser.parse("décarbonateront"); // On construit une query Hibernate en appelant cette méthode. On peut passer autant de // classes que l'on veut en paramètre. Ici, on ne fait une recherche que sur la classe Article org.hibernate.Query hibQuery = searchSession.createFullTextQuery(query, Article.class); //listing de tous les résultats List result = hibQuery.list(); Date dateFinLucene = new Date(); // exécution hibernate simple Date dateDebutSansLucene = new Date(); Session session = sessionFactory.getCurrentSession(); Criteria criteria = session.createCriteria(Article.class); criteria.add(Restrictions.like("contenu", "%décarbonateront%")); List result2 = criteria.list(); Date dateFinSansLucene = new Date(); System.out.println("Résultats lucene : " + result.size() + " en " + (dateFinLucene.getTime() - dateDebutLucene.getTime()) + " ms"); System.out.println("Résultats sans lucene : " + result2.size() + " en " + (dateFinSansLucene.getTime() - dateDebutSansLucene.getTime()) + " ms"); }
Voici ce que m’écrit la console :
Résultats lucene : 1906 en 1446 ms
Résultats sans lucene : 56 en 1724 ms
On voit donc que d’une part, Hibernate Search renvoie plus de résultats, et d’autre part que la recherche par Hibernate Search est plus rapide (je ne montre ici qu’un exemple, mais c’est une tendance générale, j’ai lancé le test plusieurs fois).
Mais pourquoi Hibernate renvoie plus de résultats ? C’est simple, comme je l’avais dit en introduction, Hibernate Search permet de faire des approximations dans les recherches. Ici, ce que cela signifie concrètement, c’est que les Articles trouvés par Hibernate Search contiennent aussi bien « décarbonateront » que « décarbonaterait », que « décarbonatera » etc. On voit donc ici la nette supériorité d’une recherche texte plein sur une recherche simple. Et ce serait encore plus flagrant, si comme indiqué dans les commentaires, on faisait les recherches sur plusieurs champs de plusieurs objets. Et le code ne contiendrait pas une ligne de plus.
Recherche avec « Highlight »
En général, quand on fait une recherche dans un moteur de recherche (sur le web, par exemple), le moteur de recherche nous présente un extrait du texte qui contient les mots-clés que l’on a recherchés. C’est très pratique pour indiquer à l’utilisateur dans quel contexte se situent les mots qu’il a recherchés. Et Hibernate Search le permet.
J’ai repris la méthode précédente, et l’ai un peu modifiée :
@Test public void testHighlight() throws ParseException, IOException, InvalidTokenOffsetsException { // création de la FullTextSession, spécifique à Hibernate Search FullTextSession searchSession = Search.getFullTextSession(sessionFactory.getCurrentSession()); // Construction d'un QueryParser. Il permet de faire une recherche sur un champ donné en paramètre : ici, "contenu" // On peut également instancier un "MultiFieldQueryParser", auquel on fait passer un tableau de champs en paramètre // On indique également la version de lucene et l'analyzer qui sont utilisés QueryParser parser = new QueryParser(Version.LUCENE_32, "contenu", new FrenchAnalyzer(Version.LUCENE_32)); // On construit une Query lucene, en appelant parse(String), à laquelle on fait passer en paramètre // le(s) mot(s) à rechercher. org.apache.lucene.search.Query query = parser.parse("début dénoyauter"); // On construit une query Hibernate en appelant cette méthode. On peut passer autant de // classes que l'on veut en paramètre. Ici, on ne fait une recherche que sur la classe Article org.hibernate.Query hibQuery = searchSession.createFullTextQuery(query, Article.class); //listing de tous les résultats List result = hibQuery.list(); // instanciation d'un "QueryScorer" QueryScorer scorer = new QueryScorer(query); // Formatter, qui permet de marquer les mots trouvés SimpleHTMLFormatter formatter = new SimpleHTMLFormatter("[highlight]", "[/highlight]"); // Construction du highlighter Highlighter highlighter = new Highlighter(formatter, scorer); // Fragmenter, le deuxième paramètre permet d'indiquer la taille du fragment conservé, en bytes highlighter.setTextFragmenter(new SimpleSpanFragmenter(scorer, 100)); for (Object object : result) { Article article = (Article) object; System.out.println(highlighter.getBestFragment(new FrenchAnalyzer(Version.LUCENE_32), "contenu", article.getContenu())); System.out.println(); } }
Voici un exemple de ligne que la console renvoie :
vélomoteur confronta perméabilisais attroupassions bituré [highlight]débutant[/highlight] défoncions avicole madéfiasse
On voit bien qu’un des mots recherchés (ici, un mot proche) est entouré des balises que l’on a indiquées dans le code.
Vérification orthographique
Quand on fait une recherche que le moteur de recherche interprète comme mal orthographiée, le moteur de recherche suggère une orthographe qui lui parait plus appropriée. Là encore, Hibernate Search le permet. La correction orthographique passe par la construction d’un index spécifique. Il faut donc ajouter une méthode que l’on va appeler après l’injection des données, et qui va construire l’index de correction orthographique en fonction des données injectées.
Voici la méthode en question :
private void buildSpellCheckIndex(String fieldName) throws Exception { FullTextSession searchSession = Search.getFullTextSession(sessionFactory.getCurrentSession()); SearchFactory searchFactory = searchSession.getSearchFactory(); DirectoryProvider[] providers = searchFactory.getDirectoryProviders(Article.class); org.apache.lucene.store.Directory directory = providers[0].getDirectory(); IndexReader spellReader = null; SpellChecker spellchecker = null; try { //Instanciation de l'objet qui va lire l'index spellReader = IndexReader.open(directory); //instanciation d'un dictionnaire LuceneDictionary dict = new LuceneDictionary(IndexReader.open(directory), fieldName); // construction de l'index (en dur, c'est moche mais tant pis). spellDir = FSDirectory.open(new File("indexes/spell_Article")); //construction du SpellChecker spellchecker = new SpellChecker(spellDir); // construction du répertoire spellchecker.indexDictionary(dict); } finally { if (spellReader != null) spellReader.close(); } }
J’ai mis le répertoire de l’index en dur. C’est moche, mais ça suffira pour l’exemple.
Maintenant qu’on a créé l’index, on peut tester cette correction orthographique. Voici une méthode de test :
@Test public void testCorrection() throws IOException { // création de la FullTextSession, spécifique à Hibernate Search FullTextSession searchSession = Search.getFullTextSession(sessionFactory.getCurrentSession()); // début de la transaction SearchFactory searchFactory = searchSession.getSearchFactory(); DirectoryProvider[] provider = searchFactory.getDirectoryProviders(Article.class); //récupération du répertoire de l'index org.apache.lucene.store.Directory directory = FSDirectory.open(new File("indexes/spell_Article")); //construction du SpellChecker SpellChecker spell = new SpellChecker(directory); //on indique l'algo à utiliser pour la comparaison des chaines de caractère. Ici il s'agit de la distance de Levenstein. //on peut également utiliser la distance de Jaro Winkler (JaroWinklerDistance) ou NGram (NGramDistance). spell.setStringDistance(new LevensteinDistance()); //permet d'indiquer la distance entre les chaines de caractères. //Concrètement, plus la valeur s'approche de 0, plus le nombre de suggestions calculées sera important spell.setAccuracy(0.8f); //récupération des suggestions String[] suggestions = spell.suggestSimilar("anticonstititionel", 10); System.out.println("Suggestions : "); for (String s : suggestions) { System.out.println(s); } }
Quand je lance ce test, voici les suggestions que la console m’affiche :
Suggestions :
anticonstitutionnel
Comme indiqué en commentaire, on peut jouer sur l’algorithme utilisé pour récupérer les suggestions, et également sur la distance pour ramener plus ou moins de suggestions. L’idéal dans une application est de faire une méthode proposant des suggestions, qui est appelée seulement quand la recherche de l’utilisateur est peu/pas fructueuse.
Réindexation
Comme je l’avais dit plus haut, la synchronisation des indexes et de la base de données ne se fait que sur les opérations hibernate, ce qui peut impliquer que l’index s’encrasse, si on effectue des opérations directement en base ou si l’on remonte un backup.
C’est pour cette raison que Hibernate Search offre la possibilité de réindexer les documents. Voici par exemple une méthode qui réindexe tous les documents :
@Test public void reindex() throws InterruptedException { FullTextSession searchSession = Search.getFullTextSession(sessionFactory.getCurrentSession()); Date date = new Date(); //récupération d'un réindexeur. il est possible de recréer les index que pour certaines classes, en les passant en paramètre MassIndexer indexer = searchSession.createIndexer(); //lancement de l'indexeur en mode synchrone. on peut utiliser la méthode start() pour le lancer //en mode asynchrone indexer.startAndWait(); System.out.println("temps d'exécution : " + (new Date().getTime() - date.getTime()) + " ms"); }
On peut donc imaginer une routine qui réindexerait régulièrement les documents (avec Quartz dans Spring, par exemple), ou proposer une option dans l’administration d’une application pour relancer l’indexation ponctuellement. Attention cependant, la réindexation est très couteuse en ressources (RAM, CPU et temps). Et il est recommandé d’optimiser l’indexeur pour limiter son temps d’exécution (voir la doc : MassIndexer).
Conclusion
A travers cet article un peu long, j’ai présenté quelques un des avantages d’une recherche texte plein, et la bonne intégration du moteur Lucene dans Hibernate, via Hibernate Search. Cette recherche texte plein peut apporter un confort considérable à l’utilisateur dans une application.
Avant d’utiliser Hibernate Search, il faut cependant être certain que l’application a besoin de la fonctionnalité, parce que l’indexation est un processus consommateur de ressources (aussi bien espace disque, que RAM et CPU).
A noter qu’il est tout à fait possible d’indexer des documents comme des PDF, fonctionnalité que je n’ai pas présentée ici (voir http://community.jboss.org/wiki/HibernateSearchAndOfflineTextExtraction).
Voici le projet qui m’a servi pour les tests : TestHibernateSearch.zip