Plugin de moteur de recherche multi-modèles

Nous vous proposons un plugin pour intégrer un moteur de recherche interne multi-modèles à une application CakePHP. Ses caractéristiques sont les suivantes :

  • Utilise les index Full-Text de MySQL.
  • Facile à installer : il suffit de télécharger les fichiers et de créer une seule table, quel que soit le nombre de Modèles à indexer.
  • Non-intrusif : inutile de créer d’index Full-Text sur les tables existantes.
  • Plusieurs mode de recherche : langage naturel, langage naturel avec extension de requête, et recherche booléenne.

1. Installation

1.1. Fichiers

Le code source du plugin Search est hebergé sur Github.com :

http://github.com/kalt/search/tree/master

Deux méthodes pour installer les fichiers : soit en cliquant sur ‘download’ puis en extrayant le répertoire ’search’ vers le répertoire ‘plugins’ de l’application, soit en exécutant la commande suivante (pour les familiers de Git) :

git clone git://github.com/kalt/search.git

1.2. DB table

Nous devons créer une table dans la base de données selon le schéma inclu ici : search/config/sql/search.sql

2. Mise en place

2.1. Modèles

Nous allons lier les modèles que nous voulons indexer au comportement SearchableBehavior, en donnant la liste des champs à indexer.

class Article extends AppModel
{
	var $actsAs = array('Search.Searchable' => array(
		'fields' => array('title', 'body')
	));
}

C’est la seule chose à faire. Dès qu’un enregistrement sera ajouté ou mis à jour, les champs ‘title’ et ‘body’ seront inclus dans l’index de recherche.

2.2. Formulaire de recherche

Créons maintenant un petit formulaire de recherche. La plupart du temps, la meilleure place pour ce genre de fonction est dans le layout commun à toutes les pages du site : le moteur de recherche est ainsi disponible sur le site entier.

Le formulaire doit obligatoirement contenir un champ texte nommé ‘q’ ( »q » pour « query »).

echo $form->create('Search', array('url' => array(
	'plugin' => 'search', 
	'controller' => 'searches',
	'action' => 'index'
)));
 
echo $form->input('q', array('label' => 'Mots clés :'));
 
echo $form->end("Rechercher");

2.3. Pages de résultats

Nous allons maitenant créer la vue qui va afficher les résultats paginés de la recherche : {app}/views/plugins/search/searches/index.ctp

Les variables disponibles dans cette vue sont :

  • $q : les termes recherchés.
  • $data : les enregistrements paginés correspondants à la recherche.

Exemple :

<?php $paginator->options(array('url' => $this->passedArgs)); ?>
 
<h1>Résultats de la recherche</h1>
 
<p>Votre recherche : <?php echo $q; ?></p>
 
<div id="paginator-counter">
    <?php echo $paginator->counter(array('format' => "Page %page% sur %pages%, %current% documents sur %count%")); ?> 
</div>
 
<?php foreach($data as $row):
    $model_name = key($row);
 
    switch($model_name)
    {
        case 'Article':
            $link = $html->link($row['Article']['title'], array(
            	'plugin' => null,
                'controller' => 'articles',
                'action' => 'view',
                $row['Article']['id']
            ));
            $description = $row['Article']['body'];
            break;
 
        case 'Video':
            $link = $html->link($row['Video']['title'], array(
            	'plugin' => null,
                'controller' => 'videos',
                'action' => 'play',
                $row['Video']['id']
            ));
            $description = $row['Video']['description'];
            break;
    } ?>
    <div class="ressource">
        <h2><?php echo $link; ?></h2>
        <p align="justify"><?php echo $description; ?></p>        
    </div>
<?php endforeach; ?> 
 
<div class="paging">
    <?php echo $paginator->prev('<< '.__('Previous', true));?>
 |  <?php echo $paginator->numbers();?>
    <?php echo $paginator->next(__('Next', true).' >>');?>
</div>

3. Options

3.1. Modes de recherche

Deux types de recherche sont disponibles :

  • boolean (défaut) : MySQL effectue une recherche booléenne, c’est-à-dire que tous les termes de la recherche doivent être présents dans les résultats.
  • natural : MySQL effectue une recherche en langage naturel et retourne les résultats par ordre décroissant de pertinence.

Plus d’informations sur la recherche en texte intégral sont disponibles sur la documentation de MySQL.

Pour changer de mode, il suffit d’ajouter la ligne suivante dans {app}/config/bootstrap.php

Configure::write('Search.mode', 'natural');

3.2. Extension de requête

Il s’agit d’une option du mode de recherche en langage naturel. Plus d’infos ici : recherche en texte intégral avec extension de requête.

