Skip to main content
Uygulama Geliştirme•24 dk okuma

pgvector ve Meilisearch ile Hibrit Arama: Türkçe Sorgularda RRF

Mustafa Kürşad BaşerMustafa Kürşad BAŞER
•
pgvector ve Meilisearch ile Hibrit Arama: Türkçe Sorgularda RRF

Skoru Değil Sırayı Birleştirin

Özet (TL;DR)

  • Meilisearch kök bulmaz. Türkçede eşleşme ön ek aramasına ve yazım hatası toleransına kalır: faturamı sorgusu faturası kelimesini tek harflik bir hata sayarak bulur, faturalar ise "Fatura iptali" başlığını hiç bulmaz.
  • Yazım hatası toleransı ürün kodunu da esnetir. Varsayılan ayarlarla AX-88231 araması AX-88232 sayfasını 0,832 skorla getirir. Sayılarda toleransı kapatan disableOnNumbers ayarı Meilisearch 1.15'ten beri var.
  • RRF yalnızca sıraya bakar ve her listeden 1/(k + sıra) puan toplar. Skorları aynı ölçeğe getirmek gerekmez, ama iki listenin ortak gürültüsü de aynı şekilde ödüllendirilir. Listeleri birleştirmeden önce düzeltin.
  • pgvector'da HNSW, LIMIT 100 yazsanız da varsayılan hnsw.ef_search = 40 yüzünden en fazla 40 aday döndürür. Filtreli sorguda sayı daha da düşer. 0.8.0'dan beri hnsw.iterative_scan bu açığı kapatıyor.
  • Birleştirme ayarını ortalama bir sayıya bakarak yapmayın. Sorgu türüne göre etiketlenmiş küçük bir sette sorgu başına sonuca bakın, ortalamanın sakladığı kaybı orada görürsünüz.

Diyelim ki bir destek sitesinin arama kutusuna AX-88231 şarj kutusu yazıldı. Vektör araması ilk sıraya genel bir "kulaklık şarj sorunları" yazısını koyuyor, istenen belge üçüncü sırada kalıyor. Meilisearch doğru belgeyi birinci getiriyor ama hemen arkasına başka bir modelin, AX-88232'nin sayfasını ekliyor. Aynı sitede "paketim gelmedi" yazan kullanıcıya ise Meilisearch hiçbir şey döndürmüyor, çünkü belgelerin hiçbirinde ne "paket" geçiyor ne "gelmedi".

LayeredMindAI'da bu iki motor zaten yan yana duruyor. Embedding'ler PostgreSQL'de pgvector ile tutuluyor, tam metin araması Meilisearch'te. Böyle bir kurulumda soru kaçınılmaz: kullanıcıya hangisinin listesini göstereceksiniz? Benim cevabım ikisini birden, ama skorlarıyla değil sıralarıyla birleştirerek.

İki arama nerede ayrışıyor

Meilisearch: kök yok, ön ek ve yazım hatası var

Meilisearch metni charabia adlı tokenizer'dan geçirir. Latin alfabesi için yaptığı normalizasyon, harfleri bileşenlerine ayırıp aksan işaretlerini atmak ve küçük harfe çevirmektir. Türkçede bunun sonucu şu: ç, ğ, ö, ş ve ü harfleri c, g, o, s ve u'ya iner. Noktasız ı için charabia'da ayrıca küçük bir Türkçe normalizer var ve onu i'ye çeviriyor. Böylece isik, IŞIK ve ışıklı sorgularının üçü de "Işıklı klavye ayarları" başlığını buluyor.

Kötü haber şu ki Meilisearch kök bulma (stemming) yapmıyor. Eşleşme tam kelime üzerinden yürüyor, ön ek araması (prefix search) da yalnızca sorgunun son kelimesine uygulanıyor. Eklemeli bir dilde bu, sonucu sık sık tesadüfe bırakıyor. Aşağıdaki sonuçları altı belgelik küçük bir dizinde, Meilisearch 1.44 ve varsayılan ayarlarla aldım:

SorguDönen belge (skor)Neden
isikIşıklı klavye ayarları (0,962)ı → i, ş → s, ardından ön ek eşleşmesi
faturaFatura iptali (0,985), Faturalarınızı e-posta ile almak (0,962)fatura, faturalarınızı kelimesinin ön eki
faturalarYalnızca Faturalarınızı e-posta ile almak (0,975)fatura, faturalar sorgusunun ön eki değil, tersi gerekir
faturamıFatura iptali (0,235)faturamı ile gövdedeki faturası arasında tek harf fark var, yazım hatası sayıldı
vaturaHiçbiriİlk harfteki hata iki hata sayılır, altı harfli kelimenin bir hata hakkı var
AX-88231AX-88231 (0,999), AX-88232 (0,832)88231 beş karakter, bir hata hakkı var

