Dokumente in OpenSearch mit Python indizieren

Mit nur wenigen Befehlen ist es relativ einfach JSON Daten-Strukturen eins zu eins in OpenSearch zu übertragen

Einleitung

Grundsätzlich ist OpenSearch ein Framework für Suchaufgaben und Datenanalyse. Dabei unterstützt es primär das Auffinden von Daten in sehr großen Datapools und kann bei Analyseaufgaben unterstützen. Es ist primär keine Dokumentendatenbank zum Persistieren von Datenstrukturen. Allerdings ist OpenSearch für diese hybride Anforderung vorbereitet und unterstützt das mit vielen Werkzeugen. Insbesondere wenn die Datenstrukturen von Natur aus als JSON (und vor allem strukturiert) vorliegen, bietet es sich an, ausschließlich OpenSearch zur Speicherung zu nutzen, ohne weitere Storages zu verwenden. Das Bild ändert sich, wenn die Daten nicht natürlicherweise in JSON vorliegen, vornehmlich binäre Dokumente (OpenOffice, MS Office, PDF, usw. usf.). OpenSearch bietet da keine sinnvolle Möglichkeit an. Es ist zwar möglich ein Dokument Base64-Encoded in das JSON einzubetten, aber damit tun wir uns keinen Gefallen. Auch wenn es schon Persistenzen der Quelldaten gibt (wie z.B. RDBMS) gibt, dupliziert man nicht alles nach OpenSearch, wenn es der Suche und Analyse nicht dient. In dem Fall hinterlegt man im JSON Datensatz eine Referenz (URI, Object-ID, UUID, …) und verweist somit auf die Originaldaten. Hat man so eine Persistenz nicht, kann es auch ein Pfad zu einer Datei sein oder besser ein BlobStorage.

Ich möchte aber hier doch auf den Ansatz eingehen, der strukturierte JSON Daten komplett in die Hände von OpenSearch gibt. Ich werde auch mal ein Artikel zur Datensicherung und Wiederherstellung schreiben, damit man sich nicht den Kritikpunkt aussetzen muss, man arbeite ansonsten mit sehr flüchtigen Daten.

Mastodon Toots speichern

Im Nachbar-Blog BeanDev: Mastodon beschäftige ich mich mit der Mastodon API und der Bibliothek Mastodon.py. Es wäre zu empfehlen, da mal reinzuschauen, weil einige Dinge von dort übernommen werden. In den folgenden Beispielen importiere ich einfach einige Toots der Local Timeline. Der Vorteil ist, dass es dafür i.d.R. keinerlei Authentifizierung benötigt.

Das Auslesen ist sehr trivial:

from mastodon import Mastodon
from opensearchpy import OpenSearch

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

local = mastodon.timeline_local()

In local befinden sich damit die letzten 20 Toots. Die Mastodon API erzwingt das Paging (ausgenommen bei den Streams) und die Größe einer Page kann nur bis 40 Objekte ausgedehnt werden.

Die API unterstützt mit ein paar Hilfsfunktionen das Auslesen weiterer Seiten. Das machen wir auch ein paar mal, damit es ein paar Dokumente gibt:

...
page = None
for _ in range(5):
    page = mastodon.timeline_local(limit=40) if page is None else mastodon.fetch_next(page)

Das ist einfach genug für unser Beispiel. Nun müssen wir das in unseren toot-Index bekommen, den wir im letzten Blog-Artikel angelegt hatten.

Die OpenSearch.py API unterstützt auch dabei und da wir das JSON Dokument eins zu eins übertragen, ist es schon fast langweilig:

from mastodon import Mastodon
from opensearchpy import OpenSearch

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

host = 'localhost'
port = 9201

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'

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:
        client.index(index_name, body=toot)

Das war es schon. Es sollten nun 200 Toots in unserem Index sein, fragen wir mal nach http://localhost:9200/_cat/indices?v:

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     93.3kb         93.3kb
green  open   .kibana_1 oPhh4WkXTha54EXU4SdJ7A   1   0          1            0        5kb            5kb

Hm, docs.count = 0?