Cette option est par défaut à null : la recherche avec extension de requête sera activée si l’expression recherchée ne contient qu’un seul mot.

Pour changer cette option, il suffit d’ajouter cette ligne dans {app}/config/bootstrap.php

Configure::write('Search.withQueryExpansion', true/false);

Si false, la recherche avec extension ne sera jamais utilisée, quelle que soit l’expression cherchée. Si true, la recherche avec extension sera toujours utilisée.

3.3. Caractères autorisés dans l’expression recherchée

De base, les caractères acceptés sont les lettres et les chiffres. Par défaut, nous avons ajouté l’espace, les lettres accentuées, la cédille et le « o-e collés ».

Il est possible de remplacer cette liste de caractères autorisés en ajoutant cette ligne dans {app}/config/bootstrap.php

Configure::write('Search.allowedChars', array(' '));

Ici par exemple, nous autorisons l’espace ‘ ‘. Au final donc, tous les signes qui ne sont ni des lettres, ni des chiffres, ni un espace, seront supprimés de l’expression à chercher.

4. Réécriture de l’URL

Par défaut, l’URL des résultats de recherche est : /search/searches/mots+cles

Nous pouvons changer cette URL en créant une nouvelle route dans {app}/config/routes.php

Router::connect(
	'/recherche/*',
	array('plugin' => 'search', 'controller' => 'searches', 'action' => 'index')
);

L’URL deviendra alors /recherche/mots+cles.

5. Indexer des données existantes

Dans le cas où nous souhaiterions installer le plugin dans une application existante dont les tables à indexer contiennent déjà des données, il nous suffit d’ajouter les 3 actions suivantes dans {app}/app_controller.php

/**
 * Construit l'index des données existantes d'un modèle
 */
function admin_build_search_index()
{
	$this->autoRender = false;
 
	$model =& $this->{$this->modelClass};
 
	if(!isset($model->Behaviors->Searchable))
	{
		echo "Erreur : le modèle {$model->alias} n'est pas lié au SearchableBehavior.";
		exit;
	}
 
	$data = $model->find('all');
 
	foreach($data as $row)
	{
		$model->set($row);
 
		$model->Behaviors->Searchable->Search->saveIndex(
			$model->alias,
			$model->id,
			$model->buildIndex()
		);
	}
 
	echo "L'index des données du modèle {$model->alias} a été créé.";
}
 
/**
 *  Supprime l'index des données d'un modèle
 */
function admin_delete_search_index()
{
	$this->autoRender = false;
 
	$model =& $this->{$this->modelClass};
 
	if(!isset($model->Behaviors->Searchable))
	{
		echo "Erreur : le modèle {$model->alias} n'est pas lié au SearchableBehavior.";
		exit;
	}
 
	$model->Behaviors->Searchable->Search->deleteAll(array(
		'model' => $model->alias
	));
 
	echo "L'index des données du modèle {$model->alias} a été supprimé.";
}
 
/**
 * Reconstruit l'index des données existantes d'un modèle
 */
function admin_rebuild_search_index()
{
	$this->admin_delete_search_index();
	$this->admin_build_search_index();
}

Ceci fait, nous accèdons à une URL du style /admin/{uncontroleur}/buildsearch_index pour construire l’index des données existantes du modèle de ce contrôleur. Attention, cela peut prendre un certain temps, en fonction du volume de données à indexer.

Toutes les suggestions d’amélioration et les critiques sont les bienvenues dans les commentaires.

Pierre-Emmanuel Fringant

Commentaires

excellent ! premiers tests avec les options par défaut et ça fonctionne à merveille

je vais avoir l’occasion de tester plus en détail sur un projet en-cours, je vous ferai un retour

ça pouvait pas mieux tomber ;)

merci pour le plugin

Merci ! J’attends effectivement les retours avec impatience, notamment sur les modes de recherche. J’ai choisi de mettre le mode booléen par défaut parce que j’ai constaté de meilleurs résultats qu’avec le mode langage naturel, mais c’était sur une seule application…

j’ai rajouté dans la vue:

$paginator->options(array(’url’ => $this->passedArgs));

sans ça les liens de pagination n’ont pas les mot clés

Merci, j’ai mis l’exemple à jour.

Bravo ! heu, sans vouloir me la jouer, le choix de « recherche en langage naturel » me paraît peu heureux… la recherche en langage naturel, c’est pas super au point encore, et en plus, ça n’est pas vraiment ce qu’il y a dans MySql (qui marche moyennement d’ailleurs, on est d’accord).

Salut !

Tout d’abord, bravo et merci pour cette excellente initiative. C’est un besoin récurrent dans les projets, et il n’existait pas de solution facilement intégrable dans Cake à ce jour.

