SearchService
Technische Dokumentation · Query Engine · Suggest/Search · Filter · Fuzzy · NotFound

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.

Merksatz
suggest() = schnelle Vorschläge während des Tippens.
search() = komplette Query-Sprache (Text + Filter + und/oder + Gruppen) – inklusive Direkt-Lookup.
Direkt testen (anklicken & in der Suche öffnen)
Hinweis: id:/slug: sind streng (1 Treffer oder 0). Keine Fehlertoleranz, kein Fuzzy.
1. Architektur & Ziele
Was SearchService macht – und warum
Kurzüberblick
SearchService ist die zentrale Query-Engine von NephroFood. Sie liest einen vorgebauten 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

Index als „Single Source“
Der Index enthält:
  • items[] mit Labels, Tokens, Preview-Werten, Gruppen, Keywords, optional Portionen
  • prefix{} als Bucket-Map (Prefix → Item-IDs) zur schnellen Kandidatenreduktion
  • meta mit Parametern wie prefix_len
Zwei Modi: Suggest vs Search
suggest(): „Was könnte der User meinen?“ → schnelle Vorschläge.
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).

Wichtiger Grundsatz (Kontrakt)
parseQuery() ist ein reiner Parser und liefert ausschließlich strukturierte Daten zurück (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“
Das verhindert „Parser-Chaos“ und hält die API sauber: Parser bleibt Parser, Search bleibt Orchestrator.
Praxis-Hinweise
  • 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.
1.1 Datenfluss
Von Query → Parser → Kandidaten → Filter → Ranking → Output
Ziel dieses Abschnitts
Hier geht es nicht um „Syntax-Regeln“, sondern um den konkreten Ablauf in 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?“).
Fluss (vereinfacht)
Orchestrator: search() · Parser: parseQuery()
search(raw): 0) trim + max_query_len cut 1) Direct Lookup? (id:/slug:) -> found/notfound (fast exit) 2) parseQuery(raw) -> terms / term_groups / filters / filter_groups 3) candidatesFor(terms...) -> Kandidaten-Map (dedupe) 4) hard exclude: negative keywords 5) passesFilterGroups(...) -> Filter (AND / OR-distributiv) 6) matchesTermGroups(...) + ggf. Fuzzy-Fallback (Termlogik) 7) scoreItem(...) + Normalisierung 0..99 (PrimaryExact => 100) 8) packItem(...) + match_terms / matched_terms 9) NotFound? -> logNotFound()
Warum Direct Lookup zuerst?
id:/slug: ist „exakt 1 oder 0“ und muss vor dem Parser passieren:
  • kein Fuzzy
  • kein Candidate-Scan
  • kein Ranking
Ergebnis: Das ist der schnellste Pfad – und verhindert, dass Parser-Regeln versehentlich in den Lookup hineinfunken.
Warum Filter vor Ranking?
Filter sind harte Bedingungen. Würde man erst ranken und dann filtern, wäre es:
  • langsamer (viel mehr Items würden gescored)
  • unübersichtlich beim Debuggen (Top-Treffer verschwindet später)
Darum: erst „darf überhaupt rein?“, dann „wie gut passt es?“.

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:
  • terms (flach, fürs Candidate-Sammeln/Scoring)
  • term_groups (OR über Gruppen, AND innerhalb Gruppe)
  • filters (legacy AND)
  • filter_groups (DNF/OR bei Filtern, distributiv)
„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:
  • Wenn filter_groups gesetzt: OR über Gruppen, AND in Gruppe.
  • Sonst fallback: legacy filters als AND.
  • Fehlende Preview-Werte → Filter kann nicht erfüllt werden.
„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:
  • PrimaryExact (oder Direct Lookup) → 100
  • Sonst → 0..99 relativ zu maxScore
„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?
Debug-Rezept (wenn etwas „komisch“ wirkt)
  1. Nur Text testen: Apfel
  2. Dann ein Filter: Apfel, Kalium < 150
  3. Dann OR-Filter: Kalium < 100 oder Phosphat < 100, Natrium < 50
  4. Dann Gruppe + Filter: Gruppe: obst, Kalium < 150
