Der erste Blogpost zu Suchdiensten für ein WebGIS gab eine Übersicht zum Thema Suchdienste und warum eine präzise, schnelle Suchfunktion heutzutage insbesondere für eine spezialisierte Suche wichtig ist. Der zweite Teil vertieft einige Punkte und gibt zugleich Einblick in eine mögliche Implementation.
Architektur
Im ersten Blogpost haben wir die Architektur eines Suchdiensts mit Elasticsearch anhand des Beispiels einer Suche für Daten der Amtlichen Vermessung (AV) bereits erläutert. Zur Übersicht ist das Komponentendiagramm hier nochmals eingefügt:
Die AV-Daten sind als Originaldatensatz in einer PostgreSQL-Datenbank gespeichert. Daraus werden die Daten inklusive Geometrien periodisch mittels Python in Elasticsearch importiert. Gegen aussen kapselt der Suchdienst den Zugriff auf Elasticsearch, damit keine aufwändigen Operationen ausgeführt oder einfach alle Daten abgesaugt werden können.
Aufbau des Suchindexes
Die Import-Routine besteht aus einem kleinen, aber feinen Python-Skript (160 Zeilen inklusive Kommentare), welches die Daten von PostgreSQL zu Elasticsearch kopiert und die Indices konfiguriert. Der Datenimport kann einfach konfigurativ erweitert werden. Um einen neuen Datensatz hinzuzufügen, genügt es, eine zusätzliche Datei mit einem SQL-Statement (.sql) und die Elasticsearch Index-Einstellungen (.json) zur Importkonfiguration hinzuzufügen:
Index-Konfiguration
Beim Start des Datenimports werden zuerst die Index-Einstellungen für Elasticsearch eingelesen. Konkret handelt es sich dabei um Mappings sowie Analyzers und Tokenizers, welche definieren, wie die Daten für die Suche aufbereitet werden. Für die Suche nach EGRIDs (eindeutige Grundstücksidentifikatoren) kommt beispielsweise folgende Konfiguration zum Einsatz (egrid.json):
{
"settings": {
"analysis": {
"tokenizer": {
"egrid_tokenizer": {
"type": "simple_pattern",
"pattern": "[0-9]{12}"
}
},
"analyzer": {
"egrid_analyzer": {
"tokenizer": "egrid_tokenizer",
"filter": [ "lowercase" ]
}
}
}
},
"mappings": {
"properties": {
"egris_egrid": {
"type": "text",
"analyzer": "egrid_analyzer"
},
"nummer": { "type": "text" },
"nummer_grundstueck": { "type": "text" },
"gemeinde": { "type": "text" },
"geometry": { "type": "geo_shape" }
}
}
}
Die Analyzer können – in Abhängigkeit von den Anforderungen und den Daten – unterschiedlich komplex aufgebaut werden. Beispielsweise möchten wir für Adressen eine Suche über mehrere Felder inklusive Autovervollständigung und Fuzzy Search ermöglichen. Dazu hängen wir für jede Adresse Strassennamen, Hausnummer, Gemeinde und Ortschaft in einem neuen Feld zu einer Zeichenkette zusammen. Das neue Feld zerschneiden wir dann mit einem sogenannten Edge N-Gram Tokenizer (dieser zerteilt Worte immer ab Wortbeginn in sogenannte N-Gramme auf, was nützlich ist für «search-as-you-type»-Anfragen; also Anfragen, bei denen die Suche bereits während der Eingabe anläuft). Hier die Index-Konfiguration für die Adresssuche (adresse.json)
{
"settings": {
"analysis": {
"tokenizer": {
"ngram_tokenizer": {
"type": "ngram",
"min_gram": 3,
"max_gram": 3,
"token_chars": [ "letter", "digit" ]
},
"edgeNgram_tokenizer": {
"type": "edge_ngram",
"min_gram": 1,
"max_gram": 3,
"token_chars": [ "letter", "digit" ]
}
},
"analyzer": {
"my_analyzer": {
"tokenizer": "ngram_tokenizer",
"filter": [ "lowercase" ]
},
"strHausnr_analyzer": {
"tokenizer": "edgeNgram_tokenizer",
"filter": [ "lowercase" ]
},
"lowercase_analyzer": { "tokenizer": "lowercase" }
}
}
},
"mappings": {
"properties": {
"strassenname": {
"type": "text",
"fields": { "raw": { "type": "keyword" } },
"analyzer": "strHausnr_analyzer",
"copy_to": "adresse"
},
"hausnummer": {
"type": "text",
"fields": { "raw": { "type": "keyword" } },
"analyzer": "strHausnr_analyzer",
"copy_to": "adresse"
},
"gemeinde": {
"type": "text",
"fields": { "raw": { "type": "keyword" } },
"copy_to": "adresse"
},
"ort": {
"type": "text",
"fields": { "raw": { "type": "keyword" } },
"copy_to": "adresse"
},
"adresse": {
"type": "text",
"fields": {
"ngram": {
"type": "text",
"analyzer": "my_analyzer"
},
"raw": { "type": "keyword" }
},
"analyzer": "lowercase_analyzer",
"search_analyzer": "lowercase_analyzer"
},
"geometry": { "type": "geo_shape" }
}
}
}
Um einzelne Felder oder exakte Treffer einzelner Felder stärker zu gewichten und damit die Suchergebnisse zu verbessern, kann es sinnvoll sein, das gleiche Feld mehrmals und auf verschiedene Arten zu indizieren. Ein String-Feld kann beispielsweise für die Volltextsuche als Text-Feld, zugleich aber für die Sortierung als Keyword gemappt werden.
Datentransfer
Bevor wir aber eine Suche durchführen können, müssen nun auch die Daten aus der PostgreSQL-Datenbank in Elasticsearch geladen werden. Für den Adressimport sieht die SQL-Abfrage wie folgt aus:
SELECT
'adresse' AS _index,
t_id AS _id,
gemeinde,
strassenname,
hausnummer,
plz,
ort,
ST_AsGeoJSON(ST_Transform(geom, 4326))::json As geometry
FROM av_1700.ga_gebaeudeeingang
Das Select-Statement ist so aufgebaut, dass die zurückgegebenen Datensätze schon in einer für Elasticsearch geeigneten Repräsentation sind. Die Spaltennamen (_index
und _id
) bezeichnen hierbei den Elasticsearch-Index und den eindeutigen Dokument-Identifier. Ausserdem transformieren wir mithilfe der PostGIS-Extension von PostgreSQL die Geometrien in ein GeoJSON im Bezugssystem WGS 1984 (EPSG:4326). Damit stehen uns dann im Suchdienst auch die räumlichen Suchparameter zur Verfügung.
Die PostgreSQL-Client-Library psycopg2 bietet mit RealDictCursor eine Möglichkeit, jede Resultatzeile als Python-Dictionary zu erhalten. Diese können wir direkt an Elasticsearch weiterreichen:
def upsert(conn, es: Elasticsearch, sql_query):
"""
Fetch data from PostgreSQL and pour into Elasticsearch.
You need to make sure that the query results in a valid Elasticsearch document (e.g. has _index and _id columns).
See https://elasticsearch-py.readthedocs.io/en/master/helpers.html#bulk-helpers
:param conn: psycopg2 connection object
:param es: Elasticsearch connection object
:param sql_query: query to get all data
"""
cur = conn.cursor(cursor_factory=psycopg2.extras.RealDictCursor)
try:
cur.execute(sql_query)
return bulk(es, cur)
finally:
cur.close()
Weil PostgreSQLs cur.execute()
einen Iterator zurückgibt und Elasticsearchs bulk()
einen Iterator akzeptiert, werden die Daten effektiv gestreamt. Dieses Verfahren eignet sich somit auch für sehr grosse Datenmengen.
Nun haben wir die Suchindices eingerichtet und können uns im folgenden Abschnitt mit der eigentlichen Suchabfrage befassen.
Suchdienst
Die Web API ist ein stateless Python-Service basierend auf dem Falcon Microframework. Wie andere Python Web-Microframeworks (zum Beispiel flask, bottlepy oder FastAPI) ermöglicht es uns, mit sehr wenig Code einen Service zu schreiben. Der Code besteht praktisch nur aus Logik zur Formulierung einer Elasticsearch-Abfrage und zur Umwandlung der Resultate in ein GeoJSON (mithilfe des Python packages geojson).
def search_adresse(self, query: str, limit: int) -> Iterable[GeometryResult]:
"""
Searches in index 'adresse' in fields 'hausnummer' and 'adresse' and gives back the respective geometry. Search in field 'adresse' with fuzziness
"""
# short circuit if query is an e-grid (14 chars beginning with 'CH')
if is_probably_egrid(query):
return
answer = self._es.search(
index='adresse',
body={
"query": {
"bool": {
"should": [
{ "match": { "strassenname": { "query": query } } },
{ "match": { "strassenname.raw": { "query": query } } },
{ "match": { "ort.raw": { "query": query } } },
{ "match": { "hausnummer": { "query": query } } },
{ "match": { "adresse.ngram": { "query": query } } },
{ "match": { "adresse": { "query": query, "fuzziness": 2 } } }
]
}
},
"sort" : [
"_score",
{ "hausnummer.raw": "asc" },
{ "strassenname.raw": "asc" }
]
}
)
def prefer_no_hausnummer(hit: Dict[str, Any]):
if hit['_source']['hausnummer'] is None:
return 0
else:
return 1
sorted_hits = sorted(answer['hits']['hits'], key=prefer_no_hausnummer)
for hit in sorted_hits:
d = hit['_source']
if d['hausnummer'] is not None:
label = f"{d['strassenname']} {d['hausnummer']}, {d['plz']} {d['ort']} (Adresse {d['gemeinde']})"
else:
label = f"{d['strassenname']}, {d['plz']} {d['ort']} (Adresse {d['gemeinde']})"
yield GeometryResult(label=label, geometry=d['geometry'])
Fazit
Eine Suchfunktion lässt sich zwar grundsätzlich «einfach» mit gezielten SQL-Abfragen auf einer bestehenden Datenbank implementieren. Aber sobald die Lösung eine gewisse Komplexität erreicht, zum Beispiel wenn über verschiedene Felder mit N-Grams und einer Gewichtung gesucht werden soll, ist die Einführung einer Search-Engine mit viel weniger Aufwand verbunden.
Elasticsearch bietet einen leistungsfähigen Suchalgorithmus und sehr viele Konfigurationsmöglichkeiten, mit denen man effizient zahlreiche Anforderungen für verschiedenste Anwendungen umsetzen kann.
Daneben gibt es noch weitere Möglichkeiten: Falls die Datenmenge klein ist und die Suche äussert responsiv sein muss, kann auch der Einsatz einer client-side Search-Engine interessant sein. Mit MiniSearch haben wir auch schon mit sehr kleinem Aufwand eine Fuzzy Search für Strassennamen und Hausnummern implementiert, die komplett im Webbrowser läuft.
Falls Sie an Suchdiensten und -lösungen interessiert sind, können Sie uns gerne kontaktieren.