Deux petites suggestions : - il serait peut être intéressant d’associer un petit helper chargé de générer la liste de résultats sans avoir à intégrer la gestion des différents modèles dans la vue : autrement dit, générer (par défaut) les urls de résultats en fonction du modèle courant, en ayant la possibilité de forcer le controller (et l’action) dans l’appel du behavior. - le choix d’un shell serait peut être plus judicieux pour lancer/relancer l’indexation, plutot que des actions du app_controller.

Encore bravo en tout cas.

Sébastien

@Guillaume : c’est bien ce que j’ai moi-même constaté, et c’est la raison pour laquelle j’ai défini la recherche en mode booléen comme mode de recherche par défaut.

@Sébastien : je ne vois pas trop ce que pourrais apporter un helper, la gestion des différents modèles est totalement dépendante de l’existant, même la génération des liens. Pourrais-tu m’expliquer ta vision plus en détail, acommpagnée d’exemples ?

Pour ce qui est du shell pour la mise à jour des index, c’est une excellente idée, je vais creuser de ce côté.

Merci pour vos retours !

Idem de mon côté Je ne comprends pas les résultats avec l’option de recherche langage naturel, bon j’ai testé sur une appli en développement et les tables ne contiennent pas grand chose, mais le mode booléen me convient très bien. A voir avec des vrais données…

Enorme le plugin enorme. C’est juste domage que j’ai codé entierement ma fonction de recherche avant de le trouver. Du coup je dois jeter ce que j’ai fait pour prendre la tienne!!

Par contre, une question subsiste :

  • Comment puis-je faire le filtrage des données : Je veux un article, mais pas toutes les pièces jointes associées, comment je fais des infos du type contain.

Je ne vois pas comment il serait possible de faire. La méthode renvoie les enregistrements avec un read(), avec le niveau de récursivité par défaut, tu dois donc faire avec ce qui est renvoyé et n’afficher que ce dont tu as besoin.

Le problème est que dans certains cas le résultat peut être enorme. J’ai par exemple des objets documents qui sont liés a des objets attachments. Si je ramène toutes les pieces jointes (jusqu’a 10megas) d’un document a chaque fois qu’un utilisateur fais des recherches, je tue mes performances.

Je vais essayer plonger dans ton code (je l’ai pas fait encore car je suis partisan du on ne touche pas quand ça marche). Je pense que un unbind model devrait me permettre d’obtenir des résultats probants.

Dans ce cas tu dois modifier le fichier search/controllers/searchcontroller.php, à la fin de l’action index, remplace :

foreach($results as $row)
{
    $data[] = ClassRegistry::init($row['Search']['model'])->read(null, $row['Search']['model</em>id']);
}
Par :
foreach($results as $row)
{
    $model = ClassRegistry::init($row['Search']['model']);
    $model->recursive = -1;
    $data[] = $model->read(null, $row['Search']['model_id']);
}

[...] Plugin de moteur de recherche multi-modèles [...]

J’utilise le système multilingue. L’utilisation de ce controller pose-t-il un problème pour la recherche dans les différentes langues ? Si oui, existe-t-il une solution ?

Merci

Le plugin n’est pas adapté au multilingue, mais il suffit de mettre un index Full-Text sur la colonne « content » de la table i18n et d’effectuer la recherche dessus.

Juste pour savoir comment devrais-je nommer la table necessaire pour le moteur de recherche?

Est-ce que c’est qui choisit mon propre nom? Car je n’ai pas compris la phrase suivante:

« Nous devons créer une table dans la base de données selon le schéma inclu ici : search/config/sql/search.sql ».

Merci d’avance!

La table doit s’appeler ’search_index’. ’search/config/sql/search.sql’ contient la définition de la table et de ses colonnes.

Est ce que tu connaitrais un moyen / outil pour faire de la recherche full-text dans des documents ? Pour l’instant j’utilise ton plugin de recherche, mais j’aimerai l’etendre pour lui permettre de rechercher aussi dans les documents. A ce propos, j’ai fait le choix de stocker les dits documents dans la base afin de pouvoir rechercher dedans, mais je ne sais pas ce que ca va donner et ca risque de me couter cher en taille de la base. Je suis entrain de reconsidérer la solution du stockage filesystem, mais je pressens de plus grosses difficultés encore pour rechercher dedans.

Si tu as un avis sur ces deux questions …

Bonjour, Je suis dans la situation suivante: les tables de ma bases de données ne sont vides et donc contiennent des données. Mais je ne comprend pas comment indexer mes tables pour la recherche s’effectue en ciblant le contenu de mes tables.

De plus je ne comprend pas ceci: » - quel doit etre le chemin pour « {app}/app_controller.php »?