Wichtig (Suggest vs Search)
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.
1.2 Caches & Performance
Warum die Klasse trotz PHP schnell bleibt
Performance-Idee in einem Satz
SearchService ist schnell, weil er nicht den kompletten Index “durchsucht”, sondern zuerst die Kandidatenmenge stark reduziert (Prefix-Buckets + max_scan + Dedupe) und danach mit harten Filtern aussiebt – erst am Ende wird gerankt.
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.
Kandidaten-Reduktion: die “3 Bremsen”
  1. Prefix (meta.prefix_len): nur IDs aus dem passenden Bucket.
  2. max_scan (suggest/search): harte Obergrenze, wie viele Kandidaten maximal gesammelt werden.
  3. Dedupe-Map ($candidateMap[id]=item): jedes Item nur einmal – auch wenn mehrere Terms es finden.
“Supplement Scan” als Sicherheitsnetz
Prefix-Buckets sind schnell, aber nicht unfehlbar (Index-Build, ungünstige Token, Buckets zu voll/zu leer). Darum kann candidatesFor() optional zusätzlich scannen:
  • nur solange die Kandidaten-Map kleiner als maxScan ist
  • nur auf “direkten Term-Match” (kein teurer Vollscan mit Ranking)
Ergebnis: weniger “Treffer fehlt obwohl Token vorhanden”.
Skalierung & Komplexität (praktisch)
  • 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, adaptiveMaxDistance und max_scan die wichtigsten Stellschrauben.
  • Filter helfen auch Performance: je früher harte Filter greifen, desto weniger Items müssen gerankt werden.
Typische Performance-Fallen
  • 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.
Optionaler Ausbau (wenn Index sehr groß wird)
Wenn du irgendwann in “sehr groß” kommst (viele zehntausend Items), sind das die typischen nächsten Schritte:
  • 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.
2. Index-Format
Welche Struktur SearchService erwartet · Was ein Item enthalten sollte · Warum bestimmte Felder wichtig sind
Grundidee des Index
Der SearchService arbeitet nicht direkt auf “foods.json”, sondern auf einem vorkompilierten Suchindex. Dieser Index enthält:
  • 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.
Index-Struktur (Top-Level)
{ "items": [ { ... }, { ... } ], "prefix": { "bis": ["id1","id2", ...], ... }, "meta": { "prefix_len": 3, ... } }
items[]
Enthält alle Datensätze. SearchService baut daraus intern:
  • byId(): Map id → item (für Buckets, Scoring, Filter).
  • slugMap(): Map normSlug(slug) → item (für slug:-Lookup).
prefix{}
Key = Prefix der normierten Query, Value = Liste von IDs aus items. Beispiel (bei prefix_len=3):
Query "biskuit..." -> norm() -> "biskuit" Prefix = "bis" idx["prefix"]["bis"] = ["id1","id2",...]
meta.prefix_len
Definiert die Bucket-Länge für prefix. Wenn du das änderst, muss der Index neu gebaut werden – sonst passen Buckets und Query-Normalisierung nicht zusammen.

