Indexer Wikipédia dans Solr

En regardant le coucher de Soleil

Drôle d'époque. Après deux jours magiques à ParisWeb, et un retour chez moi dans une ambiance de guerre civile, le retour à la réalité est… difficile.

La reprise du quotidien après un tel événement est toujours une période cafardogène. Pour éviter de sombrer dans la déprime la plus grise, je vous propose de nous fixer un objectif un tantinet ambitieux : et si nous indexions la plus grande base de connaissance au monde dans le meilleur moteur de recherche ? (Si ça ne vous plait pas, vous pouvez plutôt vous abonner au tag #sudweb.)

Chercher dans Wikipédia grâce à Solr ? Si, c'est possible.

Des données, des données, des données…

Wikipédia fournit régulièrement des fichiers de dumps permettant de récupérer toutes les données du site. Nous nous contenterons de récupérer les articles complets (sans les révisions, ni les commentaires), et en français uniquement. Je vous mâche le travail, il n'y a qu'un seul fichier à récupérer.

Configurer le schéma

La documentation de Solr fournit un exemple de schéma pour indexer Wikipedia. Nous allons l'optimiser pour prendre en compte la langue française (je pars ici d'une install vierge de Solr).

Commençons par ajouter un nouveau fieldtype : text_fr, dans le fichier schema.xml.

<fieldtype name="text_fr" class="solr.TextField">
  <analyzer type="index">
    <tokenizer class="solr.StandardTokenizerFactory"/>
    <filter class="solr.SynonymFilterFactory" synonyms="synonyms_fr.txt" ignoreCase="true" expand="true"/>
    <filter class="solr.StandardFilterFactory"/>
    <filter class="solr.StopFilterFactory" ignoreCase="true" words="stopwords_fr.txt"/>
    <filter class="solr.WordDelimiterFilterFactory" generateWordParts="1" generateNumberParts="1" catenateWords="1" catenateNumbers="1" catenateAll="0" splitOnCaseChange=
    <filter class="solr.ISOLatin1AccentFilterFactory"/>
    <filter class="solr.LowerCaseFilterFactory"/>
    <filter class="solr.SnowballPorterFilterFactory" language="French" protected="protwords_fr.txt" />
  </analyzer>
  <analyzer type="query">
    <tokenizer class="solr.StandardTokenizerFactory"/>
    <filter class="solr.SynonymFilterFactory" synonyms="synonyms_fr.txt" ignoreCase="true" expand="true"/>
    <filter class="solr.StandardFilterFactory"/>
    <filter class="solr.StopFilterFactory" ignoreCase="true" words="stopwords_fr.txt"/>
    <filter class="solr.WordDelimiterFilterFactory" generateWordParts="1" generateNumberParts="1" catenateWords="0" catenateNumbers="0" catenateAll="0" splitOnCaseChange=
    <filter class="solr.ISOLatin1AccentFilterFactory"/>
    <filter class="solr.LowerCaseFilterFactory"/>
    <filter class="solr.SnowballPorterFilterFactory" language="French" protected="protwords_fr.txt" />
  </analyzer>
</fieldtype>

Vous remarquerez que ce type de champ nécessite trois fichiers pour fonctionner.

  1. synonyms_fr.txt, pour gérer les synonymes dans l'indexation. Exemple :

    aiki => aikido
    resto => restaurant
    
  2. stopwords_fr.txt, contient la liste des mots basiques qui ne doivent pas être indexés ;

  3. protwords_fr.txt, contient la liste des mots qui ne doivent pas être retraités par le *stemmer* français ;

Ensuite, toujours dans le même fichier, configurons nos fields :

<fields>
  <field name="id"        type="string" indexed="true" stored="true" required="true"/>

  <field name="title"     type="string"     indexed="false" stored="true"/>
  <field name="search_title"     type="text_fr"     indexed="true" stored="false"/>
  <field name="body"    type="text_fr"    indexed="true" stored="true" termVectors="true"/>

  <field name="revision"   type="sint"    indexed="true" stored="true"/>
  <field name="user"        type="string"  indexed="true" stored="true"/>
  <field name="userId"     type="int" indexed="true" stored="true"/>
  <field name="timestamp" type="date"    indexed="true" stored="true"/>
 </fields>

 <uniqueKey>id</uniqueKey>
 <defaultSearchField>search_title</defaultSearchField>
 <copyField source="title" dest="search_title"/>

Notez que l'on stocke le corps de l'article. C'est parce que nous allons utiliser les results highlighting. Si vous n'avez pas besoin de cette fonctionnalité, remplacez «stored=true» par «stored=false».

Et on importe

Nous aurions pu décider de convertir le fichier xml de wikidédia en sql, de l'importer dans une base, et d'utiliser un sql import handler. Mais pourquoi se compliquer la tâche ? Solr peut importer directement du xml. Configurons notre import de données dans le fichier data-config.xml :

