Feinheiten beim Mappen mit OpenSearch - Teil 1

OpenSearch bietet eine unglaublich große Funktionsvielfalt, wie man Dokumente indizieren kann. Dieses Mapping ist enorm wichtig, damit man wirklich effizient suchen kann

Einleitung

Das Mappen von Dokumenten zu einem Index ist im Prinzip die wichtigste Aufgabe überhaupt. Wenn OpenSearch nicht weiß, wie Attribute zu behandeln sind, haben wir nicht mehr gewonnen, als eine Volltextsuche, die fast immer alles Mögliche findet, aber nicht das, was wir suchen.

Ich werde ab und zu ein paar Artikel zum Mappen schreiben. Es ist unmöglich, alles zu erfassen. Man schaue sich nur die Dokumentation von ElasticSearch und OpenSearch an, berechtigterweise gibt es dazu auch Bücher.

Grundsätzlich werden wir wieder mit Microblogging Toots von Mastodon aus dem Fediverse arbeiten. In den bisherigen Blog-Artikeln haben wir ja schon einige Scripte geschrieben.

Da ich mit dem Mapping frisch beginnen will, löschen wir unsere Spielwiese (wer schon viele Daten drin hat und das ungern machen will, legt sich einfach einen neuen Index mit anderem Namen an).

Alten Index löschen

Am besten man legt sich das Script als osDeleteIndex.py an:

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, # enables gzip compression for request bodies
    use_ssl = False
)

# Der Name des Index
index_name = 'toots'

client.indices.delete(index=index_name)

Index erstellen

Jetzt sind die Daten erstmal weg und wir bauen ein neues Mapping auf. Das wird sich wieder nur auf ein paar Attribute konzentrieren. Mit der Zeit werden wir mehr Mappings hinzufügen.

Zunächst geht es um einen mächtigeren Filter, damit wir besser auf dem Content suchen können. OpenSearch liefert von Haus aus, eine Menge Filter und Analyzer mit, die man beliebig konfigurieren und zusammenstecken kann.

Der Content meiner Toots in meiner Timeline sind üblicherweise in Deutsch oder Englisch. Zudem ist der Content immer mit vielen HTML Auszeichnungen “verseucht”. Wenn ich den Content suche, will ich nicht bei der Suche nach “Span” alle Nachrichten mit “span” Element finden (das sind sehr viele). Überhaupt soll die Suche nicht nach Groß/Kleinschreibung unterscheiden. Bestimmte Wörter machen keinen Sinn, dass sie Suchbar sind (und, oder, at, to, is, …). Diese Stoppwörter brauchen wir nicht.

Da wir das alles für ein Attribut vereinen wollen, müssen wir der Index-Konfiguration, erstmal die Filter einrichten und den Analyzer deklarieren:

Zwei Stoppfilter:

             "filter": {
                "english_stop": {
                    "type":       "stop",
                    "stopwords":  "_english_"
                },
                "german_stop": {
                    "type":       "stop",
                    "stopwords":  "_german_"
                },
            }

Die Konstanten _english_ und _german_ sind vorgefertigte Listen in OpenSearch. Anstatt dieser Konstanten kann man auch ein Array von eigenen Wörtern nehmen.

Den Analyzer my_html_analyzer bauen wir nun mit den obigen Filtern und noch dem lowercase Filter zusammen:

            "analyzer": {
                "my_html_analyzer": {
                    "type": "custom",
                    "tokenizer": "standard",
                    "filter": [
                        "lowercase",
                        "english_stop",
                        "german_stop"
                    ],
                    "char_filter": [
                        "html_strip"
                    ]
                }

Der Tokenzier ist standard, d.h. an den üblichen Wortgrenzen werden Worte erkannt. Dann haben wir eine Kette von Filtern: lowercase, english_stop und german_stop. Also erst alles in Kleinbuchstaben, dann die englischen, danach die deutschen Stoppwörter aus dem HTML Text entfernen. Der char_filter html_strip entfernt alle HTML Tags. Es gibt sicher noch weitere nette Filter, die den Text noch etwas vereinfachen (und besser durchsuchbar machen), da verweise ich aber erstmal auf euren Spieltrieb.

Das Mapping erweitere ich nur darum, dass wir OpenSearch von Anfang an sagen, dass das Array der Tags in einem Toot aus Objekten besteht. Aber mich interessiert nur der Name des Tags:

    "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",
                "fielddata": True
            },
            "tags": {
                "type": "nested",
                "properties": {
                    "name": {"type": "keyword"}
                }
            }
        }
    }

