Comment utiliser Lucene dans vos applications?

Lucene
La recherche Full-text (recherche (en) plein texte appelée aussi recherche en texte intégral ou recherche de texte libre) 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.
Lucene est un moteur de recherche libre écrit en Java qui permet d’indexer et de rechercher du texte. C’est un projet open source également disponible pour les langages Ruby, Perl, C++, PHP. Il existe aussi une implémentation pour C# et .NET.

Intérêt de la recherche full-text

Dans une application reposant sur une base de données (SQL pour la plupart), lorsqu’on veut faire un recherche d’une chaîne de caractère sur un champ texte, on écrit une requête qui ressemble à:

SELECT * FROM t WHERE description LIKE ‘%toto%’;

Ce genre de requête est vraiment très coûteux et n’est pas ce qui est le plus fiable pour trouver les résultats.
En effet, même si ce champ description est indexé (avec les méthode classique pour les bases de données), la recherche n’en bénéficie pas.
Une clause LIKE 'toto%' utilise l’index pour rechercher les enregistrements dont la description commence par ‘toto‘, tandis que la clause LIKE '%toto%', qui recherche les enregistrements dont la description contient ‘toto‘, n’utilise pas l’index, alors l’indexation “normale” n’apporte rien pour les recherches sur des sous-chaines.

Avec une indexation/recherche full-text, chaque mot (et on verra que ça va même plus loin) est indexé, et à chacun d’entre eux, on associe une référence vers les documents qui contiennent ce mot.
Il faut bien sûr d’abord indexer tout le contenu, ce qui peut être assez long, mais ensuite la recherche est accélérée dans des proportions très importantes, le gain de performances augmente exponentiellement avec le volume de données. En effet, il n’y a plus à parcourir entièrement tous les enregistrements d’une table pour trouver les correspondances, il suffit de retrouver les mots dans l’index.
Vous trouverez dans ce diaporama, un comparatif de différents outils full-text, dont les bases de données.

Je ne parlerai pas ici de Hibernate Search qui permet de configurer l’index avec quelques lignes d’annotations. Ça aurait été trop facile! J’aborderai donc la méthode sans Hibernate.

Lucene et Maven

Pour utiliser Lucene dans votre application, il faut ajouter les librairies à votre projet. Voici la déclaration des dépendances avec Maven. Nous utilisons la version 3.0.2, dernière en date.

Pour débuter, on peut ne déclarer que la dépendance sur le Core

<dependency>
  <groupId>org.apache.lucene</groupId>
  <artifactId>lucene-core</artifactId>
  <version>3.0.2</version>
  <scope>compile</scope>
</dependency>

Mais en fait on a rapidement besoin de Analyzers qui lui sera la seule nécessaire à déclarer car on accède aux classes du Core par transitivité.

<dependency>
  <groupId>org.apache.lucene</groupId>
  <artifactId>lucene-analyzers</artifactId>
  <version>3.0.2</version>
  <scope>compile</scope>
</dependency>

Lucene et Java

Attention à la version de Lucene des exemples et documentations que vous trouverez, j’utilise Lucene 3.0.2, une grande partie des références que j’ai trouvé se réfèrent à Lucene 2.X, l’API a évolué entre ces versions, certaines classes / méthodes sont dépréciées, d’autres sont nouvelles.

Création de l’index

L’analyseur

L’analyseur est un outil essentiel de Lucene: lors de l’indexation, il permet de “nettoyer” le texte, comme enlever la ponctuation et les mots insignifiants, ainsi que d’autres comportements que nous allons voir juste après.

Important: assurez-vous d’utiliser le même analyseur pour l’indexation et la recherche.

Pour commencer, vous pouvez déclarer un analyseur standard: StandardAnalyzer.

Analyzer analyzer =
    new StandardAnalyzer(Version.LUCENE_30);

Cet analyseur contient déjà des filtres tels que le LowerCaseFilter pour tout indexer et rechercher en casse basse, ainsi qu’un StopFilter qui utilise une liste de mots (tokens), les stop words, qui sont trop courant dans les textes, donc sans réelle pertinence et ne sont alors pas pris en compte. Par défaut, c’est une liste de mots en anglais définie en interne qui est utilisée.
Mais vous pouvez fournir votre propre liste en paramètre:

