Hibernate Search: Volltextsuche in Spring-Boot-Projekten mit Apache Lucene

Mit Hibernate Search lässt sich das Datenmodell von Spring-Boot-Anwendungen mit Annotationen und ohne Server einfach um eine Volltext-Suchfunktion erweitern.

In Pocket speichern vorlesen Druckansicht 1 Kommentar lesen

(Bild: Black Jack/Shutterstock.com)

Lesezeit: 14 Min.
Von
  • Bernhard Jungwirth
Inhaltsverzeichnis

Die einfache Auffindbarkeit von Daten wird immer wichtiger. Anwender nutzen eine Suche in Onlineshops oder im Betriebssystem zum Auffinden von Dateien, Apps oder Einstellungen. All diese Suchfunktionen arbeiten mit einem Suchindex, dessen Verwaltung und Aktualisierung im Hintergrund geschieht. Dadurch ist es möglich, priorisierte Suchvorschläge oder eine Klassifikation der Treffer anzuzeigen, um die Suche weiter zu verfeinern.

Von diesen Vorteilen können auch eigene Softwareprojekte profitieren und Benutzern die gewohnte Funktionalität anbieten. Je nachdem, welche Datenmenge zu verarbeiten ist, ist es möglich, die Programmbibliothek zur Volltextsuche direkt zu integrieren oder den Index an einen Elasticsearch-Server auszulagern.

Young Professionals schreiben für Young Professionals

Dieser Beitrag ist Teil einer Artikelserie, zu der die Heise-Redaktion junge Entwickler:innen einlädt – um über aktuelle Trends, Entwicklungen und persönliche Erfahrungen zu informieren. Bist du selbst ein "Young Professional" und willst einen (ersten) Artikel schreiben? Schicke deinen Vorschlag gern an die Redaktion: developer@heise.de. Wir stehen dir beim Schreiben zur Seite.

Relationale Datenbanken sind noch immer eine zentrale Komponente in modernen IT-Systemen. Objektorientierte Programmiersprachen wie Java verwenden Object-Relational Mapper (ORM), um das Datenmodell der Klassen des Programmcodes in Tabellen der Datenbank zu erstellen. Anwendungen im Java-Framework Spring Boot verwenden als Standardimplementierung von ORM Hibernate. Hibernate ist ein Open Source ORM-Framework, das in Java implementiert ist und von Red Hat gesponsert wird. Spring Boot vereinfacht die Entwicklung von Webanwendungen mit Java dadurch, dass eine Konfiguration per Konvention bereits vorgegeben ist. Von Hibernate gibt es neben dem bekannten ORM-Projekt noch weitere Teilprojekte, darunter Hibernate Search.

Dieses Projekt erstellt automatisch einen Suchindex zu den Daten, die Hibernate ORM verwaltet. Hibernate Search speichert den Suchindex direkt mit Apache Lucene oder mit dem Suchserver Elasticsearch. Ersteres ist eine Programmbibliothek zur Volltextsuche und ebenfalls Open Source. Elasticsearch ist ein eigener Suchserver und verwendet als Basis wiederum Apache Lucene. Die Anforderungen der Hochverfügbarkeit und Lastverteilung erfordern den Einsatz von Elasticsearch als eigenem Suchserver. Sind diese Anforderungen nicht erfüllt, genügt die Programmbibliothek Apache Lucene. Das Beispielprojekt stellt die Verwendung dieser Bibliothek mit Hibernate Search vor. Hier ist es von Vorteil, dass keine eigene Infrastruktur zu betreiben ist.

Die Version 6.0 von Hibernate Search ist im Dezember 2020 erschienen und umfasst viele Neuerungen. Bis zur Version 5 war Apache Lucene fester Bestandteil von Hibernate Search, aber mit Version 6.0 ist die API überarbeitet und die Such-Backends sind abstrahiert. Somit können Entwickler Apache Lucene und Elasticsearch gleichwertig verwenden und Aktualisierungen schneller einbinden. Zurzeit ist die aktuelle Version 8.7 von Apache Lucene in den Abhängigkeiten referenziert, und der Suchserver Elasicsearch lässt sich in den Versionen 5.6, 6.8 oder 7.10 anbinden.

