Eine Mutter tippt "Turnen" und erwartet das Kinderturnen. Ein Vater tippt "maerchen", weil sein Handy keine Umlaute vorschlägt, und will die Märchenstunde. Ein Trainer tippt "U12" und sucht das "U 12 Training". Für Lokido, eine Familienplattform für Termine im Nordwesten, brauchte ich eine Suche, die mit solchen Eingaben umgehen kann. Die Plattform läuft auf gewöhnlichem Shared Hosting. Ein Suchserver war keine Option.
Die Anforderungen
Gesucht wird über Termine, Terminreihen, Vereinsgesuche und Anbieter mit eigener Seite. Die Datenmenge liegt im Bereich einiger tausend Datensätze. Die harte Bedingung kam aus einer anderen Ecke: Termine in Moderation oder außerhalb ihres Sichtbarkeitsfensters antworten auf ihrer Detailseite mit 404, damit niemand von außen erkennt, dass es sie gibt. Die Suche durfte diese Regel nicht über Treffer, Trefferzahlen oder Vorschläge unterlaufen.
Der Aufbau
Die Grundlage sind normalisierte Suchspalten direkt an den Doctrine-Entities. Beim Speichern wird der Text kleingeschrieben, Umlaute werden aufgelöst und Sonderzeichen entfernt. Gesucht wird mit LIKE über diese Spalten, mehrteilige Eingaben werden in Wörter zerlegt und einzeln geprüft. Damit findet "maerchen" wie "marchen" dieselbe Märchenstunde.
Darüber sitzt ein Such-Service mit einer eigenen Schnittstelle. Er rankt nach Relevanz und Datum und prüft die Sichtbarkeit mit genau derselben Logik, die auch Listen und Zähler verwenden. Ein Architekturtest stellt sicher, dass kein zweiter Zählpfad entsteht. Das ist der Teil, den ich bei jedem ähnlichen Projekt wieder so bauen würde.
Findet die Suche nichts, versucht sie es in festen Schritten weiter und sagt auf der Ergebnisseite, welchen Schritt sie gegangen ist:
- jedes Wort einzeln statt aller zusammen
- Einzahl statt Mehrzahl
- ein Korrekturvorschlag bei Tippfehlern
- gesetzte Filter lösen
- den ganzen Landkreis statt nur der Stadt
Der letzte Schritt landet dort, wo die Plattform ohnehin gegliedert ist, auf Landkreisebene. Wie so ein Landkreis aussieht, zeigt zum Beispiel der Hub für den Landkreis Oldenburg.
Für wiederkehrende Anlässe wie Laternenumzüge oder Weihnachtsmärkte gibt es feste Themenrouten. Die erlaubten Slugs stehen mit ihren Suchbegriffen in der Bundle-Konfiguration als YAML. Alles, was dort nicht steht, antwortet mit 404. Es gibt dafür keine Entity und keine Admin-Oberfläche, weil sich die Liste zweimal im Jahr ändert.
Die Vorschläge beim Tippen kommen über einen kleinen JSON-Endpunkt und einen Stimulus-Controller im Kopfbereich. Ohne JavaScript führt der Link auf eine normale Suchseite. Ausgewertet werden nur Suchbegriffe als Tageszähler, ohne Personenbezug. Eingaben, die nach E-Mail-Adresse oder langer Ziffernfolge aussehen, werden verworfen, bevor sie gezählt werden.
Was teurer war als geplant
Die erste Normalisierung hat Einzelzeichen-Wörter zerlegt. Aus "U 12" wurden die Fragmente "u" und "12", aus "E-Jugend" wurden "e" und "jugend", und bei "Rock 'n' Roll" ging das "n" verloren. Die Fragmente trafen fast alles und damit nichts Brauchbares. Die Lösung waren zusätzliche Verbund-Varianten, die solche Einzelzeichen mit dem Nachbarwort zusammenziehen.
Direkt danach kam der zweite Fehler. Die Verbünde entstanden auch über Feldgrenzen hinweg, also aus dem letzten Wort des Titels und dem ersten Wort der Beschreibung. Das erzeugte Treffer für Wörter, die in keinem der beiden Felder standen. Die Normalisierung musste deshalb je Feld getrennt laufen. Beide Korrekturen zusammen haben etwa so viel Zeit gekostet wie das ursprüngliche Fundament.
Aus der Praxis
Was ich daraus mitnehme: Testdaten für eine Suche sollten nicht aus sauberen Titeln bestehen, sondern aus dem, was Vereine tatsächlich eintragen. Die Fehler oben wären mit echten Titeln aus der Datenbank am ersten Tag aufgefallen.
Was davon übertragbar ist
Der Ansatz passt für Symfony-Anwendungen mit einigen hundert bis wenigen tausend durchsuchbaren Datensätzen auf Hosting ohne eigene Dienste. Das trifft auf viele Verzeichnisse, kleinere Shop-Kataloge und Veranstaltungskalender zu. Zwei Teile lassen sich auch einzeln übernehmen: die Regel, dass Sichtbarkeit und Zählung genau eine Implementierung haben und ein Test das absichert, und eine Null-Treffer-Kette, die dem Nutzer sagt, was sie getan hat, statt eine leere Seite zu zeigen.
Wo der Weg aufhört
Sobald die Datenmenge in den Bereich mehrerer zehntausend Datensätze wächst oder die Relevanz mehr braucht als Treffer auf einzelne Wörter, etwa Phrasen, Wortnähe oder gewichtete Synonyme, tragen LIKE-Abfragen über normalisierte Spalten nicht mehr. Dann ist ein echter Index nötig. Weil der Such-Service hinter einer Schnittstelle liegt, bleibt dieser Wechsel ein Austausch der Implementierung und kein Umbau der Anwendung.
Die Suche läuft jetzt seit ein paar Tagen. Als Nächstes schaue ich mir an, welche Begriffe ohne Treffer bleiben, weil das die ehrlichste Liste dessen ist, was in den Daten noch fehlt.