Andreas Bruns

Softwareentwicklung für Oldenburg und Bremen

Lucene: Volltextsuche leicht gemacht

Wenn man in einem Datenbestand bestimmte Daten sucht, wird man zumeist die Möglichkeiten der jeweils eingesetzten Datenbank nutzen. Sobald man jedoch eine Volltextsuche benötigt, wird es mit einer reinen Datenbanklösung kompliziert. Dann sollte man im Java-Bereich einen Blick auf Apache Lucene und Apache Solr werfen.

Während es sich bei Lucene um eine Java-Bibliothek für Volltextsuche handelt, nutzt die Webanwendung Solr die Funktionen von Lucene und bietet komfortable Anfrage- und Verwaltungsmöglichkeiten über HTTP-Schnittstellen. In diesem Artikel stelle ich ein kleines Projekt mit Lucene vor, allerdings sollte man vor dem Einsatz von Lucene abwägen, ob sich die Anforderungen nicht mit Solr noch einfacher realisieren lassen. Lucene bietet für die Volltextsuche eine Vielzahl von Funktionen an:

  • wie DB-Suche: Felder, boolsche Ausdrücke, Wildcards: title:“rot“ AND desc:“Schuh“
  • Fuzzy Search (Unscharfe Suche): mahlen -> mahlen, malen, Wahlen
  • Stemming von Wörtern (Verwendung des Wortstamms): rote Schuhe -> rot, Schuh
  • Verstärkungsfaktoren (z.B. Produkt): Name*4, Eigenschaften*2, Beschreibung*1
  • Ignorieren von ‚Stopwords‘, z.B: einer, eine, eines, der, die“, das, wie, ohne
  • Unterstützung von vielen Sprachen: Klasse StandardAnalyzer ersetzen durch Klasse GermanAnalyzer für deutsche Sprache
  • vieles mehr

Dokumentenfelder definieren

Als Testdaten habe ich die als CSV extrahierten AKW-Daten von ScraperWiki genommen (Blog-Artikel). Alle AKWs werden von Lucene als Dokument in einen Index abgelegt. Für jeden Java-Typ bietet Lucene eigene passende Typen an. Lediglich bei dem Typ String muss der Entwickler sich für die Klasse StringField (z.B. für IDs oder aufzählbare Fachwerte) oder die Klasse TextField (für Wörter des Textinhalts) entscheiden:

Feld Beispiel Java-Typ Lucene-Feld
Bezeichnung Neckarwestheim 2 String TextField
Kuerzel GKN 2 String StringField
Typ DWR String StringField
Betreiber EnBW String StringField
Standort Neckarwestheim String StringField
Bundesland Baden-Württemberg String StringField
Status In Betrieb String TextField
Außer Betrieb 2022-12-31 Date LongField
Latitude 49.0993439987 Double DoubleField
Longitude 9.175 Double DoubleField
Wikipedia URL http://de.wiki… String StringField

Index mit Dokumenten schreiben

Wenn wir uns für die passenden Felder entschieden haben, können wir mit den AKW-Daten den Index anlegen. Wir schreiben den Index in das Dateisystem (Klasse FSDirectory), damit wir uns die Daten anschließend mit dem Programm Luke anschauen können. Es ist jedoch auch möglich, die Daten nur flüchtig im Speicher zu halten (Klasse RAMDirectory).

Zur Analyse der Texte bietet Lucene die Klasse StandardAnalyzer, die von der Sprache Englisch ausgeht. Für die deutsche Sprache sollten wir jedoch die Klasse GermanAnalyzer einsetzen, sodass wir deutsches Stemming nutzen und deutsche ‚StopWords‘ ignoriert werden.

public class AkwIndexWriter implements Constants {