faturamı satırı dikkat istiyor. Sonuç doğru ama doğru olmasının sebebi yanlış. Meilisearch iyelik ekini tanımadı, "mı" ile "sı" arasındaki farkı yazım hatası saydı ve bunun bedeli 0,235'lik bir skor oldu. Ek iki harf daha uzun olsaydı eşleşme kaybolacaktı. Hata hakkının kuralları belli: beş harften kısa kelimeye hata hakkı yok, 5 ile 8 harf arasına bir, 9 ve üstüne iki hata tanınıyor. İlk harfteki hata iki hata sayılıyor.

Ürün kodlarında aynı kural ters tepiyor. 88231 beş karakter, yani bir hata hakkı var ve 88232 bu hakkı kullanarak eşleşiyor. showRankingScoreDetails açıldığında bu, AX-88232 için typoCount: 1 olarak görünüyor. Uzun JSON çıktısını JSON formatlayıcı ile okumak kolaylaşır. 1.15 sürümünden beri typoTolerance.disableOnNumbers sayılarda toleransı kapatıyor:

bash
docker run -d --name pg -e POSTGRES_PASSWORD=x -p 5432:5432 pgvector/pgvector:0.8.2-pg18docker run -d --name meili -p 7700:7700 getmeili/meilisearch:v1.44.0 curl -s -X POST 'http://localhost:7700/indexes/belgeler/search' \  -H 'Content-Type: application/json' \  --data-binary '{"q": "AX-88231", "showRankingScore": true}'# (id, _rankingScore): [(3, 0.999), (4, 0.832)] curl -s -X PATCH 'http://localhost:7700/indexes/belgeler/settings/typo-tolerance' \  -H 'Content-Type: application/json' \  --data-binary '{"disableOnNumbers": true}' # aynı arama:                         [(3, 0.999), (4, 0.492)]# aynı arama, "matchingStrategy": "all": [(3, 0.999)]

Ayardan sonra AX-88232 hâlâ listede, ama 0,492 skorla. Bu kez yalnızca ax kelimesi eşleşiyor, çünkü varsayılan matchingStrategy: "last" yeterli sonuç bulamayınca sorgunun sonundaki kelimeleri tek tek düşürüyor. "all" verildiğinde AX-88232 listeden tamamen çıkıyor. Kodlar ayrı bir alanda duruyorsa disableOnAttributes ile toleransı yalnızca o alanda kapatmak daha ince bir çözüm.

Embedding: anlamı bulur, kodu karıştırır

Embedding modeli metni sabit uzunlukta bir vektöre çevirir, iki metnin yakınlığı da bu vektörlerin arasındaki açıyla ölçülür. "Paketim gelmedi" ile "kargo gecikmesi" arasında ortak tek kelime yok, ama iyi bir çok dilli modelde bu iki ifadenin vektörleri birbirine yakın düşer. Meilisearch'ün boş döndüğü sorgu, vektör aramasının en rahat ettiği sorgudur. Türkçe ekler de bu tarafta sorun olmaktan çıkar.

Tersi de doğru. AX-88231 ile AX-88232, çoğu modelin gözünde neredeyse aynı metindir. Model kodu alt parçalara böler ve tek hanelik farkın vektöre etkisi küçük kalır. Ürün kodu, sipariş numarası, IBAN, şube ya da kişi adı gibi tam eşleşme isteyen sorgularda vektör araması doğru belgeyi bulsa bile onu komşularından ayıramaz.

Sorgu türüÖrnekMeilisearchVektör araması
Kod, numaraAX-88231, TR-2024-118Bulur, yakın kodları da getirebilirKomşu kodlardan ayıramaz
Özel adKadıköy şubesiBulurBenzer adları karıştırabilir
Ekli kelimefaturalarımıÖn eke ve tesadüfe kalırEtkilenmez
Doğal dilpaketim gelmediOrtak kelime yoksa boş dönerGüçlü
Yazım hatasıfatüra, kulaklikNormalizasyon ve hata hakkıyla bulurModele göre değişir

Reciprocal Rank Fusion: skor yerine sıra

