Özgür Işık Damar
8 dk okuma

Türkçe bir ürün kataloğu için CLIP vs SigLIP: bir marka-yakınlığı ablasyonu

İkinci aya geldiğimizde CTO'muz 'CLIP kalitesinde mi?' diye sormayı bırakmış, 'SigLIP kadar adil mi?' diye sormaya başlamıştı.

hybrid-searchembeddingscomputer-vision

2,4 milyon ürün görselimiz vardı. Çoğu satıcılar tarafından telefonla çekilmişti, çoğunun başlığı Türkçeydi ve metin encoder'ımız o başlıkları yakalayamıyordu. İlk görsel arama CLIP ViT-B/32 üzerinde canlıya çıktı. Bir hafta sonra marka yakınlığı skorları çok spesifik bir yönde sapıyordu: her koyu renkli ayakkabı modele Nike gibi görünüyordu.

O cümle CLIP'ten neden çıktığımızın bütün hikâyesi. Geri kalanı sadece fatura.

Embed ettiğimiz katalog

Görsel tower, hibrit arama pipeline'ının içinde oturuyor. Metin, BM25 ve görsel embedding paralel çekiliyor; ardından fusion'a giriyorlar. Görsel tower, görsel sorgular için — bir fotoğraf yükleme, bir screenshot, müphem bir "şuna benzer bir şey" — yükün yaklaşık üçte birini taşıyor. Üstüne bir de text-to-image projection head üzerinden metin sorgularının küçük ama göz ardı edilemez bir kısmını.

Katalog ölçeği, anlatması kolay olan kısımdı. SKU başına bir görselle 2,4M görsel, ortalama genişlik 1200 px civarı, JPEG kalitesi her yerde — çünkü satıcı telefonları tutarlı değil. Anlatması zor olan kısım dağılımdı. Katalogun yaklaşık yüzde 38'i giyimdi. Giyim içinde uzun kuyruk Türk ve Makedon yerel markalardı — bir CLIP eğitim setinin asla görmediği isimler.

Bariz seçim: CLIP ile çıkmak

CLIP, bariz başlangıç noktasıydı. Ekibin altyapısı zaten ona kurulu. Vektör boyutu Qdrant'ın named-vector slot'una temiz oturuyor. Nasıl bağlanacağına dair iyi Türkçe blog yazıları vardı. Throughput sayıları biliniyordu.

# İlk canlıya aldığımız versiyon. Bir kere embed, bir kere upsert, sonsuza dek search.
# Koyu ayakkabıları Nike yapan versiyon buydu.
import torch
from transformers import CLIPModel, CLIPProcessor
 
model = CLIPModel.from_pretrained("openai/clip-vit-base-patch32").eval().to("cuda")
proc  = CLIPProcessor.from_pretrained("openai/clip-vit-base-patch32")
 
def embed_image(pil_img) -> list[float]:
    inputs = proc(images=pil_img, return_tensors="pt").to("cuda")
    with torch.no_grad():
        feats = model.get_image_features(**inputs)
        feats = feats / feats.norm(dim=-1, keepdim=True)
    return feats.cpu().numpy()[0].tolist()

Offline benchmark'ta CLIP iyiydi. 8.000 görsel-only sorgudan oluşan etiketli sete karşı Recall@10 0,71. Latency A10'da embed başına 38 ms — görsel adımı için ayırdığımız 80 ms bütçenin çok altında.

Canlıya çıkardık ve aramalar makul görünüyordu. Görünüyordu.

Fark ettiğimiz gün

Marka yakınlığı (brand affinity), merchant ops ekibinin başka bir amaçla kurduğu bir metrikti. Her satıcının ürünlerini, görsellerinin satıcının amaçladığı marka kimliği etrafında ne kadar temiz kümelendiğine göre skorluyordu. Minimalist İskandinav giyim satan bir butiğin kendi katalogu içindeki tutarlılığı yüksek olmalı. Çok markalı bir mağazanınki tasarım gereği daha düşük olmalı, ama içindeki markalar yine de tanınabilir kalmalı.

Rollout'tan sonraki çarşamba, merchant ops lead bana bir grafik yolladı. Katalogdaki her koyu renkli ayakkabının marka yakınlığı skoru — en az altmış Türk ve Makedon butik markası boyunca — "Nike-şeklinde"ye doğru kaymıştı. Görsel embedder hepsini aynı komşuluğa projekte ediyordu. Yerel üretim Yıldız deri botlar ile bir çift Nike Cortez, görsel uzayda yakın komşulardı. Paylaştıkları tek şey koyu olmak ve ayakkabı şeklinde olmaktı.

