Moteur de recherche : des recherches par pertinence... vraiment pertinentes

  • De le 12 octobre 2010
  • Difficulté : 3/4

Moteur de recherche : des recherches par pertinence... vraiment pertinentes Le moteur de recherche interne à Magento propose une classification des produits par pertinence (relevance). Ce tri n'a en réalité rien de très pertinent... Cet article explique pourquoi et propose une solution pour y remédier.

Sommaire

Introduction

Par défaut lors d'une recherche, les produits retournés sont classés par ce que Magento appel pertinence (relevance) :

Tri par pertinence

Curieux de connaître ce qui se cache réellement derrière cette requête, je me suis penché sur le module CatalogSearch natif à Magento.

Principe

Magento enregistre en base de données l'ensemble des mots clés saisis par les internautes. Il associe chacun de ces mots clés à une liste de produits dont le titre, la description ou encore la référence correspondent à l'occurrence indiqué. Les attributs de recherche peuvent être personnalisés et élargis depuis la gestion des attributs.

Lors de la reconstruction de l'index de recherche dans la gestion du cache, Magento concatène dans la table catalogsearch_fulltext le contenu des attributs de l'ensemble des produits. C'est dans le champ data_index que la recherche sera effectuée. Une très bonne idée pour optimiser les temps de traitement lors d'une recherche :

Table catalogsearch_fulltext

Si j'indique l'occurrence Surakarta dans le moteur de recherche, le produit dont l'identifiant est 2 apparaîtra.

Penchons nous à présent sur le système de tri par pertinence. Pour chaque recherche, le mot clé associé à une liste de produits est stocké en base. Ces informations sont présentes dans la table catalogsearch_result :

Table catalogsearch_result

Ces données sont générées à chaque nouvelle requête. Dans notre exemple, l'occurrence associé aux 2 produits est fontaine. Le mot clé apparaît en effet dans le champ data_index de la table catalogsearch_fulltext pour les 2 produits.

Moteur de recherche

On constate ici que les produits sont classés par pertinence (relevance). Dans la base de données la pertinence calculée pour les 2 produits sur le terme fontaine est de 1.0000.

Ce nombre représente le résultat d'un MATCH AGAINST sur le champ data_index. La construction de la requête SQL se situe dans la méthode prepareResult la classe Mage_CatalogSearch_Model_Mysql4_Fulltext :

app/code/core/Mage/CatalogSearch/Model/Mysql4/Fulltext.php

public function prepareResult($object, $queryText, $query) {
     /* ... */
     $sql = sprintf("INSERT INTO `{$this->getTable('catalogsearch/result')}` "
          . "(SELECT '%d', `s`.`product_id`, MATCH (`s`.`data_index`) AGAINST (:query IN BOOLEAN MODE) "
          . "FROM `{$this->getMainTable()}` AS `s` INNER JOIN `{$this->getTable('catalog/product')}` AS `e`"
          . "ON `e`.`entity_id`=`s`.`product_id` WHERE (%s%s%s) AND `s`.`store_id`='%d')"
          . " ON DUPLICATE KEY UPDATE `relevance`=VALUES(`relevance`)",
               $query->getId(),
               $fulltextCond,
               $separateCond,
               $likeCond,
               $query->getStoreId()
          );
     /* ... */
}

Ce qui nous donne lors d'une recherche sur le terme fontaine la requête suivante :

Requête

NSERT INTO `catalogsearch_result` (SELECT '1', `s`.`product_id`,
MATCH (`s`.`data_index`) AGAINST ("fontaine" IN BOOLEAN MODE)
FROM `catalogsearch_fulltext` AS `s`
INNER JOIN `catalog_product_entity` AS `e`ON `e`.`entity_id`=`s`.`product_id`
WHERE ((`s`.`data_index` LIKE "%fontaine%")) AND `s`.`store_id`='1')
ON DUPLICATE KEY UPDATE `relevance` = VALUES(`relevance`) 

Limites et incohérences

L'utilisation du BOOLEAN MODE associé au MATCH AGAINST au sein de la requête paraît plutôt étrange. Dans notre exemple, le terme fontaine apparaît une fois de plus dans le champ data_index du premier produit que sur le deuxième. Il serait logique que l'un des 2 produits est une valeur plus élevée pour le champ relevance. Or dans les 2 cas le nombre retourné est 1.0000.