Durch die Abstrahierung der Such-Backends ist auch die Search-DSL (Domain Specific Language) neu entwickelt, da sie ebenfalls auf Apache-Lucene-Abfragen aufbaute. Diese Abfragesprache ist nun typsicher, aussagekräftiger und sie umfasst auch Lambda-Ausdrücke. Weitere Details finden sich im Blog von Hibernate in der Release-Meldung zu Hibernate 6.0. Für die Umstellung eines Projekts von Hibernate Search 5 auf Version 6 gibt es einen eigenen Migrationsleitfaden, da durch die vielen Änderungen beim Versionswechsel einiges anzupassen ist.

Die Beispielanwendung ist eine Spring-Boot-Anwendung, die Gesetze und einzelne Paragrafen speichert und als REST-API anbietet. Das Datenmodell dazu besteht aus der Entität LegalDocument, die die allgemeinen Daten des Gesetzes umfasst, und darin enthalten ist eine Liste von Articles mit dem eigentlichen Gesetzestext des Paragrafen. Der Datenzugriff erfolgt über ein Spring-Data-Repository, das für jede Entität erstellt wird. Zusätzlich ist Spring-Data-REST in das Projekt eingebunden und exponiert das Datenmodell automatisch als REST-Endpunkt. Über diese Schnittstelle sind die Daten bereits abrufbar. Der vollständige Quelltext zum Projekt ist auf GitHub verfügbar.

Damit ist das Datenmodell fertig und die Einträge lassen sich lesen, erstellen und bearbeiten. Als Nächstes kommt der Suchindex für die Volltextsuche in den Paragrafen hinzu. Die Abhängigkeiten lassen sich über Maven einbinden, wie das folgende Listing zeigt:

<dependency>
  <groupId>org.hibernate.search</groupId>
  <artifactId>hibernate-search-engine</artifactId>
  <version>${hibernate.search.version}</version>
</dependency>
<dependency>
  <groupId>org.hibernate.search</groupId>
  <artifactId>hibernate-search-mapper-orm</artifactId>
  <version>${hibernate.search.version}</version>
</dependency>
<dependency>
  <groupId>org.hibernate.search</groupId>
  <artifactId>hibernate-search-backend-lucene</artifactId>
  <version>${hibernate.search.version}</version>
</dependency>

Auf jeden Fall nötig sind die Such-Engine und die Mapper-Bibliothek, die das Datenmodell mit dem Suchindex verbindet. Zur direkten Speicherung des Suchindex mit Apache Lucene ist dieses als Backend noch dabei.Die Konfiguration für Hibernate Search befindet sich in der Datei hibernate.properties im Ressourcen-Ordner src/main/resources/. Die Einstellung für die Eigenschaft directory.type ist auf ein lokales Dateisystem gesetzt und zusätzlich ist noch der Pfad angegeben, wo das Programm den Suchindex ablegt. Folgende Codzeilen zeigen, wie man die Konfiguration des Suchindex einfügt:

hibernate.search.backend.directory.type = local-filesystem
hibernate.search.backend.directory.root = C://Tools//Search//Playground
hibernate.search.backend.analysis.configurer = LegalDocumentAnalysisConfigurer

Die Java-Klassen für das Datenmodell sind mit der Annotation @Entity und die Attribute mit @Column erweitert, damit Hibernate ORM sie erkennt. Zur Aufnahme der Daten in den Suchindex kommt die Annotation @Indexed zur Klasse hinzu. Damit das Programm die einzelnen Felder in den Index übernimmt, erhalten sie eine der @*Field-Annotationen. Das Feld title ist als @FullTextField annotiert. Das bedeutet, dass für dieses Feld eine Textanalyse ausgeführt wird. Der Text wird in einzelne Tokens gesplittet und anschließend normalisiert.