Merchant ops lead kibarca sordu: bunu bilerek mi yaptık?

Bilerek yapmamıştık. Eğitim verisiydi.

CLIP bunu neden yapıyordu

CLIP, açık webden kazınmış yaklaşık 400 milyon görsel-metin çifti üzerinde eğitilmişti. O corpus'ta Nike, Adidas, Puma için fotoğraf-metin çiftleri, herhangi bir Türk veya Makedon yerel markası için olanları kat be kat aşıyordu. CLIP'in görsel uzayında "ayakkabı + koyu renk" etrafındaki yoğun bölgeler popüler markalarla dolu; uzun kuyruk markalar ise basit en-yakın-komşu geometrisi tarafından bu yoğun komşuluğa çekilen seyrek karakollar.

Bu CLIP'te bir bug değil. CLIP, eğitildiği şeyi tam olarak yapıyor. Bug, katalogumuzun CLIP ile yarı yolda buluşmasında. Katalog uzun kuyruk markalarda ağır. CLIP global markalarda ağır. İki dağılım, önemli olduğu yerde örtüşmüyordu.

CLIP'in üzerine kendi marka etiketlerimizle bir head fine-tune etmek için iki gün harcadım. Yardımcı oldu, biraz. Recall@10 belki 2 puan arttı, marka adaleti kıpırdamadı. Bias'ı düzeltmek için gereken bilgi head'de değildi. Encoder'daydı.

Bu yüzden encoder'ı değiştirdik.

SigLIP swap'i

CLIP'ten SigLIP'e geçişle ilgili kimsenin sana söylemediği şey, diff'in ne kadar küçük olduğu. Mimariler, embedding kodunun nadiren değişeceği kadar benzer:

# Bütün migrasyon maliyeti. Bir import satırı, bir model id.
# Aşağıdaki pipeline değişmedi.
from transformers import AutoModel, AutoProcessor
 
model = AutoModel.from_pretrained("google/siglip-base-patch16-256").eval().to("cuda")
proc  = AutoProcessor.from_pretrained("google/siglip-base-patch16-256")
 
def embed_image(pil_img) -> list[float]:
    inputs = proc(images=pil_img, return_tensors="pt").to("cuda")
    with torch.no_grad():
        feats = model.get_image_features(**inputs)
        feats = feats / feats.norm(dim=-1, keepdim=True)
    return feats.cpu().numpy()[0].tolist()

Hepsi bu. Aynı input, aynı output shape, aynı Qdrant upsert yolu. Katalog elbette baştan vektörize edilmek zorundaydı — her görsel GPU farm'da bir kez daha embed edilir — ama kodda diff bir import satırıydı.

Küçük olmayan şey, altta neyin değiştiği. SigLIP'in eğitim loss'u, CLIP'in contrastive softmax'i yerine bir sigmoid loss. Pratik sonuç şu: SigLIP'in görsel uzayı, marka-zengin bölgelerin etrafında daha az kıvrımlı; çünkü loss, her çifti batch içindeki diğer her çifte karşı ayırt etmeye zorlamıyor. Objektif, batch başına değil çift başına. Verinin seyrek olduğu yerlerde geometri daha düz kalıyor.

Aynı 8k sorguya karşı sayılar

Offline sette:

  • Recall@10: CLIP 0,71, SigLIP 0,83. +12 puan.
  • Marka adaleti indeksi: CLIP 0,42, SigLIP 0,70. +28 puan.
  • P95 embed latency: CLIP 38 ms, SigLIP 53 ms. +15 ms.

Latency bedeli gerçekti, ödedik. SigLIP-Base patch-16-256, CLIP ViT-B/32'den belirgin biçimde daha ağır. Latency'i geri kazanmak için daha küçük siglip-small-patch16-224'e geçmeyi düşündük, eval'i koşturduk, marka adaleti rakamının düştüğünü izledik ve büyüğü tuttuk.

Manşet, marka adaleti yükselişiydi. Uzun kuyruk markalar artık popüler marka komşuluklarına emilmiyordu. Yıldız botlar ile Nike Cortez vektör uzayında ayrıldı. Yakın komşu olmaktan çıktılar. Gerçekte ne olduklarına döndüler: iki farklı markadan, ikisi de tesadüfen koyu olan iki farklı ayakkabı.

