Follow me
RSS feed
My sources
My Viadeo

Créer son moteur de recherche avec Ruby et Xapian

Greg | 29 Dec 2009

DevIl y a quelque mois de cela, j'avais publié dans GNU/Linux Magazine France un article expliquant comment créer son propre moteur de recherche avec Ruby. Je m'étais alors basé sur Ferret, inspiré de Lucene. Aujourd'hui, en lisant Rails Magazine #5, j'ai découvert Xapian.

Xapian est une librairie permettant d'indexer des documents. Elle est écrite en C++ et propose des bindings pour différents langages, dont Ruby. Je vous propose donc de réécrire le moteur de recherche mis en place dans GLMF #107 en utilisant ce moteur.

La méthode utilisée sera exactement la même que celle mise en place dans mon premier article. Nous allons donc créer deux scripts : un crawler/indexer et une interface de recherche. Je ne vous réexpliquerai pas le rôle des différents éléments en vous laissant le soin de refaire un peu de lecture, si besoin. Je vais me concentrer sur le code en mettant en avant les éléments à modifier pour passer de Ferret à Xapian

Installer Xapian

Avant de commencer, nous allons installer Xapian. Ce travail se fera en deux étapes. Tout d'abord, nous devons récupérer, compiler et installer xapian-core :

wget http://oligarchy.co.uk/xapian/1.0.17/xapian-core-1.0.17.tar.gz
tar zxvf xapian-core-1.0.17.tar.gz
cd xapian-core-1.0.17

./configure && make && sudo make install

Il faut maintenant installer le binding pour Ruby. Vous avez plusieurs solutions pour cela. Soit récupérer xapian-bindings, soit (solution que je préfère), récupérer et installer le gem (non officiel) :

git clone http://github.com/xspond/xapian-ruby.git
cd xapian-ruby

gem build xapian-ruby.gemspec
sudo gem install xapian-ruby-0.1.2.gem

Crawler et Indexer

La partie crawler est exactement la même et nous la reprendrons donc telle quelle.

Pour l'indexeur, nous avions vu à l'époque qu'il suffit de créer une instance de Ferret::Index::Index et de lui ajouter les éléments à indexer :

require 'ferret'

# Création de l'index
index = Ferret::Index::Index.new( :path => "index" )

# Ajout du premier document :
index << { :uri => "http://example.com/", :title => "A FooBar example", :content => "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis eget blandit tortor. Vestibulum vitae sem..." }

# Ajout du second document :
index << { :uri => "http://example.com/page.html", :title => "A second FooBar example", :content => "Ut ornare euismod mauris quis molestie. Fusce eget metus sit amet justo pretium ullamcorper sit..." }

# ...

Dans cet exemple, l’option :path permet de préciser le répertoire dans lequel devront être stockés les différents fichiers permettant la persistance de l’index.

Si maintenant nous voulons faire la même chose avec Xapian, voici le code que nous obtenons :

 1 require 'xapian'
 2 
 3 # Création de la base
 4 database = Xapian::WritableDatabase.new("index", Xapian::DB_CREATE_OR_OPEN)
 5 
 6 # Création de l'index
 7 index = Xapian::TermGenerator.new()
 8 index.database = database
 9 index.stemmer = Xapian::Stem.new("french")
10 
11 # Ajout du premier document :
12 document = Xapian::Document.new()
13 document.data = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis eget blandit tortor. Vestibulum vitae sem..."
14 document.add_term( "Uhttp://example.com/")
15 document.add_term( "TA FooBar example" )
16 index.document = document
17 index.index_text("Lorem ipsum dolor sit amet, consectetur adipiscing elit. Duis eget blandit tortor. Vestibulum vitae sem...")
18 database.add_document(document)
19 
20 # Ajout du premier document :
21 document = Xapian::Document.new()
22 document.data = "Ut ornare euismod mauris quis molestie. Fusce eget metus sit amet justo pretium ullamcorper sit..."
23 document.add_term( "Uhttp://example.com/page.html")
24 document.add_term( "TA second FooBar example" )
25 index.document = document
26 index.index_text("Ut ornare euismod mauris quis molestie. Fusce eget metus sit amet justo pretium ullamcorper sit...")
27 database.add_document(document)