Ok, da funktioniert noch etwas nicht, aber ich löse mal auf: Der Befehl ist grundsätzlich erstmal eine nette Aufforderung. Die index Methode erlaubt noch einen Parameter `refresh=True’ zu setzen, das sollte man aber im Batch nicht machen. Das kostet zu viel Performance, wenn wir unmittelbar für die einzelnen Dokumente nicht an dem Ergebnis interessiert sind.

Man sollte also nach dem Lauf durch alle 200 Toots einen expliziten Refresh durchführen.

client.indices.refresh(index=index_name)

Nun endlich sehen wir etwas http://localhost:9200/_cat/indices?v:

health status index     uuid                   pri rep docs.count docs.deleted store.size pri.store.size
green  open   toots     14ADrQSgSgeXEWDxzO2o3A   4   0        200            0    840.3kb        840.3kb
green  open   .kibana_1 oPhh4WkXTha54EXU4SdJ7A   1   0          1            0        5kb            5kb

200 Toots verbrauchen 840,8 kB an Daten.

Dann schauen wir erst mal nach, was mit unserem Mapping passiert ist. Wie schon erwähnt, übernimmt OpenSearch eine Menge an Mapping Arbeit, wenn wir das nicht explizit festgelegt haben:

Die Information über den Index bekommen wir mit http://localhost:9200/toots/ und die Ausgabe hat sich nun gewaltig verändert. Zig Attribute wurden von OpenSearch entdeckt. Bei einigen kann man mitgehen, was das Mapping betrifft, bei anderen Attributen klappt das überhaupt nicht.

Gerade die Attribute, die oft leer sind und dann leere Arrays oder als null im Status-Object sind, werden von OpenSearch als text interpretiert. Die selbst gesetzten Mappings wurden natürlich nicht überschrieben.

Aber egal, darum kümmern wir uns später. Schauen wir mal nach den Dokumenten http://localhost:9200/toots/_search?pretty=true:

{
  "took" : 41,
  "timed_out" : false,
  "_shards" : {
    "total" : 4,
    "successful" : 4,
    "skipped" : 0,
    "failed" : 0
  },
  "hits" : {
    "total" : {
      "value" : 200,
      "relation" : "eq"
    },
    "max_score" : 1.0,
    "hits" : [
  ...

Der Aufruf brauchte 41ms, die Anfrage ging über alle 4 shards (das sind unsere Apache Lucene Prozesse) und alle haben reagiert. Die totale Anzahl wären 200 Dokumente. Mit dem Array hits[] folgen die Ergebnisse. Dort sind aber nur 10 aufgelistet. Auch OpenSearch hat Limits. Hängt man den &size=n Parameter an, kann man mehr Ergebnisse bekommen. Allerdings auch nicht mehr, als in der globalen Konfiguration hinterlegt ist. Wenn man das wirklich braucht, gibt es spezielle Streaming-Möglichkeiten. Aber das ist eher unwahrscheinlich.

Schaut man sich den ersten Toot an, sieht man erst ein paar Meta-Informationen mit einem _:

    "hits" : [
      {
        "_index" : "toots",
        "_type" : "_doc",
        "_id" : "tSibmYABzTa-ID4sDwiJ",
        "_score" : 1.0,
        "_source" : {
          "id" : 108255279217081900,
          "created_at" : "2022-05-06T13:41:10.477000+00:00",
          "in_reply_to_id" : 108254896893762953,
          "in_reply_to_account_id" : 107769893804592435,
          "sensitive" : false,
   ...

Es wird der Index angegeben (man kann nämlich auf mehrere Indexe gleichzeitig abfragen und dann braucht man die Unterscheidung), den Typ _doc. Dann eine _id, die eine Zeichenfolge ist und nichts mit der Status-ID von Mastodon zu tun hat. Darüber müssen wir später noch reden. Der Score sagt uns, dass zu unserer Anfrage ein Volltreffer gelandet wurde (kein Wunder, wir haben ja keine Einschränkungen gesetzt) und dann folgt endlich mit _source unser Toot. Schaut man da tiefer rein, ist das ziemlich vergleichbar mit dem Original, auch die HTML Tags sind weiterhin da (siehe content).

Jetzt hängt es etwas von den Testdaten, die in eurem OpenSearch gelandet sind, weil die Local Timeline sich verändert. Man kann aber mal ein paar Suchanfragen ausprobieren:

Ok, wie ich es schon angekündigt hatte. Meistens findet man mehr als man eigentlich wollte. Besonders die Suche über alle Felder ist nicht hilfreich.

Das mit der Suche über die URL mit dem q= Parameter bietet leider nicht alle Möglichkeiten, die OpenSearch in seinem Repertoire hat. Deswegen ist es sinnvoller, mit der API zu arbeiten, die JSON DSL Abfrage-Objekte im Request Body verwendet. Das geht über den Browser sowieso nicht und könnte nur mit Tools wie Postman realisiert werden. Zu der Query API kommen wir in einem anderen Artikel.

Ein weiteres Problem gibt es, wenn wir erneut Toots aus einer Timeline importieren. Eventuell erwischen wir einen großen Teil der alten Status-Nachrichten, und die würde OpenSearch erneut anlegen. Der Grund ist, dass wir die _id von OpenSearch generieren lassen. Es gibt nur einen empfohlenen Weg, man setzt selbst die ID:

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']
        client.index(index_name, id=id, body=toot)

Nun sieht man, dass alle _id mit der _source.id korrespondieren.

Würde man das Script mehrere Male hintereinander aufrufen, erhöht sich die Zahl der Dokumente dann nicht jedes Mal um 200, sondern nur um 1 oder 2 oder vielleicht gar nicht. Je nachdem, was auf der Timeline los ist. D.h. es werden keine Duplikate mehr angelegt.

’ Dokumente oder Index löschen

Bei dem vielen Testen will man auch mal aufräumen. Das geht einfach:

from opensearchpy import OpenSearch

host = 'localhost'
port = 9201

# 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'

all={
    "query": { "match_all": {} }
}

client.delete_by_query(index=index_name, body=all, refresh=True)

Das sieht man dann unmittelbar mit http://localhost:9200/_cat/indices?v

health status index     uuid                   pri rep docs.count docs.deleted store.size pri.store.size
green  open   toots     Umfh_0jMTCabcNOXWbB29Q   4   0          0          201      1.9mb          1.9mb
green  open   .kibana_1 oPhh4WkXTha54EXU4SdJ7A   1   0          1            0        5kb            5kb

Es ist sinnvoll sich ein paar Scripte mit dem Erstellen eines Index, Löschen von Dokumenten oder auch Löschen eines kompletten Index (was auch das Mapping entfernt) durchführt.

Löschen eines Index ist noch radikaler:

...
# Der Name des Index
index_name = 'toots'

client.indices.delete(index=index_name)

Danach muss man aber den Index neu anlegen, bevor man wieder Daten hinzufügt.

Im nächsten Artikel steige ich in die Query-API ein. Das Mapping muss auch nochmal komplett überarbeitet werden, damit wir sinnvoller Suchen und Finden können.