. »Ceci fait, nous accèdons à une URL du style /admin/{uncontroleur}/buildsearch_index pour construire l’index des données existantes du modèle de ce contrôleur. Attention, cela peut prendre un certain temps, en fonction du volume de données à indexer. »!

Merci d’avance!

Tu dois copier les 3 actions de la fin du tutoriel dans ton fichier appcontroller.php, qui doit se trouver à la racine de ton application. Ceci fait, tu n’as plus qu’à te rendre à l’URL suivante, si par exemple ton controller s’appelle « Articles » : /admin/articles/buildsearch_index.

Bonsoir en fait il m’affiche l’erreur suivante:

Missing Controller

Error: AdminController could not be found.

Error: Create the class AdminController below in file: app\controllers\admin_controller.php

Notice: If you want to customize this error message, create app\views\errors\missing_controller.ctp

Que dois-je faire?

Tu dois activer le routage admin en décommentant la ligne suivante dans {app}/config/core.php :

Configure::write('Routing.admin', 'admin');

Grand merci ça marche!

ENcore bravo pour le plugin

Bonjour, j’ai suivi scrupuleusement tout ce qui est écrit dans l’article, mais un problème subsiste :

lorsque je lance la fonction rebuildsearchindex, la page mouline bien, récupère bien les infos de la table que je veux indexer, mais n’insère rien dans la table search_index.

les messages confirmant la suppression et la création de l’index sont affichés.

Une idée ?

Hi,

Thanks for making this plugin – I wish I saw it 3 hours ago!

It worked for me almost first time, but I noticed that the rebuildsearchindex action wasn’t quite working correctly when I attached the behaviour to a plugin model. Basically, it was saving ‘model’ into the search_index table instead of ‘Plugin.model’

Here’s my simple fix and good luck in next year’s world cup from an Englishman. (You’re going to struggle without Zidane and that clown Gallas in your team I think!)


function admin_build_search_index() {

    $this-&gt;autoRender = false;

    $plugin_prefix = ($this-&gt;plugin) ? Inflector::camelize($this-&gt;plugin).'.' : '';

    $model =&amp; $this-&gt;{$this-&gt;modelClass};

    if(!isset($model-&gt;Behaviors-&gt;Searchable))
    {
        echo "<pre>Error : the {$model-&gt;alias} model is not linked with Searchable Behavior.</pre>";
        exit;
    }

    $data = $model-&gt;find('all');

    foreach($data as $row)
    {
        $model-&gt;set($row);

        $model-&gt;Behaviors-&gt;Searchable-&gt;Search-&gt;saveIndex(
            $plugin_prefix.$model-&gt;alias,
            $model-&gt;id,
            $model-&gt;buildIndex()
        );
    }

    echo "<pre>Search index for model {$model-&gt;alias} have been built.</pre>";
}

Ca marche ! Merci !

Par contre une petite question : j’ai besoin de rechercher mes données à partir de mots-clés (pour cela j’utilise votre plugin), mais également à partir de dates, d’id d’utilisateurs et autres données non textuelles.

Comment faire pour sélectionner d’abord mes données d’après les paramètres non textuels, et ensuite faire une recherche par mots-clés sur le résultat de la première sélection en utilisant votre plugin ?

j’espère avoir été compréhensible.

Vincent

J’ai le même souci que Gabriel, à savoir la non population de la table search_index lors d’une construction ou reconstruction d’index.

Je travaille sur une application existante et des tables déjà remplies.

Merci d’avance pour votre aide.

Est-il possible d’avoir un tuto pour implémenter l’auto-completion ajax dans ce plugin de recherche ?

Bjr, j’ai un petit pb d’url avec le plugin due à mon inexpérience. ma page est http://www.monsite.fr/articles. A prtir de cette page je lance une recherche. Mais je me retrouve avec des Url du style http://www.monsite.fr/search/articles/ Toutes mes Url dans mon layout sont passées de monsite.fr/articles en monsite.fr/search/articles. Comment faire pour éviter cela et garder mes Url ? Merci

@dorio: Tout à fait juste, j’ai oublié de le préciser. Il faut ajouter la clé de tableau 'plugin' => null dans tous les liens du layout, car si l’on se trouve dans une des pages gérées par le plugin, le layout pense que tous les liens vont vers le plugin… Exemple :

echo $html->link("Retour accueil", array('controller' => 'pages', 'action' => 'display', 'home'));
Devient :
echo $html->link("Retour accueil", array('plugin' => null, 'controller' => 'pages', 'action' => 'display', 'home'));

Participez

Pour insérer une portion de code, utilisez <pre lang="php">...</pre>