    public void writeIndex(List<AkwCsvData> akws) throws IOException {
         Analyzer analyzer = new GermanAnalyzer(Version.LUCENE_41);

         IndexWriterConfig config = new IndexWriterConfig(Version.LUCENE_41, analyzer);
         config.setOpenMode(IndexWriterConfig.OpenMode.CREATE);

         Directory indexDirectory = FSDirectory.open(new File("./AkwIndex"));
         IndexWriter w = new IndexWriter(indexDirectory, config);

         for(AkwCsvData akw : akws) {
             Document doc = new Document();
             doc.add(new TextField(FIELD_BEZEICHNUNG, akw.getBezeichnung(), Field.Store.YES));
             doc.add(new StringField(FIELD_KUERZEL, akw.getKuerzel(), Field.Store.YES));
             doc.add(new StringField(FIELD_TYP, akw.getTyp(), Field.Store.YES));
             doc.add(new StringField(FIELD_BETREIBER, akw.getBetreiber(), Field.Store.YES));
             doc.add(new StringField(FIELD_STANDORT, akw.getStandort(), Field.Store.YES));
             doc.add(new StringField(FIELD_BUNDESLAND, akw.getBundesland(), Field.Store.YES));
             doc.add(new TextField(FIELD_STATUS, akw.getStatus(), Field.Store.YES));
             doc.add(new LongField(FIELD_AUSSER_BETRIEB, akw.getAusserBetrieb().getTime(), Field.Store.YES));
             doc.add(new DoubleField(FIELD_LATITUDE, akw.getLatitude(), Field.Store.YES));
             doc.add(new DoubleField(FIELD_LONGITUDE, akw.getLongitude(), Field.Store.YES));
             doc.add(new StringField(FIELD_WIKIPEDIA_URL, akw.getWikipediaUrl(), Field.Store.YES));
             w.addDocument(doc);
         }
         w.close();
     }
}

Index lesen und Abfragen durchführen

Nach dem Einlesen des Indexes können wir mit einer Query und einer optionalen Sortierreihenfolge nach Dokumenten suchen. Das Ergebnis der Suche ist ein Objekt der Klasse TopDocs, das die Dokumentennummern mit Bewertungen der gefundenen Dokumente enthält. Die Klasse IndexSearcher liefert uns anschließend zu einer Dokumentennummer das entsprechende Dokument.

public class AkwIndexReader implements Constants {

    private static final int MAX_RESULTS = 100;

    public List<AkwResultDocument> searchByField(String field, String value) throws IOException, ParseException {
        Analyzer analyzer = new GermanAnalyzer(Version.LUCENE_41);
        Query query = new QueryParser(Version.LUCENE_41, field, analyzer).parse(value);
        return doSearch(query, null);
    }

    public List<AkwResultDocument> searchByField(String field, String value, SortField sortField) throws IOException, ParseException {
        Analyzer analyzer = new GermanAnalyzer(Version.LUCENE_41);
        Query query = new QueryParser(Version.LUCENE_41, field, analyzer).parse(value);
        return doSearch(query, sortField);
    }
    
    public List<AkwResultDocument> searchByDoubleRange(String field, Double min, Double max) throws IOException, ParseException {
        Query query = NumericRangeQuery.newDoubleRange(field, min, max, true, true);
        return doSearch(query, null);
    }

    public List<AkwResultDocument> searchByDoubleRange(String field, Double min, Double max, SortField sortField) throws IOException, ParseException {
        Query query = NumericRangeQuery.newDoubleRange(field, min, max, true, true);
        return doSearch(query, sortField);
    }

    public List<AkwResultDocument> searchByLongRange(String field, Long min, Long max, SortField sortField) throws IOException {
        Query query = NumericRangeQuery.newLongRange(field, min, max, true, true);
        return doSearch(query, sortField);
    }
    
    private List<AkwResultDocument> doSearch(Query query, SortField sortField) throws IOException {
        List<AkwResultDocument> akws = new ArrayList<AkwResultDocument>();
        Directory indexDirectory = FSDirectory.open(new File("./AkwIndex"));
        IndexReader indexReader = null;
        try {
            indexReader = DirectoryReader.open(indexDirectory);
            IndexSearcher searcher = new IndexSearcher(indexReader);

            TopDocs topDocs = null;
            if (sortField != null) {
                topDocs = searcher.search(query, MAX_RESULTS, new Sort(sortField));
            } else {
                topDocs = searcher.search(query, MAX_RESULTS);
            }

            ScoreDoc[] hits = topDocs.scoreDocs;
            for (ScoreDoc scoreDoc : hits) {
                Document d = searcher.doc(scoreDoc.doc);
                akws.add(new AkwResultDocument(
                        d.getField(Constants.FIELD_BEZEICHNUNG).stringValue(),
                        d.getField(Constants.FIELD_KUERZEL).stringValue(),
                        d.getField(Constants.FIELD_TYP).stringValue(),
                        d.getField(Constants.FIELD_BETREIBER).stringValue(),
                        d.getField(Constants.FIELD_STANDORT).stringValue(),
                        d.getField(Constants.FIELD_BUNDESLAND).stringValue(),
                        d.getField(Constants.FIELD_STATUS).stringValue(),
                        d.getField(Constants.FIELD_AUSSER_BETRIEB).numericValue().longValue(),
                        d.getField(Constants.FIELD_LATITUDE).numericValue().doubleValue(),
                        d.getField(Constants.FIELD_LONGITUDE).numericValue().doubleValue(),
                        d.getField(Constants.FIELD_WIKIPEDIA_URL).stringValue()
                        ));
            }
        } catch (IOException e) {
            e.printStackTrace();
            throw(e);
        } finally {
            indexReader.close();
        }
        return akws;
    }
}

