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

Seite 4: Facettensuche bei Hibernate Search

Inhaltsverzeichnis

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);          
  }
}