Mister Elastic (Linux-Magazin, April 2014)

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.

Polyglott oder Perl

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.

Unscharfe Suche

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.

Listing 1: fs-index

    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.

Lokaler Google

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.

Listing 2: fs-search

    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.

Suche in GPS-Bilderdaten

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.

Stunde der Wahrheit

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.

Listing 3: IPhonePicGeo.pm

    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.

Listing 4: photo-index

    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;

Dokumentation veraltet

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.

Listing 5: photo-gps-match

    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.

Infos

[1]

Listings zu diesem Artikel: ftp://www.linux-magazin.de/pub/listings/magazin/2014/04/Perl

[2]

Elastic Search Download-Seite: http://www.elasticsearch.org/overview/elkdownloads/

[3]

Die neue Bay Bridge über die San Francisco Bay ist endlich fertig: http://usarundbrief.com/103/p1.html

[4]

Elasticsearch Tutorial: http://joelabrahamsson.com/elasticsearch-101/

[5]

Michael Schilli, "Kartentrick", http://www.linux-magazin.de/Ausgaben/2008/08/Kartentrick

[6]

Michael Schilli, "Der Mörder ist nimmer der Gärtner", http://www.linux-magazin.de/Ausgaben/2007/03/Der-Moerder-ist-nimmer-der-Gaertner

[7]

"Elasticsearch Geo Distance Filter", http://www.elasticsearch.org/guide/en/elasticsearch/reference/current/query-dsl-geo-distance-filter.html

Michael Schilli

arbeitet als Software-Engineer bei Yahoo in Sunnyvale, Kalifornien. In seiner seit 1997 laufenden Kolumne forscht er jeden Monat nach praktischen Anwendungen der Skriptsprache Perl. Unter mschilli@perlmeister.com beantwortet er gerne Ihre Fragen.