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

Production'da RRF ile altı ay: k=60'ın sana söylemediği

1/(60+rank)'deki 60, 2009 tarihli bir makaleden geliyor. Kopyalayan ekiplerin çoğu onu Qdrant default'u sanıyor.

hybrid-searchqdrantproduction

Benchmark notebook'unda Recall@10 harika görünüyordu. Production'a aldıktan üç hafta sonra merchant CRM ekibi ekran görüntülerini yağdırmaya başladı. "Neden mavi gömlek aradığımda lacivert battaniye geliyor?" Cevap k'daydı.

Tüm yazı tek paragrafta bu. Geri kalanı, RRF'i internetin kopyala-yapıştır yaptığı default sabitiyle canlıya almadan önce — yani Ağustos 2025'te — okumuş olmayı istediğim detaylar.

Buraya nasıl geldik

Türkiye–Kuzey Makedonya arasında bir cross-border katalog üzerinde hibrit arama çalıştırıyoruz. Yaklaşık 7 milyon canlı ürün, üç retrieval modalitesi, tek bir fusion katmanı. Modaliteler her zamanki şüpheliler:

  • çok dilli dense bir metin encoder'ı
  • IDF reweighting ile bir BM25 sparse index
  • cross-modal retrieval için bir görsel embedding tower'ı (CLIP/SigLIP)

Qdrant native hibrit arama'yı çıkardığında geçiş bir öğleden sonra sürdü. API temiz, fusion içeride hazır, default fusion stratejisi de RRF — Reciprocal Rank Fusion. Formül kimsenin sorgulamadığı kısım:

score(d) = modaliteler üzerinden sum: 1 / (k + rank_modalite(d))

O k bir sabit. Qdrant docs 60 kullanıyor. Elastic docs 60 kullanıyor. HuggingFace örnekleri 60 kullanıyor. Ekipteki on yıllık search'çü kıdemli 60 kullanıyordu. Ben de 60 kullandım.

Naif benchmark, sonra gerçek kullanıcılar

Offline eval setimiz 4.200 etiketli sorguydu. k=60 ile nDCG@10 0,61'di. Recall@10 0,78. Notebook'ta sayılar iyiydi. Feature flag arkasında yüzde 5'le çıktım, bir hafta dashboard izledim, yüzde 100'e çıkardım. Latency tutuyordu. Error rate düzdü. Her şey yeşildi.

Sonra ekran görüntüleri başladı.

Üsküp ofisinden bir merchant ops lead lacivert battaniye olanı yolladı. Bir müşteri hizmetleri süpervizörü Adidas Samba beyaz aramasında bir çift beyaz çorap dönen birini gönderdi — Samba markalı, teknik olarak match, ama çorap. Sonra data ekibinden bir junior bir haftalık gerçek production log'undan eval setini yeniden kurdu ve offline sayılar daha da kötüleşti: nDCG@10 0,61'den 0,49'a düştü.

Eval set temizdi. Production değildi. İlginç hataların hepsi tek bir modalitenin kuyruğunun fusion sonucunun başına çıkmasıydı.

k=60 sana özellikle neden zarar veriyor

Formüle tekrar bak. k=60 ile, tek bir modalitede 200. sırada olan bir sonuç fused score'a hâlâ 1/(60+200) = 0,0038 katkı veriyor. Küçük bir sayı. Ama bir sorgu, tek bir modaliteyi birçok aday üzerinde çok zayıf eşliyorsa, o küçük katkılar üst üste binmeye başlıyor. Daha kötüsü, dense metin tower'ı 50–200 arası sıralarda düşük kaliteli, semantik olarak komşu sonuçlar üretme alışkanlığı taşıyordu. Mavi gömlek dediğinde lacivert battaniye gibi. Model yanlış değildi. Model "bunlar bir nevi benzer" diyordu. RRF de o ılık görüşü alıp, diğer iki modalitenin söyleyecek bir şeyi olmadığında ilk 10'a sokmaya yetecek kadar ağırlık verdi.