Outch ! Xapian est, à première vue, un peu plus lourd que Ferret. La première chose à faire est de créer la base (ligne 4) puis l'indexer (ligne 7) en lui affectant cette base (ligne 8). Vous remarquerez que l'indexer travaille en fonction d'une langue (ligne 9). Ceci fait l'ajout de document se fait en trois étapes :

A partir de là, je suis certain que vous vous posez plein de questions...

Pourquoi, alors que nous passons le document à l'indexer, fait-il utiliser index_text ?

Tout simplement parce que nous ne voulons peut-être pas toujours indexer le contenu complet du document. En effet, dans le cas d'un crawler web par exemple, nous pourrions indexer le document en ne prenant en compte que les mots clés (balise meta, name="keywords"). Dans ce cas, nous ferons plutôt quelque chose comme cela :

 1 # nous partons du principe que nous avons :
 2 #  body     = contenu de la page
 3 #  url      = l'URL de la page
 4 #  title    = le titre de la page
 5 #  keywords = les mots clés récupérés dans la balise meta
 6 
 7 document = Xapian::Document.new()
 8 document.data = body
 9 document.add_term( "U#{url}")
10 document.add_term( "T#{title}" )
11 index.document = document
12 keywords.each do |kw|
13   index.increase_termpos
14   index.index_text(kw)
15 end
16 database.add_document(document)

Notez que nous pouvons pondérer nous même les mots clés. En effet, index_text accepte, en second paramètre, un entier permettant de donner un poids à chacun. Par défaut il est à 1.

Qu'est-ce donc que ces add_term ?

Avec Ferret, nous passions notre document sous forme de Hash. Et bien les terms d'un document sont un peu la même chose. Mais afin de les différencier, nous les préfixons (ici U pour l'URL, T pour le titre). Ainsi lors de la recherche nous pourrons récupérer ces informations.

Maintenant que nous savons cela, nous pouvons coder notre crawler/indexer :

  1 require 'digest/md5'
  2 require 'net/http'
  3 require 'uri'
  4 
  5 require 'rubygems'
  6 require 'hpricot'
  7 require 'xapian'
  8 
  9 DEBUG = true
 10 
 11 class HTTPDocument
 12   attr_reader :highlevel, :pages
 13   
 14   def initialize( )
 15     @level = 0
 16     @highlevel = 0
 17     @pages = []
 18      
 19     @database = Xapian::WritableDatabase.new("index", Xapian::DB_CREATE_OR_OPEN)
 20 
 21     @indexer = Xapian::TermGenerator.new()
 22     @indexer.database = @database
 23     @indexer.stemmer = Xapian::Stem.new("french")
 24   end
 25   
 26   def indexer( uri, title, content, digest )
 27     puts "[INDEX] - Index #{uri}..."
 28     @highlevel = @level if @highlevel < @level
 29     @pages << uri
 30     
 31     document = Xapian::Document.new()
 32     document.data = content
 33     document.add_term( "U#{uri}")
 34     document.add_term( "T#{title}" )
 35     document.add_term( "M#{digest}" )
 36 
 37     @indexer.document = document
 38     @indexer.index_text(content)
 39 
 40     @database.add_document(document)
 41   end
 42   
 43   def crawler( uri )    
 44     puts "[CRAWL] - Level ##{@level} : #{uri}..."
 45 
 46     # Récupération de la ressource
 47     url = URI.parse( uri )
 48     
 49     # Récupération du contenu
 50     response = Net::HTTP.get_response( url )
 51     
 52     # En cas d'erreur, nous ignorons la page
 53     if response.class != Net::HTTPOK
 54       puts "\t[ERROR] - #{uri} : Bad URL!" if DEBUG
 55     end
 56 
 57     # Récupération du contenu de la page
 58     pageContent = response.body
 59 
 60     # Calcul du MD5 de la page
 61     pageDigest = Digest::MD5.hexdigest( pageContent )
 62     
 63     # Si la page a déjà été indexé nous l'ignorons
 64     if @database.term_exists( "M#{pageDigest}" )
 65       puts "\t[IGNORE] - #{uri} : Already parsed!" if DEBUG
 66       return
 67     end
 68     
 69     # Indexation
 70     case response.content_type 
 71       when "text/html"
 72         pageDocument = Hpricot( pageContent )
 73         pageTitle = (pageDocument/"title")[0]
 74         pageTitle = ((pageTitle.nil?)?"":pageTitle.inner_html) # "
 75         
 76         indexer( uri, pageTitle, pageContent, pageDigest )
 77         
 78         # Parcour des liens
 79         (pageDocument/"a").each do |element|
 80           href = element['href']
 81           begin
 82             __href = URI.parse( href )
 83             if __href.scheme.nil?
 84               __href.scheme = url.scheme
 85               __href.host = url.host
 86               __href.port = url.port
 87               __href.path = __href.path.gsub( "./", "/" ).gsub( "//", "/" )
 88               href = __href.to_s
 89             end
 90         
 91             if url.host == URI.parse( href ).host
 92               href += "/" if URI.parse( href ).path == ""
 93               unless @database.term_exists( "U#{href}" )
 94                 @level = @level + 1
 95                 crawler( href ) 
 96                 @level = @level - 1
 97               end
 98             else
 99               puts "\t[IGNORE] - #{href} : Host not match!" if DEBUG