Die Details der Textanalyse folgen weiter unten in diesem Artikel. Für die Attribute Kurztitel (titleShort) und Abkürzung (abbreviation) wird die @KeywordField-Annotation verwendet. Dabei wird der Inhalt des Feldes nicht analysiert, sondern nur normalisiert. Zusätzlich ist beim Attribut abbreviation die Eigenschaft aggregable aktiviert, damit lässt sich eine Aggregierung auf dieses Attribut ausführen. Wie sich LegalDocument.java einfügen lässt, demonstriert folgendes Listing.

@Entity
@Indexed
public class LegalDocument extends AbstractEntity {

    @FullTextField
    @Column(length = 1024)
    private String title;
    
    @KeywordField(name = "titleShort")
    private String titleShort;

    @KeywordField(aggregable = Aggregable.YES)
    private String abbreviation;

    @OneToMany(mappedBy = "document", cascade = CascadeType.ALL)
    @JsonBackReference
    private List<Article> articles;

// getter and setter
}

Die Article-Klasse ist ebenfalls mit der @Indexed-Annotation versehen. Der Titel des Artikels (title) und der eigentliche Gesetzestext (text) sind wiederum als @FullTextField annotiert und werden somit für den Suchindex ausgewertet. Jeder Paragraf verfügt über ein Datum des Inkrafttretens und Außerkrafttretens. Diese Datumsfelder sind als @GenericField annotiert. Das @GenericField unterstützt verschiedene Typen in Java, und bei der Suche wird ein exakter Vergleich gezogen.

Die Suchabfragen laufen im Datenmodell auf der Ebene der Artikel. Für den Zugriff auf den Suchindex des übergeordneten Gesetzes wird das LegalDocument mit @IndexedEmbedded annotiert. Zur Suche nach allen Artikeln eines Gesetzes anhand der Abkürzung können Entwickler den Punktoperator document.abbreviation verwenden. Das nächste Listing zeigt, wie man Article.java korrekt einfügt:

@Entity
@Indexed
public class Article extends AbstractEntity {

    @GenericField(name = "number", sortable = Sortable.YES)
    private String number;

    private String eli;

    @FullTextField(name = "title", analyzer = "german")
    @GenericField(name = "title_sort", sortable = Sortable.YES)
    private String title;
    
    @FullTextField(name = "text", analyzer = "german")
    @Column(length = 4096)
    private String text; 

    @GenericField
    @Column(columnDefinition = "DATE")
    private LocalDate effectiveDate;

    @GenericField
    @Column(columnDefinition = "DATE", nullable = true)
    private LocalDate expireDate;

    @IndexedEmbedded
    @ManyToOne
    @JoinColumn(name = "document_id", nullable = false)
    @JsonManagedReference
    private LegalDocument document;
    // getter and setter
}

Jede Änderung einer Entität über die Hibernate Session in der Anwendung aktualisiert den Suchindex automatisch. Beim Starten des Beispielprojekts überträgt ein SQL-Skript die Daten an die Datenbank. Dazu genügt es, in Spring Boot ein Skript mit dem Namen data.sql in den Ressourcen-Ordner zu legen. Damit der Index sowohl die neu ergänzten Daten als auch Einträge, die bereits vorhanden waren, übernimmt, gilt es, einen MassIndexer zu erstellen und zu initialisieren.

Als Parameter benötigt der MassIndexer die Klassen, die in den Index aufzunehmen sind. Im Beispielprojekt wird nur die Klasse Article übergeben, da das LegalDocument nicht explizit in den Suchindex kommt. Zusätzlich ist noch zu konfigurieren, wie viele Threads für den Indizierungsvorgang zu verwenden sind. Wie sich der Indexer ausführen lässt, zeigt folgende Codepassage:

@PostConstruct
public void initialize() {
  SearchSession searchSession = Search.session(entityManager.getEntityManagerFactory().createEntityManager());
  MassIndexer indexer = searchSession.massIndexer(Article.class).threadsToLoadObjects(2);
  try {
    indexer.startAndWait();
    } catch (InterruptedException e) {
      e.printStackTrace();
    }
 }

