Part 1 of 6
Production'da Çalışan Bir Restoran Öneri Sisteminin Temelleri
Ne inşa ediyoruz: Sıfırdan, kişiselleştirilmiş bir restoran öneri motoru: bir şehrin restoran kataloğu, gerçek müşteri yorumlarını yapılandırılmış attribute'lara ve mekan başına yazılı bir portreye dönüştüren bir LLM pipeline'ı, bu profiller üzerinde semantic search ve kişiselleştirilmiş feed'i süren kullanıcı başına bir taste profile. Ve hiçbirine ölçmeden güvenilemeyeceği için — bütün sisteme not veren bir eval suite.
TLDR: Production AI "soruyu modele gönder" demek değildir — retrieve, rank, generate demektir: bir veritabanı binlerce adayı ucuza daraltır, model yalnızca önüne konanlar üzerinde akıl yürütür. Bu bölüm retrieval yarısını sıfırdan öğretiyor: embedding'ler (bir metnin ne anlama geldiğini kodlayan sayı listeleri), bir sorgu ile bir dokümanın neden farklı şekilde embed edilmesi gerektiği — yoksa arama sessizce jenerik sonuçlar döndürür — ve "glutensiz" sorusunun neden asla benzerlikle cevaplanmaması gerektiği: vibe'lara vector search, dealbreaker'lara SQL WHERE. Bölüm, keyword ve vector sıralamalarını yalnızca pozisyonları kullanarak birleştiren fusion numarasıyla ve koca bir şehrin vector'lerinin zaten çalıştırdığınız PostgreSQL'in yirmi megabyte kadarına sığdığını gösteren kapasite hesabıyla kapanıyor — ayrı bir vector database'e gerek yok.
Bir LLM'e "Antalya'da Konyaaltı plajına yakın Beaver nasıl bir yer?" diye sorun; hiçbir yıldız puanının veremeyeceği bir şey alırsınız: "samimi bir mekan, ağırlıklı genç bir kitle, rahat bir akşam için iyi." 4.3'lük bir puan ve okunmamış yorumlardan örülü bir duvar değil — gerçek bir cevap.
Bu seri, bu yeteneği doğru düzgün inşa etme sürecini belgeliyor: gerçek AWS altyapısında, gerçek maliyet kısıtlarıyla, gerçek kullanıcılara gidecek production bir restoran öneri sistemi. Stack: Python ML servisinin yanında bir Java/Spring GraphQL API, retrieval için pgvector extension'lı PostgreSQL ve dil işleri için Claude. Başlangıç için tek şehir: Antalya, birkaç bin restoran.
Part 1 temelleri kapsıyor — daha ortada mimari yokken mimariyi belirleyen kavramları.
🎯 "Direkt LLM'e sorarız" neden production'da hayatta kalamıyor
Naif versiyon — kullanıcının isteğini olduğu gibi bir LLM'e göndermek — dört yerden çöküyor:
Hallucination. Restoran önerisi isteyin; cevaptaki isimlerin bir kısmı gerçekte var olmayacak. Bu, yaması beklenen bir bug değil; ürettiği metni hiçbir şeye karşı doğrulamadan makul görünen metin üreten bir modelin doğası.
Güncellik. Model, geçen ay neyin açılıp neyin kapandığından habersiz.
Hafıza. Bu spesifik kullanıcının nelere bayıldığına dair hiçbir şey bilmiyor.
Maliyet kontrolü. Her cevap, her istekte, sonsuza kadar, sağlayıcı ne ücret istiyorsa o kadar tutuyor.
Sektörün cevabı RAG — Retrieval-Augmented Generation — ve kulağa geldiğinden çok daha az egzotik: gerçekleri kendi veritabanında tut, önce ilgili kayıtları retrieve et ve LLM yalnızca önüne konanlar üzerinde akıl yürütsün. LLM doğruluk kaynağı olmaktan çıkıp verinizin üzerinde bir dil katmanına dönüşüyor. Kendisine hiç gösterilmemiş bir restoranı uyduramaz.
Production'daki neredeyse her AI sistemi aynı üç aşamalı pipeline'a indirgenir:
RETRIEVE — binlerce adayı, ucuza, bir veritabanıyla yaklaşık 30'a indir RANK — asıl hesaplama gücünü bu 30'u bu spesifik kullanıcı için sıralamaya harca GENERATE — insana dönük açıklamayı yaz ("deniz manzaralı, samimi bir meyhane — sakin yerel mekan sevginle örtüşüyor")
Bu pipeline'ı aklınızda tutun; aşağıdaki her bölüm bunun bir yerine oturuyor.
Bir çerçeve kararı daha. Netflix ve Spotify collaborative filtering ile çalışır — "sizin gibi kullanıcılar bunu da beğendi" — milyonlarca kullanıcı-ürün etkileşiminden öğrenilir. Yeni bir ürünün öğrenecek etkileşimi yoktur (cold-start problemi), bu yüzden yaklaşım tersine döner: ürünleri zengin biçimde tarif et ve kullanıcıların sevdiklerini söyledikleriyle eşleştir. Bunun adı content-based recommendation ve bu sistemin kullanıcı-kullanıcı etkileşim matrisi yerine derin restoran profillerine bu kadar yatırım yapmasının sebebi de bu.
🧮 Embedding'ler — keşke bana da böyle anlatılsaydı dediğim haliyle
Bir embedding, bir metnin ne anlama geldiğini temsil eden bir sayı listesidir. Embedding modeli, benzer anlamlar benzer sayı listeleri üretene kadar milyarlarca cümle çifti üzerinde eğitilmiş bir sinir ağıdır. Bu listeye vector denir; burada kullanılan model metin başına 1024 sayı üretiyor.
İki boyutta görmek daha kolay:
"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]
İlk ikisi neredeyse aynı yönü gösteriyor — biri Türkçe, biri İngilizce olmasına rağmen. Anlam çeviride hayatta kalır; yön, anlamı kodlar. Benzerlik cosine similarity ile ölçülür: iki vector arasındaki açının kosinüsü. 1.0 = aynı anlam, 0 = alakasız. Bir kere elle hesaplayın (çarpma ve toplamadan ibaret), gizem kalıcı olarak buharlaşır.
Türkçe bir seyahat ürünü için bu çok dillilik özelliği işin ta kendisi: Türkçe bir sorgu, İngilizce yazılmış olabilecek restoran profillerini bulmak zorunda. Model seçiminin bu kadar önemli olması da bundan — bu sistemin yalnızca İngilizce için optimize edilmiş değil, çok dilli eğitilmiş bir embedding modeline ihtiyacı var.
❓ "Token token mı embed ediyor, yoksa bütün cümleye tek vector mü?"
İkisi de — farklı katmanlarda. Model içeride metni token'lara (kelime parçalarına) böler ve token başına bir vector üretir — sonra pooling denen son bir adım (genellikle düpedüz ortalama almak) hepsini teke indirir. İçeri üç kelime de girse üç paragraf da girse: dışarı tam olarak bir adet 1024 sayılık vector çıkar.
Bu, gerçek bir tasarım kısıtı doğuruyor: 10.000 kelimeyi tek bir vector'e sıkıştırın, her detay bulamaca seyrelir. RAG dünyasının chunking pratiği bundan (uzun dokümanı böl, her parçayı ayrı embed et); burada da restoran başına embed edilen metnin yaklaşık 250 kelimeyle sınırlanması bundan — tek bir vector'ün rahatça taşıyabileceği yük bu.
❓ "İki taraf da vector oluyorsa, sorgu da küçük bir doküman değil mi?"
Benim varsayımım da buydu ve ince bir yerden patlıyor. Üçünü de aynı şekilde embed ederseniz, bir embedding modelinin şu üç metne ne yaptığına bakın:
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
Model A'yı B'ye en yakın koyar. Neden? Çünkü embedding'ler benzerliği ölçer — ve A ile B gerçekten benzer: ikisi de kısa, ikisi de muğlak, ikisi de dilek kıvamında. C bambaşka türden bir metin: uzun, somut, betimleyici. Ama kullanıcının istediği C. Retrieval'ın ihtiyacı "hangi metin sorguma benziyor" değil — "hangi metin sorguma cevap veriyor." Benzerlik ve alaka ayrı sorulardır ve düz bir embedding yalnızca ilkini bilir.
Bu hata production'da görünmezdir: hiçbir şey exception fırlatmaz, hiçbir log düşmez. 1 numaralı sonuç, doğru restoran yerine jenerik bir eşleşme oluverir ve doğru cevaplar listenin tepesinden sessizce kayar gider — alarmı yoktur.
Çözüm: Cohere'inki gibi modeller milyonlarca gerçek (sorgu → onu cevaplayan doküman) çifti üzerinde eğitilir ve iki rolü de öğrenir — ama metninizin hangi rolü oynadığını onlara sizin söylemeniz gerekir. input_type flag'i tam olarak bu: içerik kaydederken search_document, arama yaparken search_query. Bir çeviri gibi düşünün: search_query modu "romantik bir yer"i alır ve fiilen "bu dileğin tarifi neye benzerdi?" diye sorar — sonra vector'ü oraya, tarif-uzayına, portre C'nin hemen yanına yerleştirir. Aynı metin, farklı flag, sahiden farklı bir vector.
"Farklı flag = farklı vector"ten iki mühendislik kuralı doğrudan düşüyor:
- Asla karıştırma. Index'te duran her vector aynı modelden ve aynı flag'den gelmek zorunda — yoksa iki farklı haritanın koordinatlarını karşılaştırıyorsunuz demektir.
- Cache'i flag ile anahtarla. Embedding cache'inin anahtarı flag'i içermeli (hash(model + input_type + text)), yoksa yanlış moddan bir vector servis edilir ve o sessiz hataya geri dönersiniz.
Bu kod tabanında input_type, her çağrıda zorunlu ve valide edilen bir parametre — çünkü unutulması mümkün olan bir flag, fitili gecikmeli yanan bir production incident'ıdır.
❓ "Peki tam olarak neyi embed ediyorsunuz? Restoranın JSON'ını mı?"
Bu soru, retrieval kalitesini index'ten de model seçiminden de daha fazla belirliyor. Bu sistemde her restoranın zengin, yapılandırılmış verisi var:
{ "vibe": {"cozy": true, "lively": false},
"crowd": {"age_profile": "young"},
"ambiance": {"noise_level": "quiet", "view": "sea"},
"dietary": {"gluten_free_options": false} }
Cazip hamle, bu JSON'ı doğrudan embed etmek. Yapmayın. Embedding modelleri doğal dil üzerinde eğitilir — cümleler, paragraflar, insan metni. Onlara JSON verdiğinizde süslü parantezler, tırnaklar ve key'ler hiçbir anlam taşımadan token yer; daha kötüsü, "lively": false ya da "gluten_free_options": false gibi değerler anlam-uzayında neredeyse hiç iz bırakmaz (false hangi yönü gösterir ki?). Vector bulanık çıkar ve — tanıdık tema — hiçbir şey hata vermez. Kalite düpedüz düşer.
İşe yarayan desen, tek gerçek, iki temsil:
- JSON, JSON kalır — SQL hard filter'larını, UI'ı ve analytics'i besler. Yapı, veritabanlarının iyi olduğu iştir.
- Vector için düzyazı yaz. Her restorana yaklaşık 250 kelimelik, doğal dilde bir portre — "Konyaaltı'da denizin üstünde sakin, mum ışıklı bir mekan. Ağırlıklı genç bir kitleyi uzun, sakin akşam yemekleri için çekiyor; hafta sonu bile sohbet seviyesinde bir gürültü..." — yapılandırılmış attribute'lardan ve onları destekleyen evidence'tan bir LLM tarafından yazılıyor. Embed edilen metin işte bu, çünkü embedding modelinin üzerinde büyüdüğü türden metin bu; kullanıcıların arama yaparken yazdığı türden dil de bu.
Faydalı bir bakış açısı: portre, okuru embedding modeliymiş gibi yazılır. Aranabilir anlam açısından yoğun (vibe, kitle, vesile, imza yemekler), pazarlama süsü sıfır ("unutulmaz bir lezzet yolculuğu" değer taşıyan hiçbir vector yönü kazandırmaz) ve tek bir vector'ün seyrelmeden taşıyabileceği uzunlukla sınırlı.
❓ "Restoranlar değişir. Modeller değişir. Her seferinde her şeyi yeniden mi embed ediyorsunuz?"
Vector, türetilmiş bir değerdir — iki şeyden türetilir: geldiği metin ve onu hesaplayan model. İkisinden biri değiştiği anda, saklanan vector sessizce bayatlamıştır. Ve "sessizce" bu yazının bütün teması: bayat bir vector hata vermez, yanlış sıralar.
Bu yüzden bu sistemdeki her vector'ün yanında iki küçük yol arkadaşı gezer:
Kaynağın fingerprint'i. Bir restoranın portresi üretilirken, üretildiği malzemeyi hash'leyip o hash'i vector'ün yanına yazıyoruz. Bir sonraki pipeline koşusunda güncel malzemeyi hash'leyip karşılaştırıyoruz. Fingerprint aynı → değişen bir şey yok → atla, sıfır öde. Farklı → portreyi yeniden üret, yeniden embed et, üzerine yaz. Bu tek ucuz numara, "bütün kataloğu her gece yeniden işle"yi "gerçekten değişen bir avuç kaydı yeniden işle"ye çevirir — ve pipeline'ı istediğiniz an güvenle yeniden koşulabilir yapar ki bu kulağa geldiğinden daha önemli: batch job'lar çöker ve düpedüz yeniden başlatılabilen çökmüş bir job, on sayfa recovery mantığına bedeldir.
Bir model etiketi. Her vector, kendisini hangi embedding modelinin ürettiğini kaydeder. input_type bölümündeki kuralı hatırlayın — farklı modellerin vector'leri farklı haritaların koordinatlarıdır, asla karşılaştırılamaz. Bu kuralın bir yaşam döngüsü sonucu var: daha yeni bir embedding modeline geçtiğimiz gün "kademeli" geçemeyiz — yarısı taşınmış bir index, aynı anda iki haritayı karşılaştırıyor olurdu. Etiket, migration'ı dürüst kılıyor: her vector'ü yeni modelle (öyle etiketlenmiş halde) backfill et, yeni set üzerinde kaliteyi doğrula, sonra retrieval'ı tek hamlede yeni sete çevir. Etiket ayrıca altı ay sonra auditor'ün soracağı soruyu da cevaplıyor: bu sonucun arkasındaki vector'ü hangi model üretti?
Bunların hiçbiri AI sihri değil — cache invalidation, yani kitaptaki en eski zor problem, yeni bir kostümle. Vector, anlamın cache'idir ve cache'lere anahtar gerekir.
🗺️ En yakın vector'leri hızlı bulmak — HNSW
Retrieval artık şu demek: bir sorgu vector'ü verildiğinde, cosine mesafesi en küçük K vector'ü bul. Nearest-neighbor search.
Önce dürüst bir sayı: bizim ölçeğimizde — beş bin civarı restoran, ki bu az bir sayı — brute force, yani her vector'ü tek tek kontrol etmek, tek haneli milisaniye sürüyor. Bu ölçekte index'e ihtiyaç yok. Bir milyon vector'de olurdu ve standart araç HNSW (Hierarchical Navigable Small World).
Zihinsel model bir otoyol sistemi 🛣️ — graph'ın en üst katmanında az sayıda node ve uzun menzilli bağlantılar var (şehirlerarası otoyollar); alt katmanlar daha yoğun ve yerel (şehir içi sokaklar). Arama tepeden girer, sorguya doğru açgözlüce sıçrar, bir seviye iner, sonucu inceltir — haritada zoom yapar gibi. Milyonlar yerine birkaç yüz karşılaştırma.
Trade-off, approximate kelimesinin içinde: komşu kaçırabilir. Metriği recall@K — gerçek top-K en yakının kaçını index gerçekten döndürdü? Index burada yine de kuruluyor (bu boyutta bedava) ve recall, exact scan'e karşı ölçülüyor — çünkü teknik bir tartışmada ölçülmüş bir sayı, alıntılanmış bir blog yazısını her zaman döver.
🧱 Vector'lerin duvara tosladığı yer: gürültülü cuma, sakin salı, çölyak her zaman
Bu tasarımın en iyi stres testi bir arkadaştan, tek bir Slack mesajıyla geldi:
"Cuma akşamı eski dostlarla gürültülü, uzun bir meyhane sofrası. Salı günü sakin bir köşe, tek başıma bir öğle yemeği ve bir kitap. Bir de partnerim çölyak — birlikte gittiğimiz her yerde glutensiz bir tercih değil, tıbbi bir zorunluluk. Aynı ben, üç bambaşka masa. Senin şu sistem bunların hepsini anlayacak mı?"
Tek mesajda üç ayrı ders.
1. Cuma ve salı kısımları, embedding'lerin en parlak anı. Dolaylı, mood yüklü bir metin ("gürültülü, uzun meyhane sofrası") anlam uzayında canlı-ve-geç-saate-kadar-açık bölgesine temiz oturur; "sakin bir köşe ve bir kitap" sakin-gündüz bölgesine düşer. Tek kişi, iki mood — ve tek bir benzerlik skoru ikisine birden hizmet edemezdi. Etmesi de gerekmiyor: her sorgu kendi bağlamını taşır ve kendi bölgesini bulur. Semantic search, tam tasarlandığı gibi çalışıyor.
2. Çölyak asla benzerlikle ele alınamaz. Vector search en yakın eşleşmeleri döndürür — ve sıradan bir fırın-kafe, diğer bütün boyutlarda, tamamen glutensiz çalışan bir mekana %90 benzeyebilir. Çölyak bir misafir için "yakın", bir felakettir: buradaki yanlış cevap kötü bir öneri değil, zarardır. Glutensiz, vegan, şu-an-açık, 2-km-içinde gibi kısıtlar hard filter'dır: öneren değil, garanti eden SQL WHERE cümleleri.
Vibe'lara probabilistic araçlar. Dealbreaker'lara deterministic araçlar. Hangisinin hangisi olduğunu bilmek işin yarısı.
3. Kelimenin tam hali hâlâ önemli. "künefe" araması, yazılı portresinde harfiyen künefe geçen mekanı döndürmek zorunda. Embedding'ler spesifikleri bulanıklaştırır; Türkçe stemming'li klasik full-text search ("manzara / manzaralı / manzarası" hepsi eşleşir) tam üstüne basar.
Yani production retrieval hybrid çalışır: vector search İLE keyword search'ü yan yana koşturursunuz (ikisinin de üstünde hard filter'lar) ve elinizde aynı sorgu için ayrı ayrı sıralanmış iki liste olur:
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)
Şimdi bunları tek listede birleştirmek zorundasınız — ve tuzak şurada: skorlar karşılaştırılamaz. Cosine 0 ile 1 arasında yaşar; keyword rank skorları keyfi bir ölçekte. Cosine 0.83, ts_rank 12.4'ten iyi midir? (ts_rank, Postgres'in full-text alaka skoru; birimi keyfidir — sorun da zaten tam olarak bu.) Sorunun cevabı yok — bunlar iki farklı ölçüm aletinden çıkmış değerler.
Reciprocal Rank Fusion (RRF) bunu, skorları çöpe atıp yalnızca her sonucun pozisyonunu kullanarak çözer. Her restoran her listeden puan kazanır: 1 / (60 + listedeki sırası). Topla, toplama göre sırala:
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 kazanıyor çünkü iki listede de üst sıralarda — ki istediğiniz davranış tam olarak bu: iki bağımsız sinyalin uzlaşması, tek listedeki şampiyonluğu döver. 60 sabiti, orijinal makaleden gelen bir sönümleyici sadece (1. sıra ile 3. sıra arasındaki farkın aşırı dramatikleşmesini engelliyor); kimse tune etmez. Pozisyonlar herhangi iki liste arasında karşılaştırılabilir, skorlar asla — bütün numara bu ve topu topu üç satır SQL.
💾 Gerekmediği ortaya çıkan vector database
Özel vector database'ler artık başlı başına bir ürün kategorisi: Pinecone, Qdrant, Weaviate, Milvus — artı AWS OpenSearch gibi managed seçenekler. Nedir bunlar? Vector saklamak ve "en yakın N'i bul" sorusunu aşırı hızlı, aşırı ölçekte cevaplamak için özel yapılmış veritabanları.
Anahtar kelime ölçek. Karmaşıklıklarını on milyonlarca vector, saniyede binlerce sorgu ya da katı tenant izolasyonu varsa hak ederler. Birini benimsemeden önce, kendi gerçek workload'unuz için kapasite hesabını yapın:
yaklaşık 5.000 restoran × 1024 boyut × 4 byte ≈ 20 MB
Yirmi megabyte. Bu yazıya iliştirilen görsellerden daha küçük. Bu arada AWS'nin managed seçeneği (OpenSearch Serverless), aylık tabanı yüzlerce dolar olan compute unit'ler üzerinden faturalandırıyor — bu workload'un yüzde birin kesri kadarını kullanacağı bir kapasite için. (AWS 2026'da scale-to-zero bir tier çıkardı, ama kullanıcıya dönük bir arama servisi asla boşta durmaz — süregiden trafik compute unit'leri sıcak tutar ve fatura başladığı yere geri düşer.)
Alternatif: pgvector, PostgreSQL için bir extension — yani zaten çalışan, zaten yedeklenen, zaten parası ödenen veritabanı. Tek bir CREATE EXTENSION vector; ve Postgres vector'lerden, cosine mesafesinden ve HNSW index'lerinden anlar hale geliyor.
Ve kıymeti bilinmeyen asıl kazanç maliyet değil. Artık tek bir SQL sorgusu her şeyi yapıyor — hard filter'lar, coğrafi yarıçap, Türkçe full-text, vector benzerliği — tek yerde, tek transaction'da. İkinci bir datastore yok, sync pipeline'ı yok, "gerçek veri" ile "arama verisi" arasında drift yok.
Ders genelleşiyor: altyapı seçmeden önce kapasite aritmetiğini yapın. On dakikalık çarpma işlemi bir servisi, bir pipeline'ı ve dört haneli yıllık bir faturayı kurtardı.
⏭️ Sırada
Temeller hazır: retrieval katmanının şekli ve arkasındaki gerekçeler. Part 2 mimariyi anlatıyor — ML kodu neden Java platformunun yanındaki bir Python servisinde yaşıyor, ikisi tek veritabanını birbirinin ayağına basmadan nasıl paylaşıyor — ve kimsenin size hazır vermediği kısım: isimler, konumlar ve açılış saatleri bir restoran kataloğunun kolay yarısı. Değerli yarısı — "vibe": "cozy, mostly young crowd" — veri olarak hiçbir yerde satılmıyor. O attribute katmanını kendi elimizle inşa ediyoruz: bir LLM pipeline'ı, her iddia için evidence zorunluluğu ve pipeline kimsenin kredi kartını şaşırtamasın diye sıkı bir bütçe tavanıyla. Sonraki yazı bu.
Bu alana göz kırpan backend mühendisleri için: bu iş veritabanları, index'ler, trade-off'lar ve maliyet hesabından ibaret — yani zaten yaptığınız iş — artı doğru düzgün öğrenmeye değer, sahiden yeni tek bir primitive.
Vector, yeni column'dur.