İki listeyi birleştirmenin ilk akla gelen yolu skorları toplamaktır ve sorun tam orada başlar. Meilisearch'ün _rankingScore değeri kendi sıralama kurallarının (kelime, yazım hatası, yakınlık, alan, konum, tam eşleşme) bir bileşimidir. pgvector ise bir mesafe döndürür. İkisi aynı ölçekte değildir, aynı sorgu için bile aynı dağılıma sahip değildir.

Cormack, Clarke ve Büttcher'in 2009'da SIGIR'da yayımladığı iki sayfalık bildirideki yöntem bu sorunu hiç kurmuyor. Her belge, içinde göründüğü her listeden 1 / (k + sıra) puan alıyor ve puanlar toplanıyor: RRF(d) = Σ 1 / (k + r(d)). Sıra 1'den başlıyor, listede olmayan belge o listeden puan almıyor.

k = 60 değeri makalede bir pilot denemede seçilmiş ve sonraki doğrulamalarda hiç değiştirilmemiş. Pilot tablosu okumaya değer: k = 0 için MAP 0,2072, k = 60 için 0,2145, k = 500 için 0,2098. 20 ile 100 arasındaki bütün değerler birbirine çok yakın, en yüksek değer bile 60'ta değil 80'de (0,2147). Yazarlar da 60'ın neredeyse en iyi olduğunu ama seçimin kritik olmadığını yazıyor. Yani 60 sihirli bir sayı değil, düz bir platonun ortasından alınmış makul bir değer.

k'nin işi üst sıraların ağırlığını yumuşatmak. k = 60 iken birinci sıra 1/61 ≈ 0,0164, onuncu sıra 1/70 ≈ 0,0143 puan alıyor, aradaki fark yüzde 15 kadar. Buna karşılık iki listede de beşinci olan bir belge 2/65 ≈ 0,0308 alıyor ve yalnızca bir listede birinci olan belgeyi rahatça geçiyor. RRF'nin asıl söylediği bu: iki bağımsız sistemin üzerinde anlaştığı belge, birinin çok beğendiği belgeden önce gelir.

python
from collections import defaultdict def rrf(listeler, k=60, agirliklar=None):    """listeler: her biri en iyiden kötüye sıralı belge kimlikleri."""    agirliklar = agirliklar or [1.0] * len(listeler)    skor = defaultdict(float)    for liste, w in zip(listeler, agirliklar):        for sira, belge in enumerate(liste, start=1):  # sıra 1'den başlar            skor[belge] += w / (k + sira)    # eşitlikte kimliğe göre sırala ki sonuç her çalıştırmada aynı olsun    return sorted(skor.items(), key=lambda x: (-x[1], x[0])) # "AX-88231 şarj kutusu sıfırlama" sorgusu için iki listemeili = ["ax88231-sarj-sifirlama", "ax88231-garanti", "ax88232-kablo", "sarj-kutusu-temizlik"]vektor = ["kulaklik-sarj-sorunlari", "sarj-kutusu-temizlik", "ax88231-sarj-sifirlama",          "bluetooth-eslestirme", "ax88232-kablo"] if __name__ == "__main__":    for belge, s in rrf([meili, vektor]):        print(f"{s:.5f}  {belge}") # $ python3 rrf.py# 0.03227  ax88231-sarj-sifirlama# 0.03175  sarj-kutusu-temizlik# 0.03126  ax88232-kablo# 0.01639  kulaklik-sarj-sorunlari# 0.01613  ax88231-garanti# 0.01562  bluetooth-eslestirme
Meilisearch ve vektör aramasının sıralı listeleri, aynı belgeleri birleştiren çizgiler ve RRF puanlarıyla oluşan ortak liste. Yanlış ürün AX-88232 üçüncü sıraya çıkıyor.
İki listede de görünen belge öne çıkar, gürültü olsa bile

Çıktıda bir sorun var. Yanlış ürünün sayfası ax88232-kablo üçüncü sıraya çıktı ve doğru ürünün garanti sayfasını geçti. RRF uzlaşmayı ödüllendiriyor, iki liste de bu belgeyi gürültü olarak içeriyor ve iki gürültünün toplamı sinyal gibi görünüyor. Çözüm birleştirmede değil girdide. Meilisearch tarafında disableOnNumbers açılıp matchingStrategy: "all" verildiğinde AX-88232 anahtar kelime listesinden düşüyor ve aynı hesap şunu veriyor:

python
from rrf import rrf, vektor # disableOnNumbers + matchingStrategy "all" sonrası Meilisearch listesimeili = ["ax88231-sarj-sifirlama", "ax88231-garanti", "sarj-kutusu-temizlik"] for belge, s in rrf([meili, vektor]):    print(f"{s:.5f}  {belge}") # 0.03227  ax88231-sarj-sifirlama# 0.03200  sarj-kutusu-temizlik# 0.01639  kulaklik-sarj-sorunlari# 0.01613  ax88231-garanti# 0.01562  bluetooth-eslestirme# 0.01538  ax88232-kablo

Ağırlıklı skor toplamı neden kırılgan

Aynı sorguyu bir de skorlarla birleştirelim. Vektör tarafında skor 1 − kosinüs mesafesi, Meilisearch tarafında _rankingScore. Listede olmayan belge sıfır alıyor.

python
# Aynı sorgu, bu kez motorların verdiği skorlarlameili = {"ax88231-sarj-sifirlama": 0.99, "ax88231-garanti": 0.86,         "ax88232-kablo": 0.83, "sarj-kutusu-temizlik": 0.61}        # _rankingScorevektor = {"kulaklik-sarj-sorunlari": 0.862, "sarj-kutusu-temizlik": 0.851,          "ax88231-sarj-sifirlama": 0.847, "bluetooth-eslestirme": 0.811,          "ax88232-kablo": 0.804}                                    # 1 - cosine mesafesi def minmax(d):    lo, hi = min(d.values()), max(d.values())    return {b: (s - lo) / (hi - lo) for b, s in d.items()} def agirlikli(a, b, alfa=0.5):    belgeler = set(a) | set(b)    s = {x: alfa * a.get(x, 0.0) + (1 - alfa) * b.get(x, 0.0) for x in belgeler}    return sorted(s.items(), key=lambda x: (-x[1], x[0])) print("ham skorlar:")for belge, s in agirlikli(meili, vektor):    print(f"  {s:.3f}  {belge}")print("min-max sonrası:")for belge, s in agirlikli(minmax(meili), minmax(vektor)):    print(f"  {s:.3f}  {belge}") # ham skorlar:#   0.918  ax88231-sarj-sifirlama#   0.817  ax88232-kablo#   0.730  sarj-kutusu-temizlik#   0.431  kulaklik-sarj-sorunlari#   0.430  ax88231-garanti#   0.406  bluetooth-eslestirme# min-max sonrası:#   0.871  ax88231-sarj-sifirlama#   0.500  kulaklik-sarj-sorunlari#   0.405  sarj-kutusu-temizlik#   0.329  ax88231-garanti#   0.289  ax88232-kablo#   0.060  bluetooth-eslestirme

Ham toplamda yanlış ürün ikinci sıraya çıkıyor. Min-max normalizasyonu ise vektör listesindeki 0,804 ile 0,862 arasındaki 0,058'lik farkı 0 ile 1 arasına geriyor. Pratikte anlamsız bir fark birden belirleyici oluyor, her listenin son elemanı da sıfır alıp listede hiç yokmuş gibi davranıyor. Üç yöntemin üçü de ilk sırada anlaşıyor ama ikinci sıra seçtiğiniz normalizasyona göre değişiyor. Skor tabanlı birleştirme (score fusion) etiketli veriyle kalibre edilirse iyi çalışabilir, ama her model değişikliğinde bu ayarı yeniden yapmanız gerekir. RRF'de bu karar hiç yok.

Ağırlık ve eşitlik

RRF'yi sorgu türüne göre ağırlıklandırmak kolay: terim w / (k + sıra) olur. Sorguda ürün kodu kalıbı varsa anahtar kelime listesine daha yüksek ağırlık vermek, regex ile yakalanabilecek basit bir kural. Eşitlikleri de unutmayın. Listelerden yalnızca birinde birinci olan iki belge aynı puanı alır, 1/61. Bunu kimliğin sözlük sırasıyla bozmak, aşağıdaki değerlendirmede göreceğiniz gibi doğru belgeyi üçüncü sıraya atabilir. Kod içeren sorguda eşitliği anahtar kelime listesi lehine bozmak daha savunulabilir bir kural.

pgvector: aday listesini eksiksiz almak

RRF yalnızca önüne konan listeleri birleştirir, listede olmayan belge sıfır puan alır. Bu yüzden pgvector tarafında ilk iş, istediğiniz derinlikte aday aldığınızdan emin olmak. Bu iş iki yerde aksıyor: indeksin kendi tavanında ve filtrede.

