Nouveau Critére : trouver les articles (ou autre) similaires

Attention, cette contribution est EN CHANTIER : elle n’est peut-être pas fonctionnelle.

Trouver des articles avec un texte similaire en utilisant le moteur d’indexation et la déclaration d’un nouveau critère de boucle.

Nota SPIP-Contrib : le système d’indexation à évolué à partir de SPIP 1.9, donc cette contrib n’est probablement pas utilisable en l’état. Elles est cependant conservée comme base potentielle d’une évolution ... un plugin ?

Idée

SPIP stocke dans les table index_* des informations sur l’indexation des articles. Pour l’instant, ces tables ne sont utilisées que pour calculer les résultats d’une recherche.

Leur structure est simple :
-  il y a une table dictionnaire centrale, qui liste tous les mots importants rencontrés sur le site,
-  pour chaque type d’objet il y a une table qui lie les ID avec les mots qui se trouvent dans cet objet. Ce lien est pondéré avec un nombre de points (qui correspond à l’importance du mot dans le texte).

Au lieu de faire une recherche en partant d’un mot comme le fait actuellement le critère {recherche}, on aimerait trouver les articles semblables à un autre (en comptant le nombre de mot qu’ils partagent).

Il existe une mesure simple pour faire cela : On peut voire deux documents comme des vecteurs de N dimensions, chacune correspondant à un mot dans le dictionnaire. Les valeurs stockées dans le vecteur d’un document représentent le poids de chaque dimension (chaque mot) dans ce document.

Par exemple :

dico doc1 doc2
petit 0 3
wiki 10 3
spip 3 10
chemin 4 0

Une mesure de la similarité entre les deux documents est le produit vectoriel entre les deux vecteurs :

S = 0*3+10*3+3*10+4*0 = 60

Croyez moi (ou corrigez moi), mais cela correspond à une requête sur les tables d’index de SPIP :

SELECT 
            primaire.id_article,
            secondaire.id_article, 
            SUM(primaire.points*secondaire.points) AS similarite
FROM 
         spip_index_articles AS primaire, 
         spip_index_articles AS secondaire 
WHERE 
           primaire.hash = secondaire.hash AND 
           primaire.id_article = 2
GROUP BY secondaire.id_article

Critère

On va donc implémenter un nouveau critère [1] : {similaire} pour les boucles :

function critere_similaire($idb, &$boucles, $param, $not) {
  $boucle = &$boucles[$idb];
  
  if ($param != 'similaire' OR $not)
	erreur_squelette(_T('info_erreur_squelette'), $param);

  $boucle->select[] = 'SUM(primaire.points*secondaire.points) AS similarite';

  $boucle->from[] = 'spip_index_'.$boucle->id_table.' AS primaire';
  $boucle->from[] = 'spip_index_'.$boucle->id_table.' AS secondaire';

$boucle->where[] = 'primaire.'.$boucle->primary.'=\'".'.calculer_argument_precedent($idb,
                                                    $boucle->primary,
                                                    $boucles).'."\''; 

 $boucle->where[] = 'primaire.hash = secondaire.hash';
  $boucle->where[] = $boucle->id_table.'.'.$boucle->primary.' = secondaire.'.$boucle->primary;

  $boucle->group = 'secondaire.'.$boucle->primary;  
}

-  On ajoute un nouveau champ dans le select. Il s’agit du calcul du produit scalaire entre les deux vecteurs, il faut noter que l’ordre sera du moins similaire au plus, il faudra donc plutôt utiliser ce critère avec {inverse}.

-  On ajoute trois nouvelle table sur lesquelles faire la jointure :

  • 2 tables index_TYPE, pour comparer l’objet actuel avec tous les autres indexés,

On peut déjà remarqué que l’on utilise la variable id_table pour connaître le type de l’objet pris en compte dans la boucle

-  on ajoute quatre clause WHERE :

  • la table primaire contraints à l’objet considéré. On récupère son ID grâce à la fonction calculer_argument_precedent,
  • on prend tous les autres objet ayant les mêmes mots dedans (on vérifie avec le hash du mot),
  • la dernière contrainte met en rapport la liste d’article similaire avec la table de l’objet principal (celle ci utilise $boucle->id_table.'.'.$boucle->primary comme index),

-  on groupe la colonne secondaire pour ne pas avoir les objets similaire en double (c’est à ce moment que le produit scalaire est calculé),

Balise

Le nouveau compilateur de spip permet aussi d’ajouter ses propres balises. Ici on veut pouvoir récupérer la nouvelle colonne calculé par notre requête : la « similarité ».

Voici le code, il s’agit du même code que la boucle POINTS qu’offre une boucle recherche :

function balise_SIMILARITE($p) {
	return rindex_pile($p, 'similarite', 'similaire');
}

On peut utiliser ce critère dans n’importe quelle boucle à l’intérieur d’une boucle ayant le critère {similaire}. On va donc recherche dans les boucles englobantes la première qui utilise ce critère.

On peut ensuite récupérer la valeur de cette colonne dans la pile fournie par le compilateur.

Tout cela est fait automatiquement par la fonction rindex_pile.

