BM25'in cross-encoder'ımı yendiği hafta — ve reranker'ı yine de neden tuttum
Bazen en akıllı mühendislik kararı üç hafta önce kurduğun şeyi silmektir. Bazen sadece bir if'in arkasına almaktır.
Hibrit pipeline'ımızın sonuna bir BGE-Reranker aşaması koymak için üç hafta harcamıştım. Cross-encoder, P95 latency'e 40 ms ekledi. Karşılığında nDCG@10'u 6 puan yükseltti. Sonra Makedonca pazaryerinden sorgular gelmeye başladı. Yarısı için BM25 tek başına — fusion yok, dense vektör yok, rerank yok — tüm pipeline'ı yeniyordu.
O cümle bir haftamı mahvetti. Hafta da bana cross-encoder'ın aslında ne işe yaradığını öğretti.
Pipeline'ın yapısı
Hibrit arama motorunun üç aşaması var: retrieval, fusion, rerank. Retrieval aşaması Qdrant'a karşı paralel olarak üç named-vector sorgusu koşturuyor: BM25 sparse, dense text, dense image. Fusion aşaması üçünün üzerinde Reciprocal Rank Fusion çalıştırıyor. Rerank aşaması ise fused listenin top 50'sini alıp (sorgu, doküman) çiftleri üzerinde BGE-Reranker-Base cross-encoder koşturarak nihai sıralamayı üretiyor.
Reranker, hem sorguyu hem de dokümanı aynı anda okuyabilen tek aşama. Retrieval aşaması ikisini ayrı ayrı embed eder ve mesafe karşılaştırır. Fusion aşaması hiçbir şey okumaz; sadece rank listelerini birleştirir. Cross-encoder, sistemde modelin "tam olarak bu sorgu, tam olarak bu başlık ve tam olarak bu açıklama bir arada" diyebildiği tek yer.
Latency bütçesinin içinde servis edebilmek için üç hafta çalıştım. Kazanç temizdi: global eval setinde nDCG@10 0,74'ten 0,80'e çıktı. Roadmap review'ında rahatça savunabileceğin türden bir sayı.
Makedonca Cuma
Cross-encoder yüzde 100 trafikte iki hafta geçtikten sonra, data lead bir planlama toplantısı için locale bazında metrikleri çekti. Manşet iyiydi. Detay değildi.
locale nDCG@10 önce nDCG@10 sonra delta
----- -------------- ------------- -------
tr-TR 0,71 0,81 +10
en-US 0,78 0,84 +6
mk-MK 0,69 0,65 -4Makedonca sorgular dört puan kaybetmişti. Az önce canlıya aldığım o pahalı şey, onları daha kötüye götürüyordu.
Bir gün üzerinde durdum. Retrieval aşaması her locale için aynı code path'ti. Fusion aşaması da aynıydı. Locale'e bağlı iş yapan tek parça cross-encoder'dı, çünkü dil, bir cross-encoder'ın eğitim yoluyla örtük olarak özümsediği bir şey. BGE-Reranker-Base ağırlıklı olarak İngilizce ve Çince üzerinde eğitilmiş. Türkçe, transfer üzerinden tesadüfi yardım alıyor. Makedonca ise — Kiril ve Latin karışık, çoğu zaman Arnavutça veya Sırpça parçalarıyla code-switch yapılmış — model için fiilen hiç görmediği bir dağılım.
Cross-encoder, Makedonca bir sorgu ve Makedonca bir ürün açıklamasıyla karşılaştığında, alakaya göre neredeyse rastgele skorlar üretiyordu. BM25'in genellikle yakaladığı fused listenin top-50'sini yeniden sıralarken, doğru sonuçları aşağı, gürültülüleri yukarı iten bir karışıklık yaratıyordu.
Makedonca sorguların yarısı için en basit sistem — sadece BM25, fusion yok, rerank yok — tüm pipeline'ı geçiyordu. Eval'i üç kez koşturdum, çünkü doğru olmasını istemiyordum.
Doğruydu.
Silme refleksi
İlk içgüdüm cross-encoder'ı söküp atmaktı. Üç haftalık iş, gitti. Latency bütçesi gevşeyecekti. Altyapı maliyeti düşecekti. Ekibin başında bekleyeceği bir hareketli parça eksilecekti. "Bütün bu konuda yanıldım" şeklinde sessiz, ego biçimli bir çekim vardı.
İki şey beni durdurdu.
Birincisi, Türkçe ve İngilizce sayıları. Reranker, Türkçe'de +10 puan, İngilizce'de +6 puan kazançtı. Bunlar iki en büyük trafik segmenti. Makedonca'yı düzeltmek için onu atmak, arama altyapısı tarihindeki en pahalı locale-spesifik bug fix olurdu.
İkincisi, merchant CRM gerçek Türkçe satın alma niyetli sorguları reranker'lı ve rerankersız sistemde sessizce tekrar oynatıyordu. Reranker açıkken top-3 sonuçlardaki click-through-rate yüzde 8 yüksekti. O sayı nDCG değil. Para.
Doğru cevap "üç hafta önce kurduğun şeyi sil" değildi. Doğru cevap "üç hafta önce kurduğun şeyi bir if'in arkasına al" idi.
Koşullu rerank
Fix'in şekli, bug'dan daha basitti.
// Sadece cross-encoder'ın yardım ettiği gösterilmiş locale'ler için rerank yap.
// Liste her gece eval'den data-driven besleniyor — mk-MK baseline üstüne çıkarsa
// otomatik geçer. Dil görüşlerini hardcode etmiyoruz.
func shouldRerank(query Query) bool {
cfg := rerankerConfig.Current()
if cfg.GlobalEnabled == false {
return false
}
locale := query.Locale.Normalize()
return cfg.LocalesWithLift[locale]
}
func RunPipeline(ctx context.Context, q Query) ([]Result, error) {
fused, err := retrieveAndFuse(ctx, q)
if err != nil {
return nil, err
}
if shouldRerank(q) {
return rerankTop50(ctx, q, fused)
}
return fused[:min(20, len(fused))], nil
}LocalesWithLift bir sabit değil. Nightly evaluator tarafından bakım gören bir map. Her gece eval harness'i önceki günün sorgularını her locale için her iki pipeline'dan — rerank açık ve kapalı — yeniden koşturuyor ve map'i güncelliyor. Bir locale rerank açıkken en az 1,5 puanlık nDCG@10 yükselişi gösterirse, listeye giriyor. Aksi halde girmiyor.
Bunu canlıya almakta beni rahatlatan kısım buydu. Karar artık ben değildim, masada oturup hangi dillerin cross-encoder'ı alacağına karar veren. Karar dataydı. Yarın Makedonca için çalışan yeni bir reranker gelseydi, map yükselişi yakalardı ve biti otomatik çevirirdi. Türkçe bir gün reranker'dan fayda görmeyi bıraksaydı, kimse koda dokunmadan map'ten çıkarılırdı.
Locale-bilinçli eval harness
Politikayı savunulabilir kılan mühendislik işi, bu harness'ti. Önceki haftanın production log'larından çekilmiş, locale bazında katmanlanmış 35.000 sorguluk donmuş bir örnek üzerinde gece çalışıyor. Katmanlama, küçük trafiğe sahip Makedonca gibi locale'lerin stabil bir sayı üretecek kadar örnek almasını sağlıyor.
async def evaluate_rerank_lift(
queries: list[Query],
locales: list[str],
) -> dict[str, float]:
# Her sorguyu iki pipeline'dan da geçir, locale başına nDCG@10 farkını hesapla.
# Katmanlı sampling, mk-MK'nın dürüst olabilecek kadar hacme sahip olmasını garantiler.
lifts: dict[str, list[float]] = defaultdict(list)
for q in queries:
baseline = await pipeline_without_rerank(q)
candidate = await pipeline_with_rerank(q)
b = ndcg_at_k(baseline, q.relevance_labels, k=10)
c = ndcg_at_k(candidate, q.relevance_labels, k=10)
lifts[q.locale].append(c - b)
return {loc: statistics.mean(vals) for loc, vals in lifts.items()}O fonksiyonun çıktısı, tüm LocalesWithLift politikası. Bir locale rerank'a girer eğer ortalama yükselişi 1,5'i geçer ve bootstrap yüzde 90 güven aralığı pozitifse. 1,5 eşiği sihirli bir sayı değil; rerank'in değer ettiğini düşündüğümüz latency maliyetine — yaklaşık 40 ms — kalibre edilmiş.
Rerank kapalıyken neye düşersin
Rerank aşamasını atlamak da bedava değil. Fused liste hâlâ üç modalite üzerinde RRF ile üretiliyor ve fused listenin başı genelde iyi — ama başın içindeki sıra, cross-encoder'ın yeniden sıralaması olmadan daha kaba kalıyor. Rerank fallback devreye girdiğinde fused sıranın daha düşünceli olmasını sağlamak zorunda kaldık.
def confidence_aware_fallback(fused: list[ScoredDoc], q: Query) -> list[Result]:
# Cross-encoder atlandığında, fusion skoru ile locale başına tıklama
# verisi üzerinde eğitilmiş küçük bir logistic regression'ın confidence ağırlıklı
# karışımına geri düş. Ucuz, deterministik, model yüklemesi yok.
blend = []
for doc in fused[:20]:
ctr_prior = locale_ctr_prior(q.locale, doc.category)
blended = 0.6 * doc.fusion_score + 0.4 * ctr_prior
blend.append(Result(doc=doc.id, score=blended))
blend.sort(key=lambda r: r.score, reverse=True)
return blendLogistic regression, sistemin orada olduğunu kendine hatırlatman gereken parçalarından biri. Mikrosaniyelerde koşar. Locale ve kategori bazında ayrıştırılmış altı aylık geçmiş tıklama verisi üzerinde eğitildi. Beyin değil. "Bu locale'de, bu kategoride, bu kaba şekildeki ürünlere daha sık tıklanıyor"u yakalayan küçük, aptal, faydalı bir istatistik parçası. Cross-encoder pipeline'da olduğunda bu sinyali tamamen bastırır. Cross-encoder yokken küçük sinyal bastırılmaktan kurtulur ve kabul edilebilir bir fallback ile fark edilir biçimde daha kötü bir fallback arasındaki farkı yapar.
Maliyet tarafı
Bunu engineering manager'a kolayca satılan bir ship yapan, maliyet rakamlarıydı. Makedonca trafiği toplam sorguların yüzde 12'si civarında. Onun üzerinde rerank'i atlamak toplam reranker inference'ının yüzde 22'sini kurtardı, çünkü Makedonca sorgular orantısız biçimde uzundu: daha fazla token, daha büyük cross-encoder context, çift başına daha uzun compute. O yükü düşürmek, daha önce genişletmeyi planladığımız GPU kapasitesini boşalttı. Genişleme için açtığımız altyapı ticket'ı, koşullu rerank canlıya alındıktan bir hafta sonra iptal edildi.
Makedonca için rerank'i atlayarak kazanılan latency bütçesi — P95'te yaklaşık 38 ms — özellikle Makedonca sorgular için biraz daha büyük bir retrieval limit'ine yeniden yatırıldı. Daha büyük aday havuzu, çalışacak daha fazla BM25 başı, doğru ürünün baştan top 20'de olma olasılığının yükselmesi. Data lead'in locale grafiği, üç hafta sonra Makedonca'yı rerank-öncesi baseline'a göre +2 puanla geri getirdi. Ona bir reranker inşa ettiğimiz için değil. Zaten çalışanı kırmayı bıraktığımız için.
O çeyrek planlama dokümanına yazdığım
Çeyreklik yazımın sonundaki satır şuydu: reranker bazen kullanılacak bir araçtır, pipeline default'u değil. O cümle, bir yıllık arama işinin on bir kelimeye sıkıştırılmış hali.
Hibrit arama literatürü pipeline'ları monolitik sunma eğiliminde: retrieve, fuse, rerank, ship. Production'da o pipeline'ın her aşaması bir şeye koşullu. Rerank aşaması modelin dili bilip bilmemesine koşullu. Görsel aşaması sorgunun görsel niyete sahip olup olmamasına koşullu. Dense metin aşaması modelin katalog benzeri data görmüş olup olmamasına koşullu. Koşulsuz çalışan tek şey BM25, çünkü BM25'in itiraz edecek bir görüşü yok.
Bazen en akıllı mühendislik kararı, üç hafta önce kurduğun şeyi silmektir. Bazen ise sadece bir if'in arkasına almaktır. Durumunun hangisine ihtiyaç duyduğunu bilmek, işin senior versiyonu. O ayki benimki if'e ihtiyaç duyuyordu.
Reranker hâlâ production'da. Türkçe ve İngilizce'ye hâlâ +10 ve +6'larını veriyor. Makedonca hâlâ BM25, küçük logistic regression ve genişletilmiş aday havuzunu alıyor. Pipeline bir şey ekleyerek değil, ne zaman çekileceğini öğrenerek akıllandı.
// madem buradasın
- 11 dk okuma
Qdrant ile hibrit arama: BM25 + dense + görsel'in kitabında olmayan tarafı
Anahtar kelime, dense vektör ve görsel embedding'i tek bir sıralamada birleştirirken gerçekten ne bağlıyorsun — named vector, fusion, drift ve Türkçe aramanın apostroflarla bozulduğu gün.
hybrid-searchqdrantembeddingsproduction - 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