Die Implementierung einer einfachen Suche nach Titel und Abkürzung eines Gesetzes ist in folgendem Listing abgebildet:

SearchSession searchSession = Search.session(entityManager);
SearchResult<Article> result = searchSession.search(Article.class)
    .where(f -> f.match()
       .fields("document.title", "document.abbreviation")
       .matching(request.keyword))
    .fetch(20);
return new SearchResponse<>(result.hits(),
new PageMetadata(20, 0, result.total().hitCount()), null);

Per EntityManager lässt sich eine Hibernate Search Session erstellen und mit ihr die Suche ausführen. Für den Zugriff auf die eingebetteten Felder vom LegalDocument kommt als Prefix der Name des Attributs (document) zum Einsatz. Der Lambda-Ausdruck übergibt der where-Methode das Suchprädikat, und der Suchbegriff ist im matching-Aufruf angegeben. Der Aufruf der fetch-Methode führt die Suche aus und liefert die Daten als SearchResult zurück.

Das SearchResult umfasst in result.hits() die Suchergebnisse, und der Befehl result.total().hitCount() fragt die Gesamtanzahl der Suchtreffer ab. Die Steuerung der Paginierung erfolgt über die Parameter Offset und Limit der fetch-Methode. Folgendes Listing zeigt die einfache Suche nach Titel und Abkürzung mit Paginierung:

SearchResult<Article> result = searchSession.search(Article.class)
    .where(f -> f.match()
	.fields("document.title", "document.abbreviation")
      .matching(request.keyword))
    .fetch(0,20);

Die Sortierung geschieht per Default nach der Relevanz der Suchergebnisse. Es ist auch möglich, andere Sortierreihenfolgen einzustellen. In der Felddefinition für den Suchindex muss dafür die Sortierbarkeit (sortable) aktiviert sein. Im Beispiel ist das beim Feld number und title der Fall. Ein @FulltextField ist nicht sortierbar, da der Text in einzelne Wörter zerteilt ist, aber es lassen sich mehrere Indexfelder für ein Attribut einer Klasse definieren.

Das folgende Beispiel zeigt zwei Varianten zur Steuerung der Sortierung: Entweder ist das Feld fix im Code definiert, oder über eine Komposition lassen sich die Sortierfelder dynamisch hinzufügen. Zur Steuerung der Paginierung und Sortierung über eine REST-Schnittstelle gibt es im Spring-Data-Projekt ein Pageable-Interface. Die Suche in dem nächsten Code-Beispiel verwendet das Objekt, um die Parameter für die Paginierung und die Sortierung der Suchabfrage zu übergeben – hier folgt der Code für die einfache Suche mit einer Sortierung:

SearchResult<Article> result = searchSession.search(Article.class)
    .where(f -> f.match().fields("document.title", "document.abbreviation")
    .matching(request.keyword))

    // Variante A
    .sort(f -> f.field("title_sort").desc())

    // Variante B
    .sort(f -> f.composite( b -> {
      if(request.page != null)  {
        request.page.getSort().get().forEach(s -> {
          b.add(f.field(s.getProperty()).order(s.isAscending() ? SortOrder.ASC : SortOrder.DESC));
        });
      }            
    }))
    .fetch(
      request.page.getPageNumber() * request.page.getPageSize(),
      request.page.getPageSize()
    );

In vielen Online-Shops sind nach einer Suche passende Kategorien und Preisbereiche aufgelistet. Die Bezeichnung dafür ist Facettensuche und Hibernate Search bietet sie ebenfalls an. Durch die Facettensuche lässt sich das Suchergebnis einfach einschränken und verfeinern, beispielsweise auf eine bestimmte Marke oder einen Preisbereich. Bis Version 5 von Hibernate Search hieß diese Funktion auch noch Faceting, mit Version 6 wurde sie umbenannt in eine allgemeine Aggregation-DSL.