Warum das Format “medizinisch” sauber ist
Das Index-Format trennt klar: Textsuche (Tokens/Label/Keywords) vs. harte Filter (Preview-Nährwerte). Gerade für Dialyse/Diabetes ist das wichtig, weil Filter sonst “interpretiert” werden müssten (Einheiten, Umrechnungen, Freitext) – und das kann gefährlich werden.
Beispiel: kompletter Item-Datensatz (aus items[])
Dieser Datensatz zeigt alle wichtigen Bereiche: Identität (id/slug), Anzeige (label), Gruppierung, Tokens, Portion, Keywords, Negative Keywords und Preview-Nährwerte.
{ "id": "b87db670-ad70-4749-8a92-a919dcb606d9", "slug": "waffeln-zubereitung-haushalt", "name_de": "Waffeln", "name_en": "", "sub_de": "Zubereitung Haushalt", "sub_en": "", "label_de": "Waffeln – Zubereitung Haushalt", "label_en": "", "groups": [ { "id": "b3f1a2c9-7d84-5c1a-9e52-2b9e7a4d1f66", "slug": "backwaren-und-gebaeck", "name_de": "Backwaren und Gebäck", "name_en": "Baked Goods and Pastries", "icon": "fa-duotone fa-cake-candles" } ], "tokens_de": [ "backwaren", "backwaren und gebaeck", "g:backwaren und gebaeck", "gebaeck", "gid:b3f1a2c97d845c1a9e522b9e7a4d1f66", "haushalt", "und", "waffeln", "zubereitung" ], "tokens_en": [ "and", "backwaren und gebaeck", "baked", "g:backwaren und gebaeck", "gid:b3f1a2c97d845c1a9e522b9e7a4d1f66", "goods", "pastries" ], "portion": { "amount": 80, "unit": "g", "source": "food", "hint": { "de": "1 Waffel", "en": "" } }, "keywords": { "de": [], "en": [] }, "negative_keywords": { "de": [], "en": [] }, "preview": { "kalium_mg": 103, "phosphor_mg": 116, "natrium_mg": 115, "zucker_g": 9, "eiweiss_g": 6, "wasser_ml": 43, "energie_kcal": 318, "cap_ratio": 0.47, "pral_meq": 4.06, "calcium_mg": 54, "purin_mg": 5 } }
Identität & Anzeige (id/slug/name/label)
  • id ist der Primärschlüssel für Buckets & Dedupe.
  • slug ist der Goldstandard für exakte Treffer (und für slug:-Lookup).
  • label_de ist das UI-Label (SearchService nutzt itemLabel() als Anzeige-Fallback).
  • name/sub werden für primaryStrings() kombiniert (name, label, name+sub).
Tokens & Keywords (Text-Matching)
  • tokens_de/tokens_en sind die Hauptbasis fürs Matching & Scoring.
  • keywords werden im Service zusätzlich als Tokens nachgeladen (Sicherheitsnetz).
  • Gruppentokens wie g:... / gid:... – das ist super für die Textsuche.
