Explizites Dokument-Mapping zum Indizieren in OpenSearch

Man kann das dynamische Mapping von OpenSearch nehmen oder besser gleich explizit festlegen, wie die Daten indiziert werden.

Einleitung

Die meisten Tutorials fangen sofort damit an zu zeigen, wie man in OpenSearch (oder ElasticSearch) Dokumente speichert, damit man sie durchsuchen kann. Entgegen anderen Persistenzen, erwartet OpenSearch keine expliziten Schemadefinitionen. In vielen RDBMS muss man via DDL (beispielsweise mit SQL) mit CREATE TABLE eine Tabelle anlegen und die Spalten mit den Datentypen definieren.

Ok, OpenSearch ist weder Relational noch darauf reduziert nur eindimensionale Daten pro Datensatz aufzunehmen. Man spricht nicht mal von Records, sondern von Documents. Diese Dokumente haben auch nur ein Format: JSON. Was vermutlich wieder die Frage nach dem Sinn eines so spezialisierten Tools wie OpenSearch aufwirft. Aber wie es schon der Name sagt, es geht darum einen durchsuchbaren Index auf Basis von strukturierten Attributen bereitzustellen. Diese Attribute werden als JSON deklariert und der Index wird auf eben diese Attribute angewendet.

Diese Definition der Anwendung von Suchvorschriften auf Attribute eines JSON Dokumentes ist auf der einen Seite extrem einfach, aber bei komplexeren Daten kann man schon mal verzweifeln, wenn man die Daten nicht mehr wiederfindet oder (was weitaus häufiger vorkommt) man zu viel Ergebnisse bekommt, obwohl man dachte, man hätte die Suche genügend eingeschränkt.

OpenSearch nimmt einem das Mapping grundsätzlich ab. D.h. speichert man ein JSON Dokument in einen Index, dann erzeugt OpenSearch aus den erkannten Daten eine Schemadefinition und das dazugehörige Mapping. Das reicht für die ersten Schritte aus, aber man kommt sehr schnell an Grenzen und sehr früh zu dem zwangsläufigen Schritt OpenSearch etwas dabei zu helfen, wie die Attribute zu interpretieren (mappen) sind.

Attribute Mapping

JSON hat nicht sehr viele Datentypen, Datum/Zeit wird sogar als Zeichenkette definiert und Bedingungen für Werte sind nicht standardisiert. Das führt dazu, dass man an Beispieldaten nur bedingt erkennen kann, was gemeint ist.

Legt man in OpenSearch einen neuen Index ohne Mapping-Informationen an, versucht OpenSearch bei jedem Hinzufügen eines Dokumentes die Attribute mittels eines dynamischen Mappings zu indizieren. Das ist immer dann sinnvoll, wenn man vorab sowieso nicht weiß, wie die JSON Daten aussehen werden.

Häufig weiß man das aber und dann sollte man auch ein Mapping explizit definieren.

Nehmen wir mal ein Mastodon Toot (gekürzt):