<dataConfig>
    <dataSource type="FileDataSource" encoding="UTF-8" />
    <document>
    <entity name="page"
        processor="XPathEntityProcessor"
        stream="true"
        forEach="/mediawiki/page/"
        url="/var/www/solrdemo/dumps/frwiki-latest-pages-articles.xml"
        transformer="RegexTransformer,DateFormatTransformer"
        >
        <field column="id"    xpath="/mediawiki/page/id" />
        <field column="title"     xpath="/mediawiki/page/title" />
        <field column="body"      xpath="/mediawiki/page/revision/text" />
        <field column="revision"  xpath="/mediawiki/page/revision/id" />
        <field column="user"      xpath="/mediawiki/page/revision/contributor/username" />
        <field column="userId"    xpath="/mediawiki/page/revision/contributor/id" />
        <field column="timestamp" xpath="/mediawiki/page/revision/timestamp" dateTimeFormat="yyyy-MM-dd'T'hh:mm:ss'Z'" />
        <field column="$skipDoc"  regex="^(?i)#redirect.*" replaceWith="true" sourceColName="text"/>
       </entity>
    </document>
</dataConfig>

Redémarrez Solr, lancez l'indexation, attendez (une heure ou deux), et paf ! 2400000 document indexés, ce qui vous en conviendrez est plus que suffisant pour s'amuser.

Un peu de result highlighting

Étant donné que nous disposons d'un sacré paquet de texte, autant en profiter un peu, non ? (oui, j'ai des loisirs de geeks). Au hasard, je vous propose de mettre en place un peu de result highlighting (( Pour les francophones acharnés, surbrillance des résultats, mais c'est moche )) .

Bon, alors pour ceux qui aiment bien avoir un retour visuel, ça pourrait ressembler à ça :

Démo Result highlighting

Pour ce faire, nous allons utiliser les paramètres qui vont bien, et que je vous propose ci-dessous sous forme de tableau php (et tant pis pour les pythoneux, faudra s'en contenter ((Notez comme s'extériorise ma haine et ma jalousie envers les gens qui ont la chance de travailler avec un vrai langage de programmation)) ).

$req = array(
  'q' => 'Ma recherche',
  'qt' => 'dismax', // Nous envoyons directement le contenu du formulaire à Solr, par conséquent dismax est plus adapté
  'qf' => 'title^2 body', // On cherche dans title et body, avec une priorité plus importante pour le champ title
  'hl' => 'true', // Activitation de l'highlighting
  'hl.fl' => 'title,body',  // Seuls ces champs seront pris en compte pour la surbrillance
  'f.body.hl.alternateField' => 'body',  // Si aucun snippet n'est trouvé dans le champ body, on renvoie le champ complet
  'hl.fragsize' => 150,  // Les snippets font 150 caractères…
  'hl.snippets' => 3,  // … et on renvoie 3 snippets maxi
  'hl.maxAlternateFieldLength' => 200,  // Si on renvoie le champ body comple, on limite à 200 caractères
  'hl.simple.pre' => '',  // On veut que notre surbrillance soit encadrée par des balises 'strong'
  'hl.simple.post' => '',
));

Si vous exécutez une requête contenant ces paramètres, vous noterez que la réponse est séparée en deux parties : d'un côté, les résultats, et de l'autre, les snippets générés. Vous remarquerez également que les snippets en question sont a peu près illisibles à l'œil nu, mais ça, c'est une autre histoire.

<?xml version="1.0" encoding="UTF-8"?>
<response>
<responseHeader>
  <bla>bla</bla>
</responseHeader>
<result name="response" numFound="50" start="0">
  <doc>
    <str name="id">9</str>
    <int name="revision">2338009</int>
    <date name="timestamp">2002-10-31T09:16:01Z</date>
    <str name="title">Algèbre de boole</str>
  </doc></result>
<lst name="highlighting">
  <lst name="9">
    <arr name="body">
      <str>
L' &lt;em&gt;alg&amp;#232;bre&lt;/em&gt; g&amp;#233;n&amp;#233;rale, ou &lt;em&gt;alg&amp;#232;bre&lt;/em&gt; abstraite, est la branche des math&amp;#233;matiques qui porte principalement sur l'&amp;#233;tude des structures &lt;em&gt;alg&amp;#233;briques&lt;/em&gt; et de leurs relations. L'appellation &lt;em&gt;alg&amp;#232;bre&lt;/em&gt; g&amp;#233;n&amp;#233;rale s'oppose &amp;#224; celle dalg&amp;#232;bre &amp;#233;l&amp;#233;mentaire ; cette derni&amp;#232;re enseigne le calcul &lt;em&gt;alg&amp;#233;brique&lt;/em&gt;, c'est-&amp;#224;-dire les r&amp;#232;gles de manipulation des formules et des expressions &lt;em&gt;alg&amp;#233;briques&lt;/em&gt;.Historiquement, les structures &lt;em&gt;alg&amp;#233;briques&lt;/em&gt; sont apparues dans diff&amp;#233;rents</str>
    </arr>
  </lst>