HNSW mi, IVFFlat mı

HNSWIVFFlat
YapıKatmanlı yakınlık grafiğiVektörleri kümelere (lists) bölüp sorguya yakın kümeleri tarar
İndeks oluşturmaDaha yavaş, daha çok bellekDaha hızlı, daha az bellek
Hız ve recall dengesiDaha iyiDaha zayıf
Boş tabloda oluşturmaOlur, eğitim adımı yokÖnerilmez, kümeler mevcut veriden çıkarılır
Oluşturma parametresim (16), ef_construction (64)lists: 1 milyon satıra kadar satır/1000, üstünde √satır
Sorgu parametresihnsw.ef_search (40)ivfflat.probes (1, başlangıç için √lists)

HNSW, Malkov ve Yashunin'in tarif ettiği katmanlı grafik. Arama seyrek üst katmandan girip her katmanda sorguya yaklaşarak yoğun alt katmana iniyor. m her düğümün katman başına en fazla kaç bağlantı tutacağını, ef_construction indeks kurulurken tutulan aday listesinin boyunu belirliyor. İkisini büyütmek recall'u artırır, karşılığında indeks daha yavaş kurulur, m büyüdükçe de şişer. Sorgu anındaki karşılığı hnsw.ef_search. 50 bin satırlık, 16 boyutlu rastgele vektörlerden oluşan bir tabloda 100 sorguyla, indekssiz kesin sonuca göre recall@10 şöyle çıktı:

hnsw.ef_searchrecall@10
100,884
200,968
40 (varsayılan)0,994
1000,999
2001,000

Bu sayılar yalnızca bu yapay veri için geçerli, gerçek embedding'lerde ölçümü kendi verinizle tekrarlayın. ef_search'ün bir başka etkisi ise her veride aynı: dönen satır sayısının da tavanı.

sql
SELECT count(*) AS aday FROM (  SELECT id FROM belge ORDER BY emb <=> :q LIMIT 100) t;--  aday-- --------    40 BEGIN;SET LOCAL hnsw.ef_search = 100;SELECT count(*) AS aday FROM (  SELECT id FROM belge ORDER BY emb <=> :q LIMIT 100) t;--  aday-- --------   100COMMIT; -- İndeks vector_cosine_ops, sorgu iç çarpım operatörüyleEXPLAIN (COSTS OFF)SELECT id FROM belge ORDER BY emb <#> :q LIMIT 10;--  Limit--    ->  Sort--          Sort Key: ((emb <#> '[0.3,-0.1,...]'::vector))--          ->  Seq Scan on belge

Birleştirmeye 100 aday vermek istiyorsanız ef_search en az 100 olmalı. SET LOCAL ayarı yalnızca o transaction için değiştirir, bağlantı havuzundaki diğer sorgulara sızmaz.

Aynı bloğun son kısmı mesafe metriğiyle ilgili. pgvector'da <-> L2, <#> negatif iç çarpım, <=> kosinüs mesafesi. Embedding'leriniz birim uzunluğa normalize edilmişse kosinüs ile iç çarpım aynı sırayı verir ve README performans için iç çarpımı öneriyor. <#> negatif değer döndürür, çünkü PostgreSQL indeks taramasını yalnızca artan sırayla yapabiliyor. Benzerliği göstermek için -1 ile çarpmanız gerekir. Asıl tuzak operatör sınıfı: indeks vector_cosine_ops ile kurulduysa ve sorguda <#> kullanırsanız planlayıcı indeksi görmez, tabloyu baştan sona tarar.

Filtreli aramada eksilen sonuçlar

HNSW'de WHERE koşulu indeks tarandıktan sonra uygulanır. Koşul satırların yüzde 10'unu tutuyorsa varsayılan ayarlarla ilk 40 adaydan ortalama 4'ü kalır. Aynı tabloda denediğimde LIMIT 10 yazdığım sorgu 5 satır döndürdü, yüzde 1'lik filtrede hiç satır gelmedi:

sql
CREATE EXTENSION IF NOT EXISTS vector;SELECT setseed(0.42);CREATE TABLE belge (id bigint PRIMARY KEY, kategori int NOT NULL, emb vector(16) NOT NULL);-- 50 000 satır, 16 boyutlu rastgele vektörler, kategori = id % 10INSERT INTO belgeSELECT i, i % 10,       (SELECT array_agg(random() - 0.5) FROM generate_series(1, 16) WHERE i > 0)::vectorFROM generate_series(1, 50000) AS i;CREATE INDEX belge_emb_hnsw ON belge  USING hnsw (emb vector_cosine_ops) WITH (m = 16, ef_construction = 64);ANALYZE belge; \set q '''[0.3,-0.1,0.2,0.4,-0.3,0.1,0.0,0.2,-0.2,0.3,0.1,-0.4,0.2,0.0,-0.1,0.3]''' EXPLAIN (COSTS OFF)SELECT id FROM belge WHERE kategori = 3 ORDER BY emb <=> :q LIMIT 10;--  Limit--    ->  Index Scan using belge_emb_hnsw on belge--          Order By: (emb <=> '[0.3,-0.1,...]'::vector)--          Filter: (kategori = 3) SELECT count(*) FROM (  SELECT id FROM belge WHERE kategori = 3 ORDER BY emb <=> :q LIMIT 10) t;-- 5         (filtre yüzde 10, ef_search = 40) SELECT count(*) FROM (  SELECT id FROM belge WHERE id % 100 = 7 ORDER BY emb <=> :q LIMIT 10) t;-- 0         (filtre yüzde 1) SET hnsw.iterative_scan = strict_order;-- aynı iki sorgu: 10 ve 10
HNSW indeksinin 40 aday döndürdüğü, yüzde 10'luk filtreden sonra 5 satır kaldığı ve iterative scan açıkken taramanın 10 satıra ulaşana kadar sürdüğü akış
Filtre indeks taramasından sonra uygulanır

Ekim 2024'te çıkan pgvector 0.8.0'dan önce tek çare ef_search'ü tahminle büyütmekti. 0.8.0 ile gelen iterative index scan, yeterli sonuç bulunana kadar indeksi taramayı sürdürüyor. strict_order sonuçları mesafeye göre kesin sırada verir, relaxed_order sırayı biraz bozma pahasına daha iyi recall verir. Taramanın tavanı hnsw.max_scan_tuples (varsayılan 20 000). Filtre çok seçiciyse bu tavana takılıp yine eksik sonuç alabilirsiniz. Az sayıda farklı değerle filtreliyorsanız (tenant, dil, belge türü) kısmi indeks, çok sayıda değerde partitioning README'nin önerdiği yollar.

Denemeyi kendi makinenizde tekrarlamak için pgvector/pgvector:0.8.2-pg18 imajı yeterli. Konteynerlere yeniyseniz Docker temelleri yazısı başlangıç için işinizi görür. PostgreSQL temelleri için de SQL ve ilişkisel veri tabanı rehberi var.

Meilisearch'ün kendi hibrit araması

Meilisearch hibrit aramayı 1.6 sürümünde (Ocak 2024) deneysel olarak getirdi, yapay zekâ destekli arama 1.13 ile (Şubat 2025) kararlı hâle geldi. Sorguya hybrid: { embedder, semanticRatio } eklemek yetiyor, semanticRatio varsayılanı 0,5. Embedding'leri kendiniz üretiyorsanız userProvided kaynağıyla vektörleri her belgenin _vectors alanında gönderebilirsiniz.

Bu birleştirme RRF değil. 1.44'ün kaynağında (crates/milli/src/search/hybrid.rs) anahtar kelime skoru 1 − semanticRatio, semantik skor semanticRatio ile çarpılıp iki liste bu ağırlıklı skorlarla karşılaştırılıyor. Yani ağırlıklı bir skor birleştirmesi. Bir ayrıntı daha var: anahtar kelime araması sayfayı dolduracak kadar sonuç bulduysa ve her birinin skoru 1 − semanticRatio ile çarpıldığında 0,45'i geçiyorsa semantik arama hiç çalıştırılmıyor. Varsayılan oranla bu, bütün sonuçların 0,9'un üstünde olması demek.

bash
# userProvided embedder, 4 boyutlu elle yazılmış vektörler, sorgu vektörü AX-88231'e yakıncurl -s -X POST 'http://localhost:7700/indexes/hibrit/search' \  -H 'Content-Type: application/json' \  --data-binary '{"q": "AX-88231", "vector": [0.95, 0.05, 0.0, 0.1], "limit": 1,                  "showRankingScore": true,                  "hybrid": {"embedder": "harici", "semanticRatio": 0.5}}'# limit 1: [(3, 0.999)]                          semanticHitCount = 0# limit 3: [(3, 0.999), (4, 0.996), (5, 0.659)]  semanticHitCount = 2