{
    "id": 108247697988744452,
    "created_at": "2022-05-05 05:33:10.112000+00:00",
    "in_reply_to_id": null,
    "in_reply_to_account_id": null,
    "sensitive": false,
    "spoiler_text": "",
    "visibility": "public",
    "language": "de",
    "uri": "https://social.tchncs.de/users/beandev/statuses/108247697988744452",
    "url": "https://social.tchncs.de/@beandev/108247697988744452",
    "content": "<p>Was will die neue <a href=\"https://social.tchncs.de/tags/LucaApp\" class=\"mention hashtag\" rel=\"tag\">#<span>LucaApp</span></a> nicht l\u00f6sen, was die alte schon nicht konnte?</p>",
    "reblog": null,
    "application": {
        "name": "Fedilab",
        "website": "https://fedilab.app"
    },
   ...

Hier sieht man einige Attribute, deren Mapping klar sein sollte (und über das dynamische Mapping zweifelsfrei erkannt werden). Darunter fallen alle boolean und number Werte, soweit sie Daten enthalten. Alle Werte mit null können nicht erkannt werden. Bekommen wir mal einen Toot mit einem Attribute "reblog": true oder ist es gar eine komplexe Struktur (und tatsächlich hat man eine Unterstruktur mit Detaildaten des boosts)?

Bei Zeichenketten gibt es zwei Typen, die OpenSearch unterscheidet: text und keyword. Der Typ text wird für Zeichenfolgen genutzt, die Phrasen sind und von denen man Teile (Worte / Token) wiederfinden möchte. Legt man keyword fest, dann handelt es sich um einen exakten Wert, den man so auch suchen will. Im Fall des obigen Attributes visibility würde man keyword verwenden. Für content den Typ text. Es ist sogar so, dass man vielleicht die Phrase in content in den Index ohne HTML tags transformieren will. Warum soll ich bei der Suche nach span alle Toots wiederfinden wollen, die eine HTML span Formatierung aufweisen?

Also muss man sich von Anfang an mit dem Mapping von Attributen der zu erwartenden Dokumente auseinandersetzen.

Man muss nicht für alle Attribute ein Mapping definieren. Das erspart bei sehr umfangreichen Sets von Attributen einiges an Arbeit. Status-Objekte in Mastodon sind schon ziemlich reichhaltig, da ist man froh, dass man sich auf nur die Spezialfälle konzentrieren kann.

Ein Mapping ist relativ einfach deklariert:

{
  "properties": {
    "visibility": { "type": "keyword" },
    "language": { "type": "keyword" },
    "uri": { "type": "keyword" },
    "url": { "type": "keyword" },
    "spoiler_text": { "type": "text" },
    ...
  }
}

Eine Liste der Möglichkeiten findet sich (leider nur) in der ElasticSearch Dokumentation.

Komplexere Mappings sind auch möglich. Zum Beispiel das Entfernen der HTML Tags, bevor der Text in den Index geht. Dazu gibt es das mächtige Werkzeug der Analyzer. Diese Analyzer können an verschiedenen Stellen eingesetzt werden und kümmern sich um die Transformation von Attributen. Grundsätzlich kann man das bei der Abfrage von Daten nutzen, aber auch schon, bevor sie in den Index gespeichert werden.

Analyzer werden global in den Settings angelegt und bekommen einen Namen:

   "settings": {
      "analysis": {
         "analyzer": {
            "my_html_analyzer": {
               "type": "custom",
               "tokenizer": "standard",
               "char_filter": [
                  "html_strip"
               ]
            },
         }
      }
   }

Nun kann man sich auf den Analyzer in den Mappings beziehen:

{
  "properties": {
    "content": { 
      "type": "text",
      "analyzer": "my_html_analyzer" 
    },
    ...
  }
}

Ganz wichtig ist, dass das Dokument bei Speichern nicht verändert wird. D.h. die HTML-Tags sind weiter vorhanden. Mit dieser Konfiguration wurden die Tags nur aus dem Such-Index herausgehalten.

Index mit Python anlegen

Nun mal etwas Praxis. OpenSearch hat eine wirklich umfangreiche REST API, aber auch eine Menge Clients, die einen davor bewahren, auf HTTP Ebene mit dem Cluster zu kommunizieren. Für Python gibt es opensearch-py und kann mit pip/pip3 installiert werden.

pip install opensearch-py

Nun das Script zum Erstellen des Index:

from opensearchpy import OpenSearch
host = 'localhost'
port = 9200

# Client zu dem Dev Cluster (ohne SSL, ohne Anmeldung)
client = OpenSearch(
    hosts = [{'host': host, 'port': port}],
    http_compress = True
    use_ssl = False
)

# Der Name des Index
index_name = 'toots'

# Die Einstellungen
index_body = {
    'settings': {
        'index': {
            'number_of_shards': 4
        },
        "analysis": {
            "analyzer": {
                "my_html_analyzer": {
                    "type": "custom",
                    "tokenizer": "standard",
                    "char_filter": [
                        "html_strip"
                    ]
                },
            }
        }
    },
    "mappings": {
        "properties": {
            "visibility": { "type": "keyword" },
            "language": { "type": "keyword" },
            "uri": { "type": "keyword" },
            "url": { "type": "keyword" },
            "spoiler_text": { "type": "text" },
            "content": {
                "type": "text",
                "analyzer": "my_html_analyzer"
            }
        }
    }
}

# Den Index anlegen:

response = client.indices.create(index_name, body=index_body)
print(f'Index erstellt: {response}')

Im Prinzip haben wir nur alles zusammengesetzt, was in diesem Artikel beschrieben wurde. Als Ausgabe sollte nun kommen:

Index erstellt: {'acknowledged': True, 'shards_acknowledged': True, 'index': 'toots'}

Wir schauen und das mal mit http://localhost:9200/toots/_settings?pretty=true an:

{
  "toots" : {
    "settings" : {
      "index" : {
        "number_of_shards" : "4",
        "provided_name" : "toots",
        "creation_date" : "1651825845620",
        "analysis" : {
          "analyzer" : {
            "my_html_analyzer" : {
              "type" : "custom",
              "char_filter" : [
                "html_strip"
              ],
              "tokenizer" : "standard"
            }
          }
        },
        "number_of_replicas" : "1",
        "uuid" : "-UoFyWfnStybxDe4-gB3-Q",
        "version" : {
          "created" : "135247927"
        }
      }
    }
  }
}

Die Index-Statistik kann man auch abfragen mit http://localhost:9200/_cat/indices?v.

health status index     uuid                   pri rep docs.count docs.deleted store.size pri.store.size
yellow open   toots     -UoFyWfnStybxDe4-gB3-Q   4   1          0            0       832b           832b
green  open   .kibana_1 oPhh4WkXTha54EXU4SdJ7A   1   0          1            0        5kb            5kb

Oh, der Health Status ist yellow. Die Ursache ist, dass wir in unserem Entwicklungs-Cluster nur eine Instanz (einen Knoten) haben und das kann potenzieller Datenverlust bedeuten. Nur warum ist der Index .kibana (genutzt vom Dashboard) auf green?

Da schauen wir mal in diesen Settings nach http://localhost:9200/.kibana/_settings?pretty=true

{
  ".kibana_1" : {
    "settings" : {
      "index" : {
        "number_of_shards" : "1",
        "auto_expand_replicas" : "0-1",
        "provided_name" : ".kibana_1",
        "creation_date" : "1651750745683",
        "number_of_replicas" : "0",
        "uuid" : "oPhh4WkXTha54EXU4SdJ7A",
        "version" : {
          "created" : "135247927"
        }
      }
    }
  }
}

Im Kibana Index wurde "number_of_replicas" : "0" gesetzt. Damit wurde OpenSearch versichert, dass wir wissen was wir tun: Wir haben keinen extra Knoten zur Replikation.

Da wir pedantisch sind, machen wir das für unseren Index auch:

index_update = {
    'settings': {
        'index': {
            'number_of_replicas': 0
        }
    }
}

upd_response = client.indices.put_settings(index=index_name, body=index_update)
print(f'Index aktualisiert: {upd_response}')

Und nun ist unser Status auch green:

health status index     uuid                   pri rep docs.count docs.deleted store.size pri.store.size
green  open   toots     -UoFyWfnStybxDe4-gB3-Q   4   0          0            0       832b           832b
green  open   .kibana_1 oPhh4WkXTha54EXU4SdJ7A   1   0          1            0        5kb            5kb

Aber wir haben noch kein Dokument in unserem Index. Der perfekte Moment für einen Cliffhanger. Das mache ich im nächsten Blog-Artikel.