Skip to main content

Erkenntnisse aus der Verbesserung der Volltextsuche bei Snyk mit Elasticsearch

Artikel von
Headshot of Sergey Vasilkov

Sergey Vasilkov

blog banner snyk preview early access

4. November 2021

0 Min. Lesezeit

Elasticsearch ist eine beliebte Open-Source-Suchmaschine. Dank ihrer Echtzeitgeschwindigkeit und robusten API ist sie bei Entwicklern eine beliebte Wahl, wenn sie ihren Projekten Volltextsuchfunktionen hinzufügen möchten. Abgesehen davon, dass sie generell sehr beliebt ist, ist sie auch die Engine, zu der wir derzeit die Funktion für Issues in unseren Snyk-Berichten migrieren! Sobald wir alles für Issues optimiert haben, setzen wir Elasticsearch auch in anderen Bereichen der Berichterstellung ein.

Snyk-Issues-Dashboard mit Sicherheitslücken, Lizenzen, Bewertungen, CVE-Kennungen, betroffenen Projekten und Reifegrad der Exploits

Elasticsearch ist leistungsstark, kann aber anfangs auch kompliziert wirken (es sei denn, Sie kennen sich bereits mit Suchmaschinen aus). Da ich bei der Implementierung bei Snyk erst kürzlich viel über dieses Tool gelernt habe, möchte ich mein Wissen weitergeben – von Entwickler zu Entwickler!

Manchmal reicht ein Standard-Analyzer nicht aus

Elasticsearch bleibt schnell, indem unterschiedliche Daten eben unterschiedlich behandelt werden. Unter den vielen Feldtypen gibt es in Elasticsearch Textfelder – ein reguläres Feld für Textinhalte (d. h. Zeichenfolgen). Damit die in diesem Feld gespeicherten Informationen durchsuchbar sind, führt Elasticsearch beim Einlesen eine Textanalyse durch. Dabei werden die Daten in Tokens (Begriffe) umgewandelt und diese Tokens sowie weitere relevante Informationen wie Länge und Position im Index gespeichert. Standardmäßig verwendet Elasticsearch für die Textanalyse einen Standard-Analyzer.

Aus der offiziellen Dokumentation: „Der Standard-Analyzer bietet sofort einsatzbereite Unterstützung für die meisten natürlichen Sprachen und Anwendungsfälle. Wenn Sie den Standard-Analyzer unverändert verwenden möchten, ist keine weitere Konfiguration erforderlich.“

In einfachen Fällen kann die Standardanalyse ausreichen. Sehen wir uns an, wie sie funktioniert: Mit dem folgenden Befehl können wir die generierte Analyse für einen beliebigen Text anzeigen:

POST _analyze
{
  "analyzer": "standard",
  "text": "Regular Expression Denial of Service (ReDoS)"
}

And the result will look like:

{
  "tokens" : [
    {
      "token" : "regular",
      "start_offset" : 0,
      "end_offset" : 7,
      "type" : "<ALPHANUM>",
      "position" : 0
    },
    {
      "token" : "expression",
      "start_offset" : 8,
      "end_offset" : 18,
      "type" : "<ALPHANUM>",
      "position" : 1
    },
    {
      "token" : "denial",
      "start_offset" : 19,
      "end_offset" : 25,
      "type" : "<ALPHANUM>",
      "position" : 2
    },
    {
      "token" : "of",
      "start_offset" : 26,
      "end_offset" : 28,
      "type" : "<ALPHANUM>",
      "position" : 3
    },
    {
      "token" : "service",
      "start_offset" : 29,
      "end_offset" : 36,
      "type" : "<ALPHANUM>",
      "position" : 4
    },
    {
      "token" : "redos",
      "start_offset" : 38,
      "end_offset" : 43,
      "type" : "<ALPHANUM>",
      "position" : 5
    }
  ]
}

Hier sehen wir genau, was der Standard-Analyzer macht: Er zerlegt Text in Tokens und erzeugt für jeden Abschnitt Metadaten, die im Index gespeichert werden.

Durchsuchen unseres indizierten Textes

Nach der Indizierung können wir die Volltextsuche mit einem Standard-Analyzer testen. Dazu erstellen wir ein Beispieldokument mit zwei Feldern: title und description. Elasticsearch ordnet das Dokument automatisch zu und erkennt Textfelder.

PUT text-search-index/_doc/1
{
  "title": "Regular Expression Denial of Service (ReDoS)",
  "description": "The Regular expression Denial of Service (ReDoS) is a type of Denial of Service attack. Regular expressions are incredibly powerful, but they aren'\''t very intuitive and can ultimately end up making it easy for attackers to take your site down."
}

Suchen wir nun mit der folgenden Abfrage nach „express“:

GET text-search-index/_search
{
  "query": {
    "match": {
      "title": {
    "query": "express"
      }
    }
  }
}