Produit 1 : (x3)

15116190 Enabled None Fontaine Bali en pierre naturelle Parce qu'elles sont aussi belles que des sculptures et qu'elles apaisent les sens par leur ruissellement cristallin, les fontaines participent au bien-être au quotidien. Une fontaine zen et naturelle 99.5 1

Produit 2 : (x2)

60136410 Enabled None Fontaine Surakarta Cette vasque entremêle différentes pierres provenant d’Indonésie Une fontaine qui apaise les sens 129 1

Avec une relevance calculée identique, les produits sont finalement triés par identifiant.

Même si le principe de classification par nombre d'occurrence serait fonctionnel, est-il réellement pertinent ? Je ne suis pas convaincu sur le fait qu'un produit présentant d'avantage le mot clé dans sa description soit plus pertinent qu'un autre. Pour l'avoir testé sur un e-commerce en production je peux garantir qu'il y a parfois des surprises.

Sur un site de vente en ligne le moteur de recherche est une priorité. De nombreux internautes utilisent la recherche avant même de s'attarder sur la hiérarchisation des catégories. Je pense qu'il est indispensable de s'y attarder...

Solution

Nous savons que pour influer sur le champ relevance de la table catalogsearch_result, il nous faut surcharger la classe Mage_CatalogSearch_Model_Mysql4_Fulltext, dans le but de modifier la requête générée. Le tout est de trouver une classification qui puisse être vraiment pertinente. J'ai opté pour la solution suivante : un produit sera considéré comme pertinent s'il est plus populaire que les autres, c'est à dire qu'il attire d'avantage les visiteurs et par conséquent qu'il est plus recherché.

Pour arriver à nos fins nous avons besoin de connaître le nombre de consultations pour chaque produit, et Magento enregistre ces données dans la table report_event, parfait !

La table report_event enregistre plusieurs types d'événements : la consultation d'un produit, l'ajout d'un produit au comparateur, les produits ajoutés au panier... Nous supprimerons dans la requête tout ce qui ne concerne pas le simple affichage de la fiche produit (event_type_id).

Il nous faudra ajouter une jointure sur la table report_event afin d'y extraire le nombre de consultation du produit. Ce qui ressemblera finalement à :

Requête

REPLACE INTO `catalogsearch_result` (SELECT '1', `s`.`product_id`, ((COUNT(`r`.`object_id`))/10000)
FROM `catalogsearch_fulltext` AS `s`
INNER JOIN `catalog_product_entity` AS `e` ON `e`.`entity_id`=`s`.`product_id`
INNER JOIN `report_event` AS `r` ON `r`.`object_id`=`s`.`product_id`
WHERE ((`s`.`data_index` LIKE '%fontaine%')) AND `s`.`store_id`='1' AND `r`.`event_type_id` = 1
GROUP BY `r`.`object_id`) 

Nous divisons le résultat par 10000 puisque le champ relevance est de type décimal avec 4 chiffres après la virgule. Les données seront ainsi plus précises...

Nous pouvons maintenant surcharger notre classe !

Module

Architecture du module
  • app/code/local/Magentix/RelevanceSearch/etc/
  • config.xml
  • app/code/local/Magentix/RelevanceSearch/Model/CatalogSearch/Mysql4/
  • Fulltext.php
  • app/etc/modules/
  • Magentix_RelevanceSearch.xml
Développement du module

Commençons par déclarer le module à Magento :

app/etc/modules/Magentix_RelevanceSearch.xml

<?xml version="1.0"?>
<config>
     <modules>
          <Magentix_RelevanceSearch>
               <active>true</active>
               <codePool>local</codePool>
          </Magentix_RelevanceSearch>
     </modules>
</config> 

Spécifions ensuite la surcharge dans le fichier de configuration :

app/code/local/Magentix/RelevanceSearch/etc/config.xml

<?xml version="1.0"?>
<config>
     <modules>
          <Magentix_RelevanceSearch>
               <version>0.1.0</version>
          </Magentix_RelevanceSearch>
     </modules>
     <global>
          <models>
               <catalogsearch_mysql4>
                    <rewrite>
                         <fulltext>Magentix_RelevanceSearch_Model_CatalogSearch_Mysql4_Fulltext</fulltext>
                    </rewrite>
               </catalogsearch_mysql4>
          </models>
     </global>