100             end
101           rescue => e
102             puts "\t[ERROR] - Error at #{href} : #{e.message}" if DEBUG
103           end            
104         end
105       when "text/plain"
106         pageDocument = pageContent
107         pageTitle = uri
108         
109         indexer( uri, pageTitle, pageContent, pageDigest )
110       else
111         puts "\t[IGNORE] - #{uri} : Not Text or HTML!" if DEBUG
112     end
113   end
114 end
115 
116 site = HTTPDocument.new( )
117 
118 b = Time.now
119 site.crawler( ARGV[0] )
120 e = Time.now
121 
122 puts "#{site.pages.size} indexed :"
123 site.pages.each do |p|
124   puts "\t- #{p}"
125 end
126 puts "High level : #{site.highlevel}"
127 puts "Time : #{e - b}s"

L’interface de recherche

Avec Ferret voici ce que j'avais mis en place la dernière fois :

index = Index::Index.new(:path => './index')
index.search_each('content:"' + search_term + '"') do |id, score|
  out.write( "<p>" )
  out.write( "<a href='#{index[id][:url]}'>#{index[id][:title]}</a> <small>- [score of #{score}]</small><br />" )
  highlights = index.highlight('content:"' + search_term + '"', 0,
                                   :field => :content,
                                   :pre_tag => "<b>",
                                   :post_tag => "</b>")
  out.write( "<small>#{highlights}</small>" )
  out.write( "<small><a href='#{index[id][:url]}'>#{index[id][:url]}</a></small>")
  out.write( "</p>\n" )
end

Et voici comment nous faisons la même chose avec Xapian :

 1 database = Xapian::Database.new("index")
 2 
 3 enquire = Xapian::Enquire.new(database)
 4 
 5 qp = Xapian::QueryParser.new()
 6 qp.stemmer = Xapian::Stem.new("french")
 7 qp.database = database
 8 qp.stemming_strategy = Xapian::QueryParser::STEM_SOME
 9 
10 enquire.query = qp.parse_query(search_term)
11 
12 # On ne prend que les 10 premiers résultats.
13 matchset = enquire.mset(0, 10)
14 
15 matchset.matches.each do |m|
16   url = ''
17   title = ''
18     
19   m.document.terms.each do |t|
20     title = t.term.gsub(/^T/, "") if /^T/.match(t.term)
21     url = t.term.gsub(/^U/, "") if /^U/.match(t.term)
22   end
23   out.write( "<p>" )
24   out.write( "<font size='+1'><a href='#{url}'>#{title}</a></font> <small>- [score of #{m.percent}%]</small><br />" )
25   out.write( "<small><a href='#{url}'><font color='green'>#{url}</font></a></small>")
26   out.write( "</p>" )
27 end