final Set stopWordsSet = getStopWordsSet();
Analyzer analyzer =
    new StandardAnalyzer(Version.LUCENE_30, stopWordsSet)

Voici la liste que j’utilise pour le texte en français:

a afin ai ainsi après attendu au aujourd auquel aussi autre autres aux auxquelles auxquels avait avant avec c car ce ceci cela celle celles celui cependant certain certaine certaines certains ces cet cette ceux chez ci combien comme comment concernant contre d dans de debout dedans dehors delà depuis derrière des désormais desquelles desquels devers devra doit donc dont du duquel durant dûs elle elles en entre environ est et etc eu eux excepté hormis hors hélas hui il ils j je jusqu jusque l la laquelle le lequel les lesquelles lesquels leur leurs lorsque lui lû ma mais malgré me merci mes mien mienne miennes miens moins mon moyennant même mêmes n ne ni non nos notre nous néanmoins nôtre nôtres on ont ou outre où par parmi partant pas passé pendant plein plus plusieurs pour pourquoi proche près puisque qu quand que quel quelle quelles quels qui quoi quoique revoici revoilà s sa sans sauf se selon seront ses si sien sienne siennes siens sinon soi soit son sont sous suivant sur ta te tes tien tienne tiennes tiens ton tous tout toute toutes tu un une va vers voici voilà vos votre vous vu vôtre vôtres y à été être î

Ces filtres constituent un bon début, mais il est encore possible (et nécessaire) d’apporter des améliorations, en ajoutant d’autres filtres. Pour cela il faut redéfinir StandardAnalyzer:

final Set stopWordsSet = getStopWordsSet();
Analyzer  analyzer =
	new StandardAnalyzer(Version.LUCENE_30, stopWordsSet){
	@Override
	public TokenStream tokenStream(final String fieldName, final Reader reader) {
		TokenStream stream = super.tokenStream(fieldName, reader);
		stream = new ASCIIFoldingFilter(stream);
		stream = new FrenchStemFilter(stream);
		return stream;
	}

Je redéfinis ici la méthode tokenStream. En fait les flux sont imbriqués les uns dans les autres, chacun appliquant son propre filtre sur le précédant.

ASCIIFoldingFilter: remplace ISOLatin1AccentFilter, dont le nom est plus parlant sur sa fonction: convertir dans leur équivalent les caractères qui ne sont pas dans le bloc “Latin Basic” (les 127 premiers caractères ASCII), typiquement toutes les lettres accentuées.

StemFilter: permet de ne considérer que le radical des mots. Un exemple concret sera plus parlant: en cherchant chat, vous pourrez trouver chat chats chatte chaton.
Pour en savoir plus sur le stemming ou Lexémisation.
Pour l’anglais, utilisez PorterStemFilter, pour le français FrenchStemFilter et suivez le modèle NomDeLaLangueStemFilter pour d’autres langues.

L’IndexWriter

final IndexWriter writer = new IndexWriter(
	FSDirectory.open(new File("c:/myIndex")),
	analyzer,
	createIndex,
	MaxFieldLength.UNLIMITED
);

Le premier argument détermine où et comment sera stocké l’index, ici avec un FSDirectory, donc dans le système de fichiers, il est aussi possible de le stocker en mémoire avec un RAMDirectory.
Ensuite il faut bien-sûr l’analyseur définit précédemment,
l’index sera-t-il écrasé si il existe déjà,
une limite pour la longueur des champs (en nombre de caractères): MaxFieldLength.LIMITED est à 10000, MaxFieldLength.UNLIMITED est à Integer.MAX_VALUE.
D’autres constructeurs avec plus de paramètres permettent une plus grande personnalisation.

Nous sommes maintenant près pour la création de l’index:

public void createLuceneIndex(final List elements) throws Exception{

	final Analyzer analyzer = getAnalyzer();

	IndexWriter writer=null;
	try{
		writer = this.getIndexWriter(analyzer, true);
		final int size = elements.size();
		int i=1;
		for (final T element : elements) {
			logger.info("indexing element "+(i++)+" of "+size);
			addInLuceneIndex(element, writer, analyzer);
		}
		writer.optimize();
	}finally{
		if(null!=writer)
			writer.close();
	}
}

protected void addInLuceneIndex(T element, IndexWriter writer, Analyzer analyzer) throws Exception{
	final Document document = this.toDocument(element);
	if(null!=document)
		writer.addDocument(document, analyzer);
}

La méthode toDocument pour convertir un objet métier en Document Lucene.

protected Document toDocument(User user) {
	final Document d = new Document();

	String value;
	Field field;

	value = user.getId().toString();
	d.add(new Field("id", value, Field.Store.YES, Field.Index.NOT_ANALYZED));

	value=StringUtils.trimToEmpty(user.getLogin());
	field = new Field("login", value, Field.Store.NO, Field.Index.ANALYZED)
	field.setBoost(10);
	d.add(field);

	return d;
}

Un document Lucene est composé de champs. Pour chaque champ, on donne un nom et une valeur.
Si la valeur est stockée: Field.Store.YES, elle pourra être récupérée lors de la recherche, mais ce n’est pas toujours nécessaire: Field.Store.NO.
La valeur peut être indexée telle qu’elle: Field.Index.NOT_ANALYZED, typiquement pour les identifiants, sinon la valeur est analysée: Field.Index.ANALYZED et ce sont les tokens produits par l’analyseur qui sont indexés.
Le boost un coefficient de pondération pour le classement des résultats par pertinence. Par défaut, la pertinence (ou scoring) du résultat tient compte de la longueur des champs, e.g: si le mot clé est trouvé dans un champ de 20 caractères, alors le résultat sera considéré comme plus pertinent que si le même mot clé est trouvé dans un champ de 1000 caractères. Le coefficient de boost permet de personnaliser cette gestion de pertinence.
Remarque: il est probable que certains champs auront été saisis avec un éditeur de texte enrichi et par conséquent stockés en base avec toutes les balises (HTML le plus souvent) de formatage. Veillez donc à ce que l’analyseur ne travaille qu’avec le texte, il faut donc en enlever de la chaine de caractère passée en paramètre, tous les tags HTML et remplacer les codes des caractères spéciaux (e.g: les accents) par leur valeur.

Les types de valeurs

La valeur est toujours une chaine de caractère. Donc pour les autres types il faut convertir les valeurs dans un format de chaîne que l’analyseur pourra exploiter.

Pour les booléens, le mieux est de passer la chaine correspondant à la représentation sous forme d’entier du booléen, c’est-à-dire true => "1" et false => "0". On obtient ces valeurs comme suit:

boolean b = true;
value = String.valueOf(BooleanUtils.toInteger(b));

Pour les valeurs numériques, il faut utiliser les méthodes de la classe NumericUtils.
Pour les types int et long:

int i = 1;
value = NumericUtils.intToPrefixCoded(i)

long l = 1;
value = NumericUtils.longToPrefixCoded(l);

Des méthodes équivalentes existent pour les types float et double.

Pour les dates, dans le cadre de mon utilisation, j’ai simplement utilisé le timestamp, ce qui revient à un long:

Date d = new Date();
value = NumericUtils.longToPrefixCoded(d.getTime());

Mise à jour de l’index

Pour mettre à jour un document dans l’index, il faut le supprimer puis ajouter la nouvelle version.

final Term idTerm = new Term("id");
writer.deleteDocuments(idTerm.createTerm(user.getId()));

Ce code peut se lire “supprimer les document dont la colonne 'id' vaut user.getId()“.

Recherche dans l’index

La recherche dans l’index nécessite aussi l’utilisation d’un analyseur. Cet analyseur doit être configuré de la même manière que pour l’indexation (donc mettez le code qui l’instancie dans une méthode qui sera appelée dans les deux cas), sans quoi on risque d’obtenir des résultats inattendus.

Création de la requête

Il faut construire un objet de type Query, qui est en fait un super-type qui a des implémentations adaptées à différentes utilisation.

La recherche par mot-clé

C’est le type de recherche le plus courant lorsqu’on utilise un outil tel que Lucene.

La requête à exécuter est obtenue à partir d’un parser que l’on obtient comme suit:

protected QueryParser getQueryParser(final Analyzer analyzer) {
	QueryParser parser = new MultiFieldQueryParser(Version.LUCENE_30,
		new String[]{
			"login",
			"lastname",
			"firstname",
			"description"
		},
		analyzer);
	return parser;
}

À partir du parser, on construit la requête qui consistera à trouver les documents pour lesquels un au moins des champs nommés contient le ou les mots clé recherché. C’est une façon de faire une recherche globale sur le document.

protected Query keywordQuery(String value) throws Exception{
	final Analyzer analyzer = getAnalyzer();

	if(StringUtils.isEmpty(value) || null==analyzer)
		return null;
	final QueryParser parser = this.getQueryParser(analyzer);
	parser.setFuzzyMinSim(0.6f);
	final Query query = parser.parse(value);
	return query;
}

Remarquez setFuzzyMinSim qui est utilisé pour les fuzzy queries, qui peuvent être utiles pour prendre en compte les fautes de frappes lors de la saisie de mots clé à rechercher. En effet, les fuzzy query permettent d’obtenir des résultats de recherche sur des mots “proches” les uns des autres. Le concept de proximité est issu ici de la notion de distance entre mot, correspondant à l’algorithme de Levenshtein.
Une distance de 1 entre deux mots correspond à soit un échange de lettre, un retrait ou un ajout de lettre pour passer de la premiere occurence de mot à l’autre.
Par défaut le coefficient de similarité est de 0.5, ce qui fournissait des résultats un peu trop éloigné, un coefficient de 0.6 constitue un meilleur compromis.
Dans le code de l’exemple, le coefficient n’est pris en compte que pour une requête fuzzy, c’est-à-dire que les mots de la recherche seront suffixés par le caractère ~, qui est une syntaxe qui est n’est pas destinée à être utilisée par la plupart des utilisateurs. En fait, pour exécuter des fuzzy queries, il faut plutôt instancier la classe FuzzyQuery.

La recherche par terme

C’est une recherche exacte sur les documents dont le champ field vaut value.
C’est adapté pour rechercher un identifiant ou un booléen valant “0” ou “1”.

protected Query termQuery(String field, String value){
	return new TermQuery(new Term(field, value));
}

Recherche sur des intervalles

Les cas d’utilisation les plus courants sont sur les numériques.

Comme nous l’avons vu précédemment, lors de l’indexation, les valeurs numériques doivent être converties dans un format de chaine de caractère.

Pour les types int et long:

protected Query intRangeQuery(String field,
		Integer min, Integer max,
  		boolean includeBoundaries){

	TermRangeQuery rangeQuery = new TermRangeQuery(field,
  		NumericUtils.intToPrefixCoded(min.intValue()),
  		NumericUtils.intToPrefixCoded(max.intValue()),
  		includeBoundaries, includeBoundaries);

  	return rangeQuery;
}

protected Query longRangeQuery(String field,
		Long min, Long max,
		boolean includeBoundaries){

	TermRangeQuery rangeQuery = new TermRangeQuery(field,
		NumericUtils.longToPrefixCoded(min.longValue()),
		NumericUtils.longToPrefixCoded(max.longValue()),
		includeBoundaries, includeBoundaries);

	return rangeQuery;
}
 

Pour les dates, j’ai aussi utilisé un timestamp, donc un long:

protected Query dateRangeQuery(String field,
		Date minDate, Date maxDate,
		boolean includeBoundaries){

	return longRangeQuery(field,
		Long.valueOf(minDate.getTime()),
		Long.valueOf(maxDate.getTime()),
		includeBoundaries);
}

Recherche multi-critère

Pour effectuer une recherche avec plusieurs clauses, il faut utiliser la classe BooleanQuery, qui permet d’obtenir un requête composée d’autres requêtes.

BooleanQuery query = new BooleanQuery();
query.add(query1, Occur.MUST);
query.add(query2, Occur.MUST_NOT);
query.add(query3, Occur.SHOULD);

Occur.MUST: la clause doit apparaitre dans les résultats, c’est l’équivalent du SQL AND.
Occur.MUST_NOT: la clause ne doit pas apparaitre dans les résultats, c’est l’équivalent du SQL NOT ou AND NOT.
Occur.SHOULD: la clause peut apparaitre dans les résultats, sans obligation (sauf bien-sûr si il n’y a qu’une seule clause), c’est l’équivalent du SQL OR.

Exécution de la requête

protected List search(Query query, final String lang)throws Exception{
	final File indexDir = this.getIndexDir(lang);

	if(!indexDir.exists()){
		logger.info(new StringBuffer("Directory '").append(indexDir).append("' does not exist"));
		return null;
	}

	final Searcher searcher = new IndexSearcher(IndexReader.open(FSDirectory.open(indexDir)));

	final TopDocs topDocs = searcher.search(query,Integer.MAX_VALUE);
	final Map docScores = MapUtils.orderedMap(new HashMap());

	for (int i = 0; i < topDocs.totalHits; i++) {
		final Document document = searcher.doc(topDocs.scoreDocs[i].doc);
		final Long elementId = Long.valueOf(document.get("id"));
		docScores.put(elementId, Float.valueOf(topDocs.scoreDocs[i].score));
	}

	final List elementIds= docScores.isEmpty()
		? Arrays.asList(new Long[]{Long.valueOf(0)})
		: new ArrayList(docScores.keySet());

	logger.info(new StringBuffer()
		.append(elementIds.size())
		.append(" elements matching query: ")
		.append(elementIds));

	return elementIds;
}

Utilisation des résultats

Recherche des documents dans Lucene, nous obtenons la liste des identifiants des objets correspondant à la recherche.

List ids = searchInLuceneIndex(keyword);

À partir de cette liste, vous allez charger les objets correspondants en appelant le DAO, qui va générer une requête qui ressemblera à:

SELECT * FROM t_user WHERE id IN(115, 8, 69);

115, 8, 69 étant, pour l’exemple, les identifiants des documents trouvé dans Lucene.
Seulement, l’ordre dans lequel les résultats sont retournés ne correspond pas à l’ordre des identifiants renvoyés par Lucene (ordre qui prenait en compte des critères de pertinence). Il faut donc réordonner la liste d’objets obtenue dans l’ordre des identifiants:

private void restoreUserLuceneOrder(final List listSelectedIds, final List searchResult){
	if(!CollectionUtils.isEmpty(listSelectedIds)){
		if(listSelectedIds.get(0).longValue()!=0){
			Collections.sort(searchResult, new Comparator() {
				public int compare(User e1, User e2) {
					int i1 = listSelectedIds.indexOf(e1.getId());
					int i2 = listSelectedIds.indexOf(e2.getId());
					return i1-i2;
				}
			});
		}
	}
}

Conclusion

J’espère que ce retour d’expérience profitera à ceux qui voudraient mettre en place cet outil d’une puissance impressionnante. Pour aller plus loin, la meilleure source est: la documentation. Veillez à utiliser la version 3.x, l’API a évolué par rapport à la version 2.x. Pour le moment la plupart des exemples que vous trouverez sur le Web sont en version 2.x, et si ils sont souvent compatibles, ils nécessitent parfois quelques adaptations, comme cela a été le cas pour certains morceau de code que je présente ici.

Nombre de vue : 1903

COMMENTAIRES 4 commentaires

  1. jerome dit :

    Très bel article, je cherchais justement une intro à Lucene
    Merci

  2. Emmanuel Humez dit :

    Très bon article également !

    Il existe aussi SolR, basé sur Apache Lucene, permettant de faire de la recherche facettée (ou recherche paramétrée).

    Pour .Net, il y a http://solrsharp.codeplex.com/ ou http://code.google.com/p/solrnet/ avec notamment une application sample avec un client web en ASP.NET MVC 2 🙂

  3. maya dit :

    salut,merci pour l’explication de lucène
    je cherche de faire un moteur de recherche par lucène quii affiche le resultat selon le modèle de recherche sac de mot

  4. fatma dit :

    salut,très bon article
    je cherche de faire indexer une ontologie en utilisant Lucene le résultat c’est d’afficher chaque index qui contient l’un des composants de l’ontologie (concept, relation individu…)

AJOUTER UN COMMENTAIRE