limit: 1 ile semantik arama atlandı. limit: 3 ile anahtar kelime listesi sayfayı dolduramadı, semantik arama devreye girdi ve AX-88232 vektörü sorguya çok yakın olduğu için 0,996 aldı. Semantik skor da ayrı bir ölçekte. Bu denemede skorlar (1 + kosinüs) / 2 ile birebir örtüştü, yani sorguyla hiç ilgisi olmayan dik bir vektör bile 0,5 alıyor. Yukarıdaki faturamı sorgusunun anahtar kelime skoru 0,235'ti.

Vektörler zaten Meilisearch'teyse yerleşik hibrit arama tek istekle iş görüyor. Embedding'ler pgvector'da duruyorsa aynı vektörleri Meilisearch'e de yazmak, iki deponun senkron kalmasını gerektirir. Ben bu durumda iki ayrı sorguyu uygulama katmanında RRF ile birleştirmeyi tercih ederim. Vektörler tek yerde kalır, birleştirme kuralı da sizin kontrolünüzde olur.

Ölçmeden ayar yapmayın

k'yi, ağırlıkları ya da semanticRatio'yu değiştirmeden önce bir test setine ihtiyacınız var. Büyük olması gerekmiyor. Gerçek sorgu kayıtlarından seçilmiş, sorgu türüne göre (kod, doğal dil, ekli kelime) etiketlenmiş birkaç düzine sorgu ve her biri için beklenen belgeler başlangıç için yeter.

recall@k, beklenen belgelerin ilk k sonuç içindeki oranı. RAG için bu ölçü daha anlamlı, çünkü modele giden bağlam ilk k belgeden ibaret. MRR (mean reciprocal rank) ise ilk doğru belgenin sırasının tersinin ortalaması ve kullanıcının ilk sonuca baktığı bir arama kutusu için daha uygun. Manning, Raghavan ve Schütze, ilk k sonuçtaki kesinliğin (precision@k) yaygın ölçüler arasında en az kararlı olanı olduğunu not ediyor. Küçük sette tek sorgu ortalamayı oynatır.

python
from rrf import rrf # Her sorgu: beklenen (ilgili) belgeler, Meilisearch listesi, vektör listesisorgular = {    "AX-88231 şarj kutusu": ({"ax88231-sarj-sifirlama"},        ["ax88231-sarj-sifirlama", "ax88231-garanti", "sarj-kutusu-temizlik"],        ["kulaklik-sarj-sorunlari", "sarj-kutusu-temizlik", "ax88231-sarj-sifirlama"]),    "TR-2024-118 sipariş": ({"siparis-takip"},        ["siparis-takip", "siparis-iptal"],        ["kargo-gecikme", "siparis-iptal", "iade-sureci"]),    "faturamı iki kez ödedim": ({"cift-cekim", "iade-sureci"},        ["fatura-iptali", "e-fatura"],        ["cift-cekim", "iade-sureci", "fatura-iptali"]),    "paketim gelmedi": ({"kargo-gecikme"},        [],        ["kargo-gecikme", "siparis-takip", "iade-sureci"]),    "iade faturası": ({"fatura-iptali", "iade-sureci"},        ["fatura-iptali", "iade-sureci", "e-fatura"],        ["iade-sureci", "cift-cekim", "fatura-iptali"]),} def recall_at(sonuc, ilgili, k):    return len(set(sonuc[:k]) & ilgili) / len(ilgili) def rr(sonuc, ilgili):    for sira, belge in enumerate(sonuc, start=1):        if belge in ilgili:            return 1 / sira    return 0.0 yontemler = {    "yalnız Meilisearch": lambda m, v: m,    "yalnız vektör":      lambda m, v: v,    "RRF (k=60)":         lambda m, v: [b for b, _ in rrf([m, v])],}print(f"{'yöntem':<20}{'recall@2':>10}{'MRR':>8}")for ad, f in yontemler.items():    r2 = [recall_at(f(m, v), ilgili, 2) for ilgili, m, v in sorgular.values()]    mrr = [rr(f(m, v), ilgili) for ilgili, m, v in sorgular.values()]    print(f"{ad:<20}{sum(r2)/len(r2):>10.3f}{sum(mrr)/len(mrr):>8.3f}") print()print(f"{'sorgu (RR)':<26}{'Meili':>8}{'vektör':>8}{'RRF':>8}")for q, (ilgili, m, v) in sorgular.items():    print(f"{q:<26}" + "".join(f"{rr(f(m, v), ilgili):>8.2f}" for f in yontemler.values())) # $ python3 degerlendir.py# yöntem                recall@2     MRR# yalnız Meilisearch       0.600   0.600# yalnız vektör            0.500   0.667# RRF (k=60)               0.700   0.767## sorgu (RR)                   Meili  vektör     RRF# AX-88231 şarj kutusu          1.00    0.33    1.00# TR-2024-118 sipariş           1.00    0.00    0.33# faturamı iki kez ödedim       0.00    1.00    0.50# paketim gelmedi               0.00    1.00    1.00# iade faturası                 1.00    1.00    1.00

