Volltextsuche mit Ferret
Jens Krämer
Ferret?
- Text-Suchmaschine
- Lucene -> Ruby -> C
- Autor: David Balmain
- Begann als Port von Lucene nach Ruby
- Mittlerweile > 90% in C implementiert
- Index ist Menge von Dokumenten
- Dokumente haben Felder mit Feldname und Inhalt.
Basics
index = Ferret::I.new :path => '/tmp/index'
index << { :content => "The Ruby Way" }
index << { :content => "Agile Web Development with Rails" }
index.optimize
index.search_each('ruby') do |doc_id, score|
doc = index[doc_id]
puts doc[:content] # => The Ruby Way
end
index.close
- Zentraler Anlaufpunkt ist die Index-Klasse, :path gibt an wo der
Index liegt bzw. angelegt werden soll. Lässt man den Pfad weg, wird der
Index ausschließlich im RAM gehalten.
- Dokumente werden in Form von Hashes hinzugefügt.
- Optimization optimiert Index für schnelle Suche und Sortierung
- search_each ruft Block mit jedem Treffer auf und übergibt Dokument-Id
und Score (Ferret's Maß für die Relevanz des Dokuments in Bezug auf die
Anfrage). Alternativ kann mittels #search die Ergebnismenge als Ganzes
abgerufen werden.
- Gebräuchliche Optionen: :limit (default: 10, :all für alle),
:offset, :sort
- Zugriff auf Dokument erfolgt mit Hilfe der Dokument-Id
- Zugriff auf Feldinhalte mit Feldnamen
- Schließen des Index entfernt eventuelle write locks
Ferret Query Language
- Wildcard
- content:W?ldca*
- WildcardQuery.new(:content, "W?ldca*")
- Range
- date:[20050725 20050905}
- date: <= 20061231
- Phrase
- "quick|speedy|fast <> fox"
- Fuzzy
- Boolean
- (quick fast) AND fox
- +qui* -brown +animal:fox
- OR ist default
- '+' für unbedingt erforderliche Worte
- '-' um Worte auszuschließen
- sonst - 'nice to have'
- Die PhraseQuery passt sowohl auf 'quick red fox',
als auch auf 'fast brown fox'
Sortieren
result = index.search('ruby', :sort => 'date DESC, title')
sort = Ferret::Search::Sort.new([
Ferret::Search::SortField.new(:date, :reverse => true)
Ferret::Search::SortField.new(:title)
])
result = index.search('ruby', :sort => sort)
Sortierung anhand sort string oder sort object
Analyzing
-
- "Ich bin nur ein kleiner Blindtext"
- ["ich", "bin", "nur", "ein", "klein", "blindtext"]
- ["klein", "blindtext"]
- LowerCaseFilter eliminiert Großbuchstaben
- StemFilter entfernt Endung (kleiner -> klein)
- StopFilter lässt nur noch 'klein' und 'blindtext' übrig
- Analyzing findet statt für Dokumente und Queries
Custom Analyzer
class GermanStemmingAnalyzer < Ferret::Analysis::Analyzer
include Ferret::Analysis
def initialize(stop_words = FULL_GERMAN_STOP_WORDS)
@stop_words = stop_words
end
def token_stream(field, str)
StemFilter.new(StopFilter.new(
LowerCaseFilter.new(StandardTokenizer.new(str)),
@stop_words), 'de')
end
end
Analyzer mit Stemming und StopFilter
für deutschsprachige Inhalte
Feld-Eigenschaften
- :index - durchsuchbar? Mit Analyzing?
- :store - Originalinhalt abrufbar?
- :boost - Wichtung
- :term_vector - Detailinfo über Struktur des Inhalts
- Felder haben diverse Eigenschaften, die bestimmen, wie ihre Inhalte
verarbeitet werden.
- :index - Feld durchsuchbar oder nicht, mit Analyzing oder ohne
(untokenized, bspw. für URLs, Ids und Schlüsselwörter)
- :store - Speicherung der Feldinhalte im Index
- :term_vector - Menge und Häufigkeit von Termen, optional mit
Positionen und Offsets (notwendig für Highlighting)
- :boost - Wichtung des Feldes in Relation zu den anderen
Index-Updates
index = Ferret::I.new :key => :id
index << { :id => 1,
:content => "Agile Web Development with .NET" }
index << { :id => 1,
:content => "Agile Web Development with Rails" }
result = index.search('agile')
puts result.total_hits
# => 1
puts index[result.hits[0].doc][:content]
# => "Agile Web Development with Rails"
- Aktualisieren von Dokumenten ist nicht möglich
- Lösung: Löschen und Re-Indizieren
- mit der :key-Option wird ein Feld als Schlüssel festgelegt;
das löschen der eventuell vorhandenen alten Version mit selbem Wert
für das Schlüsselfeld passiert dann automatisch
Lower Level API
- QueryParser
- IndexSearcher
- IndexReader
- IndexWriter
acts_as_ferret
class Product < ActiveRecord::Base
acts_as_ferret
end
Product.rebuild_index
results = Product.find_by_contents( 'suchbegriff',
:limit => :all )
- Acts_as_ferret vereinfacht die Handhabung des Ferret-Index in
Rails-Anwendungen enorm.
- Mittels AR-callbacks wird dafür gesorgt, dass der Index immer
aktualisiert wird, wenn Records erstellt, geändert oder
gelöscht werden.
- Mapping von Ferret-Suchergebnissen auf DB-Records passiert
automatisch.
- Standardmäßig werden alle Attribute indiziert.
- rebuild_index baut aus der gesamten Datenbasis einen neuen Index auf
- Der Index wird in RAILS_ROOT/index/RAILS_ENV/model_class_name abgelegt.
- find_by_contents durchsucht den Index und selektiert anschließend die
entsprechenden AR-Objekte. Verhält sich ansonsten wie Ferret's
#search (:limit, :offset, :sort).
Customizing
class Product < ActiveRecord::Base
acts_as_ferret :fields => {
:name => { :boost => 10 },
:description => { }
}, :ferret => {
:analyzer => GermanStemmingAnalyzer.new
}
end
- Nur die benannten Attribute werden indiziert.
- nicht auf DB-Attribute beschränkt, wird ein Methodenname
angegeben wird der Rückgabewert der Methode indiziert.
- Feld-spezifische Optionen analog zu FieldInfos.
- Nach Änderungen an der Konfiguration muss der Index neu gebaut
werden.
Im Controller
def search
per_page = 10
@query = params[:q]
return if @query.blank?
page = params[:page].to_i rescue 1
@results = Product.find_by_contents(
@query,
:limit => per_page,
:offset => per_page * (page-1)
)
@pages = Paginator.new( self, @results.total_hits,
per_page, page )
end
- Action für Suche mit Paging
- AAF-Suchergebnisse sind Array plus total_hits-Attribut
Erweiterte Suche
class Product < ActiveRecord::Base
acts_as_ferret :fields => {
:name => { :boost => 10 },
:description => { },
:price_searchable => { :index => :untokenized }
}
def price_searchable
"%09d" % price
end
end
- Preis wird auf feste Länge gebracht (Voraussetzung für
funktionierende Range-Queries)
- :untokenized damit Analyzer keine Modifikationen vornimmt.
Erweiterte Suche (Controller)
def search
query = @query = params[:q]
return if query.blank?
@price_to = params[:price_to].to_i rescue nil
if @price_to
query <<
" price_searchable: <= #{'%09d' % (@price_to*100)}"
end
@results = Product.find_by_contents query
end
- Gegebene Preisobergrenze wird wieder auf feste Länge gebracht.
- RangeQuery wird an Nutzeranfrage angehängt.
- Action wird langsam unübersichtlich -> Auslagerung in
eigenes Model empfehlenswert.
Lazy Loading
DB-Queries vermeiden
class Product < ActiveRecord::Base
acts_as_ferret :fields => {
:name => { :boost => 10, :store => :yes },
:description => { }
}
end
@results = Product.find_by_contents query, :lazy => true
Lazy bedeutet in dem Zusammenhang, dass Aaf nicht sofort alle
Suchergebnisse aus der DB holt, sondern nur Proxy-Objekte
zurückgibt, die nur mit den im Index gespeicherten (:store => :yes)
Felddaten gefüllt sind.
Im Idealfall erübrigt sich damit für die Anzeige des Ergebnisses
der DB-Zugriff ganz.
Bei Bedarf wird der jeweilige Record implizit nachgeladen.
AR-Optionen
- Suche mittels AR-conditions weiter einschränken
- Eager loading
Product.find_by_contents( query,
{ :limit => :all },
:conditions => 'is_public=1',
:include => :category )
multi_search
Suche über Klassengrenzen hinweg
Product.multi_search query, [ News ], :limit => :all
acts_as_ferret :fields => { ... }, :store_classname => true
- Suche gibt Instanzen verschiedener Klassen zurück
- Sortierung nach Relevanz wie gehabt
more_like_this
Finde ähnliche Datensätze
similar_records = record.more_like_this(
:field_names => [ :name, :content ],
:min_doc_freq => 3,
:min_term_freq => 3,
:min_word_length => 3,
:max_word_length => 10
)
- Sucht nach relevanten Termen in diesem Dokument
- Baut aus diesen Termen Query und gibt Ergebnis zurück
- Geht (noch) nicht mit DRb server
- Verhalten über Optionen feingranular steuerbar.
Live-Betrieb
- Threadsafe, aber das reicht nicht...
script/ferret_server start
acts_as_ferret :fields => { ... }, :remote => true
- Ferret ist zwar threadsafe, unterstützt aber keine
gleichzeitigen Schreibzugriffe durch verschiedene Prozesse.
- Gängige Lösung: zentraler Indexer-Prozess über den alle Zugriffe
auf den Index laufen.
- Aaf bringt eigenen DRb-basierten Server mit, dessen Verwendung
mittels :remote => true aktiviert wird.
DRb-Server-Konfiguration
config/ferret_server.yml
production:
host: localhost
port: 9010
pid_file: log/ferret.pid
- :remote => true greift nur dann, wenn zu RAILS_ENV passender
Abschnitt in Konfiguration vorhanden.
- Nur für :production definieren: lokales Arbeiten ohne DRb in
development-Umgebung und mit im Live-Betrieb
Referenzen
- Acts_as_ferret
- lingr.com
- sachsen-gesetze.de
- kitchen.technorati.com
- omdb.org - ohne acts_as_ferret, aber reichlich komplexes Szenario, inkl. custom DRb-Server
- lingr.com - Volltextsuche über Chatroom-Mitschnitte inklusive
speziellem multi-lingualem Analyzer
- sachsen-gesetze.de - VT-Recherche über sächsische Verkündungsmedien
- kitchen.technorati.com - Microformat-Suche --> einer der ersten Nutzer des DRb-Servers