Jetzt suchen wir AKW-Dokumente

In der folgenden Start-Klasse suchen wir jetzt endlich AKW-Dokumente:

  • AKWs in deren Bezeichnung ‚Karlsruhe‘ vorkommt
  • AKWs in deren Status ‚Rückbau‘ vorkommt und in deren Status ‚Im‘ vorkommt
  • AKWs nördlich von Bremen sortiert nach Breitengrad
  • AKWs sortiert nach Stilllegungsdatum, die zwischen 2015 und 2020 stillgelegt werden

Bei den Beispielen fällt auf, dass 14 AKWs sich im Status „Im Rückbau“ befinden. Aber warum finden wir die AKWs bei der Suche nach ‚Rückbau‘, aber nicht bei der Suche nach ‚Im‘ ? Richtig – bei Einsatz der Klasse GermanAnalyzer werden deutsche ‚StopWords‘ ignoriert.

public class Start {

    public static void main(String[] args) throws Exception {
        List<AkwCsvData> akws = new AkwCsvImporter().importCsvData();

        System.out.println("****** Folgende " + akws.size() + " AKWs importiert: ******");
        for(AkwCsvData akw : akws) {
            System.out.println(akw.getBezeichnung() + ": " + akw.getStatus() + " (" + akw.getStandort() + ", " + akw.getBundesland() + ")");
        }

        System.out.println("****** Schreibe Index ******");
        AkwIndexWriter akwIndexWriter = new AkwIndexWriter();
        System.out.println("Index at: " + Constants.INDEX_DIRECTORY);
        akwIndexWriter.writeIndex(akws);

        AkwIndexReader akwIndexReader = new AkwIndexReader();

        SortField sortBezeichnung = new SortField(Constants.FIELD_BEZEICHNUNG, Type.STRING);
        List<AkwResultDocument> result = akwIndexReader.searchByField(Constants.FIELD_BEZEICHNUNG, "Karlsruhe", sortBezeichnung);
        System.out.println("****** Lese Index mit Abfrage: 'Bezeichnung:Karlsruhe' => " + result.size() + " ******");
        for (AkwResultDocument akw : result) {
            System.out.println(akw.getBezeichnung() + " -> " + akw.getStatus());
        }

        result = akwIndexReader.searchByField(Constants.FIELD_STATUS, "Rückbau");
        System.out.println("****** Lese Index mit Abfrage: 'Status:Rückbau' => " + result.size() + " ******");
        for (AkwResultDocument akw : result) {
            System.out.println(akw.getBezeichnung() + " -> " + akw.getStatus());
        }

        result = akwIndexReader.searchByField(Constants.FIELD_STATUS, "Im");
        System.out.println("****** Lese Index mit Abfrage: 'Status:Im' => " + result.size() + " ******");
        for (AkwResultDocument akw : result) {
            System.out.println(akw.getBezeichnung() + " -> " + akw.getStatus());
        }
        
        SortField sortLatitude = new SortField(Constants.FIELD_LATITUDE, Type.DOUBLE);
        result = akwIndexReader.searchByDoubleRange(Constants.FIELD_LATITUDE, new Double(53), null, sortLatitude);
        System.out.println("****** AKWs nödlich von Bremen sortiert nach Breitengrad => " + result.size() + " ******");
        for (AkwResultDocument akw : result) {
            System.out.println(akw.getBezeichnung() + " (" + akw.getBundesland() + ") -> " + akw.getLatitude());
        }

        SortField sortAusserBetrieb = new SortField(Constants.FIELD_AUSSER_BETRIEB, Type.LONG);;
        DateFormat dateInstance = DateFormat.getDateInstance(DateFormat.SHORT, Locale.GERMAN);
        Long startDate = Long.valueOf(dateInstance.parse("01.01.2015").getTime());
        Long endDate = Long.valueOf(dateInstance.parse("31.12.2020").getTime());
        result = akwIndexReader.searchByLongRange(Constants.FIELD_AUSSER_BETRIEB, startDate, endDate, sortAusserBetrieb);
        System.out.println("****** AKWs die zwischen 2015 und 2020 stillgelegt werden sortiert nach Datum => " + result.size() + " ******");
        for (AkwResultDocument akw : result) {
            System.out.println(akw.getBezeichnung() + " (" + akw.getBundesland() + ") -> " + dateInstance.format(akw.getAusserBetriebDate()));
        }
    }
}