Im Beispiel basiert die Aggregierung auf der Abkürzung eines Gesetzes. Dadurch bekommt man einen schnellen Überblick, in wie vielen Paragrafen eines Gesetzes sich der Suchbegriff auffinden lässt. Die Voraussetzung ist ähnlich wie bei der Sortierung: Das Feld muss in der Entität als aggregierbar definiert sein, und ein FullTextField lässt sich nicht aggregieren.

Für eine Suche gilt es, zuerst einen AggregationKey mit einem Namen zu definieren. Die Suche selbst ist aufgebaut wie im vorigen Beispiel, allerdings ist die Aggregierung zum Aufruf nach der matching-Methode hinzuzufügen. Nach der Ausführung der Suche lässt sich die Aggregierung über das Ergebnis auslesen.

Die Aggregierung ist ein Map-Objekt, wobei der Schlüssel (Key) das Objekt ist, nach dem aggregiert ist. Im nächsten Code-Beispiel handelt es sich um einen String, und der Wert (Value) enthält die Anzahl der Treffer. Die Anwendung der Aggregierung kann sich wahlweise auf diskrete Begriffe oder auf kontinuierliche Bereiche beziehen, wie es für Preisbereiche erforderlich ist. Die Codezeilen für eine Suche mit Aggregierung sind wie folgt:

AggregationKey<Map<String, Long>> countByAbbreviation = AggregationKey.of("countByAbbreviation");

        SearchResult<Article> result = searchSession.search(Article.class)
            .where(f -> f.match().fields("title", "document.title", "document.abbreviation")
            .matching(request.keyword))
            .aggregation(countByAbbreviation, f -> f.terms().field("document.abbreviation", String.class))
            .fetch(100);

        List<SearchAggregation> facetAbbreviation = result.aggregation(countByAbbreviation)
            .entrySet()
            .stream()
            .map(e -> new SearchAggregation(e.getKey(), e.getValue()))
            .collect(Collectors.toList());
        
        return new SearchResponse<>(
            result.hits(), 
            new PageMetadata(100, 0, result.total().hitCount()),
            facetAbbreviation);

Eine Textanalyse ermöglicht eine effizientere Suche. Beim Indizieren erfolgt sie auf die Daten und bei der Suche entsprechend auf die Suchbegriffe, und zwar in drei Schritten. Der erste Schritt ist dabei die Anwendung von Zeichenfiltern (character filters), die den Eingabetext von nicht benötigten Zeichen bereinigen. Beispielsweise lässt sich mit dem HTMLStripCharFilter eine HTML-Formatierung des Textes entfernen.

Liegt der Text der Paragrafen im HTML-Format vor, ist es möglich, die HTML-Formatierung zu bereinigen und dann gezielt nur den tatsächlichen Inhalt in den Suchindex zu übernehmen. Das Aufsplitten des Textes in einzelne Wörter (tokens) erfolgt im zweiten Schritt mit einem Tokenizer.

Im dritten Schritt kommen Token-Filter zur Anwendung, um einzelne Tokens zu normalisieren, transformieren oder zu entfernen. Ein Beispiel dafür ist die Normalisierung der deutschen Umlaute oder die Stammformreduktion: Das Stemming führt Wörter auf einen gemeinsamen Wortstamm zurück und ermöglicht somit auch verschiedene Formen eines Wortes als Suchtreffer. Die Tabelle zeigt ein Beispiel für die Transformation des Textes aus der Datenbank mit dem Analyzer für den Suchindex.

Vom Datenbank- zum Sucheintrag: Drei Schritte der Textanalyse
Eingabe für HTMLStripCharFilter
Ergebnis
<h1>Text</h1><h2>Maßnahmen</h2><div><h3 class="GldSymbol AlignJustify">§&nbsp;2.</h3><p>
Maßnahmen im Sinne dieses Bundesgesetzes sind solche, … <p></div>
Maßnahmen§ 2. Maßnahmen im Sinne des Bundesgesetzes sind solche, …
Eingabe für StandardTokenizer
Ergebnis
Maßnahmen § 2. Maßnahmen im Sinne des Bundesgesetzes sind solche, … [Maßnahmen, §,2., Maßnahmen, im, Sinne, des, Bundesgesetzes, sind, solche ]
Eingabe für LowerCaseFilter
Ergebnis
[Maßnahmen, §,2., Maßnahmen, im, Sinne, des, Bundesgesetzes, sind, solche ] [maßnahmen, §,2., maßnahmen, im, sinne, des, bundesgesetzes, sind, solche ]

