Les accents dans les recherches texte

Un bon moteur de recherche se doit d'être souple, car les mots recherchés par l'internaute peuvent être présents dans la base de données, mais sous une forme différente. L'exemple le plus évident est la présence de capitales dans le texte: si une phrase commence par "Demain, ...", ce mot doit être reconnu par le moteur de recherche même si l'utilisateur a recherché "demain" ou "DEMAIN".

Beaucoup de langues écrites utilisent des caractères spéciaux (accentués ou autres) qui posent le même problème : un même mot peut être écrit sous différentes formes selon la présence ou l'absence de ces caractères (notament à cause des capitales, qui sont souvent désaccentuées). Le mot "sérénité", s'il apparaît dans un titre, peut très bien être sous la forme "SERENITE". Le moteur doit non seulement ignorer la casse mais également l'accentuation.

Le problème des caractères spéciaux se révèle d'autant plus épineux lorsqu'il faut retrouver les mots reconnus dans le texte une fois sorti de la base de données. J'ai eu à implémenter cette fonction afin de mettre en valeur ces mots dans l'affichage des textes trouvés par notre moteur de recherche. Il m'a donc fallu trouver un moyen d'effectuer une recherche insensible à la casse et aux caractères spéciaux dans une chaîne de caractères PHP, et d'obtenir les positions exactes des mots trouvés en tenant compte des caractères multi-octets.

En recherchant une méthode satisfaisante pour effectuer ce type de recherche, je me suis aperçu que beaucoup de solutions différentes sont proposées en ligne, et que la plupart de celles-ci sont incomplètes, voire erronées. J'expose donc dans la suite de cet article les solutions que j'ai adoptées, qui fonctionnent très bien.

En base de données MySQL

Pour effectuer une recherche texte en base de données MySQL, plusieurs solutions sont possibles, la plus simple étant d'utiliser l'opérateur LIKE : lien1, lien2. Pour des comportements plus évolués, les opérateurs REGEXP (alias RLIKE) et MATCH() ... AGAINST peuvent être nécessaires, notament si des expressions régulières sont impliquées.

Comme nous n'avons actuellement pas besoin de gérer d'expressions régulières, j'ai eu le choix. Je n'ai pas retenu MATCH() ... AGAINST car cela implique de créer des index FULLTEXT sur les colonnes concernées dans la base de données et une surcharge de travail pour le moteur MySQL à chaque recherche. De plus cet opérateur a certains comportements qui ne nous sont pas utiles et que l'on ne peut pas inhiber. L'opérateur REGEXP quant à lui est à proscrire car il ne gère pas les caractères multi-octets, et je n'ai vu aucune solution en ligne qui permette de contourner ce problème.

J'utilise donc tout simplement l'opérateur LIKE, qui est implémenté pour être insensible à la casse et à l'accentuation. Il suffit pour qu'il fonctionne de manière fiable au niveau des accents de lui préciser quelle collation de caractères utiliser. La collation 'utf8_general_ci' nous fournit entière satisfaction (nous n'avons que le français à gérer pour l'instant), mais d'autres sont plus adaptées à des langues spécifiques.

Les seuls caractères Unicode que l'opérateur LIKE ne gère pas correctement sont ceux qui correspondent à deux lettres basiques, par exemple 'Œ' qui correspond à 'OE'. Pour contourner ce problème, je désaccentue les motifs recherchés puis j'ajoute à la liste de motifs toutes leurs variations possibles en fonction de ces caractères (en bas de casse pour limiter la taille de la liste, MySQL reconnaît sans problème que 'Œ' et 'œ' sont équivalents). Par exemple si la liste de motifs contient 'Caesar', la liste traitée contiendra 'caesar', 'cæsar' et 'cǽsar'.

J'ai écrit une fonction qui génère le tableau de conditions à utiliser dans un find() pour rechercher un ensemble de motifs dans un champ en base de données. Comme nos blocs de texte sont générés par TinyMCE, j'ai ajouté un traitement des motifs: les caractères spéciaux de TinyMCE sont remplacés par leur code respectif pour correspondre aux textes enregistrés.

/**
 * @param string $field: database field to search
 * @param array $patterns: patterns as strings
 * @param boolean $markUp: true if searched field contains markup (then patterns must be sanitized)
 * @return array: find condition array to match given field against given patterns (to be put in an 'OR' find condition key)
 */
public function matchFieldPatterns($field, $patterns, $markup = false) {
	$markupReplacements = array(
		'&' => '&',
		'<' => '&LT;',
		'>' => '&GT;',
	);
	$managedCollations = array('utf8_general_ci');

	$conditions = array();
	foreach ($patterns as $pattern) {
		if ($markup) { // Some characters have been sanitized for database storage
			// Sanitize pattern as well
			$pattern = str_replace(array_keys($markupReplacements), array_values($markupReplacements), $pattern);
		}
		// Add conditions to match this field against this pattern for each managed collation
		foreach ($managedCollations as $collation) {
			$conditions[] = sprintf("%s LIKE '%%%s%%' COLLATE %s", $field, $pattern, $collation);
		}
	}

	return $conditions;
}