</config>

Nous pouvons enfin réécrire la méthode prepareResult afin de modifier la requête :

app/code/local/Magentix/RelevanceSearch/Model/CatalogSearch/Mysql4/Fulltext.php

<?php

class Magentix_RelevanceSearch_Model_CatalogSearch_Mysql4_Fulltext extends Mage_CatalogSearch_Model_Mysql4_Fulltext {

     public function prepareResult($object, $queryText, $query) {
        if (!$query->getIsProcessed()) {
            $searchType = $object->getSearchType($query->getStoreId());

            $stringHelper = Mage::helper('core/string');
            /* @var $stringHelper Mage_Core_Helper_String */

            $bind = array();
            $like = array();

            $fulltextCond   = '';
            $likeCond       = '';
            $separateCond   = '';

            if ($searchType == Mage_CatalogSearch_Model_Fulltext::SEARCH_TYPE_LIKE
                || $searchType == Mage_CatalogSearch_Model_Fulltext::SEARCH_TYPE_COMBINE) {
                $words = $stringHelper->splitWords($queryText, true, $query->getMaxQueryWords());
                $likeI = 0;
                foreach ($words as $word) {
                    $like[] = '`s`.`data_index` LIKE :likew' . $likeI;
                    $bind[':likew' . $likeI] = '%' . $word . '%';
                    $likeI ++;
                }
                if ($like) {
                    $likeCond = '(' . join(' AND ', $like) . ')';
                }
            }
            if ($searchType == Mage_CatalogSearch_Model_Fulltext::SEARCH_TYPE_FULLTEXT
                || $searchType == Mage_CatalogSearch_Model_Fulltext::SEARCH_TYPE_COMBINE) {
                $fulltextCond = 'MATCH (`s`.`data_index`) AGAINST (:query IN BOOLEAN MODE)';
            }
            if ($searchType == Mage_CatalogSearch_Model_Fulltext::SEARCH_TYPE_COMBINE && $likeCond) {
                $separateCond = ' OR ';
            }

            $sql = sprintf("REPLACE INTO `{$this->getTable('catalogsearch/result')}` "
                . "(SELECT '%d', `s`.`product_id`, ((COUNT(`r`.`object_id`))/10000) "
                . "FROM `{$this->getMainTable()}` AS `s` "
                . "INNER JOIN `{$this->getTable('catalog/product')}` AS `e` ON `e`.`entity_id`=`s`.`product_id` "
                . "INNER JOIN `{$this->getTable('reports/event')}` AS `r` ON `r`.`object_id`=`s`.`product_id` "
                . "WHERE (%s%s%s) AND `s`.`store_id`='%d' AND `r`.`event_type_id` = 1 "
                . "GROUP BY `r`.`object_id`)",
                $query->getId(),
                $fulltextCond,
                $separateCond,
                $likeCond,
                $query->getStoreId()
            );

            $this->_getWriteAdapter()->query($sql, $bind);

            $query->setIsProcessed(1);
        }

        return $this;
    }

}

Après installation de l'extension, rafraîchissement de l'index et recherche sur l'occurrence fontaine nous obtenons :

Table catalogsearch_result

Le produit dont l'identifiant est 2 a été consulté une seule fois (0.0001). Le produit dont l'identifiant est 1 a été consulté 3 fois (0.0003). Ce dernier sera donc positionné avant le produit 2 (pour un tri par défaut descendant).

Note 1 : le produit n'apparaîtra pas dans les résultats de recherche s'il n'a jamais été consulté

Note 2 : il est recommandé de nettoyer de temps en temps la table report_event. Pour les sites à fort trafic le nombre d'enregistrement peut être très conséquent (ex : suppression des événements supérieurs à 3 mois).

Pour actualiser les résultats il suffira d'indexer régulièrement le cache des recherches :

Indexation résultats de recherche

Le contenu de la table catalogsearch_result sera supprimé à chaque rafraîchissement.

Téléchargement
commentaires

Commentez cet article : Moteur de recherche : des recherches par pertinence... vraiment pertinentes