Outch !1 Là encore, le code est beaucoup plus long avec Xapian. Il y a seulement deux petites choses importantes à voir ici :

  1. ligne 8, nous définissons la statégie de recherche de termes en fixant la valeur de Xapian::QueryParser#stemming_strategy à Xapian::QueryParser::STEM_SOME. Cela veut tout simplement dire que nous ne prenons pas en compte les termes commençant par une majuscule, donc pas notre U<url> ou notre T<titre>. Ce qui est plutôt une bonne chose.
  2. Pour récupérer l'URL et le titre justement, nous parcourons les termes rattachés à chaque document trouvé et nous extrayions celui préfixé, respectivement, par U et T (ligne 19 à 22).

Bien entendu, je me suis limité à la plus courte explication. Mais si vous regardez la documentation de Xapian, vous verrez que vous pouvez aller beaucoup plus loin, en gérant en particulier la correction orthographique ou la synonymie...

En attendant, voici le code complet de notre interface de recherche :

 1 #!/usr/bin/env ruby
 2 
 3 require 'rubygems'
 4 require 'mongrel'
 5 require 'xapian'
 6 
 7 class ResultHandler < Mongrel::HttpHandler
 8   def process( request, response )
 9     response.start(200) do |head, out|
10       head["Content-Type"] = "text/html"
11       
12       search_term = begin
13         Mongrel::HttpRequest.query_parse( request.params['QUERY_STRING'] )['s']
14       rescue
15         nil
16       end
17       out.write( "<html>
18 <head>
19   <meta http-equiv=content-type content='text/html; charset=UTF-8'>
20   <title>Mooteur</title>
21   <link rel='stylesheet' href='/style.css' type='text/css' media='screen' />
22 </head>
23 <body>
24   <div id='left'>
25     <table><tr>
26       <td><a href='/'><img src='/mooteur.gif' alt='Mooteur...' width='200' border='0' /></a></td>
27       <td><form action='/r'>
28         <input type='text' name='s' size='41' value='#{search_term}' />
29         <input type='submit' value='Recherche Mooteur'/>
30       </form></td>
31     </tr></table>
32   </div>")
33   
34   if search_term
35   
36     database = Xapian::Database.new("index")
37     enquire = Xapian::Enquire.new(database)
38     qp = Xapian::QueryParser.new()
39     stemmer = Xapian::Stem.new("french")
40     qp.stemmer = stemmer
41     qp.database = database
42     qp.stemming_strategy = Xapian::QueryParser::STEM_SOME
43     query = qp.parse_query(search_term)
44     # Find the top 10 results for the query.
45     enquire.query = query
46     matchset = enquire.mset(0, 10)
47 
48     out.write( "  <div id='results'>
49   <div id='head'>
50     #{matchset.matches_estimated()} r&eacute;sultats pour <b>#{search_term}</b>
51   </div>
52     
53     <div id='list'>")
54     
55     matchset.matches.each do |m|
56       url = ''
57       title = ''
58         
59       m.document.terms.each do |t|
60         title = t.term.gsub(/^T/, "") if /^T/.match(t.term)
61         url = t.term.gsub(/^U/, "") if /^U/.match(t.term)
62       end
63       out.write( "<p>" )
64       out.write( "<font size='+1'><a href='#{url}'>#{title}</a></font> <small>- [score of #{m.percent}%]</small><br />" )
65       out.write( "<small><a href='#{url}'><font color='green'>#{url}</font></a></small>")
66       out.write( "</p>" )
67     end
68 
69     out.write( "    </div>
70   </div>
71 </body>
72 </html>" )
73     end
74     end
75   end
76 end
77 
78 h = Mongrel::HttpServer.new( "0.0.0.0", "3000" )
79 h.register( "/", Mongrel::DirHandler.new("./static/") )
80 h.register( "/r", ResultHandler.new )
81 h.run.join

Xapian

Conclusion

Je terminerai, encore une fois, en vous précisant qu'il existe de très intéressants plugins Xapian pour Rails dont acts_as_xapian et xapit (à utiliser avec xapit-sync).

1 again !

Copyright © 2009 - 2011 Grégoire Lejeune.
All documents licensed under the Creative Commons Attribution-NonCommercial-ShareAlike 2.5 License, except ones with specified licence.
Powered by Jekyll.