Es ist auch zu sehen, dass content noch "fielddata": True bekommen hat. Das ist nur für ein späteres Experiment. Diese Einstellung ist sehr teuer, weil der Text des Toots damit üblicherweise im Heap des OpenSearch Nodes gehalten wird. Das kostet viel Speicher. Vorteil ist, dass man dann auf den Content sogar Funktionen ausführen kann (was beim Typ text sonst nicht geht)

Zusammengefasst das Script osCreateIndex.py:

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, # enables gzip compression for request bodies
    use_ssl = False
)

# Der Name des Index
index_name = 'toots'

# Die Einstellungen
index_body = {
    'settings': {
        'index': {
            'number_of_shards': 4
        },
        "analysis": {
            "filter": {
                "english_stop": {
                    "type":       "stop",
                    "stopwords":  "_english_"
                },
                "german_stop": {
                    "type":       "stop",
                    "stopwords":  "_german_"
                },
            },
            "analyzer": {
                "my_html_analyzer": {
                    "type": "custom",
                    "tokenizer": "standard",
                    "filter": [
                        "lowercase",
                        "english_stop",
                        "german_stop"
                    ],
                    "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",
                "fielddata": True
            },
            "tags": {
                "type": "nested",
                "properties": {
                    "name": {"type": "keyword"}
                }
            }
        }
    }
}

# Den Index anlegen:

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

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

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

Index füllen

Ok, wir haben alles gelöscht, also laden wir wieder ein paar Toots in den Index (mit dem Script copyLocalToOS.py):

import json
from mastodon import Mastodon
from opensearchpy import OpenSearch

mastodon = Mastodon (
    api_base_url='https://social.tchncs.de'
)

host = 'localhost'
port = 9200

client = OpenSearch(
    hosts = [{'host': host, 'port': port}],
    http_compress = True, # enables gzip compression for request bodies
    use_ssl = False
)

# Der Name des Index
index_name = 'toots'

print ('Import toots')

page = None
for _ in range(5):
    page = mastodon.timeline_local(limit=40) if page is None else mastodon.fetch_next(page)
    for toot in page:
        id = toot['id']
        response = client.index(index_name, id=id, body=toot)

client.indices.refresh(index=index_name)
print ('Finished')

Index durchsuchen

Da wir jetzt ein paar Filter angelegt haben, können wir sehr einfach über den Content suchen. Ich empfehle für solche Experimete mal Postman zu nutzen, da man dort eine schöne Collection an Abfragen hinterlegen kann. Zudem kann Postman für die verschiedensten Frameworks Code Snippets erzeugen.

Hier mal cURL:

curl --location --request GET 'http://localhost:9200/toots/_search' \
--header 'Content-Type: application/json' \
--data-raw '{
  "query": {
    "match": {
      "content": {
          "query": "chatkontrolle"
      }
    }
  },
     "fields": ["id", "content"],
    "_source": false
}'

Da OpenSearch auch bei GET fast immer ein Request-Body fordert, muss man auf solche Entwicklertools zurückgreifen. Der Haus-, Hof und Wiesenbrowser unterstützt das nicht.

Als Response sehen wir (aktuell) tatsächlich Ergebnisse. Auch wenn Chatkontrolle meistens großgeschrieben wird.

    "hits": {
        "total": {
            "value": 6,
            "relation": "eq"
        },
        "max_score": 5.0656624,
        "hits": [
            {

Da wir eine “match” Suche gemacht haben, darf man auch Phrasen verwenden. Wenn man Stoppwörter in der Phrase verwendet, stört es nicht das Ergebnis. Suchen wir nach class, sollte nichts erscheinen (außer man hat einen englischen Toot erwischt, wo class als normaler Text vorkommt.

Request-Body für die Suche:

{
  "query": {
    "match": {
      "content": {
          "query": "class"
      }
    }
  },
     "fields": ["id", "content"],
    "_source": false
}

Keine Ergebnisse:

{
    "took": 2,
    "timed_out": false,
    "_shards": {
        "total": 4,
        "successful": 4,
        "skipped": 0,
        "failed": 0
    },
    "hits": {
        "total": {
            "value": 0,
            "relation": "eq"
        },
        "max_score": null,
        "hits": []
    }
}

Perfekt.

Spaßeshalber könnt ihr einen Index erstellen, wo content keinen Analyzer erhält, sondern nur als Typ text definiert wird. Dann wiederholt die Suche und stellt fest, wie blöd das funktioniert. Es lohnt sich also wirklich etwas Arbeit in das Mapping zu stecken.

In einem anderen Blog-Artikel geht es dann darum, wie wir die Tags verarbeiten. Da kann man schöne Dinge wie Word-Clouds erstellen.