In der Standardeinstellung von Hibernate Search mit Apache Lucene erledigt ein Tokenizer die Aufteilung in einzelne Wörter und ein Token-Filter kümmert sich um die Kleinschreibung. Diese Analyse funktioniert für viele Sprachen, ist aber auch nicht optimiert für eine bestimmte. Die Erstellung einer individuellen Konfiguration für Apache Lucene im Backend wird mit einem LuceneAnalysisConfigurer umgesetzt.

Bei den deutschsprachigen Gesetzestexten kommt ein Standard Tokenizer zum Einsatz, außerdem die Token-Filter LowerCaseFilter, ASCIIFoldingFilter und Snowball-PorterFilter. Letzterer implementiert die Stammformreduktion, die sich mit einem Parameter für Deutsch konfigurieren lässt. Die Konfiguration erhält die Bezeichnung "german" und ist in der Article-Klasse beim @FullTextField zu platzieren. Das folgende Listing zeigt eine Textanalyse für deutschsprachige Inhalte:

@Component("LegalDocumentAnalysisConfigurer")
public class MyLegalDocumentAnalysisConfigurer implements LuceneAnalysisConfigurer {

  @Override
  public void configure(LuceneAnalysisConfigurationContext context) {
    context.analyzer("german").custom()
      .tokenizer(
          StandardTokenizerFactory.class)
      .tokenFilter(
          LowerCaseFilterFactory.class)
      .tokenFilter(
          SnowballPorterFilterFactory.class)
              .param("language", "German")
      .tokenFilter(ASCIIFoldingFilterFactory.class);          
  }
}

Der Aufruf einer Suchabfrage erfolgt im Beispiel über eine REST-API, die der SearchController implementiert. Je nach Anwendungsfall lässt sie sich vom Frontend oder von einem anderen Service aus aufrufen. Das Resultat ist ein JSON-Objekt, das die Ergebnisliste (result), die Paginierungsinformation (page) und die Aggregierung nach Abkürzungen (abbreviations) enthält. Abbildung 1 zeigt zeigt die Suche nach dem Begriff "*wirtschaft*" samt Ergebnis. Die Suche fördert sechs Einträge zutage, und die Aggregierung zeigt, dass davon vier im Klimaschutzgesetz (KSG) sowie zwei im Sozialversicherungsgesetz (ASVG) enthalten sind.

Aufruf der Suche über die REST-Schnittstelle mit dem Suchbegriff – und das Ergebnis als JSON-Objekt (Abb. 1)

Mit Hibernate Search lässt sich ein Spring-Boot-Projekt um eine Volltextsuche erweitern und vielfältig konfigurieren. Das Release 6 von Hibernate Search führt viele Neuerungen ein, und die Anwendung der Such-DSL mit den Lambda-Ausdrücken ist einfach. Da für die Kombination von Hibernate Search und Apache Lucene kein eigener Suchserver nötig ist, lässt sich jedes beliebige Projekt um eine Volltextsuche erweitern. Die Kunden profitieren von den gewohnten Funktionen einer indexbasierten Suche, und der Aufwand für Entwicklung und Wartung bleibt überschaubar. Zur Vertiefung lohnt sich ein Blick in die sehr gute Dokumentation des Projekts.

Young Professionals schreiben für Young Professionals

Bernhard Jungwirth

ist Softwareentwickler beim Umweltbundesamt in Wien und Mitentwickler von FlexLex.at – einer Webanwendung zum Erstellen individueller Gesetzessammlungen als Print-on-Demand-Produkt.

(sih)