Es werden keine Dokumente zurückgegeben, obwohl der Titel „expression“ enthält. Das ist zu erwarten (zumindest für mich, da mir das schon einmal begegnet ist!), denn der Analyzer hat kein Token „express“ erstellt, wie wir anhand der oben abgerufenen generierten Tokens sehen können.

Wenn wir die Suchabfrage in „expression“ ändern, wird das Dokument zurückgegeben:

GET text-search-index/_search
{
  "query": {
    "match": {
      "title": {
    "query": "expression"
      }
    }
  }
}

Es gibt zwar aufwendigere Abfragen, mit denen sich dieses Problem beim Token-Abgleich umgehen lässt, etwa unscharfe und Platzhalterabfragen, doch sie sind rechenintensiv. Statt also CPU-Zyklen für das Problem aufzuwenden, sollten wir unseren Analyzer überdenken.

Es ist klar, dass ein Standard-Analyzer möglicherweise nicht ausreicht, wenn wir bessere und flexiblere Suchergebnisse erzielen möchten. Dafür konfigurieren wir einen benutzerdefinierten Analyzer. Unsere Anforderungen:

  • Kürzere Suchbegriffe verwenden

  • Groß- und Kleinschreibung bei der Suche ignorieren

Dazu gehen wir folgendermaßen vor:

  1. Einen benutzerdefinierten Analyzer erstellen

  2. Einen für diesen Anwendungsfall geeigneteren Tokenizer konfigurieren

  3. Den neuen benutzerdefinierten Analyzer für ausgewählte Felder im Elasticsearch-Index aktivieren

Analyzer genauer betrachtet

Mit dem Standard-Analyzer haben wir es uns leicht gemacht und mussten nicht wirklich verstehen, was der Analyzer tut. Da wir jetzt einen benutzerdefinierten Analyzer erstellen, müssen wir nachvollziehen, was bei der Textanalyse geschieht:

  1. Zeichenfilter (optional) werden auf den zu analysierenden Text angewendet, um Zeichen zu entfernen.

  2. Ein Tokenizer zerlegt Text in Tokens oder Begriffe. Das kann auf unterschiedliche Weise geschehen, etwa anhand von Leerzeichen oder Buchstaben.

  3. Tokenfilter (optional) nehmen weitere Änderungen an Tokens vor, zum Beispiel die Umwandlung in Kleinbuchstaben oder das Entfernen bestimmter Tokens.

Ein Analyzer ist eine Kombination aus Tokenizern und Filtern. Er analysiert Text und bereitet ihn für die Suche auf. Jeder Analyzer muss genau einen Tokenizer haben, kann aber beliebig viele der beiden Filtertypen enthalten. Die Reihenfolge der Vorgänge ist immer gleich: Zeichenfilter > Tokenizer > Tokenfilter.

Unseren ersten benutzerdefinierten Analyzer erstellen

Wie wir bereits gesehen haben, verwenden standardmäßig alle Textfelder den Standard-Analyzer. Elasticsearch bietet eine große Auswahl an integrierten Analyzern, die ohne weitere Konfiguration in jedem Index verwendet werden können. Sie können aber auch eigene Analyzer erstellen. Für diesen Anwendungsfall brauchen wir einen eigenen.

Analyzer lassen sich auf verschiedenen Ebenen einrichten: Index, Feld oder Abfrage. Wir erstellen einen benutzerdefinierten Analyzer und legen ihn für ein Feld fest. So lässt sich ein benutzerdefinierter Analyzer konfigurieren. Hinweis: Die Konfigurationen werden in den Indexeinstellungen gespeichert:

PUT text-search-index
{
  "settings":{
     "analysis":{
        "analyzer":{
           "my_analyzer":{
              "type":"custom",
              "tokenizer":"standard"
           }
        }
     }
  },
  "mappings":{
      "properties":{
         "title": {
            "type":"text",
            "analyzer":"my_analyzer"
         },
         "text": {
           "type": "text"
         }
      }
   }
}

Das haben wir gemacht:

  1. Für den Index wird ein neuer benutzerdefinierter Analyzer namens my_analyzer festgelegt

  2. my_analyzer verwendet den Tokenizer standard und den Filter lowercase

  3. my_analyzer wird in der Indexzuordnung als Analyzer für die Eigenschaft „title“ aktiviert

Dieses Beispiel ist recht einfach und ändert das Verhalten des Standard-Analyzers nicht. Jetzt können wir Anpassungen vornehmen, damit die Suche mit unvollständigen Wörtern und unabhängig von Groß- und Kleinschreibung möglich ist.

Einen geeigneteren Tokenizer auswählen und konfigurieren