Das gesamte Beispiel ist bei GitHub verfügbar: https://github.com/me4bruno/blog-examples -> example-lucene). Das Projekt setzt Lucene in der Version 4.1 ein, kann einfach mit Maven gebaut werden und mit der Main-Methode der Klasse ‚Start‘ ausgeführt werden. Zur Analyse des Index können wir Luke nutzen, das sich als Jar im Verzeichnis ‚tools‘ befindet.

git clone git://github.com/me4bruno/blog-examples.git
cd blog-examples/example-lucene/
mvn clean install
mvn exec:java -Dexec.mainClass="de.bruns.example.lucene.Start"
java -jar tools/lukeall-4.1.0.jar 

Damit erhalten wir eine entsprechende Ausgabe – viel Spaß beim Einsatz von Lucene 🙂

****** Folgende 37 AKWs importiert: ******
Isar/Ohu 2: In Betrieb (Isar/Ohu, Bayern)
...
****** Schreibe Index ******
Index at: ./AkwIndex
****** Lese Index mit Abfrage: 'Bezeichnung:Karlsruhe' => 3 ******
KNK Karlsruhe I -> Im Rückbau (bis 2013)
KNK Karlsruhe II -> Im Rückbau (bis 2013)
MZFR Karlsruhe -> Im Rückbau (bis 2015)
****** Lese Index mit Abfrage: 'Status:Rückbau' => 14 ******
Mülheim-Kärlich -> Im Rückbau (bis 2014)
Stade -> Im Rückbau (bis 2015)
Würgassen -> Im Rückbau (bis 2014)
Greifswald 1 -> Im Rückbau (bis 2012)
Greifswald 2 -> Im Rückbau (bis 2012)
Greifswald 3 -> Im Rückbau (bis 2012)
Greifswald 4 -> Im Rückbau (bis 2012)
Greifswald 5 -> Im Rückbau (bis 2012)
Obrigheim -> Im Rückbau (bis 2020)
Rheinsberg -> Im Rückbau (bis 2020)
MZFR Karlsruhe -> Im Rückbau (bis 2015)
KNK Karlsruhe I -> Im Rückbau (bis 2013)
KNK Karlsruhe II -> Im Rückbau (bis 2013)
Jülich -> Im Rückbau (bis ca. 2075)
****** Lese Index mit Abfrage: 'Status:Im' => 0 ******
****** AKWs nödlich von Bremen sortiert nach Breitengrad => 11 ******
Rheinsberg (Brandenburg) -> 53.1469916667
Krümmel (Schleswig-Holstein) -> 53.41
Unterweser (Niedersachsen) -> 53.4277
Stade (Niedersachsen) -> 53.62
Brokdorf (Schleswig-Holstein) -> 53.8508333333
Brunsbüttel (Schleswig-Holstein) -> 53.8916666667
Greifswald 1 (Mecklenburg-Vorpommern) -> 54.1405861111
Greifswald 2 (Mecklenburg-Vorpommern) -> 54.1405861111
Greifswald 3 (Mecklenburg-Vorpommern) -> 54.1405861111
Greifswald 4 (Mecklenburg-Vorpommern) -> 54.1405861111
Greifswald 5 (Mecklenburg-Vorpommern) -> 54.1405861111
****** AKWs die zwischen 2015 und 2020 stillgelegt werden sortiert nach Datum => 3 ******
Grafenrheinfeld (Bayern) -> 31.01.15
Gundremmingen B (Bayern) -> 31.01.17
Philippsburg 2 (Baden-Württemberg) -> 31.01.19

Kommentare sind geschlossen.