Part 1 of 6
Grundlagen eines produktionsreifen Restaurant-Recommenders
Was wir bauen: Eine personalisierte Restaurant-Empfehlungs-Engine, komplett von null: der Restaurant-Katalog einer Stadt, eine LLM-Pipeline, die echte Kundenreviews in strukturierte Attribute und ein geschriebenes Porträt pro Lokal verwandelt, semantische Suche über diese Profile und ein Taste Profile pro User, das einen personalisierten Feed antreibt. Und weil man nichts davon ungemessen trauen darf — eine Eval-Suite, die das Ganze benotet.
TLDR: Produktions-AI heißt nicht „schick die Frage an ein Model" — sie heißt retrieve, rank, generate: Eine Datenbank engt Tausende Kandidaten billig ein, und das Model denkt nur über das nach, was ihm übergeben wurde. Dieser Teil erklärt die Retrieval-Hälfte von Grund auf: Embeddings (Zahlenlisten, die kodieren, was ein Text bedeutet), warum eine Query und ein Dokument unterschiedlich embedded werden müssen, weil die Suche sonst stillschweigend generische Treffer liefert, und warum „glutenfrei" niemals per Similarity beantwortet werden darf — Vibes bekommen Vector Search, Dealbreaker bekommen SQL-WHERE-Klauseln. Zum Schluss der Fusion-Trick, der Keyword- und Vector-Rankings allein über Positionen zusammenführt, und die Kapazitätsrechnung, die zeigt, dass die Vectors einer ganzen Stadt in rund zwanzig Megabyte des PostgreSQL passen, das ohnehin schon läuft — keine dedizierte Vector-Datenbank nötig.
Frag ein LLM „Wie ist das Beaver, in der Nähe vom Konyaaltı-Strand in Antalya?" und du bekommst etwas, das dir kein Sterne-Rating geben kann: „gemütlicher Laden, überwiegend junges Publikum, gut für entspannte Abende." Keine 4.3 und keine Wand aus ungelesenen Meinungen — eine echte Antwort.
Diese Serie dokumentiert, wie man diese Fähigkeit sauber aufbaut: ein produktionsreifes Restaurant-Empfehlungssystem auf echter AWS-Infrastruktur, mit echten Kostenbeschränkungen, auf dem Weg zu echten Usern. Der Stack: eine Java/Spring-GraphQL-API neben einem Python-ML-Service, PostgreSQL mit der pgvector-Extension fürs Retrieval und Claude für die Sprachaufgaben. Eine Stadt für den Anfang: Antalya, ein paar tausend Restaurants.
Teil 1 behandelt die Grundlagen — die Konzepte, die über die Architektur entscheiden, bevor es überhaupt eine Architektur gibt.
🎯 Warum „frag doch einfach das LLM" die Produktion nicht überlebt
Die naive Variante — die Anfrage des Users direkt an ein LLM schicken — scheitert an vier Punkten:
Hallucination. Frag nach Restaurantempfehlungen, und einige Namen in der Antwort wird es schlicht nicht geben. Das ist kein Bug, der auf einen Patch wartet; es liegt in der Natur eines Models, das plausiblen Text generiert, ohne ihn gegen irgendetwas zu prüfen.
Aktualität. Das Model hat keine Ahnung, was letzten Monat eröffnet oder geschlossen hat.
Gedächtnis. Es weiß nichts darüber, was genau dieser User liebt.
Kostenkontrolle. Jede Antwort kostet, was der Anbieter verlangt — bei jedem Request, für immer.
Die Antwort der Branche heißt RAG — Retrieval-Augmented Generation — und sie ist weniger exotisch, als sie klingt: Behalte die Fakten in deiner eigenen Datenbank, hole zuerst die relevanten Datensätze und lass das LLM nur über das nachdenken, was man ihm in die Hand gedrückt hat. Das LLM hört auf, die Quelle der Wahrheit zu sein, und wird zu einer Sprachschicht über deinen Daten. Es kann kein Restaurant erfinden, das es nie gezeigt bekam.
Fast jedes produktive AI-System läuft auf dieselbe dreistufige Pipeline hinaus:
RETRIEVE — Tausende Kandidaten billig auf etwa 30 eingrenzen, mit einer Datenbank RANK — echte Rechenleistung investieren, um diese 30 für genau diesen User zu ordnen GENERATE — die Erklärung für den Menschen schreiben („gemütliche Meyhane mit Meerblick — passt zu deiner Liebe für ruhige, lokale Orte")
Behalte diese Pipeline im Kopf; jeder Abschnitt unten fügt sich in sie ein.
Noch eine Grundsatzentscheidung. Netflix und Spotify laufen auf Collaborative Filtering — „User wie du mochten auch das" — gelernt aus Millionen von User-Item-Interaktionen. Ein neues Produkt hat keine Interaktionen, aus denen es lernen könnte (das Cold-Start-Problem), also dreht sich der Ansatz um: Beschreibe die Items reichhaltig und matche sie gegen das, was User nach eigener Aussage lieben. Das ist Content-based Recommendation — und der Grund, warum dieses System so stark in tiefe Restaurantprofile investiert statt in eine User-User-Interaktionsmatrix.
🧮 Embeddings, erklärt, wie ich sie mir damals erklärt gewünscht hätte
Ein Embedding ist eine Liste von Zahlen, die repräsentiert, was ein Text bedeutet. Ein Embedding-Modell ist ein neuronales Netz, trainiert auf Milliarden von Satzpaaren, bis ähnliche Bedeutungen ähnliche Zahlenlisten erzeugen. Die Liste heißt Vector; das hier verwendete Model gibt 1024 Zahlen pro Text aus.
In zwei Dimensionen ist das leichter zu sehen:
"romantik deniz manzaralı restoran" → [0.92, 0.15]
"quiet seaside dinner spot" → [0.89, 0.21]
"cheap fast-food burger joint" → [0.11, 0.97]
Die ersten beiden zeigen in fast dieselbe Richtung — obwohl einer türkisch und einer englisch ist. Bedeutung überlebt die Übersetzung; die Richtung kodiert die Bedeutung. Ähnlichkeit wird mit Cosine Similarity gemessen: dem Kosinus des Winkels zwischen zwei Vectors. 1.0 = gleiche Bedeutung, 0 = kein Zusammenhang. Rechne einmal eine von Hand aus (es ist Multiplikation und Addition), und das Mysterium verschwindet für immer.
Für ein türkisches Reiseprodukt ist diese Mehrsprachigkeit der ganze Punkt: Eine türkische Query muss Restaurantprofile finden, die womöglich auf Englisch geschrieben sind. Deshalb zählt auch die Modellwahl — dieses System braucht ein mehrsprachig trainiertes Embedding-Modell, keins, das nur auf Englisch optimiert ist.
❓ „Embedded es Token für Token, oder einen Vector für den ganzen Satz?"
Beides, auf unterschiedlichen Ebenen. Intern zerlegt das Model den Text in Tokens (Wortstücke) und erzeugt einen Vector pro Token — ein letzter Schritt namens Pooling (meist schlichtes Mitteln) kollabiert sie dann zu einem einzigen. Drei Wörter rein oder drei Absätze rein: exakt ein Vector mit 1024 Zahlen raus.
Das erzeugt eine echte Design-Beschränkung: Presse 10.000 Wörter in einen Vector, und jedes Detail verwässert zu Brei. Deshalb praktiziert die RAG-Welt Chunking (lange Dokumente zerteilen, jedes Stück embedden), und deshalb ist der pro Restaurant embeddete Text hier auf rund 250 Wörter begrenzt — die komfortable Nutzlast für einen einzelnen Vector.
❓ „Wenn beide Seiten zu Vectors werden — ist eine Query dann nicht einfach ein kleines Dokument?"
Das war auch meine Annahme, und sie scheitert auf subtile Weise. Schau, was ein Embedding-Modell mit diesen drei Texten macht, wenn man alle gleich embedded:
A) "romantic place" ← the user's query
B) "a nice spot for couples" ← some short, generic sentence
C) [250-word portrait of a quiet, candle- ← the restaurant that actually
lit restaurant above the sea] answers the query
Das Model legt A am nächsten an B. Warum? Weil Embeddings Ähnlichkeit messen — und A und B sind sich tatsächlich ähnlich: beide kurz, beide vage, beide wunschartig. C ist eine völlig andere Textsorte: lang, konkret, beschreibend. Aber C ist das, was der User will. Was Retrieval braucht, ist nicht „welcher Text ähnelt meiner Query" — sondern „welcher Text beantwortet meine Query". Ähnlichkeit und Relevanz sind verschiedene Fragen, und ein schlichtes Embedding kennt nur die erste.
Dieser Fehler ist in Produktion unsichtbar: Nichts wirft eine Exception, nichts loggt. Treffer #1 wird einfach ein generischer Match statt des richtigen Restaurants, und die richtigen Antworten rutschen lautlos aus der Spitze der Liste — ohne jeden Alarm.
Die Lösung: Modelle wie das von Cohere sind auf Millionen echter (Query → das Dokument, das sie beantwortet hat)-Paare trainiert und lernen so beide Rollen — aber man muss ihnen sagen, welche Rolle der eigene Text gerade spielt. Das ist das input_type-Flag: search_document beim Speichern von Inhalten, search_query beim Suchen. Man kann es sich als Übersetzung vorstellen: Der search_query-Modus nimmt „romantic place" und fragt effektiv „wie sähe die Beschreibung dieses Wunsches aus?" — und platziert den Vector dort, im Beschreibungs-Raum, direkt neben Porträt C. Gleicher Text, anderes Flag, wirklich anderer Vector.
Aus „anderes Flag = anderer Vector" fallen direkt zwei Engineering-Regeln heraus:
- Niemals mischen. Jeder im Index gespeicherte Vector muss vom selben Model und vom selben Flag stammen — sonst vergleicht man Koordinaten von zwei verschiedenen Landkarten.
- Cache pro Flag. Der Cache-Key fürs Embedding muss das Flag enthalten (hash(model + input_type + text)), sonst wird irgendwann ein Vector aus dem falschen Modus ausgeliefert — und man ist zurück beim stillen Fehler.
In dieser Codebase ist input_type ein verpflichtender, validierter Parameter bei jedem Call — denn ein Flag, das jemand vergessen kann, ist ein Produktionsvorfall mit Zeitzünder.
❓ „OK, aber was genau embeddet ihr? Das JSON des Restaurants?"
Diese Frage entscheidet mehr über die Retrieval-Qualität als der Index oder das Model. Jedes Restaurant in diesem System hat reichhaltige strukturierte Daten:
{ "vibe": {"cozy": true, "lively": false},
"crowd": {"age_profile": "young"},
"ambiance": {"noise_level": "quiet", "view": "sea"},
"dietary": {"gluten_free_options": false} }
Der verlockende Zug ist, dieses JSON direkt zu embedden. Tu es nicht. Embedding-Modelle sind auf natürliche Sprache trainiert — Sätze, Absätze, menschlichen Text. Füttert man sie mit JSON, fressen Klammern, Anführungszeichen und Keys Tokens, ohne Bedeutung zu tragen; schlimmer noch: Werte wie "lively": false oder "gluten_free_options": false registrieren im Bedeutungsraum kaum (in welche Richtung zeigt false?). Der Vector kommt matschig heraus, und — das vertraute Muster — nichts wirft einen Fehler. Die Qualität sinkt einfach.
Das Pattern, das funktioniert, heißt ein Fakt, zwei Repräsentationen:
- JSON bleibt JSON — es treibt SQL-Hard-Filter, die UI und Analytics an. Struktur ist das, was Datenbanken gut können.
- Für den Vector schreibt man Prosa. Jedes Restaurant bekommt ein natürlichsprachliches Porträt von rund 250 Wörtern — „Ein ruhiger Ort bei Kerzenlicht über dem Meer in Konyaaltı. Zieht ein überwiegend junges Publikum für lange Abendessen an; Gesprächslautstärke selbst am Wochenende..." — geschrieben von einem LLM aus den strukturierten Attributen und ihrer stützenden Evidence. Dieser Text ist es, der embedded wird, denn er ist genau die Textsorte, mit der das Embedding-Modell großgeworden ist — und genau die Sprache, die User beim Suchen tippen.
Eine nützliche Denkfigur: Das Porträt wird für das Embedding-Modell als Leser geschrieben. Dicht an durchsuchbarer Bedeutung (Vibe, Publikum, Anlass, Signature Dishes), null Marketing-Floskeln („eine unvergessliche kulinarische Reise" verdient keine Vector-Richtung, die irgendwas wert wäre) und begrenzt auf die Länge, die ein Vector tragen kann, bevor er verwässert.
❓ „Restaurants ändern sich. Modelle ändern sich. Re-embedded ihr dann einfach... jedes Mal alles?"
Ein Vector ist ein abgeleiteter Wert — abgeleitet aus zwei Dingen: dem Text, aus dem er kam, und dem Model, das ihn berechnet hat. Sobald sich eines davon ändert, ist der gespeicherte Vector stillschweigend veraltet. Und „stillschweigend" ist das Leitmotiv dieses ganzen Artikels: Ein veralteter Vector wirft keinen Fehler, er rankt nur falsch.
Deshalb trägt jeder Vector in diesem System zwei kleine Begleiter:
Einen Fingerprint der Quelle. Wenn das Porträt eines Restaurants generiert wird, hashen wir das Material, aus dem es gebaut wurde, und speichern diesen Hash neben dem Vector. Beim nächsten Pipeline-Lauf hashen wir das aktuelle Material und vergleichen. Gleicher Fingerprint → nichts geändert → skip, nichts bezahlen. Anders → Porträt neu generieren, neu embedden, überschreiben. Dieser eine billige Trick verwandelt „den ganzen Katalog jede Nacht neu verarbeiten" in „die Handvoll neu verarbeiten, die sich tatsächlich geändert hat" — und er macht die Pipeline jederzeit gefahrlos wiederholbar, was wichtiger ist, als es klingt: Batch-Jobs stürzen ab, und ein abgestürzter Job, den man einfach neu starten kann, ist zehn Seiten Recovery-Logik wert.
Ein Model-Label. Jeder Vector hält fest, welches Embedding-Modell ihn erzeugt hat. Erinnere dich an die Regel aus dem input_type-Abschnitt — Vectors verschiedener Modelle sind Koordinaten auf verschiedenen Landkarten, niemals vergleichbar. Diese Regel hat eine Lifecycle-Konsequenz: An dem Tag, an dem wir auf ein neueres Embedding-Modell upgraden, können wir nicht „schrittweise" upgraden — ein halb migrierter Index würde zwei Landkarten gleichzeitig vergleichen. Das Label macht die Migration ehrlich: jeden Vector mit dem neuen Model backfillen (entsprechend getaggt), die Qualität auf dem neuen Set verifizieren, dann das Retrieval in einem Zug umschalten. Das Label beantwortet auch die Auditoren-Frage sechs Monate später: Welches Model hat den Vector hinter diesem Ergebnis erzeugt?
Nichts davon ist AI-Magie — es ist Cache Invalidation, das älteste harte Problem des Fachs, in neuem Kostüm. Ein Vector ist ein Cache für Bedeutung, und Caches brauchen Keys.
🗺️ Die nächsten Vectors schnell finden — HNSW
Retrieval bedeutet jetzt: Finde zu einem Query-Vector die K gespeicherten Vectors mit der kleinsten Cosine-Distanz. Nearest-Neighbor Search.
Zuerst eine ehrliche Zahl: Bei unserer Größenordnung — rund fünftausend Restaurants, also nicht viel — braucht Brute Force, jeden Vector zu prüfen, einstellige Millisekunden. Bei dieser Größe ist kein Index nötig. Bei einer Million Vectors wäre er es, und das Standardwerkzeug ist HNSW (Hierarchical Navigable Small World).
Das mentale Modell ist ein Autobahnnetz 🛣️ — die oberste Ebene des Graphen hat wenige Knoten mit weitreichenden Verbindungen (Fernautobahnen); tiefere Ebenen sind dichter und lokal (Stadtstraßen). Eine Suche steigt oben ein, springt gierig Richtung Query, steigt eine Ebene ab, verfeinert — wie das Hineinzoomen in eine Karte. Ein paar hundert Vergleiche statt Millionen.
Der Tradeoff steckt im Wort approximate: Der Index kann Nachbarn verpassen. Die Metrik dafür ist recall@K — von den wahren Top-K-Nachbarn, wie viele hat der Index tatsächlich zurückgegeben? Der Index wird hier trotzdem gebaut (bei dieser Größe ist er gratis), und der Recall wird gegen den exakten Scan gemessen — denn eine gemessene Zahl schlägt in jeder technischen Diskussion einen zitierten Blogpost.
🧱 Wo Vectors scheitern: lauter Freitag, ruhiger Dienstag, Zöliakie immer
Der beste Stresstest für dieses Design kam von einem Freund, in einer einzigen Slack-Nachricht:
„Freitagabend ist es ein lautes, langes Meyhane-Essen mit alten Freunden. Dienstags eine ruhige Ecke, ein Mittagessen allein und ein Buch. Und meine Partnerin hat Zöliakie — überall, wo wir zusammen hingehen, ist glutenfrei keine Präferenz, sondern medizinisch. Derselbe Mensch, drei völlig verschiedene Tische. Wird dein Ding alle drei verstehen?"
Drei separate Lektionen in einer Nachricht.
1. Der Freitag- und der Dienstag-Teil sind Embeddings in Bestform. Indirekter, stimmungsgeladener Text („lautes, langes Meyhane-Essen") landet im Bedeutungsraum sauber im Lebhaft-bis-spät-offen-Territorium; „eine ruhige Ecke und ein Buch" landet im Ruhig-tagsüber-Territorium. Eine Person, zwei Stimmungen — und kein einzelner Similarity-Score könnte beiden dienen. Muss auch keiner: Jede Query bringt ihren eigenen Kontext mit und findet ihre eigene Region. Semantische Suche, genau wie vorgesehen.
2. Zöliakie darf niemals über Similarity behandelt werden. Vector Search liefert die nächstgelegenen Treffer — und ein gewöhnliches Bäckerei-Café kann einem dezidiert glutenfreien in jeder anderen Dimension zu 90% ähneln. Für einen Gast mit Zöliakie ist „nah dran" eine Katastrophe: Eine falsche Antwort ist hier keine schlechte Empfehlung, sie ist ein Schaden. Constraints wie glutenfrei, vegan, jetzt-geöffnet, im-Umkreis-von-2-km sind Hard Filter: SQL-WHERE-Klauseln, die garantieren statt vorschlagen.
Probabilistische Werkzeuge für Vibes. Deterministische Werkzeuge für Dealbreaker. Zu wissen, was wohin gehört, ist die halbe Arbeit.
3. Exakte Wörter zählen weiterhin. Eine Suche nach „künefe" sollte besser das Lokal liefern, in dessen geschriebenem Porträt wörtlich künefe steht. Embeddings verwischen Spezifika; klassische Full-Text Search mit türkischem Stemming („manzara / manzaralı / manzarası" matchen alle) trifft sie punktgenau.
Produktives Retrieval ist also hybrid: Vector Search UND Keyword Search laufen nebeneinander (Hard Filter über beiden), und am Ende hat man zwei separat gerankte Listen für dieselbe Query:
Vector search says: Keyword search says:
1. Beaver (cosine 0.83) 1. Künefeci Ali (ts_rank 12.4)
2. Vanilla (cosine 0.79) 2. Beaver (ts_rank 8.1)
3. Künefeci Ali (cosine 0.71) 3. Seraser (ts_rank 3.2)
Jetzt muss man sie zu einer Liste zusammenführen — und hier lauert die Falle: Die Scores sind nicht vergleichbar. Cosine lebt zwischen 0 und 1; Keyword-Rank-Scores liegen auf irgendeiner willkürlichen Skala. Ist cosine 0.83 besser als ts_rank 12.4? (ts_rank ist der Full-Text-Relevanz-Score von Postgres; seine Einheit ist willkürlich — was genau das Problem ist.) Die Frage hat keine Antwort — es sind Messwerte von zwei verschiedenen Instrumenten.
Reciprocal Rank Fusion (RRF) löst das, indem es die Scores wegwirft und nur die Position jedes Ergebnisses benutzt. Jedes Restaurant sammelt Punkte aus jeder Liste: 1 / (60 + seine Position). Aufsummieren, nach Summe sortieren:
Beaver : 1/(60+1) + 1/(60+2) = 0.0164 + 0.0161 = 0.0325 ← winner
Künefeci Ali : 1/(60+3) + 1/(60+1) = 0.0159 + 0.0164 = 0.0323
Vanilla : 1/(60+2) + 0 = 0.0161 (only in one list)
Beaver gewinnt, weil es in beiden Listen hoch rankte — und genau dieses Verhalten will man: Übereinstimmung zweier unabhängiger Signale schlägt Exzellenz in einem. Die Konstante 60 ist nur ein Dämpfer aus dem Original-Paper (sie verhindert, dass der Abstand zwischen #1 und #3 überdramatisch wird); niemand tunt sie. Positionen sind über beliebige zwei Listen vergleichbar, Scores nie — das ist der ganze Trick, und er passt in drei Zeilen SQL.
💾 Die Vector-Datenbank, die keiner brauchte
Dedizierte Vector-Datenbanken sind inzwischen eine eigene Produktkategorie: Pinecone, Qdrant, Weaviate, Milvus — plus Managed-Optionen wie AWS OpenSearch. Was sind sie? Datenbanken, gebaut, um Vectors zu speichern und „finde die nächsten N" extrem schnell zu beantworten, bei extremer Skalierung.
Das Schlüsselwort ist Skalierung. Ihre Komplexität verdienen sie sich, wenn man zig Millionen Vectors, Tausende Queries pro Sekunde oder strikte Tenant-Isolation hat. Bevor man eine einführt: die Kapazitätsrechnung für den eigenen, tatsächlichen Workload machen:
rund 5.000 Restaurants × 1024 Dimensionen × 4 Bytes ≈ 20 MB
Zwanzig Megabyte. Kleiner als die Bilder, die an diesem Post hängen. Die Managed-Option von AWS (OpenSearch Serverless) rechnet derweil in Compute Units ab, mit einer monatlichen Untergrenze im Bereich von Hunderten Dollar — für Kapazität, von der dieser Workload einen Bruchteil eines Prozents nutzen würde. (AWS hat 2026 zwar eine Scale-to-zero-Stufe ausgeliefert, aber ein userfacing Such-Service ist nie idle — anhaltender Traffic hält die Compute Units warm, und die Rechnung landet genau da, wo sie angefangen hat.)
Die Alternative: pgvector, eine Extension für PostgreSQL — die Datenbank, die ohnehin läuft, ohnehin gebackupt wird, ohnehin bezahlt ist. Ein CREATE EXTENSION vector; und Postgres versteht Vectors, Cosine-Distanz und HNSW-Indexe.
Und der unterschätzte Gewinn sind nicht die Kosten. Sondern dass eine einzige SQL-Query jetzt alles erledigt — Hard Filter, Geo-Radius, türkischen Full-Text, Vector Similarity — an einem Ort, in einer Transaktion. Kein zweiter Datastore, keine Sync-Pipeline, kein Drift zwischen „den echten Daten" und „den Suchdaten".
Die Lektion verallgemeinert sich: Mach die Kapazitätsarithmetik, bevor du Infrastruktur wählst. Zehn Minuten Multiplikation haben einen Service, eine Pipeline und eine vierstellige Jahresrechnung gespart.
⏭️ Wie es weitergeht
Die Grundlagen stehen: die Form der Retrieval-Schicht und die Überlegungen dahinter. Teil 2 behandelt die Architektur — warum der ML-Code in einem Python-Service neben einer Java-Plattform lebt, wie die beiden sich eine Datenbank teilen, ohne einander auf die Füße zu treten — und den Teil, den einem niemand fertig liefert: Namen, Standorte und Öffnungszeiten sind die einfache Hälfte eines Restaurant-Katalogs. Die wertvolle Hälfte — "vibe": "cozy, mostly young crowd" — existiert nirgendwo als Daten. Diese Attribut-Schicht bauen wir selbst, mit einer LLM-Pipeline, Evidence-Pflicht für jede Behauptung und einem harten Budget-Cap, damit die Pipeline niemandes Kreditkarte überraschen kann. Das ist der nächste Post.
Für Backend-Engineers, die mit diesem Feld liebäugeln: Es sind Datenbanken, Indexe, Trade-offs und Kostenrechnung — die Arbeit, die ihr ohnehin macht — plus ein wirklich neues Primitiv, das es sich gründlich zu lernen lohnt.
Der Vector ist die neue Spalte.