The Hitchhiker's Guide to Full-Text Search
Vorwort
Die Anforderungen an eine Elasticsearch Volltextsuche können, je nach Anwendungsfall, sehr unterschiedlich sein. Deshalb möchte ich für diesen Blogartikel keine Allgemeingültigkeit heraufbeschwören. Ich möchte meine Gedanken und Erkenntnisse, die ich bei der Implementierung einer solchen Suche gemacht habe, mitteilen, auf dass es vielleicht für den Einen oder Anderen die Einstiegshürde etwas senken möge. Die hier beschriebenen Snippets wurden für Elasticsearch 5.4 erstellt 🙊
Hilfreiches Tool für alle die nicht mit cURL arbeiten möchten, oder noch kein anderes API Werkzeug nutzen: Postman
1. Kapitel: Die Daten
Um über eine Suche etwas finden zu können, brauchen wir zunächst Daten, also Informationen über Dinge. Grundsätzlich kann es sich dabei, je nach Projekt, um verschiedene Dinge handeln, wie z.B. Produkte, Nutzerinformationen, Orte usw. Daher ist es wichtig, dass wir uns im Vorfeld Gedanken darüber machen, welche Art Information wir vorliegen haben und wie ein Nutzer diese Information durchsuchen könnte. Bevor wir also mit Elasticsearch anfangen, analysieren wir selbst erst einmal unsere Daten:
- Um welche Art von Daten handelt es sich?
- Kenne ich bereits gängige Suchabfragen von Kunden?
- Welche Ergebnisse möchte ich dem Nutzer in der Suche anzeigen?
Diese Fragen sind beim Erstellen des Suchindex und qualitativen Bewertung der späteren Ergebnisse recht wichtig. Es kann nämlich sein, dass das mathematisch beste Ergebnis nicht jenes ist, welches ich dem Nutzer aus kaufmännischer Sicht an erster Stelle präsentieren möchte. In einem Supermarkt ist ein vergleichbar günstigeres Produkt z.B. auch nicht auf Sichthöhe im Regal eingeordnet, sondern weiter unten.
Im Weiteren lässt sich auch planen, weitere Metriken (z.B. aus Verkaufsanalysen) mit einzubeziehen. Indem Werte für Bestseller o.ä. indexiert werden, können bestimmte Ergebnisse stärker gewichtet werden.
Aber werden wir jetzt mal ein wenig konkreter.
Produktbezogene Sicht der Daten
Meistens fängt man ja nicht auf einer grünen Wiese an, sondern hat bereits Daten in irgendeiner Form vorliegen. Der Einfachheit nehmen wir an, dass Produktinformationen bereits in Elasticsearch für die Katalogdarstellung eines Shops vorliegen. Diese Daten sind meist in einer produktbezogenen Sicht strukturiert und setzen sich aus typischen Attributen zusammen:name, sku, color, price, size, product_typ, description,…
Das eignet sich super, um Filter anzuwenden um z.B. nur Produkte einer bestimmten Farbe anzuzeigen. Für eine Suche eignet sich diese Struktur meiner Meinung nach nur bedingt. Zwar wird die Suche nach „rot" sicher auch Ergebnisse liefern, aber wird ein Kunde lediglich danach suchen? Suchanfragen im Alltag setzen sich meistens eher aus zwei oder drei Begriffen zusammen. Ein Kunde wird wahrscheinlich eher nach „rote Rüben" suchen. Nun kommt die Krux. Die Suche mit mehreren Begriffen über mehrere Felder wird sowohl in der Konfiguration der Analyzer, als auch in der Query schnell komplex werden, wenn die Ergebnisse qualitativ dem entsprechen sollen was man erwartet. Denn unser Begriff „rote" wird auch bei anderen roten Produkten gefunden werden, wie auch Rüben bei Rüben anderer Farbe Treffer landen wird. Die vielen Felder machen es mir, bei der Entwicklung der Datenanalyse und der Suchabfrage, schwer den Überblick zu behalten. 👀
Besser wäre doch, wenn wir nur ein Feld hätten, das suchrelevante Attribute zusammenfasst und passen dafür unsere Analyzer und Query an. Das müsste auch das Debugging übersichtlicher machen.
Anwendungsbezogene Sichtweise der Daten
Mit dieser Sichtweise möchte ich mir bewusst machen, dass ich die Produktinformationen, für meine Anwendung aufbereiten und alle relevante Daten in einem Feld aggregieren könnte, um eine einfachere Struktur zu schaffen.
Die Anwendung ist in unserem Fall natürlich die Suchanfrage eines Anwenders, der passende Ergebnisse erwartet. Kenne ich meine Produkte und meine Nutzer ein wenig, werde ich relativ schnell herausfinden, welche Attribute aus den Produktdaten für die Suche relevant sein könnten. Diese verwende ich um mir mit Hilfe des Elasticsearch Mappings ein neues Feld zu erzeugen, in welchem ich dann die Werte der Attribute kopiere. Dieses Feld wird durch den Analyzer im inverted Index von Elasticsearch, also dem Lucene Unterbau, angelegt und ist kein Feld, das zur Anzeige genutzt wird. Es taucht damit auch nicht in der "_source"
auf, sondern dient lediglich dazu darauf Suchanfragen anzuwenden. Damit ich aber ein wenig mehr Kontrolle im Debugging habe, werde ich das Feld zusätzlich, für die spätere Ausgabe über eine Suchabfrage abspeichern. Bei größeren Datenmengen, erhalte ich dadurch die Möglichkeit mir zu jederzeit anzusehen welche Werte in meinem neuen Feld, das ich zur Suche verwende, enthalten sind.
So, genug der theoretischen Gedanken. Um auszuprobieren ob unsere Ideen auch in der Praxis bestehen können, packen wir im nächsten Kapitel die Elasticsearch API an und bauen uns einen neuen Suchindex auf. 💪
2. Kapitel: Das Mapping und der Analyzer
Da es für den Index einiges vorzubereiten gibt, wird dieses Kapitel wird wohl etwas länger werden, also erstmal durchatmen. 💨
Mit den bisher zusammengetragenen Gedanken machen wir uns nun mal ans Konfigurieren des Elasticsearch Indexes. Dafür definieren wir zunächst ein Mapping mit Dokumenttypen "veggies"
(Ab Elastic 6.x werden mapping types abgelöst), in dem wir festlegen welche Felder wir für die Suche benötigen. Dieses Mapping beinhaltet zunächst nur die relevanten Produktattribute, die wir dem Nutzer anzeigen möchten und ein Feld, welches wir ausschließlich für die Suche verwenden werden. Dieses Feld aggregiert die Werte aus den Produktattributen.
Unser stark vereinfachtes mapping sieht dann wie folgt aus:
// Die vollständige Indexdefinition erhaltet Ihr gegen Ende von Kapitel 2. ... "mappings": { "veggies": { "_source": { "enabled": true }, "_all": { "enabled": false }, "properties": { "color": { "copy_to": "full_text", "type": "text" }, "name": { "copy_to": "full_text", "type": "text" }, "product_type": { "copy_to": "full_text", "type": "text" }, "full_text": { "type": "text", "store": "yes" } } } } ...
Was haben wir gemacht? Zunächst ein neues Feld namens „full_text" definiert, welches in den Produktattributen so gar nicht vorkommt. Die Werte der relevanten Felder
"name"
,"color"
, usw. kopieren wir nun mit"copy_to": "full_text"
hinzu. Mit„store": „yes"
speichern wir das Feld ab, um es später für Debugging verfügbar zu machen.
Das Feld das wir erzeugt haben muss erst noch analysiert werden, so dass die Suche auch Treffer landen kann. Die Analyse wird, je nach Definition, die im Feld enthaltenen Daten aufschlüsseln und ermöglicht uns, Dokumente auszugeben, bei welchen die Suchanfrage zu einem aus der Analyse entstandenen Fragment passt. Schauen wir uns das gleich mal für unser Beispiel an.
Mit unseren Gedanken der Produktdaten und der anwendungsbezogenen Sichtweise im Hinterkopf, fallen mir zwei recht brauchbare Analysetypen ein. Zum einen möchten wir so etwas wie eine Search-as-you-type Suche anbieten. Also bereits Ergebnisse anzeigen, während der Nutzer noch dabei ist seine Eingabe zu tätigen. Zum Anderen stelle ich mir vor, dass es für den Anwender recht komfortabel ist, wenn er Treffer auch für Synonyme der Suchwörter erhält. Für erstes möchte ich ein "prefix" und für letzteres ein "synonym" Feld erzeugen, auf welche ich später meine Suchanfrage anwende.
Unser angepasstes mapping sieht nun wie folgt aus:
... "full_text": { "type": "text", "store": "yes", "fields": { "prefix": { "type": "text", "analyzer": "full_text_prefix" }, "synonyms": { "type": "text", "analyzer": "full_text_synonyms" } } ...
Was haben wir gemacht? Für unser Suchfeld
"full_text"
haben wir zwei Unterfelder definiert, die wir unterschiedlich indexieren möchten. Dadurch können wir das gleiche Feld für verschiedene Suchanwendungsfälle verfügbar machen und da die Felder getrennt sind, später z.B. auch in den Ergebnissen unterschiedlich behandeln.
Sehen wir uns nun unsere Definition eines Subfeldes an, bemerken wir, dass dort ein benutzerdefinierter Analyzer zum Einsatz kommt. Das bedeutet, damit unsere spätere Indexierung funktioniert, sollten wir uns schleunigst darum kümmern, zu hinterlegen wie dieser Analyzer zu arbeiten hat.
{ "settings": { "analysis": { "analyzer": { "full_text_prefix": { "filter": [ "lowercase", "edge_ngram_front" ], "char_filter": "html_strip", "tokenizer": "standard" }, "full_text_synonyms": { "filter": [ "lowercase", "synonyms" ], "char_filter": "html_strip", "tokenizer": "standard" } } ...
Der
"tokenizer": "standard"
ist dafür zuständig die Daten aus dem Feld in Fragmente zu zerlegen. Dadurch erhalten wir z.B. aus dem Feld „Granny Smith" die Token [Granny, Smith]. Praktisch, denn damit können wir Treffer landen, wenn der Kunde lediglich nach „Granny" sucht. Diese Token werden wir dann aber mit Token Filter weiter modifizieren, da uns das natürlich noch nicht ausreicht.Unsere gesetzten Filter
"edge_n_gram"
und "synonyms"
kennt Elasticsearch erstmal nicht. Wir müssen sie noch spezifizieren. Der Filter "lowercase"
ist selbsterklärend, da er lediglich aus [Granny, Smith], [granny, smith] erzeugt.
Nun, kann ich mich erinnern, dass wir vor hatten eine Search-as-you-type Suche anzubieten, was uns momentan aber noch nicht gelingen würde. Wir müssen Token wie „granny" noch weiter zerlegen, um Treffer auf Teile davon zu erhalten. Dafür stellt Elasticsearch den EdgeNGram Tokenizer bereit. Nun definieren wir einen custom filter "edge_ngram_front"
und konfigurieren ihn nach unserem Geschmack.... "analysis": { "filter": { "edge_ngram_front": { "min_gram": "3", "side": "front", "type": "edgeNGram", "max_gram": "10" } }, ...
Was haben wir gemacht? Unter analysis konfigurieren wir unsere Token Filter, die wir in den benutzerdefinierten Analyzern anwenden. Wir möchten, dass unser
"edgeNGram"
von vorne beginnt („side: front"
), mindestes 3 Zeichen besitzt("min_gram": "3"
) und aus maximal 10 Zeichen besteht ("max_gram": "10"
). Der User muss also mindestens 3 Zeichen eingeben, damit ich relevante Ergebnisse ausliefern kann.
Beim Entwickeln der benutzerdefinierten Analyzer kann folgende Abfrage sehr hilfreich sein, bei welcher man sowohl das indexierte Ergebnis der Produktdaten, als auch von Suchanfragen testen kann. Allerdings muss dazu der Index angelegt worden sein, was wir in ein paar Minuten erledigen werden.
GET veggie-shop.test:9200/groceries/_analyze { "analyzer": "full_text_prefix", "text": "Granny Smith" }
... "analysis": { "filter": { "edge_ngram_front": { "min_gram": "3", "side": "front", "type": "edgeNGram", "max_gram": "10" }, "synonyms": { "type": "synonym", "synonyms": [ "karotte, mohrrübe, möhre, rübe, rübli" ] } } ...
PUT veggie-shop.test:9200/groceries { "settings": { "number_of_shards": 1, "number_of_replicas": 1, "analysis": { "filter": { "edge_ngram_front": { "min_gram": "3", "side": "front", "type": "edgeNGram", "max_gram": "10" }, "synonyms": { "type": "synonym", "synonyms": [ "karotte, mohrrübe, möhre, rübe, rübli" ] } }, "analyzer": { "full_text_prefix": { "filter": [ "lowercase", "edge_ngram_front" ], "char_filter": "html_strip", "tokenizer": "standard" }, "full_text_synonyms": { "filter": [ "lowercase", "synonyms" ], "char_filter": "html_strip", "tokenizer": "standard" } } } }, "mappings": { "veggies": { "_source": { "enabled": true }, "_all": { "enabled": false }, "properties": { "color": { "copy_to": "full_text", "type": "text" }, "name": { "copy_to": "full_text", "type": "text" }, "product_type": { "copy_to": "full_text", "type": "text" }, "full_text": { "type": "text", "store": "yes", "fields": { "prefix": { "type": "text", "analyzer": "full_text_prefix", "fielddata": true }, "synonyms": { "type": "text", "analyzer": "full_text_synonyms", "fielddata": true } } } } } } }
Was haben wir gemacht? Mit der vollständigen Definition haben wir nun einen neuen, leeren Index in Elasticsearch angelegt. Eine Abfrage mit
GET veggie-shop.test:9200/_cat/indices?v
bestätigt uns das auch.
In meiner Einführung ging ich ja davon aus, dass wir schon einen existierenden Index mit Produktdaten zur Verfügung haben. Da würde es nun ausreichen, einen Reindex mit dem alten zum neuen Index durchzuführen.
POST veggie-shop.test:9200/_reindex { "source": { "index": "groceries_old" }, "dest": { "index": "groceries_new" } }
Da in den Synonymen von Mohrrüben die Rede ist, sollten wir uns ein paar Karotten in den Index packen und wegen dem Granny Smith Beispiel legen wir auch noch Äpfel dazu. Da wir uns eine Suche vorstellen, die auch mit mehreren Suchbegriffen zurecht kommen soll, geben wir dem Obst und Gemüse unterschiedliche Farben und Namen. Nun wissen wir, welche Produkte wir benötigen und müssen sie noch indexieren. Um mehrere Dokumente in einem Elasticsearch Index anzulegen gibt es den bulk Endpoint der API, den wir uns dafür zu nutze machen.
POST veggie-shop.test:9200/groceries/veggies/_bulk {"index": {} } {"color": "gelb","name": "Adelaide","product_type": "Karotte"} {"index": {} } {"color": "rot","name": "Berlicum","product_type": "Karotte"} {"index": {} } {"color": "grün","name": "Granny Smith","product_type": "Apfel"} {"index": {} } {"color": "rot","name": "Elstar","product_type": "Apfel"}
Was haben wir gemacht? An der URL erkennt man, dass wir die Anfrage an unseren neuen Index
/groceries
und auch gleich an unseren im Mapping hinterlegten Dokumententypen/veggies
senden. Jedes Dokument wird durch eine neue Zeile definiert, welcher der Befehl{"index": {} }
, der beschreibt was zu tun ist, vorangeht. Achtung! Bei Postman muss nach dem letzen Dokument noch eine Leerzeile hinzugefügt werden, damit alle Dokumente angelegt werden.Nehmen wir jetzt noch einmal die AbfrageGET veggie-shop.test:9200/_cat/indices?v
zur Hand, können wir sehen, dass derdocs.count
nun auf 4 angewachsen ist und wir endlich bereit für die echte Suchabfrage sind.
Kapitel 3: Die Volltextsuche
Gratulation 🎉! Wenn Du bis hier durchgehalten hast, ist es nicht mehr weit, bis wir endlich Ergebnisse sehen und der eigentliche Spaß beginnen kann.Grundsätzlich erhält man aus Elasticsearch Dokumente über den _search
Endpoint der API, mit einer Datenabfrage query
.
Da wir unser Datenfeld "full_text"
mit zwei Subfeldern indexiert haben, bietet sich eine Multi-Match Query an, die Suchwörter über mehrere Felder anwendet. Schauen wir uns das also mal an.
// Die vollständige Query erhaltet Ihr etwas weiter unten. { "from": 0, "size": 5, "min_score": 0.3, "query": { "multi_match": { "query": "Apfel", "fields": [ "full_text.synonyms", "full_text.prefix" ] } } }
Was haben wir gemacht? Zunächst möchten wir bei userer Search-as-you-type Suche nur die ersten fünf Ergebnisse anzeigen und schränken mit
"from"
und"size"
die Ergebnismenge ein. Ein"min_score"
legt fest wie hoch ein Dokumentenscore über die Abfrage sein muss um noch in den Ergebnissen gelistet zu werden. Jetzt zur eigentlichen Datenabfrage. Das Feld"query"
innerhalb der"multi_match"
Abfrage stellt die konkrete Eingabe des Nutzers dar. Im Array"fields"
hinterlegen wir unsere beiden indexierten Feldfilter für Synonyme und Prefix.
Damit ist die Basis Datenabfrage geschaffen und wir erhalten alle 🍎 mit:
GET veggie-shop.test:9200/groceries/veggies/_search { "from": 0, "size": 5, "min_score": 0.3, "query": { "multi_match": { "query": "Apfel", "fields": [ "full_text.synonyms", "full_text.prefix" ] } } }
"operator"
mit dem Wert "and"
hinzufügen. Damit weisen wir Elasticsearch an, uns nur noch Ergebnisse auszugeben, in welchen alle Suchbegriffe enthalten sind.GET veggie-shop.test:9200/groceries/veggies/_search { "from": 0, "size": 5, "min_score": 0.3, "query": { "multi_match": { "query": "roter apfel", "fields": [ "full_text.synonyms", "full_text.prefix" ], "operator": "and" } } }
Search-as-you-type
Jetzt testen wir mal eben unseren Search-as-you-type Modus. Wir erinnern uns, dass dafür unser Feld prefix zuständig ist. Da wir wissen, dass bereits N-Gramme der Felder indexiert sind, bekommen wir kein Herzklopfen beim Eingeben des Suchwortes „Gran" und erwarten entspannt als Ergebnis den „Granny Smith" Apfel. Gleiches Ergebnis erhalten wir auch für „smi", da das ebenfalls als Fragment des Tokens „smith" indexiert wurde. Nebenbei bemerkt ist auch in unserer Suche zuvor, nach „roter Apfel", bereits ein N-Gramm zum tragen gekommen, da der Suchbegriff „roter" ohne das Fragment „rot", nichts passendes finden würde. Da nur das exakte Wort auf unseren, in den Produktattributen hinterlegten, Farbwert passt.
GET veggie-shop.test:9200/groceries/veggies/_search { "from": 0, "size": 5, "min_score": 0.3, "query": { "multi_match": { "query": "Gran", "fields": [ "full_text.synonyms", "full_text.prefix" ], "operator": "and" } } }
Synonyme
Nun fehlt uns noch zu überprüfen, ob unsere Synonyme funktionieren. Also schicken wir mal munter das Wort „Rübe" los und siehe da, uns werden alle 🥕 präsentiert. Kombinieren wir das noch mit einer Anfrage, bestehend aus mehreren Suchbegriffen, die sowohl die Felder synonyms
als auch prefix
berücksichtigen müssen. Dass sich unsere Gedanken und die Vorarbeit ausgezahlt haben erkennen wir, da die Suche nach „gelbe Rüben", uns nur noch eine gelbe Karotte ausspuckt. 💯
GET veggie-shop.test:9200/groceries/veggies/_search { "from": 0, "size": 5, "min_score": 0.3, "query": { "multi_match": { "query": "gelbe Rüben", "fields": [ "full_text.synonyms", "full_text.prefix" ], "operator": "and" } } }
Analyse der eingegebenen Suchbegriffe
Wichtig ist auch zu erwähnen, dass auf die Suchwörter grundsätzlich die zum Feld passenden Analyzer angewendet werden. Das bedeutet „gelbe Rübe" wird für das Feld synonyms mit dem Analyzer "synonyms"
und für "prefix"
mit dem Analyzer "prefix"
verarbeitet. Je nach Komplexität der Felder, kann das auch zu unerwarteten Ergebnissen führen. In diesem Fall bietet es sich an, einen weiteren Analyzer nur für die Suchabfrage zu erstellen, analog zu unserem bisher verwendeten Prinzip. In der Suchabfrage können wir dann diesen Analyzer für die Auswertung der Suchwörter benutzen.
Beim beurteilen, wie unsere Query angewendet wird, ist folgende Abfrage sehr hilfreich, da wir mögliche Fehler in unserer Annahme erkennen können.GET veggie-shop.test:9200/groceries/veggies/_validate/query?explain
Zum Debuggen kann es auch praktisch sein zu sehen, was im Feld "full_text"
indexiert wurde.
Als Suchergebnisse erhalten wir aber grundsätzlich nur alle Felder, die in der "_source"
enthalten sind, also das, was wir explizit mit der Dokumentenstruktur über die "_bulk API
" geschrieben haben. Da wir aber im Mapping bei unserem Feld angegeben haben, dass wir es auch speichern möchten, ist eine Ausgabe über den Parameter "stored_fields"
möglich. Damit werden uns nur noch Felder angezeigt, die ausdrücklich mit dem Parameter definiert wurden.
{ ... "query": { "multi_match": { ... } }, "stored_fields": ["full_text"] }
Für weitere Verbesserungen gibt es Mechanismen, die bei der Anwendung hilfreich sein können. Hier verlassen wir aber unseren bisher recht einfachen Einstieg in die Volltextsuche und deshalb werden die folgenden Themen nur angerissen.
Field boosting gefällig?
Wir können weitere Analysefilter definieren und einzelne Felder stärker gewichten. Im Folgenden wird das Feld ``synonymsmit dem Faktor 2 berechnet, da wir Ergebnisse die einen direkten Treffer liefern höher bewerten möchten, als jene eines N-Gramms. Das könnte bei großen Datenmengen hilfreich sein, bei welchen N-Gramme auch mal bei nicht ganz passenden Produkten erzeugt werden. Da das Feld nun ein höheres Scoring erhält, werden die relevanteren Ergebnisse zuerst angezeigt.
"multi_match"... "fields": [ "full_text.synonyms^2", "full_text.prefix" ], ...
*Fuzziness ja/nein?* Weil Nutzer sich auch mal vertippen, kann eine einfache fuzziness auch Vorteile bringen, da wir dadurch bei Eingabe von _„Grnny Simth"_ ein passendes Suchergebnis erhalten. Jedoch muss man vorsichtig sein. Fuzziness und N-Gramme können auch schnell unerwartete Ergebnisse liefern. Da muss man sich dann tiefer in die Materie buddeln und z.B. die Query grundlegend erweitern, indem die einfachedurch eine mehrstufige
query:bool:must/should:multi_matchAbfrage abgelöst wird und dadurch die Felder unterschiedlich behandelt werden können.
"function_score"... "multi_match": { "query": "Grnny Simth", "fields": [ "full_text.synonyms^2", "full_text.prefix" ], "fuzziness": "AUTO", "operator": "and" } ...
*Function score und Script score* Desweiteren kann mitund
"script_score"noch tieferer Einfluss auf die Berechnung von Ergebnissen genommen werden, um z.B. Verkaufsränge zu berücksichtigen. Das setzt natürlich voraus, dass man dafür relevante Daten gesammelt und indexiert hat. Oder man definiert
functions``` die nach Treffern aus Filtern angewendet werden, wenn man z.B. bestimmte Wörter immer höher bewerten möchte.
{ ... "query": { "function_score": { "query": { .... }, "script_score": { "script": { "lang": "painless", "inline": "_score * doc['sell_ranking'].value" } } } } }
Schlusswort
Nun haben wir einen Ansatz ausprobiert und eine Herangehensweise kennengelernt mit der man relativ einfach mit der Elasticsearch API arbeiten kann. Mit einem guten Werkzeug zur Hand, macht es mehr Spaß sich tiefer in die Materie einzuarbeiten, da man die Auswirkungen schneller überprüfen kann. Nimmt man dazu noch die offizielle Doku zur Hand, bin ich mir sicher, dass man gute Ergebnisse erzielen kann.TLDR; 😅 Mir ist wichtig mitzugeben, dass die technische Sichtweise nur eine Seite der Medaille ist. Ebenso wichtig ist es, sich Gedanken über die zur Verfügung stehenden Daten zu machen, wie man sie aufbereitet und Ergebnisse qualitativ bewerten muss. Das möchte ich mit anwendungsbezogener Sichtweise auf die Daten ausdrücken.
Viel Spaß beim ausprobieren! ✊
(Januar, 2020 - Johannes Müller)
Weitere Blog-Artikel
Composable Commerce und Shopware: Ein Interview mit unserem Entwickler Niklas
Recap zur Shopware Unconference 2024
Andreas, Don Bosco und Niklas teilen ihre Erfahrungen von der Shopware Unconference 2024 in Köln.
Unser Co-Founder Don Bosco van Hoi im Interview
Unser Co-Geschäftsführer Bosco steht und Rede und Antwort rund um Mothership, Shopware und E-Commerce.