Der auf Apache Lucene basierende Volltext-Suchengine ElasticSearch findet nicht nur zügig Ausdrücke in enorm großen Textsammlungen, sondern holt mit einigen Tricks auch Fotos aus der Tiefe, die in der Nähe eines Referenzbildes geschossen wurden.
Auf der Suche nach einer Suchmaschine zum schnellen Durchforsten von
Logdateien stieß ich neulich auf ElasticSearch ([2]), einer auf Apache
Lucene basierenden Volltextsuche mit allerhand Extras. Auf der
Downloadseite bietet das Open-Source-Projekt neben dem üblichen
Tarball auch ein Debian-Paket an, die pre-release-Version
1.0.0.RC2 installierte mit sudo dpkg --install *.deb
ohne Probleme
auf Ubuntu.
Praktischerweise liegt dem Debian-Paket auch gleich ein Bootskript bei, und
# /etc/init.d/elasticsearch start
als Root von der Kommandozeile startet den ElasticSearch-Server auf dem voreingestellten Port 9200. Die meisten Tutorials auf dem Web nutzen nun die REST-Schnittstelle, um mit dem Server über HTTP zu kommunizieren. Abbildung 1 zeigt einen GET-Request auf den laufenden Server, der den Status anzeigt.
Zum programmatischen Einfüttern der Daten und späteres Abfragen gibt es eine Reihe von REST-Clients in verschiedenen Sprachen, der offizielle Perl-Client ist das CPAN-Modul Elasticsearch. Zu beachten ist, dass Elasticsearch (Version 1.01) der Nachfolger des veralteten Moduls ElasticSearch (großes "S") ist. Zweifellos eine äußerst unglückliche Namenswahl des CPAN-Autors für den Upgrade, gerade weil die alte Version immer noch auf dem CPAN liegt und bei Suchabfragen auf search.cpan.org sogar als erstes hochkommt.
Als nutzbringende Beispielanwendung für eine Volltextsuche bietet sich eine Stichwortsuche in allen bislang im Linux-Magazin erschienenen Perl-Snapshots an. Die Manuskripte alle dieser über 200 Artikel dieser Reihe finden sich in einem Git-Repository unter meinem Home-Verzeichnis, und das Skript in Listing 1 übermittelt alle rekursiv gefundenen Textdateien über die REST-Schnittstelle an den laufenden Elasticsearch-Server zur Indizierung. Der Aufruf
$ fs-index ~/git/articles
dauerte erst einige Minuten, ein zweiter Aufruf mit angewärmtem Plattencache lief dann innerhalb von 30 Sekunden durch. Eine anschließende Suche nach dem Wort "Balkon" zeigt Ergebnisse schon nach Sekundenbruchteilen:
$ fs-search balkon /home/mschilli/git/articles/water/t.pnd /home/mschilli/git/articles/gimp/t.pnd
Die im Index gefundenen Dateien offenbaren, dass ich das Wort "Balkon" bislang nur in zwei Ausgaben verwendet habe: Einmal im Juli 2008 in einem Artikel über Perls Schnittstelle zum Fotoeditor Gimp, in dem ein von meinem Balkon aus geschossenes Foto manipuliert wurde ([5]), und einmal im März 2007, als der Perl-Snapshot eine automatische Bewässerungsanlage für meine Balkonpflanzen vorstellte ([6]).
Abbildung 1: Nach dem Starten des Daemons antwortet dieser auf Port 9200 auf API-Anfragen. |
Es zeigt sich weiter, dass Elasticsearch Groß- und Kleinschreibung ignoriert und automatisches Stemming vornimmt, denn eine Suche nach "Pflanze" liefert die gleichen Ergebnisse wie oben, obwohl in den Textdateien ausschließlich von "Balkonpflanzen" die Rede ist. Allerdings begreift das Analysetool nicht, dass "Balkone" die Mehrzahl von "Balkon" ist und liefert in diesem Fall keine Ergebnisse. Weiter treibt es Eleasticsearch manchmal zu weit mit der unscharfen Suche und bringt Treffer, die gar keine sind, weil unterschiedliche Worte mit der gleichen Zeichenfolge beginnen. Im Großen und Ganzen hilft die Suchfunktion jedoch zügig beim Auffinden der gesuchten Nadel im Heuhaufen.
Das Skript in Listing 1 nimmt in Zeile 9 das ihm von der Kommandozeile übergebene Suchverzeichnis entgegen und ruft anschließend den Konstruktor der Klasse Elasticsearch auf. Falls Suchabfragen einmal nicht das gewünschte Ergebnis bringen, lässt sich der Konstruktor mittels
my $es = Elasticsearch->new( trace_to => ['File','log'] );
dazu überreden, in der Logdatei log
alle an den Elasticsearch-Server abgesetzten
Kommandos im curl-Format auszuspucken. Per Cut-und-Paste kann der
verwirrte Entwickler den Vorgang dann Schritt für Schritt nachvollziehen.
Elasticsearch speichert die Daten einer Application unter einem Index,
den Zeile 7 mit "fs" (File System) benennt. Falls schon Daten vorliegen,
löscht die Methode delete
ihn in Zeile 13, und die umwickelnde
eval
-Anweisung fängt etwaige Fehler kommentarlos ab, wie etwa wenn der
Index noch gar nicht existiert, weil dies der allererste Aufruf von
fs-index
ist.
01 #!/usr/local/bin/perl -w 02 use strict; 03 use Elasticsearch; 04 use File::Find; 05 use Sysadm::Install qw( slurp ); 06 07 my $idx = "fs"; 08 09 my( $base ) = @ARGV; 10 die "usage: $0 basedir" if !defined $base; 11 12 my $es = Elasticsearch->new( ); 13 eval { $es->indices->delete( index => $idx ) }; 14 15 find sub { 16 my $file = $File::Find::name; 17 return if ! -f $file; 18 return if ! -T $file; 19 return if -s $file > 100_000; 20 my $content = slurp $file; 21 22 $es->index( 23 index => $idx, 24 type => 'text', 25 body => { 26 content => $content, 27 file => $file, 28 } 29 ); 30 print "Added $file\n"; 31 }, $base;
Die Funktion find
aus dem Modul File::Find wühlt sich ab Zeile 15
durch die Verzeichnisse auf der Festplatte, beginnend bei dem auf der
Kommandozeile übergebenen Startverzeichnis. Gefundene Binärdateien
ignoriert Zeile 18, und alles was keine richtige Datei ist oder größer
als 100.000 Bytes bleibt ebenfalls außen vor. Die Funktion slurp()
aus dem CPAN-Modul Sysadm::Install liest dann den Inhalt speicherwürdiger
Dateien in den Speicher, den die Methode index()
in Zeile 22 unter
dem Schlüssel content
in die Datenbank füttert. Der Name der Datei
findet unter dem Eintrag file
ebenfalls dort hinein.
Später findet das Skript
in Listing 2 Dateien zu vorgegebenen Stichworten, ähnlich wie
dies die Suchmaschinen des Internets tun. Mit fs-search '*'
aufgerufen
(die einfachen Anführungszeichen verhindern, dass die Unix-Shell sich des
Meta-Zeichens '*' bemächtigt und in einen Glob auf das lokale Verzeichnis
verwandelt) passt jedes Dokument im Index und Elasticsearch gibt
10 mehr oder weniger zufällige zurück, denn standardmäßig ist die
maximale Trefferzahl auf 10 eingestellt. Ein weiter unten vorgestelltes
Skript passt diesen Wert auf 100 an.
01 #!/usr/local/bin/perl -w 02 use strict; 03 use Elasticsearch; 04 05 my $idx = "fs"; 06 07 my( $query ) = @ARGV; 08 die "usage: $0 query" if !defined $query; 09 10 my $es = Elasticsearch->new( ); 11 12 my $results = $es->search( 13 index => $idx, 14 body => { 15 query => { 16 query_string => { 17 query => $query } } } 18 ); 19 20 for my $result ( 21 @{ $results->{ hits }->{ hits } } ) { 22 23 print $result->{ _source }->{ file }, 24 "\n"; 25 }
Die in Zeile 12 in Listing 2 aufgerufene Methode search()
nimmt den
Namen des Suchindex, unter dem die Daten liegen (wiederum "fs") und im
body
-Teil der Anfrage den Query-String entgegen. Wie die Dokumentation
auf [2] aufzeigt, versteht ElasticSearch eine ganze Reihe verschiedener
und offensichtlich historisch gewachsener Query-Formate, deswegen ist die
etwas absurd anmutende Verschachtelung query/query_string/query
notwendig.
Zurück kommt eine Referenz auf einen Array von Treffern, über die die
for
-Schleife in Zeile 20 iteriert und jeweils den ebenfalls übermittelten
Eintrag zum archivierten Dateinamen auf dem Terminal ausgibt.
ElasticSearch kann aber noch mehr. Eine interessante Erweiterung klassischer Volltextsuche ist zum Beispiel der sogenannte "Geo Distance Filter" ([7]). Speichert man zu jedem Dokument die Geo-Daten, kann der Suchengine diejenigen Einträge hervorholen, die sich in einem bestimmten Umkreis zu einem vorgegebenen Punkt auf der Erdoberfläche befinden. Dies ist zum Beispiel dann von Nutzen, wenn man des Nachts mit seinem Mobiltelefon herumirrt und ein offenes 5-Sterne-Restaurant in der näheren Umgebung sucht.
Abbildung 2: Ein mit einem iPhone geschossenes Bild der neuen Bay Bridge. |
Abbildung 3: Die GPS-Daten des iPhone-Fotos stehen mit 37° 48.87' nördlicher Breite und 122° 21.55' westlicher Länge im EXIF-Header der JPG-Datei. |
Da mein iPhone5 wie jedes andere Smartphone zu jedem geschossenen Bild die Geo-Daten im EXIF-Header der gespeicherten JPG-Dateien ablegt, bot es sich an, eine Suche zu definieren, die zu einem vorgegebenen Bild im Fotoalbum ("Gallery") des Telefons diejenigen Bilder heraussucht, die im gleichen 1-Kilometer-Radius geschossen wurden. Abbildung 2 zeigt zum Beispiel ein Foto des neu gebauten östlichen Bogens der Bay Bridge bei mir zuhause in San Francisco ([3]). Auf einem Fuß- und Radelweg kann man seit letztem Jahr dort bis zur Mitte der neuen Brücke laufen.
Abbildung 3 listet die Ausgabe des Kommandos
exiftags
auf das vom Telefon auf den Linux-Rechner übertragenen Foto
auf. Fast ganz unten steht dort, dass das Bild an einer Geo-Location mit
37° 48.87' nördlicher Breite und 122° 21.55' westlicher Länge geschossen
wurde.
Die Funktion photo_latlon()
in Listing 3 liest diese Werte mittels des
CPAN-Moduls Image::EXIF aus und rechnet sie mit dm2decimal()
aus dem Modul Geo::Coordinates::DecimalDegrees in Fließkommawerte um.
Der reguläre Ausdruck ab Zeile 47 sucht im Geo-Format nach einem
Buchstaben (N oder S für nördliche/südliche Breite, W oder E für westliche
oder östliche geographische Länge), gefolgt von der numerischen Grad-Angabe
und dem in UTF-8 kodierten kreisförmigen Grad-Symbol. Nach einem oder
mehreren Leerzeichen folgt die Minutenangabe.
01 ########################################### 02 package IPhonePicGeo; 03 # Extract decimal GPS location from Photo 04 # Mike Schilli, 2014 (m@perlmeister.com) 05 ########################################### 06 use Image::EXIF; 07 use Geo::Coordinates::DecimalDegrees; 08 09 ########################################### 10 sub photo_latlon { 11 ########################################### 12 my( $pic ) = @_; 13 14 my $exif = Image::EXIF->new(); 15 $exif->file_name( $pic ); 16 my $info = $exif->get_image_info(); 17 18 return if !exists $info->{ Latitude }; 19 20 my( $head, $d, $m ) = loc_parse( 21 $info->{ Latitude } ); 22 if( $head eq "S" ) { 23 $d = -$d; 24 } 25 my $lat = dm2decimal( $d, $m ); 26 27 ( $head, $d, $m ) = loc_parse( 28 $info->{ Longitude } ); 29 if( $head eq "W" ) { 30 $d = -$d; 31 } 32 my $lon = dm2decimal( $d, $m ); 33 34 return( $lat, $lon ); 35 } 36 37 ########################################### 38 sub loc_parse { 39 ########################################### 40 my( $field ) = @_; 41 42 return if !defined $field; 43 44 # Latitude: N 37° 25.16' 45 # Longitude: W 122° 1.53' 46 my( $head, $d, $m ) = 47 ( $field =~ /^(\w) # heading 48 \s+ 49 (\d+) # degrees 50 . # degree symbol 51 \s+ 52 ([\d.]+) # minutes 53 /x ); 54 55 return( $head, $d, $m ); 56 } 57 58 1;
So wird aus N 37° 48.87'
der Wert 37.816
und aus W 122° 21.55'
die negative Fließkommazahl -122.3555
. Google Maps bestätigt in
Abbildung 4, dass sich der Aufenthaltsort des talentierten Fotografen
zum Zeitpunkt der Bilderstellung
tatsächlich in der Mitte der San Francisco Bay auf der Bay Bridge befand.
Abbildung 4: Der Geopunkt [37.816, -122.3555] befindet sich tatsächlich auf der Bay Bridge bei San Francisco. |
Um nun herauszufinden, ob sich im Fotoalbum noch weitere Bilder befinden,
die in einem Umkreis von einem Kilometer aufgenommen wurden, speichert
photo-index
in Listing 4 alle Fotos unter dem Verzeichnis iphone
im
Home-Verzeichnis des Users auf dem lokal installierten Elasticsearch-Server.
01 #!/usr/local/bin/perl -w 02 use strict; 03 use File::Find; 04 use Elasticsearch; 05 use IPhonePicGeo; 06 07 my $idx = "photos"; 08 my $dir = glob "~/iphone"; 09 10 my $es = Elasticsearch->new( ); 11 12 eval { # Delete existing index if present 13 $es->indices->delete( index => $idx ) }; 14 15 $es->indices->create( 16 index => $idx, 17 body => { 18 mappings => { 19 photo => { 20 properties => { 21 Location => { 22 type => "geo_point" } } } } } 23 ); 24 25 find sub { 26 my $pic = $File::Find::name; 27 28 return if ! -f $pic; 29 return if $pic !~ /.jpg$/i; 30 31 my( $lat, $lon ) = 32 IPhonePicGeo::photo_latlon( $pic ); 33 return if !defined $lat; 34 35 $es->index( 36 index => $idx, 37 type => "photo", 38 body => { 39 file => $pic, 40 Location => [ $lat, $lon ], 41 }, 42 ); 43 44 print "Added: $pic ($lat/$lon)\n"; 45 46 }, $dir;
Die Funktion find()
wühlt sich auch rekursiv
durch etwaige Unterzeichnisse. Damit der Suchengine die Geodaten so
speichert, dass sie Suchabfragen später performant nutzen können, ist
ein sogenanntes Mapping erforderlich. Zu dem im Index photos
verwendeten
Dokumenttyp photo
definiert das create()
-Kommando ab Zeile 15
eine Property namens Location
vom Typ geo_point
. Die Dokumentation
auf [7] ist in diesem Punkt übrigens veraltet, und beschreibt ein nicht
mehr funktionierendes Mapping. Listing 4 wurde hingegen erfolgreich mit
Elasticsearch 1.0.0.RC2 getestet.
Von den gefundenen JPG-Bildern extrahiert Zeile 32 mit dem
Modul IPhonePicGeo
aus Listing 3 die Geo-Daten und schiebt sie
samt dem Dateinamen im Body
-Teil der index()
-Methode ab Zeile 35
in die elastische Datenbank.
Abbildung 5: In einem Kilometer Umkreis hat ElasticSearch weitere Bilder der Bay Bridge gefunden. |
Nachdem die Daten aller Fotos archiviert sind, sucht das Skript
in Listing 5 Bilder, die in 1km Entfernung zu einem auf der Kommandozeile
übergebenen Referenzbild aufgenommen wurden. Hierzu ermittelt es
zunächst die Geodaten des Referenzbildes und setzt dann mit
dem Query match_all()
eine Anfrage ab, die alle gespeicherten
Bilder zurückliefert, setzt aber gleichzeitig in Zeile 23 noch einen
Filter, der die geo_distance
auf 1km limitiert. Außerdem passt der
Parameter size
die maximal zurückgegebene Anzahl von Treffern auf
100 an (voreingestellt sind 10).
Zurück kommt eine Liste von Fotos aus der näheren Umgebung (oder bei
größeren Werten für den Radius auch aus dem etwas weiteren Umkreis), deren
Dateinamen die for
-Schleife ab Zeile 34 ans Ende eines Arrays schiebt.
Abschließend ruft die system()
-Funktion in Zeile 40 die Applikation
eog
(Eye of Gnome
) auf, die alle Treffer als Thumbnails anzeigt,
durch die der User sich nach Herzenslust klicken kann.
01 #!/usr/local/bin/perl -w 02 use strict; 03 use Elasticsearch; 04 use IPhonePicGeo; 05 06 my $idx = "photos"; 07 08 my( $pic ) = @ARGV; 09 die "usage: $0 pic" if !defined $pic; 10 11 my( $lat, $lon ) = 12 IPhonePicGeo::photo_latlon( $pic ); 13 14 my $es = Elasticsearch->new( ); 15 16 my $results = $es->search( 17 index => $idx, 18 size => 100, 19 body => { 20 query => { 21 match_all => {}, 22 }, 23 filter => { 24 geo_distance => { 25 distance => "1km", 26 "Location" => [ $lat, $lon ], 27 } 28 } 29 } 30 ); 31 32 my @files = (); 33 34 for my $result ( 35 @{ $results->{ hits }->{ hits } } ) { 36 push @files, 37 $result->{ _source }->{ file }; 38 } 39 40 system "eog", @files;
Die Geo-Funktion ist nur eine von vielen Plugin-ähnlichen Erweiterungen des Elasticsearch-Servers, einem praktischen Werkzeug, das einfach zu installieren und zu nutzen ist. Außerdem skaliert er praktisch unendlich, denn die verwalteten Indexe können auf beliebig viele Apache-Lucene-basierte Shards verteilt werden, sodass sich damit Analysen von enorm großen Datenmengen performant durchführen lassen.
Zu Elasticsearch existieren sowohl Papier- als auch elektronische Bücher, von denen ich allerdings keines so richtig empfehlen kann. Aber das Tutorial auf [4] bringt Interessierte dem Thema sicher schnell näher und auf Stackoverflow.com beantworten Freiwillige etwaige offenbleibende Fragen.
Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2014/04/Perl
Elastic Search Download-Seite: http://www.elasticsearch.org/overview/elkdownloads/
Die neue Bay Bridge über die San Francisco Bay ist endlich fertig: http://usarundbrief.com/103/p1.html
Elasticsearch Tutorial: http://joelabrahamsson.com/elasticsearch-101/
Michael Schilli, "Kartentrick", http://www.linux-magazin.de/Ausgaben/2008/08/Kartentrick
Michael Schilli, "Der Mörder ist nimmer der Gärtner", http://www.linux-magazin.de/Ausgaben/2007/03/Der-Moerder-ist-nimmer-der-Gaertner
"Elasticsearch Geo Distance Filter", http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-geo-distance-filter.html