preview (Filter: “hart”)
Numerische Filter laufen ausschließlich über 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.
groups[] (Gruppenfilter)
Gruppenfilter (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.
Achtung: phosphor_mg vs phosphat_mg
In deinem Beispiel steht in 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.)
2.1 items / prefix / meta
Wichtige Item-Felder und ihre Rolle
Feld Typ Verwendung
idstringKandidaten-ID, Direct Lookup (id:), Dedupe-Key.
slugstringDirect Lookup (slug:), PrimaryExact/Match100, UI-Fallback.
name_de/name_enstringPrimärname für primaryStrings.
label_de/label_enstringAnzeige-Label (itemLabel).
sub_de/sub_enstringUntertitel; wird als name+sub zusammengeführt.
tokens(_de/_en)arrayMatching/Scoring; “Sicherheitsnetz” durch keywords.
keywords[lang]arrayWird in itemTokens() zusätzlich als Tokens gesplittet.
previewarrayNährwerte (numerische Filter) – fehlende Werte können Filter verhindern.
group / groupsobj/arrayGruppenfilter (group_slug) – alle Gruppen werden geprüft.
portionobj|nullOptional (Output in packItem).
negative_keywords[lang]arrayHarte Exklusion (hitsNegativeKeywords).
Filter sind “hart”
Ein Item ohne preview[kalium_mg] kann nicht Kalium < 150 erfüllen. Das ist beabsichtigt (Filter = harte Bedingung).
2.2 Gruppen: group vs groups
Kompatibilität alt/neu + Matching-Verhalten
Alt
"group": { "slug":"obst", "name_de":"Obst", "icon":"..." }
Neu
"groups": [ { "slug":"obst", "name_de":"Obst", "icon":"..." }, { "slug":"bio", "name_de":"Bio", "icon":"..." } ]
Wie SearchService matcht
passesFilters() sammelt alle Gruppen-Slugs aus group und groups[], normalisiert sie (normSlug) und prüft dann in_array(want, slugs).
2.3 preview / portion / keywords
Output-Felder & “Sicherheitsnetze”
packItem() (Output)
id, slug, name(label), group(primary out), preview, portion(optional) + optional: _match (0..100) und fullmatch
Warum keywords → tokens?
Damit Treffer nicht “verschwinden”, wenn der Index-Build mal keine Tokens für ein Item produziert hat. keywords werden normalisiert, gesplittet und als Tokens ergänzt.
3. Konfiguration (cfg)
Dot-Notation, Defaults, Schalter für Verhalten
cfg() – Dot-Notation
cfg("suggest.limit") cfg("search.fuzzy.enabled") cfg("notfound.email.threshold")
Konfig-Quelle
In der Regel kommt das Array aus includes/search.config.php (oder aus dem zentralen Config-Loader).
3.1 Wichtige Keys
suggest/search/fuzzy/notfound – die Praxis-Settings
Key Typ Wirkung
suggest.limitintMax. Vorschläge.
suggest.min_query_lenintMin. Query-Länge für Suggest.
suggest.max_scanintMax Kandidaten (inkl. Supplement Scan).
suggest.supplement_scanboolErgänzt Kandidaten, falls Prefix-Bucket unvollständig ist.
suggest.fuzzy.enabledboolAktiviert Fuzzy (Boost + Fallback-Kandidaten).
suggest.fuzzy.boostintBoost - Distanz (Levenshtein).
search.limitintMax. Trefferliste.
search.max_scanintMax. Kandidaten pro Term (Search).
search.max_query_lenintSchutz: Query wird gekürzt.
search.fuzzy.only_if_no_direct_hitboolFuzzy-Boost nur wenn kein direkter Hit.
match100.contains_min_lenintAb dieser Länge wird contains als “Match100” gewertet.
notfound.enabledboolNotFound Logging aktivieren.
notfound.filestringPfad zu notfound.json (relativ zu indexPath möglich).
notfound.rate.max_per_minintRate-Limit für Bot-Schutz.
notfound.email.enabledboolMail bei NotFound (optional).
notfound.email.thresholdintMails nur bei count==1 oder Vielfachen (z.B. 3,6,9...).
3.2 Stopwords
Warum & wie Stopwords die Term-Liste bereinigen
Mechanik
stopwords() liest cfg("stopwords") - normalisiert (norm) - akzeptiert nur 1-Token Wörter stripStopwords(tokens) entfernt Stopwords aus Terms/Groups
Best Practice
Stopwords klein halten und nur wirklich “nichtssagende” Wörter aufnehmen (z.B. “und”, “oder” sind ohnehin Operatoren). Zu aggressive Stopwords können echte Lebensmittelbegriffe zerstören.
3.3 KeyMap (Nährstoffe)
Alias → Canonical Keys (z.B. k → kalium_mg)
Beispiele (Fallbacks)
na, natrium -> natrium_mg k, kalium -> kalium_mg p, phosphat, phosphor -> phosphat_mg z, zucker -> zucker_g pral -> pral_meq gruppe/group/g/category/kategorie -> group_slug
Warum Canonical Keys?
Damit Filter intern immer gegen dieselben Preview-Felder laufen (z.B. kalium_mg), unabhängig davon, welche Schreibweise der User verwendet.
4. Direkt-Lookup: id:/slug:
Exakt 1 Treffer oder 0 – ohne Fuzzy, ohne Ranking
Prinzip
Wird id: oder slug: erkannt, beendet search() sofort die normale Suche und liefert entweder found (genau ein Item) oder notfound (leeres Array).
4.1 Syntax
Erlaubte Keys, Quotes, Braces
ID
id: xxx-ccc-ccc-ddd food_id: xxx-ccc-ccc-ddd fid=xxx-ccc-ccc-ddd
Slug
slug:biskuitboden slug:{biskuitboden} slug:"biskuitboden" food_slug:'biskuitboden'
unwrapLiteral()
Entfernt {...}, "...", '...' – praktisch für Slugs oder IDs aus Copy/Paste.
4.2 Ablauf in search()
Wichtig: Der Block muss vor parseQuery() stehen
Direct Lookup (konzeptionell)
search(raw): raw trim + max len cut sel = parseDirectFoodSelector(raw) if sel: it = findByIdLiteral(...) or findBySlugLiteral(...) return [] if not found return [ packItem(it, match=100, fullmatch=true) ]
Typischer Fehler
Wenn Direct Lookup in parseQuery() eingebaut wird, bricht der Parser-Kontrakt: search() erwartet ein Struktur-Array mit Keys (terms, filters, …). Ergebnis: leere Treffer.
4.3 Troubleshooting
Wenn slug:id trotzdem nicht gefunden wird
  • Slug im Index anders? Direct Lookup ist exakt: slug:biskuitboden findet nur ein Item, dessen slug nach normSlug exakt biskuitboden ergibt.
  • 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.