Ortalamada RRF önde, MRR 0,767. Sorgu başına tabloda ise başka bir şey görünüyor: TR-2024-118 sipariş sorgusunda Meilisearch'ün 1,00'lık sonucu RRF'de 0,33'e düşüyor. siparis-iptal iki listede de ikinci olduğu için öne geçti, doğru belge ise kargo-gecikme ile 1/61'de berabere kalıp sözlük sırasında geriye düştü. Ortalama bunu saklıyor. Bu sayılar benim kurduğum beş sorguluk oyuncak bir setten geliyor, RRF'nin üstünlüğünü kanıtlamıyor. Gösterdikleri, ölçüm düzeneği ve sorgu başına bakmanın gereği.

Sonuç

Kural şu: birleştirmeden önce her listeyi ayrı ayrı düzeltin ve derinliğini garanti edin, sonra sıralarla birleştirin. Ürün kodunu esneten yazım hatası toleransını, 40'ta kesilen aday listesini, filtre yüzünden beşe inen sonucu RRF düzeltemez, çünkü yalnızca önüne konanı görür. Birleştirmenin kendisi on satırlık bir fonksiyon. Emeği hak eden kısım, o fonksiyona giren listeler ve onları ölçen sorgu seti.

Sıkça Sorulan Sorular

RRF'den sonra bir reranker gerekir mi?
Gerekmez ama işe yarar. pgvector README'si hibrit arama için RRF'nin yanında cross-encoder'ı da anıyor. Cross-encoder sorguyla belgeyi birlikte okuyup puanladığı için RRF'den daha isabetli sıralar, karşılığında her aday için bir model çağrısı ister. Makul düzen, RRF'nin ilk 20 ile 50 arasındaki adayını bir reranker'a yeniden sıralatmak.
Birleştirilmiş sonuçlarda sayfalama nasıl yapılır?
Birleştirilmiş listenin ikinci sayfası, kaynak listelerin daha derinine inmeyi gerektirir. 20 sonuçlu ikinci sayfa için iki motordan da en az 40 aday almalısınız, yoksa sayfalar arasında belge kaybolur ya da tekrar eder. Daha temiz yol, ilk istekte iki motordan geniş bir aday kümesi (örneğin 100) alıp birleştirilmiş listeyi kısa süreli önbelleğe koymak ve sayfaları oradan vermek. pgvector tarafında 100 aday için hnsw.ef_search değerini de 100'e çıkarın.
Meilisearch yerine PostgreSQL'in tam metin araması kullanılamaz mı?
Kullanılabilir ve hibrit aramayı tek SQL sorgusuna indirir. PostgreSQL'de Snowball stemmer'ını kullanan hazır bir turkish metin arama yapılandırması var. Ama kökler tutarlı değil: PostgreSQL 18'de to_tsvector('turkish', ...) fatura ve faturamı için fatura, faturası için faturas, faturalarınızı için fatur üretiyor. Yazım hatası toleransı da yok, onun için pg_trgm eklemeniz gerekir.
Embedding boyutu pgvector indeksine sığmazsa ne olur?
vector tipiyle HNSW ve IVFFlat indeksleri en fazla 2 000 boyut kabul ediyor. Daha büyük vektörleri halfvec (yarım hassasiyet) ile 4 000 boyuta kadar indeksleyebilirsiniz, indeks de küçülür. Daha ötesi için binary quantization ya da model destekliyorsa vektörün bir alt bölümünü indeksleme var.

Kaynakça

Bu yazıyı paylaş

Mustafa Kürşad Başer
Yazar

Mustafa Kürşad Başer

Kıdemli Yazılım Mühendisi

Karmaşık sorunlara zarif çözümler üretmekten keyif alan, tutkulu bir yazılım mühendisi. Kodlamanın ötesinde, teknoloji, sanat ve insan bilincinin kesişim noktalarını keşfetmekle derinden ilgileniyor.