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 KlasseGermanAnalyzer
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