Prüf-Quickcheck
1) Suche normal: ?q=biskuitboden 2) In Ergebnis-Card den slug prüfen (falls angezeigt) 3) Dann: ?q=slug:DER_EXAKTE_SLUG
5. Query-Parsing
parseQuery() → terms / term_groups / filters / filter_groups
Parser-Output (Schema)
{ "terms": ["apfel","zimt"], "term_groups": [["apfel","zimt"], ["birne","vanille"]], "filters": [{type:"num", key:"kalium_mg", op:"<", value:150}, ...], "filter_groups":[[{...},{...}], [{...}]] }
DNF (disjunktive Normalform)
OR-Gruppen werden als Liste von AND-Listen modelliert. Das macht die Auswertung “eine Gruppe reicht” sehr stabil.
5.1 Trennzeichen & Operatoren
Komma/Zeilenumbruch/Semikolon · und/oder · & |
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.
Achtung
Operator-Normalisierung (und&, oder|) passiert vor der Segmentierung. Deshalb müssen Gruppen-Literale geschützt werden (siehe 5.4).
5.2 Terms: OR/AND (DNF)
term_groups = OR über Gruppen, AND innerhalb Gruppe
Beispiel
Query: "Apfel Zimt | Birne Vanille" term_groups: [ ["apfel","zimt"], ["birne","vanille"] ]
Auswertung
matchesTermGroups() prüft: eine Gruppe muss komplett treffen (OR). Für jedes Token: direkter Label-Hit oder tokenHit (inkl. fuzzyTermHit).
5.3 Filter: num / group
Vergleichsoperatoren, Bereiche, Gruppenfilter
Numeric Filter
Kalium < 150 Phosphat <= 100 Zucker = 0 Kalium: 50-100 (Range -> >=50 und <=100)
Group Filter
Gruppe: obst g=gemuese group:{Fleisch,Wurstwaren,Eier und Alternativen}
Filter-Gruppen (OR bei Filtern)
Der Parser bildet filter_groups distributiv, wenn ein Segment nur Filter enthält. Dadurch ist möglich: Kalium < 100 oder Phosphat < 100, Natrium < 50.
5.4 Gruppen-Literale (Kommas & “und”)
protectGroupLiterals() verhindert Zerlegung
Warum nötig?
Ohne Schutz würde g:Fleisch,Wurstwaren,Eier und Alternativen in mehrere Segmente zerfallen (Komma-Split) und “und” würde zu & werden (Operator-Normalisierung).
Empfohlen (robust)
g:{Fleisch,Wurstwaren,Eier und Alternativen} group:"Fleisch,Wurstwaren,Eier und Alternativen"
Mechanik
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”.
6. Kandidaten & Scoring
Wie Treffer gefunden und gerankt werden
Grundidee
Erst Kandidaten sammeln (prefix bucket + supplement scan + fuzzy fallback), dann mit scoreItem() ranken und auf _match normalisieren.
6.1 candidatesFor()
prefix → candidates (+ supplement scan, + fuzzy fallback)
Ablauf
1) norm(q), min_query_len prüfen 2) prefix_len aus meta -> bucket key 3) ids aus idx['prefix'][bucket] -> items via byId() 4) supplement_scan: matchesAllTerms gegen byId() (wenn map < maxScan) 5) fuzzy fallback: wenn map leer und fuzzy enabled -> fuzzyCandidates()
Dedupe
Kandidaten werden über die Item-ID in eine Map geschrieben – dadurch entstehen keine doppelten Dropdown-Einträge.
6.2 scoreItem()
Label/Token-Heuristik + optional Fuzzy-Boost
Scoring-Heuristik (Wesentliches)
Label: exact +50 startsWith +20 contains +10 Tokens: exact +25 startsWith +6 contains +2 Fuzzy (optional): score += max(0, fuzzy_boost - best_levenshtein_distance)
Warum so?
Das Label ist der stärkste “User Intent” (UI-Name), Tokens sind Ergänzungen (Keywords/Index). Fuzzy soll helfen, aber nicht exakte Treffer verdrängen.
6.3 Fuzzy & adaptiveMaxDistance()
Fehlertoleranz abhängig von Wortlänge
Adaptive Max-Distance
adaptiveMaxDistance(q, ctx) liest Regeln aus {ctx}.fuzzy.adaptive_max_distance und liefert je nach Länge eine erlaubte Distanz.
Beispiel-Regeln (Konzept): - len 1..4 -> dist 0/1 - len 5..8 -> dist 1/2 - len 9..12 -> dist 2/3
Wichtig
Sehr kurze Begriffe (z.B. “tee”) sollten nicht zu tolerant sein – sonst gibt es “zufällige” Treffer.
6.4 Match 0–100
100 ist reserviert (PrimaryExact / Direkt-Lookup)
Regel
100 ist reserviert:
  • Suggest: wenn isPrimaryMatch100() true ist.
  • Search: du setzt 100 nur bei isPrimaryExact() (oder Direkt-Lookup).
  • Alle anderen Ergebnisse werden auf 0–99 normalisiert.