Merchant CRM ekran görüntülerinin hepsinin şekli aynıydı: bir modalitede ilk 20'de işe yarar bir şey yok, diğer ikisi gürültülü, RRF de gürültülü modalitelerin kuyruğunun sessiz olanın başını geçtiği bir fused liste dikiyor. k=60 sabiti, kuyruğun ne kadar bağırabileceğini kontrol eden düğme. Altmış yüksek sesli.

Referans için naif RRF

Canlıya aldığım versiyon buydu. Şu anda arama tutorial'larının yarısının ön sayfasında.

# Qdrant'ın native RRF'i. k örtük ve 60'a eşit.
# Masum görünür. Sana borçluymuş gibi davranmaz.
results = await client.query_points(
    collection_name="products",
    prefetch=[
        models.Prefetch(query=text_vec,  using="text",  limit=200),
        models.Prefetch(query=sparse,    using="bm25",  limit=200),
        models.Prefetch(query=image_vec, using="image", limit=200),
    ],
    query=models.FusionQuery(fusion=models.Fusion.RRF),
    limit=20,
)

O snippet'in hiçbir yerinde k argümanı yok. Bütün sorun bu. 60'ı sessizce miras alıyorsun.

Sweep

Aynı 4.200 sorgulu eval setini, artı production'dan yeniden kurulan seti, k ile koşturdum. Sonuçlar ince değildi.

knDCG@10 (temiz)nDCG@10 (prod)yorum
200,690,71baş baskın, kuyruk sessiz
300,740,73bizim karışım için tatlı nokta
600,610,49canlıya aldığımız default
1200,550,44kuyruk yüksek sesli
2000,480,38herkes eşit oyluyor, kötü

Uzun kuyruk sorgularda (dört ya da daha fazla token, çoğu zaman bir tamlama) k=20 daha da iyiydi — 0,77 — çünkü her modalitenin başı güvenilirdi ve smoothing'e ihtiyacımız yoktu.

Küçük k keskin. Büyük k bulanık. Default bulanık.

Heterojen k, homojen k'yı yener

Beklemediğim kısım buydu. Üç modalitenin rank dağılımları çok farklı. BM25'in başı dik — ilk on sonuç genelde önemli olan tek on, sonra uçurumdan düşüyor. Dense metin tower'ı daha yumuşak — ilk kırk hepsi makul biçimde alâkalı. Görsel tower hepsinin en yumuşağı — ilk yüz faydalı olabilir, özellikle cross-modal sorgularda.

Tek bir k kullanırsan, BM25'in güvenli başını az ağırlandırıyor ve görselin gürültülü kuyruğunu fazla ağırlandırıyorsun. Çözüm her modaliteye kendi k'sını vermekti:

# Per-modality RRF: füzyonu kendimiz yeniden yazıyoruz çünkü Qdrant'ın
# native fusion'ı tüm prefetch'ler için tek k alıyor.
def per_modality_rrf(hits_by_modality: dict[str, list[str]],
                     k_per_modality: dict[str, int]) -> dict[str, float]:
    scores: dict[str, float] = defaultdict(float)
    for modality, ids in hits_by_modality.items():
        k = k_per_modality[modality]
        for rank, doc_id in enumerate(ids, start=1):
            scores[doc_id] += 1.0 / (k + rank)
    return scores
 
# Sweep'ten sonra production'a inen değerler.
K = {"bm25": 15, "text": 40, "image": 60}

BM25 k=15: keyword eşleştiğinde anahtar kelime listesinin başı baskın olsun. Metin tower'ı k=40: ilk otuzuna güveniyoruz, sonrası bulanıklaşıyor. Görsel tower k=60'ta kalıyor çünkü resim şekilli sorgularda 100. sıra bazen hâlâ faydalı. Bu tek değişiklikle production setinde nDCG@10 0,49'dan 0,74'e çıktı.

Bu sayılarda büyü yok. Sweep'ten ne düştüyse o. Asıl mesele farklı sayılar olmaları ve default API'nin sana farklı sayılar tutturmaması.

