Volltextsuche mit Ferret

Jens Krämer


http://www.webit.de/

http://www.jkraemer.net/

Ferret?


  • 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

  • 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

  • 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

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



    
    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

    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

    • 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

    Mehr?