Appelée avec en paramètres $field='AModel.a_field', $patterns=array('Foo', '<bar>') et $markUp=true, cette fonction retourne le tableau de conditions suivant (à associer à une clé 'OR' dans l'option 'conditions' d'un find):

array(
	"AModel.a_field LIKE 'Foo' COLLATE utf8_general_ci",
	"AModel.a_field LIKE '&LT;bar&GT;' COLLATE utf8_general_ci"
)

Dans une chaîne de caractères PHP

Une fois que les textes ont été trouvés dans la base de données, il peut être utile de connaître la position des mots recherchés, typiquement pour les mettre en valeur dans l'affichage des résultats. Pour cela, il faut effectuer une deuxième recherche directement dans les chaînes PHP sorties de la base de données (éventuellement traitées, pour enlever les balises hypertexte par exemple), avec la même insensibilité à la casse et aux accents.

La manière la plus simple que j'ai trouvée pour effectuer ceci est de transformer le texte et les motifs: remplacer toutes les lettres accentuées par leur lettre basique correspondante et mettre toutes les lettres en capitales, ainsi tous les mots équivalents se retrouvent sous la même forme et il n'y a plus d'ambiguïté. La difficulté réside dans le fait que les positions seront déterminées dans le texte transformé mais utilisées dans le texte original, il faut donc bien faire attention aux caractères multi-octets.

Pour retirer les accents, j'ai opté pour un tableau de correspondance associant chaque groupe de lettres accentuées équivalentes (sous forme d'expression régulière) à la lettre basique correspondante. Il se trouve que la classe Inflector de Cake possède un tel tableau, mais quelques modifications sont à apporter car certains caractères spéciaux sont associés à plusieurs lettres basiques, apparemment dans un but de consistance avec le comportement de MySQL. Par exemple la lettre 'ü' est associée à 'ue' dans ce tableau car en MySQL, dans certaines collations répandues, ('ü' LIKE 'ue') est vrai alors que ('ü' LIKE 'u') est faux.

J'ai donc créé une classe Accents étendant la classe Inflector afin d'avoir accès à ce tableau (il est déclaré 'protected') et de le corriger avant de l'utiliser (ces corrections dépendent des langages gérés). Une fois les accents retirés du texte et des motifs grâce à la fonction statique Accents::strip(), on peut utiliser les fonctions PHP de chaînes de caractères qui gèrent les caractères multi-octets (mb_strpos(), mb_substr(), ...) pour trouver les positions des mots à mettre en valeur. Reste le problème des caractères Unicode qui doivent correspondre à deux lettres basiques et ne sont donc pas corrigées dans le tableau de correspondance, comme 'æ'. Pour remédier à cela, il suffit de décrémenter la position de chaque mot (calculée dans le texte transformé) de 1 pour chaque occurence d'un tel caractère avant ce mot dans la chaîne de caractères PHP, et on obtient la position correcte dans le texte original.

/**
 * Utility wrapper for customizing Inflector functionalities
 */
class Accents extends Inflector {

	/**
	 * Replaces accentuated or special letters from given string with corresponding basic letter
	 * @param string $string
	 * @return string 
	 */
	public static function strip($string) {
		$map = self::correctMap();
		return preg_replace(array_keys($map), array_values($map), $string);
	}

	/**
	 * Corrects some de-accentuations in Inflector's transliteration map (i.e. 'ü' => 'ue')
	 * @return array 
	 */
	public static function correctMap() {
		$map = self::$_transliteration;
		
		// Override incorrect transliterations
		$overrides = array(
			'/æ|ǽ/' => 'ae',
			'/œ/' => 'oe',
			'/ü/' => 'u',
			'/Ä/' => 'A',
			'/Ü/' => 'U',
			'/Ö/' => 'O',
		);
		foreach ($overrides as $accent => $basic) {
			$map[$accent] = $basic;
		}
		// Remove remaining incorrect transliterations
		$deletions = array('/ä|æ|ǽ/', '/ö|œ/');
		foreach ($deletions as $key) {
			unset($map[$key]);
		}
		
		return $map;
	}
}

Notre implémentation

J'ai rassemblé ces solutions dans un plug-in contenant ma classe Accents et un behavior qui met à disposition des modèles un nouveau type de find: 'search'. Si vous n'avez jamais utilisé de type de find autres que ceux de base ('first', 'all', ...), je vous invite à consulter la documentation du CookBook sur les custom find types.

Ce plugin est librement disponible sur GitHub, et contient une implémentation complète des concepts présentés pécédemment. Ce peut être une bonne source d'exemples si vous comptez créer votre propre implémentation sans partir de zéro.

Pour avoir un aperçu des résultats de la recherche, vous pouvez utiliser le champ de recherche en haut de la page.

Si vous utilisez ce plugin directement, n'hésitez pas à nous faire part de vos remarques ou suggestions !

Cette page appartient aux catégories suivantes: actualités , CakePHP , Code , Plugin , Accents.

Commentaires

Ajoutez un commentaire

5103
Petits fours servis