Parser la réponse sera finalement assez simple. Allez, je vous donne le code que j'utilise (pour les curieux, c'est du twig, mais ce serait pareil avec n'importe quel autre moteur de template).

<section id="results">
  {% block results %}
    <h3>{{ results.numFound }} résultats trouvés</h3>
    <ol>
    {% for doc in results.docs %}
      {% block doc %}
        <li>
          <a href="http://fr.wikipedia.org/wiki/{{ doc.title }}" />{{ doc.title }}</a>
          <p>
          {% for snippet in highlighting[doc.id].body %}
            {{ snippet }}…
          {% endfor %}
          </p>
        </li>
      {% endblock %}
    {% endfor %}
    </ol>
  {% endblock %}
</section>
{% endif %}
</section>

Mais… Mais… Attendez ! C'est tout crade !

Ok, j'avoue, si vous avez vous même testé tout ça jusqu'ici, vous avez remarqué que le résultat n'est pas vraiment exploitable. Le texte que nous indexons, en effet, n'est pas du texte brut : il contient toutes les balises de formattage spécifiques du langage wiki de Wikipédia.

Dans notre cas, nous n'aurons jamais besoin de ces informations aussi l'idéal serait de pouvoir filtrer ces balises dés l'import des données.

Si vous aussi, vous lisez la documentation de Solr en famille le soir au coin du feu ((Allez, avouez, je sais que vous le faites)), vous savez déjà que Solr propose des *transformers* (rien à voir avec les robots), qui permettent de filtrer les données lors de l'import.

D'ailleurs, si vous regardez le code du fichier data-config.xml que nous avons utilisé, il utilise déjà deux transformers. Tout ce que nous allons faire, c'est créer notre transformer custom. Allez hop ! Un peu de java ! Créez un fichier WikiTransformer.java

import java.util.*;
import java.io.*;
import info.bliki.wiki.filter.PlainTextConverter;
import info.bliki.wiki.model.WikiModel;

public class WikiTransformerAlt {
  public Object transformRow(Map row) {
    String body = (String)row.get("body");

    StringWriter writer = new StringWriter();

    WikiModel wikiModel = new WikiModel("http://www.mywiki.com/wiki/${image}", "http://www.mywiki.com/wiki/${title}");
    String plainStr = wikiModel.render(new PlainTextConverter(), body);

    row.put("body", plainStr);

    return row;
  }
}

Deux choses à noter : ce transformer n'est pas optimal, le nom du champ à parser (body) est codé en dur. Mais bon, ça ira pour cette fois (mais ne recommencez pas). Deuxième chose, j'utilise la librairie java gwtwiki pour faire le boulot de traduction wiki -> texte brut. Cette librairie est pour le moment lacunaire (ou alors je m'en sert mal), et le résultat ne sera pas parfait. Il faudra faire avec.

La compilation du fichier en .jar et son installation sortent du scope de cet article, aussi je vous laisse utiliser votre moteur de recherche favori… Bon, ok, je vous file les commandes :

javac -cp "/path/vers/bliki/info.bliki.wiki/bliki-core/target/*" WikiTransformer.java
jar -cf WikiTransformer.jar WikiTransformer.class
cp WikiTransformer.jar path/vers/solr/work/Jetty_0_0_0_0_8983_solr.war__solr__k1kf17/webapp/WEB-INF/lib/

Notez le chemin de destination pas trés conventionnel : c'est le seul endroit ou l'autoload semble capter le jar. Si un expert java passe par là et peut m'expliquer…

Il ne nous reste plus qu'à mettre à jour notre fichier de configuration d'import, et à relancer l'indexation :

<dataConfig>
    <dataSource type="FileDataSource" encoding="UTF-8" />
    <document>
    <entity name="page"
        processor="XPathEntityProcessor"
        stream="true"
        forEach="/mediawiki/page/"
        url="/var/www/solrdemo/dumps/frwiki-latest-pages-articles.xml"
        transformer="RegexTransformer,DateFormatTransformer,WikiTransformer"
        >
        <field column="id"    xpath="/mediawiki/page/id" />
        <field column="title"     xpath="/mediawiki/page/title" />
        <field column="body"      xpath="/mediawiki/page/revision/text" />
        <field column="revision"  xpath="/mediawiki/page/revision/id" />
        <field column="user"      xpath="/mediawiki/page/revision/contributor/username" />
        <field column="userId"    xpath="/mediawiki/page/revision/contributor/id" />
        <field column="timestamp" xpath="/mediawiki/page/revision/timestamp" dateTimeFormat="yyyy-MM-dd'T'hh:mm:ss'Z'" />
        <field column="$skipDoc"  regex="^(?i)#redirect.*" replaceWith="true" sourceColName="body"/>
       </entity>
    </document>
</dataConfig>

Et voilà, vous disposez de votre propre moteur de recherche Wikipedia. Les raffinements possibles sont nombreux, je laisse votre imagination vous guider. En attendant, rendez-vous au prochain SudWeb.