Im vorherigen Beispiel haben wir den Standard-Tokenizer verwendet. Jetzt ersetzen wir ihn durch edge_ngram, das Tokens anhand ihrer Länge (nicht anhand von Leerzeichen) zerlegt. Falls Sie sich fragen, warum ich edge_ngram statt stemmer verwende: Lesen Sie weiter!

...
"analysis":{
  "analyzer":{
    "my_analyzer":{
      "type":"custom",
      "tokenizer":"my_tokenizer"
    }
  },
  "tokenizer": {
    "my_tokenizer": {
      "type": "edge_ngram",
      "min": 3,
      "max": 8,
      "token_chars": ["letter", "digit"]
    }
  }
}
...

Das haben wir gemacht:

  1. my_tokenizer verwendet den Tokenizer edge_ngram

  2. Es werden Tokens mit einer Länge von 3 bis 8 Zeichen erstellt

  3. Tokens enthalten ausschließlich Buchstaben und Ziffern

Einen geeigneteren Tokenfilter auswählen und konfigurieren

Der oben erstellte Analyzer ermöglicht nun die Suche mit unvollständigen Wörtern mit einer Länge von 3 bis 8 Zeichen. Als Letztes müssen wir noch die Suche unabhängig von Groß- und Kleinschreibung ermöglichen. Dazu fügen wir my_analyzer einen geeigneten Tokenfilter lowercase hinzu:

...
"analysis":{
  "analyzer":{
    "my_analyzer":{
      "type":"custom",
      "tokenizer":"my_tokenizer",
      "filter":[
        "lowercase"
      ]
    }
  },
  "tokenizer": {
    "my_tokenizer": {
      "type": "edge_ngram",
      "min": 3,
      "max": 8,
      "token_chars": ["letter", "digit"]
    }
  }
}
...

Das Endergebnis

Nachdem wir alle Änderungen vorgenommen haben, können wir einen neuen Index mit einem benutzerdefinierten Analyzer erstellen. Beachten Sie vor einer Änderung des Analyzers, dass seine Einstellung für vorhandene Felder nicht über die Update-Mapping-API aktualisiert werden kann.

So sehen die Einstellungen mit den Zuordnungen für unseren einfachen Testindex aus:

PUT text-search-index
{
  "settings":{
    "analysis":{
      "analyzer":{
        "my_analyzer":{
          "type":"custom",
          "tokenizer":"my_tokenizer",
          "filter":[
            "lowercase"
          ]
        }
      },
      "tokenizer": {
        "my_tokenizer": {
          "type": "edge_ngram",
          "min": 3,
          "max": 8,
          "token_chars": ["letter", "digit"]
        }
      }
    }
  },
  "mappings":{
    "properties":{
      "title": {
        "type":"text",
        "analyzer":"my_analyzer"
      },
      "text": {
        "type": "text"
      }
    }
  }
}

Jetzt können wir ein Dokument hinzufügen und verschiedene Abfragen ausführen, um sicherzustellen, dass unsere Suche wie erwartet funktioniert:

PUT text-search-index/_doc/1
{
  "title": "Regular Expression Denial of Service (ReDoS)",
  "description": "The Regular expression Denial of Service (ReDoS) is a type of Denial of Service attack. Regular expressions are incredibly powerful, but they aren'\''t very intuitive and can ultimately end up making it easy for attackers to take your site down."
}

Nach Titeln suchen, die „express“ oder andere unvollständige Tokens enthalten; die Groß- und Kleinschreibung wird ignoriert:

GET text-search-index/_search
{
  "query": {
    "match": {
      "title": {
    "query": "express" // try: EXPRESS, exp, expression
      }
    }
  }
}

Beachten Sie, dass wir für die Texteigenschaft unseres Index keinen benutzerdefinierten Analyzer konfiguriert haben. Dort kommt also weiterhin die gute alte Standardsuche (mit dem Standard-Analyzer) zum Einsatz.

Wie geht es weiter?

In diesem einfachen Artikel haben wir einen benutzerdefinierten Analyzer für eine flexiblere Textsuche in Elasticsearch konfiguriert und einige grundlegende Einstellungen und Konfigurationen behandelt. Elasticsearch bietet eine große Auswahl an integrierten Analyzern, Tokenizern, Filtern, Normalisierern, Einstellungen für Stoppwörter und vieles mehr.

Durch ihre Kombination lassen sich hervorragende benutzerdefinierte Suchergebnisse erzielen. Wenn Sie neu bei Elasticsearch sind, hoffe ich, dass dieser Artikel hilfreich war. Bleiben Sie dran – wir möchten noch mehr über unsere Suche berichten. Als Nächstes geht es um Aggregationen! (Darauf können Sie zählen …)

Wenn Sie Teil von Snyk werden und großartige Developer-Security-Tools entwickeln möchten, sehen Sie sich unsere offenen Stellen im Engineering an.