Kelimeyle değil anlamla aramak
Lezzet Kurye destek ekibi "yemek buz gibiydi" diye yazan müşterinin şikâyetine benzer eski kayıtları bulmak istiyor. Klasik anahtar kelime araması "soğuk geldi" yazan kayıtları kaçırır, çünkü ortak kelime yoktur. Embedding (gömme vektörü), bir metni anlamını temsil eden sayı dizisine çevirir: anlamca yakın metinlerin vektörleri de birbirine yakın düşer. Anlamsal arama, öneri sistemleri, kümeleme, yinelenen kayıt bulma ve RAG'in (Retrieval-Augmented Generation) "getirme" (retrieval) katmanı bu fikre dayanır.
LLM API'leriyle çalışmak: istemci, JSON ve araç çağrısı dersinde TF-IDF ile basit bir RAG kurdun. Bu derste getirme katmanını mühendis gözüyle derinleştireceksin: vektör benzerliği, numpy ile kNN, ANN indeksleri, parçalama stratejileri, BM25 ile hibrit arama, RRF, yeniden sıralama, değerlendirme metrikleri ve yetki filtresi.
1. Embedding, boyut ve benzerlik ölçüleri
Bir embedding, sabit uzunlukta bir ondalık sayı vektörüdür; uzunluğa boyut (dimension) denir (yaygın değerler 384, 768, 1024, 1536, 3072). Benzerliği üç ölçüyle hesaplarsın:
| Ölçü | Formül | Yorum |
|---|---|---|
| Kosinüs benzerliği (cosine similarity) | a·b / (‖a‖·‖b‖) | -1..1; vektörlerin yönüne bakar, uzunluğu yok sayar |
| Nokta çarpımı (dot product) | a·b | Vektörler birim uzunluktaysa kosinüsle aynıdır ve en hızlısıdır |
| Öklid uzaklığı (L2) | ‖a − b‖ | Küçük = yakın; birim vektörlerde ‖a−b‖² = 2 − 2·cos |
Pratikte vektörler bir kez normalize edilir (L2 normu 1 yapılır); sonra her karşılaştırma tek bir nokta çarpımıdır.
import numpy as np
a = np.array([3.0, 4.0])
b = np.array([6.0, 8.0]) # a ile aynı yön, iki kat uzun
c = np.array([4.0, -3.0]) # a'ya dik
def kosinus(x, y):
return float(x @ y / (np.linalg.norm(x) * np.linalg.norm(y)))
print("cos(a, b) =", round(kosinus(a, b), 3), " cos(a, c) =", round(kosinus(a, c), 3))
an, bn = a / np.linalg.norm(a), b / np.linalg.norm(b)
print("normalize sonrası nokta çarpımı:", round(float(an @ bn), 3))
print("L2(a, b) =", round(float(np.linalg.norm(a - b)), 3), " normalize L2:", round(float(np.linalg.norm(an - bn)), 3))
2. Sahte embedding modeli ve gerçek veri
Modelimiz her kelimenin başına ve sonuna boşluk koyup üç karakterlik parçalarını çıkarır, her parçayı zlib.crc32 ile 256 kutudan birine düşürür ve sayar (hashing trick). Sonra vektörü normalize eder. Lezzet Kurye'nin gerçek lk_ratings tablosundaki yorumlar üzerinde deneyelim:
import re
import zlib
import numpy as np
def tr_kucuk(s):
return s.replace("I", "ı").replace("İ", "i").lower()
def sahte_embedding(metin, boyut=256):
v = np.zeros(boyut)
for kelime in re.findall(r"\w+", tr_kucuk(metin)):
k = f" {kelime} "
for i in range(len(k) - 2):
v[zlib.crc32(k[i:i + 3].encode()) % boyut] += 1
n = np.linalg.norm(v)
return v / n if n else v
import veri
from collections import Counter
kayitlar = veri.kayitlar("lezzet_kurye", "lk_ratings")
sayim = Counter(k["comment"] for k in kayitlar if k["comment"])
yorumlar = sorted(sayim)
M = np.array([sahte_embedding(y) for y in yorumlar]) # yorum sayısı × 256 matris
print(len(kayitlar), "puan,", len(yorumlar), "farklı yorum, matris", M.shape)
print("normlar:", np.round(np.linalg.norm(M, axis=1)[:3], 3))
3. numpy ile kNN: kaba kuvvet arama
En yakın k komşu (k-nearest neighbors, kNN) aramasında sorgu vektörünü tüm belge vektörleriyle çarparsın: M @ q tek bir matris-vektör çarpımıdır ve 100 bin belgede bile milisaniyeler sürer. En büyük k skoru bulmak için tüm diziyi sıralamak yerine np.argpartition O(n) çalışır; sonra yalnız k elemanı sıralarsın.
def en_yakin(sorgu, k=3):
s = M @ sahte_embedding(sorgu)
aday = np.argpartition(-s, k)[:k] # sırasız ilk k
aday = aday[np.argsort(-s[aday], kind="stable")]
return [(yorumlar[i], round(float(s[i]), 3)) for i in aday]
for soru in ["kurye geç geldi", "porsiyon az", "yemek soğuktu"]:
print(soru, "→", en_yakin(soru))
Son satıra dikkat: "yemek soğuktu" için ilk sonuç doğru ("Yemek soğumuştu") ama ikinci sırada "Yemek dökülmüştü" var, "Soğuk geldi"nin önünde. Sahte model "yemek" kelimesindeki ortak harfleri anlamdan önemli sayıyor. Gerçek bir anlamsal model "soğuktu" ile "Soğuk geldi" arasındaki anlam yakınlığını yakalardı. Bu, model seçiminin getirme kalitesini doğrudan belirlediğini gösterir.
# Gerçek embedding modeli (pip install sentence-transformers)
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("paraphrase-multilingual-MiniLM-L12-v2") # Türkçe destekli çok dilli model
M = model.encode(yorumlar, normalize_embeddings=True) # (13, 384)
q = model.encode(["yemek buz gibiydi"], normalize_embeddings=True)[0]
print(yorumlar[int((M @ q).argmax())])
pip install sentence-transformers modeli ilk çalıştırmada indirir (~100-500 MB). API tabanlı modellerde (OpenAI text-embedding-3-small, Voyage, Cohere) aynı vektörleri HTTP üzerinden alırsın; model adlarını sağlayıcı belgesinden kontrol et.4. ANN indeksleri ve vektör veritabanları
Kaba kuvvet arama birkaç milyon vektöre kadar iş görür. Daha büyük ölçekte yaklaşık en yakın komşu (Approximate Nearest Neighbor, ANN) indeksleri kullanılır: biraz isabetten (recall) feragat edip yüzlerce kat hız kazanırsın.
| Yöntem / Araç | Fikir | Not |
|---|---|---|
| HNSW | Katmanlı "küçük dünya" grafı; üst katmanda uzun atlamalar, altta ince arama | Çok hızlı, bellek yoğun; pgvector, Qdrant, Weaviate destekler |
| IVF | Vektörleri k-means kümelerine böl, yalnız en yakın birkaç kümede ara | nprobe arttıkça doğruluk artar, hız düşer |
| PQ (product quantization) | Vektörü sıkıştırarak sakla | Milyarlarca vektörde bellek tasarrufu |
| FAISS | Meta'nın C++/Python kütüphanesi | Kendi sunucunda, kütüphane olarak |
| Chroma | Gömülü, geliştirici dostu vektör deposu | Prototip ve küçük projeler |
| pgvector | PostgreSQL eklentisi | Veri zaten Postgres'teyse; SQL ile filtre + vektör |
# FAISS (pip install faiss-cpu)
import faiss
indeks = faiss.IndexFlatIP(M.shape[1]) # iç çarpım = normalize vektörde kosinüs
indeks.add(M.astype("float32"))
skorlar, idler = indeks.search(q.reshape(1, -1).astype("float32"), 3)
# pgvector (PostgreSQL)
# CREATE EXTENSION vector;
# CREATE TABLE yorum (id serial, metin text, sahip text, emb vector(384));
# CREATE INDEX ON yorum USING hnsw (emb vector_cosine_ops);
# SELECT metin FROM yorum WHERE sahip = %s ORDER BY emb <=> %s LIMIT 5; -- <=> kosinüs uzaklığı
5. Parçalama (chunking) stratejileri
Uzun belge tek bir vektöre sığdırılırsa anlamı "ortalanır" ve ayrıntı kaybolur; bu yüzden belgeler parçalara bölünür. LLM API'leriyle çalışmak: istemci, JSON ve araç çağrısı dersinde sabit kelime sayısıyla örtüşmeli parçalamayı yazdın. Diğer stratejiler:
- Cümle bazlı: cümle sınırlarından bölüp karakter sınırına kadar cümle eklemek; cümleler yarıda kesilmez.
- Başlık bazlı: Markdown/HTML başlıklarına göre bölmek; her parça "İade > Süre" gibi bir başlık yolu (metadata) taşır. Yönetmelik ve yardım merkezi metinleri için en iyi başlangıçtır.
- Örtüşme (overlap): ardışık parçaların bir-iki cümle paylaşması, sınırdaki bilginin kaybolmasını azaltır.
import re
def cumlelere_bol(metin):
return [c for c in re.split(r"(?<=[.!?])\s+", metin.strip()) if c]
politika = "İade süresi 14 gündür. Hasarlı ürünlerde kargo bizden. Kampanyalı ürünler iade edilebilir! Hediye kartı iade edilemez."
print(cumlelere_bol(politika))
6. Hibrit arama: BM25 + vektör ve RRF
Vektör araması "AM-4471" gibi ürün kodlarını, kişi adlarını ve nadir terimleri kaçırabilir; anahtar kelime araması ise eş anlamlıları kaçırır. Hibrit arama ikisini birleştirir. Anahtar kelime tarafında standart BM25'tir: TF-IDF'e benzer ama terim sıklığının etkisini doyurur (k1) ve uzun belgeleri cezalandırır (b).
BM25 skoru ile kosinüs skoru farklı ölçeklerdedir; doğrudan toplanamaz. RRF (Reciprocal Rank Fusion) skorları değil sıraları birleştirir: her listede r. sıradaki belge 1/(k + r) puan alır (genelde k = 60). İki listede de üst sıralarda olan belge öne çıkar.
import math
import re
from collections import Counter
def kelimeler(metin):
return re.findall(r"\w+", metin.replace("I", "ı").replace("İ", "i").lower())
class BM25:
def __init__(self, belgeler, k1=1.5, b=0.75):
self.k1, self.b = k1, b
self.tf = [Counter(kelimeler(d)) for d in belgeler]
self.uzunluk = [sum(t.values()) for t in self.tf]
self.ort = sum(self.uzunluk) / len(belgeler) if belgeler else 0
self.df = Counter(w for t in self.tf for w in t)
self.N = len(belgeler)
def idf(self, w):
n = self.df.get(w, 0)
return math.log((self.N - n + 0.5) / (n + 0.5) + 1)
def skorla(self, sorgu):
q = kelimeler(sorgu)
sonuc = []
for tf, dl in zip(self.tf, self.uzunluk):
s = 0.0
for w in q:
f = tf.get(w, 0)
if f:
s += self.idf(w) * f * (self.k1 + 1) / (f + self.k1 * (1 - self.b + self.b * dl / self.ort))
sonuc.append(s)
return sonuc
def rrf(siralamalar, k=60):
skor = {}
for sira in siralamalar:
for r, d in enumerate(sira, 1):
skor[d] = skor.get(d, 0.0) + 1 / (k + r)
return sorted(skor, key=lambda d: (-skor[d], d))
belgeler = ["iade süresi 14 gündür", "AM-4471 kablosuz kulaklık stokta", "kulaklık iadesinde kutu şart"]
bm = BM25(belgeler).skorla("AM-4471 kulaklık")
vek = [float(sahte_embedding(d) @ sahte_embedding("AM-4471 kulaklık")) for d in belgeler]
s_bm = sorted(range(3), key=lambda i: -bm[i])
s_vek = sorted(range(3), key=lambda i: -vek[i])
print("BM25:", [round(x, 2) for x in bm], s_bm)
print("Vektör:", [round(x, 2) for x in vek], s_vek)
print("RRF:", rrf([s_bm, s_vek]))
Yeniden sıralama (reranking)
Hibrit arama hızlıca 20–100 aday getirir; ardından daha pahalı bir çapraz kodlayıcı (cross-encoder) model her (soru, parça) çiftini birlikte okuyup alaka puanı verir ve en iyi 3–5'i seçer. Bi-encoder (embedding) soru ve belgeyi ayrı kodladığı için hızlıdır; cross-encoder birlikte kodladığı için daha isabetlidir ama her aday için ayrı çalışır. Çeşitlilik için MMR (Maximal Marginal Relevance) gibi yöntemler, birbirinin kopyası olan sonuçları cezalandırır.
7. Getirmeyi ölçmek: recall@k ve MRR
"Arama iyi mi?" sorusu hisle değil altın soru setiyle (golden set) cevaplanır: her soru için doğru belge(ler) önceden etiketlenir. recall@k: doğru belgenin ilk k sonuçta olma oranı. MRR (Mean Reciprocal Rank): doğru belgenin sırasının tersinin ortalaması (1. sıra 1, 2. sıra 0,5, yoksa 0).
altin = {"iade süresi": "iade-1", "kargo ücreti": "kargo-1", "garanti": "urun-4471"}
sonuclar = {"iade süresi": ["iade-1", "iade-2", "kargo-1"],
"kargo ücreti": ["iade-2", "kargo-1", "kargo-2"],
"garanti": ["kargo-2", "iade-1", "iade-2"]}
def metrikler(sonuclar, altin, k):
isabet = [altin[s] in sonuclar[s][:k] for s in altin]
rr = [1 / (sonuclar[s].index(altin[s]) + 1) if altin[s] in sonuclar[s][:k] else 0 for s in altin]
return round(sum(isabet) / len(altin), 3), round(sum(rr) / len(altin), 3)
for k in (1, 3):
print(f"recall@{k}, MRR@{k} =", metrikler(sonuclar, altin, k))
8. Metadata filtreleme ve yetki
Kurumsal aramada her kullanıcı yalnız görme yetkisi olan belgeleri görmelidir: Ali'nin iade notu Ayşe'nin sonuçlarında çıkmamalı. Filtre sıralamadan önce uygulanmalıdır (pre-filtering). Önce ilk k'yı alıp sonra yetkisizleri atmak (post-filtering) iki sorun yaratır: k'dan az sonuç döner ve hata yapılırsa yetkisiz belge LLM istemine (prompt) sızar. Vektör veritabanları bu yüzden where koşulunu indeks aramasıyla birlikte uygular. Ayrıca embedding'ler de kişisel veri sayılabilir; silinen bir belgenin vektörü de silinmelidir (KVKK).
9. Kullanım alanları
- Benzer şikâyet bulma: yeni yorumun en yakın geçmiş kayıtları ve onların ortalama puanı.
- Kümeleme: Denetimsiz öğrenme: kümeleme, PCA ve anomali tespiti dersindeki k-means'i embedding'lere uygulayıp şikâyet konularını keşfetmek.
- Anomali / yeni konu: hiçbir bilinen kayda yeterince benzemeyen (en yüksek kosinüs < eşik) yorum, yeni bir sorun türünün habercisidir.
- Yinelenen kayıt: kosinüs > 0,95 olan ürün açıklamaları ya da destek talepleri.
bilinen = M
for yeni in ["Biraz geç geldi", "Ambalaj yırtıktı", "Kurye kapıyı çarptı"]:
en_yuksek = float((bilinen @ sahte_embedding(yeni)).max())
print(f"{yeni:22} en yüksek benzerlik {en_yuksek:.2f}", "→ YENİ KONU" if en_yuksek < 0.4 else "")
Model seçimi ve maliyet
Türkçe içerik için çok dilli modelleri kendi altın setinde karşılaştır; genel liderlik tabloları senin alanını temsil etmeyebilir. Boyut arttıkça depolama ve arama maliyeti artar; bazı modeller vektörü kısaltmaya (Matryoshka) izin verir. float32 yerine int8 ya da ikili niceleme (quantization) belleği 4–32 kat azaltır.
Sorgu tarafı iyileştirmeleri
Soru ile belge farklı dilde konuşur ("kargom nerede" ↔ "takip numarası SMS ile gönderilir"). Sorgu genişletme, LLM ile varsayımsal cevap üretip onu aramak (HyDE) ve çok sorgulu arama bu boşluğu kapatır; her biri ek gecikme ve maliyettir, altın setle ölçmeden ekleme.
İndeks bakımı
Belge güncellenince eski parçaları sil, yenileri ekle (upsert); belge kimliği + parça sırası anahtardır. Silinen belgelerin vektörlerinin kalması hem yanlış cevap hem gizlilik ihlalidir.