Sorgu uzunluğuna duyarlı k

Sweep'in gösterdiği bir başka şey: kısa ve uzun sorgular farklı smoothing istiyor. Kırmızı elbise gibi iki token'lı bir sorgu neredeyse tamamen her listenin başıyla yanıtlanmalı — disambigue edecek çok şey yok, kullanıcı ne istediğini biliyor. Yazlık keten erkek pantolon bej beden 32 gibi altı token'lı bir sorgu ise ince uzun bir iğne ve biraz daha geniş bir ağdan yararlanıyor.

// chooseK modaliteye ve sorgu uzunluğuna göre k seçer.
// Aşağıdaki sabitler altı haftalık prod log üzerinde yapılan bir sweep'ten geldi.
// Kopyalama, kendi sweep'ini yap. Seninkiler farklı olacak.
func chooseK(modality string, tokenCount int) int {
    base := map[string]int{"bm25": 15, "text": 40, "image": 60}[modality]
    switch {
    case tokenCount <= 2:
        return base / 2
    case tokenCount >= 5:
        return base + 20
    default:
        return base
    }
}

Evet, Fusion.RRF'ten daha fazla kod. Evet, Fusion.RRF'ten daha iyi sonuç veriyor. Takas takastır.

Eval harness, kısaca

Bütün bunların savunulabilir olmasının sebebi harness. Donmuş bir production-log örneği üzerinde, en güncel katalog snapshot'ıyla geceleri eval koşuyoruz. Harness'a, herhangi bir locale'de nDCG@10 1,5 puandan fazla düşerse build'i kırma izni var.

async def eval_run(queries: list[QueryRecord], k_config: dict) -> Report:
    # Aynı sorgu batch'ini hem aday config'inde hem de mevcut prod config'inde koş.
    # Locale bazında karşılaştır çünkü TR ile MK dağılımları çok farklı.
    cand = await run_batch(queries, k_config)
    prod = await run_batch(queries, PROD_K_CONFIG)
    return Report(
        per_locale_ndcg=ndcg_at_k_per_locale(cand, prod, k=10),
        regression_threshold=1.5,
    )

İlginç ayarı mümkün kılan sıkıcı altyapı bu. Harness olmadan vibe'lara ve ticket'lara göre ayar yapıyorsun. Ticket'lar yavaş bir gradient.

Kimsenin söylemediği şey

1/(60+rank)'teki 60, Cormack, Clarke ve Buettcher'in 2009 tarihli bir makalesinden geliyor — TREC track'lerinde birden fazla arama motorunun sonuçlarını birleştirme üzerine. Bir avuç sabit denemişler, 60 ellerindeki corpora'da iyi performans göstermiş. Kopyalayan ekiplerin çoğu bunu Qdrant default'u sanıyor. Değil. Bir makaleden gelen, senin trafiğin için değil bir benchmark için boyutlandırılmış bir sayı.

Tek bir kural bırakacak olsam şu olurdu: k bir confidence parametresi değil, "bu modaliteye ne kadar güveniyorum" parametresi de değil. Bir smoothing parametresi — her listenin kuyruğunun fused başa ne kadar katkı vereceğine karar veriyor. Modalite başına seç. Trafiğine göre seç. Katalog veya sorgu karışımı değiştiğinde yeniden seç. Default değer başkasının trafiği.

Bizim k Şubat 2025'ten beri iki kez taşındı. Bir kere Makedonca Kiril sorgular eklendiğinde — BM25 başı daha güvenli hale geldi. Bir kere image encoder'ı değiştirdiğimizde — görsel kuyruğu çok daha az gürültülü hale geldi. Her taşıma bir sweep, bir eval ve "k tuning, Haziran 2026" başlıklı sessiz bir PR'dı. Geçen Cuma data ekibinden o junior — eval setini production loglarından yeniden kuran çocuk — Slack'ten yazdı: "yine k mı değişti?" Evet. Bir düğme, döndürdük.

// madem buradasın