Özgür Işık Damar
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

Çok dilli bir ürün kataloğu üzerinde arama motoru çalıştırıyoruz — Türkçe, İngilizce, Makedonca — yaklaşık 7,8 milyon canlı SKU ve zirvede saatte 90 bin civarı sorgu. Frontend'in tek bir arama kutusu var. Backend, üç retrieval sinyalini paralel çalıştırıp füzyonluyor. Üçünün hiçbiri tek başına yeterli değil. Birlikte fena değiller.

Bu yazı, on sekiz ay önce birinin benim için yazmasını isteyeceğim saha notları.

Tek sinyal neden yetmiyor

Temiz hikâye şu: dense vektörler anahtar kelime aramasını yener çünkü semantiği anlar. Temiz hikâye production'da çoğunlukla yanlıştır.

  • BM25, marka adlarında, model numaralarında, SKU kodlarında — kullanıcının hatırladığı tam string'i yazdığı her şeyde kazanır.
  • Dense vektörler, niyette kazanır ("çocuğa sıcak mont" → fleece kabanlar, 6-12 yaş, çocuk kategorisi) ama apostroflarda, ligature'larda ve Türkçe eklerde kaybeder.
  • Görsel embedding'ler, sorgu sandığın şeyler dışında parlar — bir fotoğraf, bir screenshot, "şu çantanın benzeri" gibi belirsiz görsel bir fikir.

Her sinyal, diğer ikisinin düşürdüğünü yakalar. Sanat füzyondadır.

Qdrant tarafı

Üç vektörü de aynı point üzerinde, Qdrant'ın named vector özelliğiyle saklıyoruz. Tek koleksiyon, tek upsert yolu, üç retrieval modu:

from qdrant_client import QdrantClient, models
 
client = QdrantClient(url=QDRANT_URL, api_key=QDRANT_API_KEY)
 
client.create_collection(
    collection_name="products",
    vectors_config={
        "text": models.VectorParams(size=512, distance=models.Distance.COSINE),
        "image": models.VectorParams(size=512, distance=models.Distance.COSINE),
    },
    sparse_vectors_config={
        "bm25": models.SparseVectorParams(
            modifier=models.Modifier.IDF,
        ),
    },
)

Dense vektörler kategori ağacımıza fine-tune edilmiş çok dilli bir sentence-transformer'dan 512 boyut. Görsel vektör, sadece görsel modda kullanılan modelden geliyor (yani cross-modal retrieval çalışıyor — fotoğrafı metinle arayabilirsin). BM25 ise Qdrant'ın IDF re-weighting'li yerel sparse indeksi.

Üç vektör, tek point, JOIN yok. Beni şaşırtan kısım buydu — üç ayrı koleksiyon tutmaya kalkıp aralarındaki drift'in iki haftamı yiyene kadar.

Retrieval

Bir sorgu üç paralel arama olarak çıkıyor, sonra füzyonluyoruz:

async def hybrid_search(query: str, limit: int = 50) -> list[Hit]:
    text_vec, sparse, image_vec = await asyncio.gather(
        embed_text(query),
        sparse_bm25(query),
        embed_image_from_text(query),  # metin → görsel embedding, CLIP tarzı head üzerinden
    )
 
    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=limit,
    )
    return results.points

Reciprocal Rank Fusion (RRF) şaşırtıcı biçimde yenilmesi zor bir baseline. Ağırlıklı skor füzyonu, öğrenilmiş rerank'lar, küçük LLM rerank denedik — RRF kazanımın %90'ını karmaşıklığın %5'iyle veriyordu. Premium sorgularda top 50 üzerinde Gemini rerank koşuyoruz, ama long tail için yayına giren RRF.

Asıl ne bozuluyor

Şimdi tutorial'ların atladığı kısım.

Apostroflar. Türk kullanıcı Adidas'ın yazıyor. Sparse tokenizer'ın bunu adidas + ın olarak ayırıyor. Dense modelin sorun yaşamıyor. Sparse skorun %60 düşüyor. Çözüm: dil bazında iyelik eki normalize eden bir tokenizer pre-pass.

Örtüşmeyen sinonimler. İngilizce "sneaker", Türkçe "spor ayakkabı" ile semantik olarak eşleşiyor — ama BM25 bunları farklı token görüyor. Dense burada seni kurtarır, ama yalnızca modelin çok dilli eğitildiyse. Çok dilli encoder kullanıyoruz; Türkçe marka adlarında yükü BM25'in çektiğini kabul ediyoruz.

Görsel olmayan görsel sorgular. Kullanıcı "düğün için kırmızı elbise" yazdığında, bu metni text→image projection head'inden geçirip görsel uzayda bir sorgu vektörü elde ediyoruz. Görsel indeksi metin sorgularda işe yarar kılan şey bu. Onsuz, kullanıcı fotoğraf tutmuyorsa görsel indeks ölü ağırlık.

Drift. Bıçağın en keskini. Kataloğu vektörleyen encoder modelle sorguyu vektörleyen encoder modelin aynı sürüm olması gerekir. İkisi ayrıştığı anda recall sessizce çöker. Model sürümlerini iki yerde pinliyoruz — batch vectorizer servisinde ve search servisinde — bump'ladığımızda tüm kataloğu yeniden vektörlüyoruz.

// Search servisinin içinde — embedding sürümü kataloğun
// indekslendiği sürümle eşleşmiyorsa başlamayı reddet.
func mustEmbedderVersion(t *testing.T) {
    expected, err := qdrantPayload.Get("embedder_version").(string)
    if err != nil || expected != embedder.Version {
        t.Fatalf("embedder drift: catalogue=%s service=%s",
            expected, embedder.Version)
    }
}

Bu kontrolü başlangıçta yapabilirsin (yapıyoruz) ya da drift'i sinirli müşterilerle keşfetmeyi kabul edebilirsin (o da geçerli bir yol, sadece daha az hoş).

Trade-off'lar

Hibrit arama sana şu fiyata mal olur:

  • İndex anında üç kat embedding compute. Batch ve async pipeline ile hafifletilir, ama gerçek para.
  • Üç kat depolama. Qdrant iyi sıkıştırır, ama 7,8M point × 3 × 512 float dediğin gerçek disk demek.
  • Sorgu anında karmaşıklık. Üç paralel çağrı, füzyon, opsiyonel rerank. Latency iyi — fan-out hızlı — ama failure yüzeyi daha geniş.

Şu üçü olmadan ihtiyacın yok:

  1. Çok dilli katalog
  2. Sadece BM25'in ölçülebilir biçimde kaçırdığı şeyleri içeren bir sorgu karışımı
  3. Mühendislik maliyetini haklı kılacak yeterli sorgu hacmi

Kataloğun sadece İngilizceyse ve 10k SKU'ysa, iyi bir synonym tablosuyla BM25 bir yıl boyunca seni geçer. Geçemeyeceği gün, sen anlarsın.

Yeni gelene söylediğim şey

Arama problemi model değil. Chunking. Tokenizer. Aynı sürüm olması gereken iki servisin arasındaki drift. Vektörler kolay kısım. Onları gün gün, her dakika yeni ürün eklenirken senkronize tutan pipeline — kıdemli mühendis isteyen kısım o.

Model, eline tutuşturulan sayılar üzerinde matematik yapıyor. Sayılar yanlışsa, matematik yanlış. O kadar.

// madem buradasın