| CARVIEW |
Créer son moteur de recherche avec Ruby et Xapian
Il 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 https://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 https://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 => "https://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 => "https://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( "Uhttps://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( "Uhttps://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 :
- Tout d'abord, nous créons ce document (ligne 12 à 15).
- Nous le passons ensuite à l'indexer (ligne 16) auquel nous demandons d'indexer le contenu (ligne 17).
- Pour terminer, nous ajoutons le document dans la base (ligne 18).
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 :
- 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.
- 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é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

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 !