Pazartesi sabahı ürün müdürümüz Slack'e iki ekran görüntüsü attı. Birincisinde arama kutusuna "TIRAŞ MAKİNESİ" yazılmış, 4.700 sonuç dönmüştü. İkincisinde aynı telefonda "tıraş makinesi" yazılmış, 11.200 sonuç vardı ve ilk sayfa bambaşkaydı. Altına tek cümle yazmıştı: "Hangisi doğru?" İkisi de değildi. O hafta 14 milyon ürünlük kataloğumuzun arama loglarına gömüldük ve zero-result oranımızın %11 olduğunu gördük: her yüz aramadan on biri boş sayfayla bitiyordu. Trafiğin yarısından fazlasının aramayla başladığı bir platformda bu, kapıdan gireni eli boş göndermekle aynı şey.
Büyük I, Küçük ı ve Bir Unicode Klasiği
İlk bulgu, Türkçe'yle uğraşan herkesin er geç çarptığı duvardı: I harfi. Unicode'un varsayılan lowercase dönüşümü I'yı i yapar; Türkçe'de ise I'nın küçüğü ı, İ'nin küçüğü i'dir. Bizim index'lerin bir kısmı standart lowercase filter ile, sorgu tarafındaki bir normalizasyon katmanı ise Java'da toLowerCase(Locale.forLanguageTag("tr")) ile çalışıyordu. Yani "TIRAŞ" index'te "tiraş" olurken sorguda "tıraş" oluyor ve ikisi asla buluşamıyordu. Çözüm tek satır gibi görünür: her yerde turkish_lowercase token filter. Ama "her yerde" kelimesi altı farklı mikroservise, üç index şablonuna ve bir mobil autocomplete servisine yayılınca iki haftalık bir temizlik operasyonuna dönüştü. Bu vakadan çıkan kural artık duvarımızda asılı: normalizasyon tek bir yerde, tek bir analyzer zincirinde yaşar; kim sorguyu nereden gönderirse göndersin.
Klavyesinde Türkçe Karakter Olmayan Müşteri
Loglar ikinci bir gerçeği gösterdi: aramaların %38'i Türkçe karakter içermiyordu. İnsanlar "kulaklik", "utu", "camasir makinesi" yazıyordu. Bunun için asciifolding şart, ama saf haliyle tehlikeli: "sık" ile "şık" aynı token'a düşerse giyim kategorisinde felaket olur. Biz preserve_original ile ikisini birden index'liyoruz ve orijinal formdaki eşleşmeye daha yüksek skor veriyoruz; "şık elbise" arayan şık elbise görüyor, "sik elbise" yazan da (evet, loglar acımasız) yine elbise görüyor. Alan stratejimiz de buna göre üç katmanlı: exact alan, folded alan, stemmed alan; hepsi multi-field olarak aynı kaynaktan üretiliyor ve sorgu üçüne farklı boost'larla dağılıyor.
Kökleri Fazla Kazıyan Stemmer
Üçüncü bela stemming'di. Elasticsearch'ün Türkçe snowball stemmer'ı sondan eklemeli dilimizde bazen fazla hevesli davranıyor: bizim katalogda "sandalye" araması "sandal" içeren sonuçlarla kirlenmişti, "kazak" ile "kaz" figürlü ürünlerin buluştuğu utanç verici sonuç sayfaları gördük. Tersine, agresiflikten kaçınıp stemming'i tamamen kapatınca da "çocuk odası" araması "çocuk odaları" başlıklı ürünleri kaçırıyordu. Dengeyi iki hamleyle kurduk: stemmed alanın boost'unu exact alanın belirgin altına çektik ve en çok aranan 2.000 terim için elle bakımlı bir keyword_marker + synonym listesi oluşturduk. Eş anlamlılar Türkçe e-ticarette sanıldığından kritik: "cep telefonu", "akıllı telefon" ve "smartphone" aynı niyettir; "bebek arabası" ile "puset" de öyle. Bu listeyi arama ekibi değil, kategori yöneticileri besliyor; ayda bir zero-result raporundan yeni adaylar çıkıyor. Synonym listesinin de kendine has bir tuzağı var: liste büyüdükçe kimse neyin neden eklendiğini hatırlamıyor. Her satıra ekleyen kişi, tarih ve örnek sorgu yazmak zorunlu; altı ayda bir de tıklama verisiyle "bu eşleşme hâlâ işe yarıyor mu" denetimi yapılıyor.
Fuzziness ayrı bir tuzaktı. AUTO fuzziness kısa Türkçe kelimelerde niyeti değiştiriyor: "kazak" bir edit mesafesiyle "kaçak" olur, "koli" "kolye" olur. Biz fuzzy eşleşmeyi yalnızca dört harften uzun token'larda, tek edit mesafesiyle ve skoru ciddi kırparak açıyoruz; iki harflik prefix'i sabitliyoruz ki en azından kelimenin başı niyeti korusun.
Autocomplete cephesi ayrı bir dosya. Öneri kutusu, tam aramadan çok daha yüksek hacim çeker; bizde arama kutusuna yazılan her üç karakterden sonra saniyede 9 bine kadar suggest isteği oluşuyor. Burada edge n-gram tabanlı ayrı bir index kullanıyoruz ve Türkçe'nin aynı dertleri burada da hortluyor: "cam" yazan kullanıcı hem "camaşır" (yanlış yazımıyla) hem "cam bardak" hem "çamaşır makinesi" niyetinde olabilir. Önerileri son 30 günün gerçek arama frekansıyla sıralıyor, folded ve orijinal formları aynı önerinin altında birleştiriyoruz; yoksa liste "çamaşır" ve "camasir" diye iki satır gösterip kullanıcıyı kendi klavyesiyle baş başa bırakıyor.
Skorun İkinci Katmanı
Bütün bunlar "doğru ürünleri bul" problemini çözüyor; "doğru sırala" problemi başka bir hayvan. BM25, "iphone kılıf" aramasında başlığında iki kez "kılıf" geçen alakasız ürünü öne çıkarmakta ısrarcıydı. Bunun için reranking katmanı kurduk: Elasticsearch ilk 300 adayı getiriyor, ayrı bir servis bu adayları LambdaMART tabanlı bir learning-to-rank modeliyle yeniden sıralıyor. Modelin beslendiği sinyaller metin skorunun yanında son 30 günün tıklama ve sepete ekleme oranları, satıcı puanı, stok durumu, fiyat rekabetçiliği ve teslimat hızı. Eğitim verisi tamamen kendi click loglarımızdan geliyor; position bias'ı düzeltmeden ilk denemede model "zaten üstte olanı üste koy" diye öğrenmişti, bunu inverse propensity ağırlıklarıyla toparladık. Cross-encoder tabanlı semantik reranking'i de denedik; nDCG'de güzel bir artış verdi ama p95 gecikmeyi 120 milisaniyelik bütçemizin dışına, 340'lara taşıdı. Şimdilik yalnızca zero-result'a düşen sorgular embedding tabanlı komşu aramayla kurtarılıyor; bu bile boş sonuç oranını tek başına iki puan düşürdü.
Analyzer Değiştirmek, Uçakta Motor Değiştirmektir
Bütün bu değişikliklerin operasyonel tarafını es geçmeyeyim, çünkü analyzer değişikliği mevcut index'e uygulanamaz; full reindex ister ve 14 milyon ürünü yeniden index'lemek bizde yaklaşık 6 saat sürüyor. Bunu alias mimarisiyle çözüyoruz: uygulama hiçbir zaman fiziksel index adına konuşmaz, "products_read" ve "products_write" alias'larına konuşur. Yeni analyzer'lı index arka planda sıfırdan doldurulur, bu sırada canlı güncellemeler her iki index'e çift yazılır, karşılaştırma testleri yeni index üzerinde koşulur ve tek bir atomik alias swap ile geçiş yapılır. Bir şey ters giderse geri dönüş de aynı hızda. Her analyzer adayını önce _analyze API'siyle, en sık 10 bin sorgudan oluşan bir fixture üzerinde token token karşılaştırıyoruz; "TIRAŞ" vakasından sonra bu fixture'a Türkçe karakter çeşitlemeleri bilerek eklendi ve CI'da her şablon değişikliğinde otomatik koşuyor.
Kaliteyi de hisle değil düzenekle ölçüyoruz. 1.500 sorguluk bir değerlendirme seti var; her sorgu için kategori yöneticilerinin puanladığı ideal sonuçlar tutuluyor ve her ranking değişikliği önce bu set üzerinde nDCG ile ölçülüyor. Offline kazanan adaylar canlıda interleaving ile yarışıyor: iki sıralamanın sonuçları aynı sayfada harmanlanıyor ve kullanıcı tıklarının hangi tarafa aktığına bakılıyor. Interleaving, klasik A/B'ye göre onda bir trafikle anlamlı sonuç veriyor; arama gibi sürekli kurcalanan bir sistemde bu hız, deney kapasitesinin kendisi kadar değerli.
Rakamların Söylediği
Altı aylık bu operasyonun bilançosu şöyle: zero-result oranı %11'den %3,8'e indi, arama sonrası sepete ekleme %9,4 arttı, "sonuç bulunamadı" ekranından çıkış oranı yarılandı. Peak saatte saniyede 3.200 aramayı p95 118 milisaniyede dönüyoruz. Ama en çok gurur duyduğum metrik daha mütevazı: ürün müdürümüzün ekran görüntüsü atma sıklığı. Arama, bitti diyebileceğiniz bir proje değil; her yeni kategori, her yeni jargon, her sezon kendi bozuk sorgularını getiriyor. Türkçe arama kuran ya da mevcut kurulumunda "TIRAŞ" vakasının bir benzerini yaşayan varsa, iletişim sayfası açık; analyzer zincirinizi birlikte didikleyelim.