Warum nicht überall 100?
100 markiert “das ist exakt das, was im UI als primärer Treffer gemeint ist”. Dadurch kann die UI leicht “Top-Treffer”/Fullmatch markieren.
7. Ausschlüsse & NotFound
Hard-Exclusion + Telemetry
Reihenfolge
1) hitsNegativeKeywords() -> sofort raus 2) passesFilterGroups() -> Filter müssen passen 3) matchesTermGroups() -> Terms müssen passen (oder fuzzy score fallback)
7.1 Negative Keywords
Harte Exklusion (z.B. Synonyme, die stören)
Mechanik
negative_keywords[lang] werden normalisiert Treffer wird ausgeschlossen, wenn: - term exakt in negative_keywords vorkommt, oder - contains in beide Richtungen (mehrwortige negative keywords)
Praxis
Sinnvoll für Begriffe, die häufig falsche Treffer erzeugen (z.B. “keks” vs. “keksgewürz” je nach Bedarf).
7.2 NotFound JSON
Logging bei 0 Treffern (nur wenn echte Terms)
Wann wird geloggt?
Nur wenn keine Treffer und terms vorhanden sind. Reine Filter-Queries (ohne Suchwörter) werden nicht als “fehlendes Lebensmittel” geloggt.
Schema (vereinfacht)
{ "schema": 1, "updated_at": "2026-..", "items": { "normed key": { "raw_samples": ["Original Query", ...], "count": 4, "first_seen": "...", "last_seen": "...", "langs": { "de": 4 }, "status": "open", "notes": "", "resolved_food_id": null, "last_email_at": null } } }
7.3 Rate-Limit & Mail
Bot-Schutz + kontrollierte Benachrichtigung
Rate-Limit
Hash aus IP+UA (optional HMAC Secret) → temp-file Counter → max_per_min. Kein Speichern von IP/UA im NotFound JSON.
Mail-Regeln
Mail nur bei status=open und nur wenn:
  • count == 1 oder count % threshold == 0 (z.B. 3,6,9…)
  • min_interval_min seit der letzten Mail überschritten
8. Testfälle & Best Practices
Schnelltests, Regression, Erweiterungen
8.1 Quick-Tests (Copy/Paste)
Kaffee Apfel Zimt Apfel, Birne Apfel Zimt | Birne Vanille Kalium < 150, Phosphat < 100 Kalium < 100 oder Phosphat < 100, Natrium < 50 Gruppe: obst, Kalium < 150 g:{Fleisch,Wurstwaren,Eier und Alternativen} slug:biskuitboden
8.2 Erweiterungen (sicher)
  • 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.
Hinweis
Bei medizinischen Anwendungen ist “stille Interpretation” gefährlich. Deshalb ist die Syntax absichtlich streng (keine automatische Umrechnung von Einheiten).