vendredi 2 avril 2010

Hibernate Search et approximation (dernière partie)

Dans la dernière partie de cette série d'articles consacrés à Hibernate Search, nous allons voir comment mettre en place l'approximation pour faciliter les recherches dans notre mini application immobilière.

Il existe plusieurs manières de rendre les recherches tolérantes aux approximations. Nous allons voir comment mettre en œuvre les techniques suivantes : 
  1. recherche par synonymes
  2. tolérance aux fautes
  3. recherche phonétique
Il est bien sur possible de combiner ces différentes techniques pour obtenir un moteur de recherche le plus flexible possible.

La recherche par synonymes
Il est possible de définir un ensemble de synonymes pour permettre une recherche "intelligente". Si je définie par exemple que 'avenue' est équivalent à 'rue', je pourrai retrouver la rue Victor Hugo en tapant 'avenue Victor Hugo'.
Pour mettre en place ce mécanisme nous allons définir un Analyzer sur notre entité JPA :
@Entity
@Indexed
@Analyzer(impl=StandardAnalyzer.class)
@AnalyzerDef(name="synonymsdef",
tokenizer = @TokenizerDef(factory = StandardTokenizerFactory.class ),
filters = { @TokenFilterDef(factory = SynonymFilterFactory.class
//synonymes
 ,params = @Parameter(name="synonyms", value="com/loicdescotte/coffeebean/conf/synonyms.txt"))
})
On définie le fichier synonyms.txt. Les mots équivalents sont sur une même ligne, séparés par une virgule :
rue, avenue

NB : il est possible de spécifier plusieurs filtres à la suite dans un analyseur.

Au moment de la recherche, on fait appel à l'analyseur rattaché à notre entité Appartement (je reprend l'exemple de l'article précédent):
String[] fields = new String[]{"adresse", "proprietaire.lastName"};
Analyzer appartAnalyzer = fullTextEntityManager.getSearchFactory().getAnalyzer(Appartement.class);
MultiFieldQueryParser parser = new MultiFieldQueryParser(fields, appartAnalyzer);
org.apache.lucene.search.Query standardQuery = standardQuery = parser.parse(filter);

La tolérence aux fautes
Elle permet de rendre les recherches plus flexibles en s'affranchissant des fautes de frappes ou d'orthographe.
En tapant par exemple 'tictor', vous pourrez trouver les résultats contenant la chaine 'Victor'.
Il existe 2 manières de mettre en place ce mécanisme.

La distance de Levenshtein :
Cet algorithme permet de calculer la distance entre le mot recherché et les résultats retournés. Pour déterminer la similitude entre 2 mots, il se base sur un prefixe commun (facultatif) et sur la similitude du sufixe.
Dans ce cas de figure, nous ne nous basons pas sur un Analyzer personnalisé mais sur un type de requête appelé FuzzyQuery. Les FuzzyQuery permettent de calculer la distance de Levenshtein automatiquement :

FuzzyQuery filterQuery = new FuzzyQuery(new Term("adresse", filter), 0.5, 5);
Dans cet exemple, les 5 premières lettres du terme recherché et du terme indexé doivent être identiques, pour ce qui est du suffixe (les lettres restantes), elles doivent être à 50% identiques.

Les n-grammes :
Cette méthode est moins couteuse en terme de ressources machine, elle consiste à découper les mots à indexer en petits morceaux et à indexer ces morceaux. Le mot recherché sera lui aussi découpé et les morceaux communs seront recherchés lors d'une requête.
'Java' pourra par exemple être découpé en 'Ja ', 'av', 'va'. Pour mettre en œuvre la technique des n-grammes, nous définissons un TokenFilter dans un Analyzer :

@TokenFilterDef(factory=NGramFilterFactory.class,
                  params = { @Parameter(name="minGramSize", value="3"),
                         @Parameter(name="maxGramSize", value="3")})
minGramSize et maxGramSize déterminent le minimum et le maximim de morceaux à créer pour un mot

La recherche phonétique
La recherche phonétique permet de trouver des équivalences basées sur le prononciation des mots.
En utilisant ce mode de recherche, le terme 'kuisine' renverra par exemple le même résultat que 'cuisine'.
Dans cet exemple nous utilisons le Double Metaphone, qui permet d'effectuer ce genre de recherches dans un grand nombre de langues dont l'anglais et le français :
@TokenFilterDef(factory = PhoneticFilterFactory.class
,params = { @Parameter(name="encoder", value="DoubleMetaphone")})

Mixer plusieurs types de requêtes
Voyons maintenant comment associer différentes requêtes grâce aux BooleanQuery, ceci peut s'avérer utile par exemple si vous voulez mixer requêtes à base d'analyseur et des FuzzyQueries.
BooleanQuery orQuery = new BooleanQuery();
    orQuery.add(firstQuery, BooleanClause.Occur.SHOULD);
    orQuery.add(secondQuery, BooleanClause.Occur.SHOULD);
La directive Occur.SHOULD permet de spécifier un OU inclusif, pour exiger que tous les critères des différentes requêtes soient replis, nous pouvons utiliser Occur.MUST.

Quelques astuces
Beaucoup de filtres sont disponibles pour simplifier les recherches, comme le StopFilter qui permet d'ignorer des mots communs comme 'et', 'ou', 'le', 'la' (le dictionnaire de base est en anglais mais il est possible de définir le sien comme pour les synonymes) ou encore le LowerCaseFilter qui permet de passer toutes les chaines de caractères en minuscules.
Pour pondérer les termes lors d'une recherche, vous pouvez utiliser l'annotation @Boost :
@Field(index=Index.TOKENIZED)
    @Boost(2.0f)
    private String lastName;
Ce code signifie que l'attribut lastName aura un poids double lors d'une recherche. Ceci modifie donc la pertinence du champ pour Lucene, en le rendant plus important dans l'analyse de l'index lors d'une recherche.

Vous pouvez télécharger ici le code source de l'application présentée au cours des différentes partie de ces tutoriels.