Ein weiteres, seit langem auf meiner “to look at”-Liste firmierendes Projekt ist Apache Lucene. Die schiere gigantische Grösse des Projekts hinderte mich bisher, mich daran zu wagen. Apache-üblich vebrirgt sich der Charme der Einfachheit hinter einem Wust von gefühlt Millionen von Bibliotheken und Dokumentationsseiten.

Aber, anders als etwa bei Maven kann man die Lucene-Dokumentation auch verstehen, wenn man nicht nacheinander Informatik, Anglistik und Philosophie studiert hat, wenn man erst mal den Einstieg gefunden hat. Dieser Text hier soll ein solcher Einstieg sein.

Was es ist

Lucene ist ein Java-Framework zum Indizieren und Wiederauffinden von Dokumenten aller Art. Bei der Suche akzeptiert es diese komplexen Suchausdrücke, die wir alle von grossen Websites her so schätzen (was daran liegen mag, dass viele dieser Websites mit Lucene als Suchmaschine arbeiten).

So kann man bespielsweise nach Meier suchen, oder auch nach M??er, wenn man nicht mehr so genaus weiss, ob er Mayer, Maier, Meier oder Meyer heisst. Oder man kann auch nach Meier~ suchen, um alle Wörter zu finden, die ‘ähnlich’ sind, wie “Meier”. Oder wenn man aus der Programmierer-Ecke kommt, mag man vielleicht einen regulären Ausdruck wie /M[ae][iy]er/. All das geht, und natürlich kann man auch Suchterme kombinieren: +Hans -Fritz +Meier, oder "Hans Meier"~5 (Hans und Meier müssen vorhanden und maximal 5 Wörter auseinander sein), oder auch Hans AND (M??er OR /M(ue|ü)ller/).

All das kann man auch für eigene Programme haben, wenn man nur Folgendes in seine pom.xml einsetzt:

    <dependency>
      <groupId>org.apache.lucene</groupId>
      <artifactId>lucene-core</artifactId>
      <version>${lucene-version}</version>
    </dependency>
    <dependency>
      <groupId>org.apache.lucene</groupId>
      <artifactId>lucene-analyzers-common</artifactId>
      <version>${lucene-version}</version>
    </dependency>
    <dependency>
        <groupId>org.apache.lucene</groupId>
        <artifactId>lucene-queryparser</artifactId>
        <version>${lucene-version}</version>
    </dependency>

(Wobei ${lucene-version} derzeit auf 6.0.0 ist)

Ein paar Grundprinzipien

Lucene speichert und sucht Objekte vom Typ org.apache.lucene.Document. Ein solches Document ist nicht dasselbe, was wir unter einem Dokument verstehen. Im Grunde ist es Lucene völlig egal, was für Dokumente wir speichern und wo wir sie speichern. Lucene kümmert sich nur um zweierlei: Die Metadaten und den Text-Inhalt. Ein solches Objekt, das Metadaten und eventuell Text enthält, ist ein Lucene-Document. Dieses kann, muss aber nicht mit irgendeiner Dokument-Datei zusammenhängen. Der Anwender bzw. Anwendungsprogrammieren muss sich selber darum kümmern, eine Verbindung zwischen Document und Dokument herzustellen (zum Beispiel in Form eines Links zum Dokument im Document).

Ein Document erstellt man zunächst leer, füttert es dann mit Fields und übergibt es anschliessend einem IndexWriter zum Abspeichern. Beim Befüllen mit Fields helfen einem die Lucene Analyzers, die aus einem Text die relevanten Wörter extrahieren und daraus Grundformen und Varianten bilden, sowie irrelevante Füllwörter ausblenden. Damit das gelingt, verwendet man idealerweise einen Analyzer der passenden Sprache, zum Beispiel den org.apache.lucene.analysis.de.GermanAnalyzer.

Allerdings droht das Scheitern schon einen Schritt vorher: Wie kommt man zum Beispiel bei einem PDF oder eine WORD-Datei überhaupt an den Text, den man dem Analyzer vorlegen kann? Dazu benötigt man einen Parser. Und um die Sache kurz zu machen: Eigentlich genügt ein Parser für alles, den man so ins Projekt holt:

    <dependency>
      <groupId>org.apache.tika</groupId>
      <artifactId>tika-parsers</artifactId>
      <version>${tika-version}</version>
    </dependency>

Tika ist ein weiteres extrem grosses und mächtiges Apache-Projekt, von dem wir jetzt nur eines wissen müssen: Es kann mehr als tausend verschiedene Dateiformate verstehen und analysieren.

Ein Dokument hinzufügen

Um ein Dokument zum Index hinzuzufügen, sind folgende Schritte nötig:

  • Lucene Index erstellen oder öffnen.
  • Dokument parsen.
  • Dokument analysieren, Text und Metadaten extrahieren.
  • Aus den Metadaten und dem Textinhalt ein Lucene-Document erstellen.
  • Das Lucene-Document im Index speichern.