Boucle Exemple

Une boucle très simple peut être utilisée pour utiliser ce critère sur les articles :

<html>
  <body>
  <BOUCLE_article(ARTICLES) {id_article}>
  blabla blabla<br/>
  #TITRE:
  <B_sim>
    Les artiles similaires sont:
    <ul>
    <BOUCLE_sim(ARTICLES) {similaire} {par similarite} {inverse} {0,5}>
    <li><a href="#URL_ARTICLE">#TITRE</a></li>
    </BOUCLE_sim>
    </ul>
  </B_sim>
  </BOUCLE_article>
  </body>
</html>

On peut aussi dire à SPIP de trier par un champ calculé dans une requête [2]. Ainsi, on trie par similarité puis on inverse, pour avoir les articles triés dans un ordre décroissant de similarité.

Ce critère va retourner tous les articles ayant au moins un mots en commun avec celui en cours, ce qui peut faire beaucoup. On limite donc la boucle à 5 résultats.

Optimisation

Pour ceux qui s’y connaissent en SQL et on une petite idée de la taille des tables d’index, le besoin d’optimisation est évident. Pour les autres, je vais expliquer un peu.

La requète SQL faite par ce critère fait trois jointures [3] sur de très grosses tables. Par exemple pour une boucle article :
-  la table articles contient tous les articles,
-  la table index_articles contient une ligne par mot indexé, par article. Cela veut dire que si l’article X a 10 mots indexé, il y a aura 10 lignes dans la table. Imaginez la taille de cette table si il y a beaucoup de longs articles.

Ainsi, il faut absolument éviter de faire toutes ces jointures. Malheureusement, la jointure de index_quelquechose sur elle même est obligatoire. Par contre, la jointure sur la table quelquechose n’est pas obligatoire.

Effectivement, elle est obligatoire si on veut appliquer ce critère directement sur la table articles. C’est plus intuitif et permet de récupérer directement les informations sur l’article.

Ici, on va exposer une méthode qui évite cette jointure sur la table article. Ce nouveau critère s’appliquera directement sur une table index_quelquechose et permettra de récupérer les IDs des articles
similaires.

Il faut d’abord dire à SPIP qu’on peut boucler sur les tables index en lui décrivant les colonnes disponibles :

include_ecrire("inc_auxbase.php");
global $tables_principales;

$tables_principales['spip_index_articles'] =
       array('field' => &$spip_index_articles, 'key' => &$spip_index_articles_key);

$tables_principales['spip_index_breves'] =
       array('field' => &$spip_index_breves, 'key' => &$spip_index_breves_key);

$tables_principales['spip_index_forums'] =
       array('field' => &$spip_index_forums, 'key' => &$spip_index_forums_key);

$tables_principales['spip_index_auteurs'] =
       array('field' => &$spip_index_auteurs, 'key' => &$spip_index_auteurs_key);

$tables_principales['spip_index_mots'] =
       array('field' => &$spip_index_mots, 'key' => &$spip_index_articles_key);

$tables_principales['spip_index_signatures'] =
       array('field' => &$spip_index_signatures, 'key' => &$spip_index_signatures_key);

$tables_principales['spip_index_syndic'] =
       array('field' => &$spip_index_syndic, 'key' => &$spip_index_syndic_key);

il faut aussi déclarer les fonctions boucle_... pour initialiser la requette sur ces tables :

function boucle_INDEX_ARTICLES($id_boucle, &$boucles) {
	$boucle = &$boucles[$id_boucle];
	$id_table = $boucle->id_table;
	$boucle->from[] =  "spip_index_articles AS $id_table";
	return calculer_boucle($id_boucle, $boucles); 
}

function boucle_INDEX_BREVES($id_boucle, &$boucles) {
	$boucle = &$boucles[$id_boucle];
	$id_table = $boucle->id_table;
	$boucle->from[] =  "spip_index_BREVES AS $id_table";
	return calculer_boucle($id_boucle, $boucles); 
}

function boucle_INDEX_FORUMS($id_boucle, &$boucles) {
	$boucle = &$boucles[$id_boucle];
	$id_table = $boucle->id_table;
	$boucle->from[] =  "spip_index_forums AS $id_table";
	return calculer_boucle($id_boucle, $boucles); 
}

function boucle_INDEX_AUTEURS($id_boucle, &$boucles) {
	$boucle = &$boucles[$id_boucle];
	$id_table = $boucle->id_table;
	$boucle->from[] =  "spip_index_auteurs AS $id_table";
	return calculer_boucle($id_boucle, $boucles); 
}

function boucle_INDEX_MOTS($id_boucle, &$boucles) {
	$boucle = &$boucles[$id_boucle];
	$id_table = $boucle->id_table;
	$boucle->from[] =  "spip_index_mots AS $id_table";
	return calculer_boucle($id_boucle, $boucles); 
}

function boucle_INDEX_SIGNATURES($id_boucle, &$boucles) {
	$boucle = &$boucles[$id_boucle];
	$id_table = $boucle->id_table;
	$boucle->from[] =  "spip_index_signatures AS $id_table";
	return calculer_boucle($id_boucle, $boucles); 
}

