NephroFood · Backend
SearchService – Technische Doku (mit Inhaltsverzeichnis & Direktlinks)
Diese Doku beschreibt die Klasse NephroFood\Service\SearchService im aktuellen Stand:
Index-Struktur, Parsing, Filterlogik (inkl. OR), Scoring/Fuzzy, Direkt-Lookups (id:/slug:),
und NotFound-Logging.
search() = komplette Query-Sprache (Text + Filter + und/oder + Gruppen) – inklusive Direkt-Lookup.
id:/slug: sind streng (1 Treffer oder 0). Keine Fehlertoleranz, kein Fuzzy.
Nach oben
search_index.json (Items + Prefix-Buckets + Meta) und stellt zwei
öffentliche APIs bereit: suggest() (Autocomplete) und search() (voller Query-Parser inkl. Filter/Logik).
Ziel ist ein Setup, das im Alltag für Patient:innen schnell und stabil ist – und gleichzeitig technisch sauber erweiterbar bleibt.
Zweck
-
Index-basierte Suche über
search_index.json: kein „Live-DB-Querying“, sondern schnelle Lookups auf vorbereiteten Daten (Tokens/Keywords/Preview/Groups). - Suggest (Autocomplete) ist optimiert für UI-Feeling: geringe Latenz, wenige Regeln, „findet Namen“ – nicht „versteht Logik“.
- Search wertet die komplette Query-Sprache aus: Text + Filter + Logik (AND/OR) + Gruppen – inkl. OR bei Filtern (DNF) und Mehrwort-OR-Gruppen.
- Stabilität und Qualität: Dedupe der Kandidaten (keine Doppeltreffer), optionales Fuzzy, „Supplement Scan“ gegen Bucket-Lücken.
- Telemetry / Wartbarkeit: NotFound-Logging (0 Treffer), Rate-Limit (Bot-Schutz), optional Mail-Trigger bei wiederkehrenden Suchanfragen.
Architektur-Bausteine
items[]mit Labels, Tokens, Preview-Werten, Gruppen, Keywords, optional Portionenprefix{}als Bucket-Map (Prefix → Item-IDs) zur schnellen Kandidatenreduktionmetamit Parametern wieprefix_len
search(): „Was hat der User exakt formuliert?“ → Parser + Filterlogik + Ranking.
Ziele (Design-Prinzipien)
| Ziel | Was das konkret bedeutet | Wie es im Code umgesetzt ist |
|---|---|---|
| Geschwindigkeit | UI muss sich „sofort“ anfühlen – Suggest darf nicht teuer sein. | Prefix-Buckets + begrenztes max_scan + Dedupe-Map. |
| Vorhersehbarkeit | Filter dürfen keine „Überraschungen“ liefern (keine stillen Einheiten-Konvertierungen). | Numeric-Filter nur als Zahl; fehlende Previewwerte → Filter kann nicht erfüllt werden. |
| Relevanz | Exakte Treffer sollen ganz oben stehen; fuzzy darf helfen, aber nicht dominieren. | Label/Token Heuristik + optionaler Fuzzy-Boost; 100 ist reserviert. |
| Robustheit | Keine „verlorenen“ Treffer durch Index-Build-Eigenheiten. | keywords werden als Tokens ergänzt; Supplement-Scan bei Bedarf. |
| Wartung | Fehlende Lebensmittel/Queries sollen sichtbar werden. | NotFound JSON + Rate-Limit + optionales Mailen (threshold + min_interval). |
terms, term_groups, filters, filter_groups).Direkt-Lookup (
id:/slug:) ist eine Abkürzung und wird absichtlich
nur in search() ausgewertet:
- kein Fuzzy
- kein Ranking
- Ergebnis ist „exakt 1 oder 0“
- Wenn du neue Query-Features ergänzt: erst Parser-Struktur erweitern, dann Auswertung (Filter/Match/Scoring) separat – so bleibt es testbar.
- „Medizinisch“ bedeutet oft: lieber streng als kreativ. Deshalb ist Filter-Parsing bewusst eng und UI/Query-Builder übernimmt die Benutzerführung.
search():
welche Schritte passieren in welcher Reihenfolge – und warum die Reihenfolge wichtig ist.
Genau das hilft später beim Debuggen („Warum sind es 0 Treffer?“ / „Warum ist das Ranking komisch?“).
id:/slug: ist „exakt 1 oder 0“ und muss vor dem Parser passieren:
- kein Fuzzy
- kein Candidate-Scan
- kein Ranking
- langsamer (viel mehr Items würden gescored)
- unübersichtlich beim Debuggen (Top-Treffer verschwindet später)
Schritt-für-Schritt (mit typischen Effekten)
| Step | Funktion/Block | Was passiert genau? | Typische Probleme (Debug) |
|---|---|---|---|
| 0 | trim + max_query_len |
Query wird abgeschnitten (Schutz), damit keine extrem langen Strings den Parser/Scan sprengen. |
„Warum fehlen hinten Teile?“ → search.max_query_len prüfen.
|
| 1 | parseDirectFoodSelector() |
Erkennt id:/slug: (inkl. Aliases, Quotes, {..}) und macht
exakten Lookup über findByIdLiteral() / findBySlugLiteral().
|
„slug findet nicht“ → Slug im Index anders? Doppelte Slugs? Normierung (normSlug) prüfen.
|
| 2 | parseQuery() |
Zerlegt Query in:
|
„Filter wirkt wie Text“ → KeyMap/Alias nicht erkannt (z.B. falscher Nährstoffname). |
| 3 | candidatesFor() |
Kandidaten werden begrenzt: Prefix-Bucket → Items → optional Supplement-Scan → optional Fuzzy-Kandidaten. Dedupe über ID-Map. | „Treffer fehlt, obwohl Token existiert“ → Bucket-Lücke → Supplement Scan prüfen; Index-Build prüfen. |
| 4 | hitsNegativeKeywords() |
Harte Exklusion auf Basis von negative_keywords[lang] (exakt/contains).
|
„Warum kommt X nie?“ → Negative keywords checken. |
| 5 | passesFilterGroups() |
Filter-Logik:
|
„0 Treffer obwohl Lebensmittel existiert“ → Filter zu streng oder Preview-Wert fehlt. |
| 6 | matchesTermGroups() |
Term-Logik:
OR über Gruppen; innerhalb der Gruppe müssen alle Tokens matchen (Label/Token + adaptive fuzzyTermHit).
Optionaler Fuzzy-Fallback wenn scoreItem() > 0.
|
„Kurzes Wort findet zu viel/zu wenig“ → adaptiveMaxDistance-Regeln prüfen (ctx.fuzzy.adaptive_max_distance).
|
| 7 | scoreItem() + Normalisierung |
Score wird über Label/Token-Heuristik berechnet, Fuzzy kann boosten.
Danach Normalisierung:
|
„Warum hat etwas nie 100?“ → 100 ist reserviert (PrimaryExact / Direkt-Lookup). |
| 8 | packItem() |
Output wird gebaut:
id, slug, name, group, preview, portion plus:
_match, fullmatch, match_terms, matched_terms.
|
„UI zeigt falsche Labels“ → itemLabel() / Sprachfallback prüfen.
|
| 9 | logNotFound() |
Nur bei 0 Treffer und nur wenn echte terms vorhanden sind:
Key wird normalisiert, deduped, gezählt, Rate-Limit geschützt, optional Mail.
|
„Warum wird nicht geloggt?“ → Query nur Filter? notfound.enabled false? Rate-Limit?
|
- Nur Text testen:
Apfel - Dann ein Filter:
Apfel, Kalium < 150 - Dann OR-Filter:
Kalium < 100 oder Phosphat < 100, Natrium < 50 - Dann Gruppe + Filter:
Gruppe: obst, Kalium < 150
suggest() nutzt nur einen Term ($q) und erzeugt keine Filterlogik.
Filter/Logik gehören in search().
Wenn Nutzer Filter „in Suggest“ tippen, ist das erwartungsgemäß nicht zuverlässig.
| Cache / State | Typ | Nutzen |
|---|---|---|
$keyMapCache |
alias → canonical |
Filter-Parsing schnell & stabil: User-Schreibweisen (k, kalium, phosphor) werden
einmalig auf Canonical Keys (z.B. kalium_mg) abgebildet.
Dadurch muss parseQuery() nicht “raten”.
|
$allowedCanonKeysCache |
Whitelist |
Preview-Felder gezielt begrenzen (z.B. für Export/Query-Builder) und gleichzeitig
group_slug als Sonderfall erlauben.
Spart “unnötige” Key-Map-Varianten und macht UI-Listen konsistent.
|
$bySlugCache |
normSlug(slug) → item |
O(1) Direkt-Lookup (slug:) ohne Candidate-Scan.
Wichtig: normiert Slugs über normSlug() (z.B. Leerzeichen/Case), damit Copy/Paste robust ist.
|
prefix buckets (idx[prefix]) |
prefix → ids | Der wichtigste Beschleuniger: Statt alle Items zu prüfen, wird nur der Bucket des Query-Prefix gescannt. Das reduziert Kandidaten von “N” auf “Bucket-Größe” – und ist der Grund, warum Autocomplete flüssig bleibt. |
-
Prefix (
meta.prefix_len): nur IDs aus dem passenden Bucket. - max_scan (suggest/search): harte Obergrenze, wie viele Kandidaten maximal gesammelt werden.
-
Dedupe-Map (
$candidateMap[id]=item): jedes Item nur einmal – auch wenn mehrere Terms es finden.
candidatesFor() optional zusätzlich scannen:
- nur solange die Kandidaten-Map kleiner als
maxScanist - nur auf “direkten Term-Match” (kein teurer Vollscan mit Ranking)
- Typischer Fall: Bucket-Größe ist moderat → Scoring läuft nur über wenige hundert Items → schnell.
-
Worst Case: sehr viele kurze Begriffe (z.B. “tee”) oder große Buckets → mehr Kandidaten → mehr Levenshtein-Aufrufe.
Dann sind
min_query_len,adaptiveMaxDistanceundmax_scandie wichtigsten Stellschrauben. - Filter helfen auch Performance: je früher harte Filter greifen, desto weniger Items müssen gerankt werden.
- Zu große max_scan-Werte → unnötig viele Kandidaten → Ranking/Fuzzy kostet.
- Zu tolerantes Fuzzy bei kurzen Queries → viele Treffer → Ranking “verwässert”.
- Kein Prefix/zu kleiner prefix_len → Buckets werden groß → Kandidaten explodieren.
- byId()-Cache analog zu
slugMap()(damit byId nicht mehrfach neu gebaut wird). - Levenshtein-Calls reduzieren: early exits, Distanz nur bis maxDist prüfen, ggf. Token-Limits.
- Prefix-Strategie verfeinern: z.B. zusätzliche Buckets nach erstem Token, oder getrennte Buckets für numbers.
- items: alle Lebensmittel/Rezepte als flache Datensätze (mit Tokens, Preview-Werten, Gruppen).
- prefix: Buckets (Prefix → IDs), damit Suggest/Search nur eine kleine Kandidatenmenge prüfen muss.
- meta: Steuerinfos (z.B.
prefix_len), damit Parser und Buckets zusammenpassen.
byId(): Mapid → item(für Buckets, Scoring, Filter).slugMap(): MapnormSlug(slug) → item(fürslug:-Lookup).
items.
Beispiel (bei prefix_len=3):
prefix.
Wenn du das änderst, muss der Index neu gebaut werden – sonst passen Buckets und Query-Normalisierung nicht zusammen.
idist der Primärschlüssel für Buckets & Dedupe.slugist der Goldstandard für exakte Treffer (und fürslug:-Lookup).label_deist das UI-Label (SearchService nutztitemLabel()als Anzeige-Fallback).name/subwerden fürprimaryStrings()kombiniert (name, label, name+sub).
tokens_de/tokens_ensind die Hauptbasis fürs Matching & Scoring.keywordswerden im Service zusätzlich als Tokens nachgeladen (Sicherheitsnetz).- Gruppentokens wie
g:.../gid:...– das ist super für die Textsuche.
preview.
Wichtig: Fehlt ein Wert (null/leer/nicht vorhanden), kann das Item den Filter nicht erfüllen.
Das ist beabsichtigt, weil “fehlend” medizinisch nicht als “OK” gewertet werden darf.
Gruppe:/g=) werden intern auf group_slug gemappt
und gegen alle Gruppen-Slugs geprüft (alt: group, neu: groups[]).
Damit kann ein Item in mehreren Kategorien “sichtbar” sein, ohne dass Filter kaputtgehen.
preview phosphor_mg, während dein KeyMap-Fallback häufig
auf phosphat_mg mappt. Das ist kein Problem, wenn dein nutrients.json / keyMap beide korrekt abbildet.
Sonst kann es passieren, dass ein Filter “Phosphat < 100” ins Leere läuft, weil der Canonical Key anders heißt.
(Best Practice: einen Canonical Key festlegen und im Index/Preview konsistent verwenden.)
| Feld | Typ | Verwendung |
|---|---|---|
id | string | Kandidaten-ID, Direct Lookup (id:), Dedupe-Key. |
slug | string | Direct Lookup (slug:), PrimaryExact/Match100, UI-Fallback. |
name_de/name_en | string | Primärname für primaryStrings. |
label_de/label_en | string | Anzeige-Label (itemLabel). |
sub_de/sub_en | string | Untertitel; wird als name+sub zusammengeführt. |
tokens(_de/_en) | array | Matching/Scoring; “Sicherheitsnetz” durch keywords. |
keywords[lang] | array | Wird in itemTokens() zusätzlich als Tokens gesplittet. |
preview | array | Nährwerte (numerische Filter) – fehlende Werte können Filter verhindern. |
group / groups | obj/array | Gruppenfilter (group_slug) – alle Gruppen werden geprüft. |
portion | obj|null | Optional (Output in packItem). |
negative_keywords[lang] | array | Harte Exklusion (hitsNegativeKeywords). |
preview[kalium_mg] kann nicht Kalium < 150 erfüllen.
Das ist beabsichtigt (Filter = harte Bedingung).
passesFilters() sammelt alle Gruppen-Slugs aus group und groups[],
normalisiert sie (normSlug) und prüft dann in_array(want, slugs).
includes/search.config.php (oder aus dem zentralen Config-Loader).
| Key | Typ | Wirkung |
|---|---|---|
suggest.limit | int | Max. Vorschläge. |
suggest.min_query_len | int | Min. Query-Länge für Suggest. |
suggest.max_scan | int | Max Kandidaten (inkl. Supplement Scan). |
suggest.supplement_scan | bool | Ergänzt Kandidaten, falls Prefix-Bucket unvollständig ist. |
suggest.fuzzy.enabled | bool | Aktiviert Fuzzy (Boost + Fallback-Kandidaten). |
suggest.fuzzy.boost | int | Boost - Distanz (Levenshtein). |
search.limit | int | Max. Trefferliste. |
search.max_scan | int | Max. Kandidaten pro Term (Search). |
search.max_query_len | int | Schutz: Query wird gekürzt. |
search.fuzzy.only_if_no_direct_hit | bool | Fuzzy-Boost nur wenn kein direkter Hit. |
match100.contains_min_len | int | Ab dieser Länge wird contains als “Match100” gewertet. |
notfound.enabled | bool | NotFound Logging aktivieren. |
notfound.file | string | Pfad zu notfound.json (relativ zu indexPath möglich). |
notfound.rate.max_per_min | int | Rate-Limit für Bot-Schutz. |
notfound.email.enabled | bool | Mail bei NotFound (optional). |
notfound.email.threshold | int | Mails nur bei count==1 oder Vielfachen (z.B. 3,6,9...). |
kalium_mg),
unabhängig davon, welche Schreibweise der User verwendet.
id: oder slug: erkannt, beendet search() sofort die normale Suche
und liefert entweder found (genau ein Item) oder notfound (leeres Array).
{...}, "...", '...' – praktisch für Slugs oder IDs aus Copy/Paste.
parseQuery() eingebaut wird, bricht der Parser-Kontrakt:
search() erwartet ein Struktur-Array mit Keys (terms, filters, …).
Ergebnis: leere Treffer.
- Slug im Index anders? Direct Lookup ist exakt:
slug:biskuitbodenfindet nur ein Item, dessenslugnachnormSlugexaktbiskuitbodenergibt. - Index neu bauen? Wenn das Lebensmittel im UI auftaucht, aber der slug im Index fehlt oder anders ist, ist es ein Index-Build-Thema.
- Mehrere gleiche Slugs? slugMap nutzt “first wins”. Doppelte Slugs sollten im Build verhindert werden.
| Eingabe | Parser-Verhalten |
|---|---|
, / Zeilenumbruch / ; |
Segment-Trenner (Blöcke). |
und / and / & / && |
AND innerhalb eines Segments. |
oder / or / | / || |
OR-Alternativen innerhalb eines Segments. |
Dezimalzahlen 150,5 |
Komma im Zahlwert wird nicht als Segment-Trenner interpretiert. |
und → &, oder → |) passiert vor der Segmentierung.
Deshalb müssen Gruppen-Literale geschützt werden (siehe 5.4).
matchesTermGroups() prüft: eine Gruppe muss komplett treffen (OR).
Für jedes Token: direkter Label-Hit oder tokenHit (inkl. fuzzyTermHit).
filter_groups distributiv, wenn ein Segment nur Filter enthält.
Dadurch ist möglich: Kalium < 100 oder Phosphat < 100, Natrium < 50.
g:Fleisch,Wurstwaren,Eier und Alternativen in mehrere Segmente zerfallen
(Komma-Split) und “und” würde zu & werden (Operator-Normalisierung).
protectGroupLiterals() ersetzt den Gruppenwert vorübergehend durch Platzhalter (GFLIT0, GFLIT1 …),
so dass Komma/und/oder-Normalisierung den Literal nicht beschädigt. Beim Parsen wird der Platzhalter wieder “entpackt”.
scoreItem() ranken und auf _match normalisieren.
adaptiveMaxDistance(q, ctx) liest Regeln aus
{ctx}.fuzzy.adaptive_max_distance und liefert je nach Länge eine erlaubte Distanz.
- Suggest: wenn
isPrimaryMatch100()true ist. - Search: du setzt 100 nur bei
isPrimaryExact()(oder Direkt-Lookup). - Alle anderen Ergebnisse werden auf 0–99 normalisiert.
status=open und nur wenn:
count == 1odercount % threshold == 0(z.B. 3,6,9…)min_interval_minseit der letzten Mail überschritten
- byId Cache: analog zu
$bySlugCache(bei sehr großen Indizes). - Slug-Uniqueness: im Index-Builder erzwingen.
- NOT/Negation: eher als explizite Syntax, nicht “-foo” (sonst Missverständnisse).
- Filter-Units: bewusst nicht parsen (mg/g), stattdessen UI/Builder verwenden.