Im Folgenden zeige ich eine Klasse, die genau das tut. (Wir verwenden hier Kotlin als Programmiersprache, aber natürlich geht es auch in Java oder Scala.)

    class FileImporter(directory: String, defaultLanguage: String="de"){

      val dir:Path=FileSystems.getDefault().getPath(directory)
      val log= Logger.getLogger("FileImporter")
      val writer : IndexWriter by lazy{
          val analyzer = when(defaultLanguage){
              "de" -> GermanAnalyzer()
              "fr" -> FrenchAnalyzer()
              "it" -> ItalianAnalyzer()
              "en" -> EnglishAnalyzer()
              else ->  StandardAnalyzer()
          }

          log.info("opening index in create or append mode")
          val conf = IndexWriterConfig(analyzer).setOpenMode(IndexWriterConfig.OpenMode.CREATE_OR_APPEND)
          val index = FSDirectory.open(dir)
          IndexWriter(index, conf)
      }

     /*
       For performance reasons, keep the IndexWriter open until the program shuts down
      */
      fun shutDown(){
          if(writer.isOpen){
              writer.commit()
              writer.deleteUnusedFiles()
              writer.close()
          }
      }
      /**
       * Index an InputStream. The Stream-Data are only analyzed, not stored.
       * @param istream InputStream with the data
       * @param handle a user defined String to identify and retrieve the original file
       * @return The plain text content as found by the parser
       * @throws IOException
       * @throws SAXException
       * @throws TikaException
       */
      fun addDocument(istream: InputStream, handle: String) : String{

          val metadata = Metadata()
          val handler = BodyContentHandler()
          val context = ParseContext()
          val parser = AutoDetectParser()

          parser.parse(istream, handler, metadata, context)

          val text = handler.toString()
          val doc = Document()

          for (k in metadata.names()) {
              val key = k.toLowerCase()
              val value = metadata.get(k)
              if (Strings.isBlank(value)) {
                  continue
              }
              if (key == "keywords") {
                  for (keyword in value.split(",?(\\s+)".toRegex()).dropLastWhile { it.isEmpty() }.toTypedArray()) {
                      doc.add(TextField(key, keyword, Field.Store.YES))
                  }
              } else {
                  doc.add(TextField(key, value, Field.Store.YES))
              }
          }

          doc.add(TextField("text", text, Field.Store.NO))
          doc.add(StringField("handle",handle, Field.Store.YES))
          writer.addDocument(doc)
          writer.commit()
          return text
      }

Das ist alles!

Dokumente suchen

Zum Suchen wollen wir Suchbegriffe wie oben beschrieben verwenden. Wir brauchen also von all den Zillionen Suchmöglichkeiten von Lucene nur eine: Den org.apache.lucene.queryparser.classic.QueryParser. Dieser Parser zerlegt die Suchanfrage in eine Lucene-genehme Form. Sodann brauchen wir einen IndexReader, der einen Index auslesen kann, und einen IndexSearcher, der einen solchen Index durchsuchen kann. Den IndexSearcher füttern wir mit unserer Query. Da eine Query je Such-Bedingungen sehr viele Resultate liefern kann (Googeln Sie mal nach “Google”), können wir ausserdem mitteilen, wieviele Treffer wir maximal sehen wollen. Und da wir die Treffer nicht nach Fund-Reihenfolge, sondern nach Bewertung gelistet haben wollen, schicken wir sie durch einen TopScoreCollector.

Also zusammengefasst:

  • Query aufbauen
  • IndexReader erstellen
  • IndexSearcher erstellen
  • TopScoreCollector mit der Maximalzahl erwünschter Treffer erstellen
  • Suche starten.

Was wir dann zurückbekommen, und hier wird es etwas verwirrlich, sind nicht Documents, sondern ein TopDocs Objekt. Dieses enthält unter Anderem ein Feld namens scoreDocs, welches ein Array aus ScoreDocs ist. Jedes dieser ScoreDocs enthält wiederum ein Feld namens doc, welches aber wieder nicht wie erhofft ein Document enthält, sondern einen Integer. Dieser Integer wiederum ist ein Index, mit dem wir die Methode doc des IndexSearchers füttern können. Und diese Methode, ja, die liefert uns das Document.

Um den ersten Treffer zu erhalten, brauchen wir also:

indexSearcher.doc(topDocs.scoreDocs[0].doc)

Naja, warum denn einfach, wenn’s auch kompliziert geht. Aber die verschiedenen Zwischentypen TopDocs und ScoreDocs enthalten natürlich auch jede Menge interssanter weiterer Felder ausser den hier eläuterten, und haben somit wohl schon ihre Berechtigung.

Der Code sieht dann so aus:

      /**
      * Query the index. Return at most 'numHits' results.
      * @param queryExpression the term(s) to find.
      * @param numHits   Number of hits to return at most
      * @return A JsonArray with Metadata of the document(s). Can be empty but is never null.
      * @throws ParseException
      * @throws IOException
      */
     fun queryDocuments(queryExpression: String, numHits: Int): JsonArray {
         val index = FSDirectory.open(dir)
         val parser = QueryParser("text", GermanAnalyzer())
         val query = parser.parse(queryExpression)
         val ir = DirectoryReader.open(index)
         val searcher = IndexSearcher(ir)
         val collector = TopScoreDocCollector.create(numHits)
         searcher.search(query, collector)
         val hits = collector.topDocs()
         val score = hits.scoreDocs
         val ret = JsonArray()
         for (sd in score) {
             val hit = searcher.doc(sd.doc)
             val jo=JsonObject()
             hit.getFields().forEach { field ->
                 jo.put(field.name(),field.stringValue())
              }
             ret.add(jo)
         }
         return ret
     }

Eines der Felder in den zurückgelieferten JsonObjects ist die beim Speichern übergebene ‘handle’, mit der die Logik ausserhalb von Lucene das Original-Dokument wieder finden sollte.

Im Prinzip ist das alles, was man braucht, um mit Lucene einzusteigen. Mit diesem Basis-Wissen sollte es einfacher sein, die umfangreiche Lucene-Dokumentation zu verstehen.