Beklemediğimiz bonus

Görsel index'i hedefleyen metin sorguları için text-to-image projection'ımız SigLIP ile gözle görülür şekilde iyileşti. Özellikle Türkçe metin sorguları. SigLIP'in eğitimi, orijinal CLIP'ten çok daha geniş bir multilingual subset içeriyordu; Türkçe başlıklar ile görsel bölgeler arasındaki hizalama daha sıkıydı. Kırmızı kemerli yazlık elbise gibi bir sorgu, CLIP'te ekstra bir fine-tune olmadan görmediğimiz bir oranda tutarlı elbise görselleri döndürmeye başladı.

Bunun için ayrıca hiçbir şey yapmadık. Bir yan etkiydi. Aynı modelin aynı anda hem daha multilingual hem uzun kuyruk markalara daha adil hâle gelmesi, takası kolaylaştıran kısım oldu.

Model büyüklüğü konusunda yanılan kısım

Ekipte bir şey iyileştiğinde "yeni model daha mı büyük?" diye sormak bir refleks. SigLIP-Base, CLIP ViT-B/32'den özellikle daha büyük değil; aynı büyüklük mertebesindeler. Kazanım büyüklük değildi. Kazanım, eğitim loss'u ve eğitim verisi karışımıydı.

İki kat parametreli CLIP-Large'a atlasaydık, marka adaleti sayılarımız hafifçe daha kötüye giderdi; çünkü aynı bias'lı veri daha agresif modellenirse aynı bias'ları derinleştirir. Test ettik. CLIP-Large'ın marka adaleti 0,39'du, CLIP-Base'ın 0,42'sine karşı. Büyük yardımcı olmadı. Farklı, oldu.

Yüzeyde sezgiye ters. Geriye dönüp bakınca bariz. Encoder bir mercek. Eğitim verisi ışık. Bias'lı ışığa odaklanmış daha büyük bir mercek, daha keskin bir bias'lı görüntü üretir.

Küçük bir marka-adaleti eval'i

Marka adaleti indeksi, 12.000 ürünlük bir sample üzerinde her gece koşuyor. Matematik egzotik değil: top-N komşularının marka etiketi üzerinde ne kadar sıkı kümelendiğini, markanın katalogdaki dağılımına göre normalize ediyor.

def brand_fairness(query_results: list[Result],
                   catalogue_brand_counts: dict[str, int]) -> float:
    # Yüksek daha adil. Tam orantılı bir sample 1,0 alır.
    # Popüler markaları fazla temsil eden bir sample 1'in altında alır.
    total = sum(catalogue_brand_counts.values())
    expected = {b: c / total for b, c in catalogue_brand_counts.items()}
    observed = Counter(r.brand for r in query_results)
    n = len(query_results)
    observed = {b: c / n for b, c in observed.items()}
    # KL-style divergence, ters çevrilmiş — yüksek = daha adil.
    return 1.0 / (1.0 + sum(
        o * math.log(o / expected.get(b, 1e-9))
        for b, o in observed.items() if o > 0
    ))

Bunu satıcı bazında ve kategori bazında takip ediyoruz. "Arama, gizliden gizliye sadece en üst markaları biliyor" durumunu satıcılardan önce yakalayan metrik, bu.

Altı ay sonra

Hâlâ SigLIP-Base-Patch16-256'dayız. Marka adaleti head'ini bir kez yeniden eğittik; bir dalga yeni Makedon butik onboard olup dağılımı tekrar kaydırdığında. Hiçbir production trafiği için CLIP'e geri dönmedik.

İkinci aya geldiğimizde CTO'muz "CLIP kalitesinde mi?" diye sormayı bırakmıştı. Onun yerine "SigLIP kadar adil mi?" diye sormaya başlamıştı. Ekibin problemi gerçekten içselleştirdiğini, ancak böyle küçük bir dilsel kayma ele veriyor. Problem hiçbir zaman stok fotoğrafçılıkta görsel kalite değildi. Problem, katalogumuza tuttuğumuz merceğin, katalogumuza benzeyen herhangi bir şey üzerinde eğitilip eğitilmediğiydi.

Eğitilmemişti. Merceği değiştirdik. Katalog, nihayet kendisi gibi göründü.

// madem buradasın