function boucle_INDEX_SYNDIC($id_boucle, &$boucles) {
	$boucle = &$boucles[$id_boucle];
	$id_table = $boucle->id_table;
	$boucle->from[] =  "spip_index_syndic AS $id_table";
	return calculer_boucle($id_boucle, $boucles); 
}

On déclare ainsi quelle est la table principale sur laquelle on fait les requettes pour chacune de ces boucles.

On peut maintenant déclarer le critère :

function critere_similaire($idb, &$boucles, $param, $not) {
  $boucle = &$boucles[$idb];
 $table = $boucle->id_table;
 
 if (!ereg("index_(.*[^s])s?$",$table,$m) OR $not)
     erreur_squelette(_T('info_erreur_squelette'), $param);

 $id = 'id_' . $m[1];

 $boucle->select[] = 'SUM(' . $table . '.points*secondaire.points) AS similarite';
 $boucle->select[] = 'secondaire.i'.$id.' AS id_similaire';

 $boucle->from[]  = 'spip_'.$table .' AS secondaire';

 $boucle->where[] = $table .'.hash = secondaire.hash'; 
 $boucle->group = 'secondaire.'.$id;

}

Le principe du critère est le même qu’avant, sauf que SPIP va directement faire le SELECT initial depuis une table index_quelquechose. On a donc juste à ajouter la jointure sur cette table et les critères en conséquence.

Malheureusement, on ne peut pas directement utiliser #ID_ARTICLE à l’intérieur de la boucle sur index_quelquechose. Il nous faut donc ajouter une balise pour récupérer l’id qui nous intéresse :

function balise_SIMILARITE($p) {
	return rindex_pile($p, 'similarite', 'similaire');
}


function balise_ID_SIMILAIRE($p) {
	return rindex_pile($p, 'id_similaire', 'similaire');
}

La boucle à faire est donc un peu plus complexe. Par exemple pour les articles :
-  on doit imbriquer une boucle articles dans la boucle index_articles,
-  on doit dire à cette boucle de sélectionner l’article ayant le même id que #ID_SIMILAIRE : {id_article=#ID_SIMILAIRE}
-  on peut exclure l’article en cours avec le critère {exclus}
-  comme le critère {exclus} est sur la boucle intérieur, on est obligé de limiter plus largement la boucle externe : {0,7} au lieu de {0,5}.

<BOUCLE_article(ARTICLES) {id_article}>
#TITRE:<br>
<B_amis>
Voici la liste des articles similaires:
<ul>
<BOUCLE_amis(index_articles){similaire}{id_article}{par similarite}{inverse} {0,7}>
<BOUCLE_selection(ARTICLES){id_article=#ID_SIMILAIRE}{exclus}>
<li><a href="#URL_ARTICLE">#TITRE:#SIMILARITE</a></li>
</BOUCLE_selection>
</BOUCLE_amis>
</ul>
</B_amis>
aucun article
<//B_amis>
</BOUCLE_article>

Notes

[1Le code proposé ici peut se placer dans mes_fonctions.php3.

[2non déclaré comme colonne à SPIP.

[3Il colle les tables les unes aux autres en faisant toutes les combinaisons possibles.

Notez bien que ce critère peut être utilisé sur tous les types de boucles avec lesquels on peut utiliser le critère {recherche}. En fait, on peut trouver les éléments similaire de tout objet qui est indexé par SPIP, il faut que la table index relative soit bien déclarée comme nouvelle table comme indiqué dans cet article.

Discussion

Aucune discussion

Ajouter un commentaire

Avant de faire part d’un problème sur un plugin X, merci de lire ce qui suit :

  • Désactiver tous les plugins que vous ne voulez pas tester afin de vous assurer que le bug vient bien du plugin X. Cela vous évitera d’écrire sur le forum d’une contribution qui n’est finalement pas en cause.
  • Cherchez et notez les numéros de version de tout ce qui est en place au moment du test :
    • version de SPIP, en bas de la partie privée
    • version du plugin testé et des éventuels plugins nécessités
    • version de PHP (exec=info en partie privée)
    • version de MySQL / SQLite
  • Si votre problème concerne la partie publique de votre site, donnez une URL où le bug est visible, pour que les gens puissent voir par eux-mêmes.
  • En cas de page blanche, merci d’activer l’affichage des erreurs, et d’indiquer ensuite l’erreur qui apparaît.

Merci d’avance pour les personnes qui vous aideront !

Par ailleurs, n’oubliez pas que les contributeurs et contributrices ont une vie en dehors de SPIP.

Qui êtes-vous ?
[Se connecter]

Pour afficher votre trombine avec votre message, enregistrez-la d’abord sur gravatar.com (gratuit et indolore) et n’oubliez pas d’indiquer votre adresse e-mail ici.

Ajoutez votre commentaire ici

Ce champ accepte les raccourcis SPIP {{gras}} {italique} -*liste [texte->url] <quote> <code> et le code HTML <q> <del> <ins>. Pour créer des paragraphes, laissez simplement des lignes vides.

Ajouter un document

Suivre les commentaires : RSS 2.0 | Atom