RAG'i doğru kurmak: chunking, embedding ve reranking
Bir LLM uygulamasına "dökümanlarından cevap ver" dedikten sonra gelen ilk hayal kırıklığı genellikle aynı: model ya alakasız paragrafları referans alıyor ya da doğru bilgiyi görmezden geliyor. Sorun modelde değil, ona beslenen bağlamda. Retrieval-Augmented Generation (RAG) pipeline'ının kalitesi büyük ölçüde üç adıma bağlı: metni nasıl parçaladığın, hangi embedding modelle vektöre çevirdiğin ve geri dönen sonuçları nasıl yeniden sıraladığın.
Bu yazıda bu üç adımı ayrı ayrı ele alıyorum, her birinde somut kod ve ölçülebilir kararlar var.
Chunking: metni parçalamanın doğru yolu
Naif yaklaşım, metni sabit karakter sayısına göre kesmek. 500 karakter, 1000 karakter, fark etmez; cümle ortasından kesiyor, anlam bütünlüğünü bozuyor. Geri dönen chunk'ın yarısı bir paragrafın sonu, yarısı başka bir paragrafın başı olunca model de tutarlı bir yanıt üretemiyor.
Sabit boyutlu chunking ve overlap
En basit yöntem sabit token sayısıyla parçalamak. LangChain'in RecursiveCharacterTextSplitter'ı bunu yapıyor. Overlap (örtüşme) eklemek, kesim noktasındaki bilgi kaybını azaltır.
from langchain.text_splitter import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=512,
chunk_overlap=64,
separators=["\n\n", "\n", ". ", " "]
)
chunks = splitter.split_text(document_text)separators listesi önemli. Splitter önce çift satır sonuna, sonra tek satır sonuna, sonra cümle sonuna göre kesmeye çalışıyor. Bu sıralama, paragraf bütünlüğünü korumada sabit karakter kesimine göre belirgin fark yaratıyor.
Semantic chunking
Daha iyi bir yol var: ardışık cümlelerin embedding benzerliğine bakarak doğal kırılma noktalarını bulmak. İki ardışık cümlenin cosine similarity'si belirli bir eşiğin altına düştüğünde orada yeni chunk başlatıyorsun.
from langchain_experimental.text_splitter import SemanticChunker
from langchain_community.embeddings import HuggingFaceEmbeddings
embedding_model = HuggingFaceEmbeddings(model_name="BAAI/bge-m3")
semantic_splitter = SemanticChunker(
embeddings=embedding_model,
breakpoint_threshold_type="percentile",
breakpoint_threshold_amount=90
)
chunks = semantic_splitter.split_text(document_text)Semantic chunking daha pahalı çünkü her cümle için embedding hesaplıyor. Ama özellikle teknik dokümantasyon gibi konu geçişlerinin keskin olduğu metinlerde chunk kalitesini gözle görülür biçimde artırıyor.
Chunk boyutu ne olmalı?
Bu sorunun kesin bir cevabı yok ama pratik bir kılavuz var. Embedding modelin context window'u sınırı belirliyor: BGE-M3 8192 token destekliyor, ancak bu kadar uzun chunk'lar retrieval precision'ı düşürüyor. 256-512 token arası çoğu kullanım için iyi bir başlangıç. Kısa chunk'lar daha hassas eşleşme sağlıyor, uzun chunk'lar daha fazla bağlam taşıyor. Doğru dengeyi kendi verinde test ederek bulacaksın.
Bir de metadata meselesi var. Her chunk'a kaynak dosya adı, sayfa numarası, başlık hiyerarşisi gibi bilgileri eklemek, hem filtreleme hem de LLM'e bağlam verme aşamasında işe yarıyor.
Embedding model seçimi
Embedding modeli, chunk'ları vektör uzayına haritalıyor. Yanlış model seçimi, ne kadar iyi chunking yaparsan yap, alakalı sonuçları geri getirmeyi imkansız kılıyor.
BGE-M3 neden iyi bir başlangıç
BAAI tarafından geliştirilen BGE-M3 (Multi-Functionality, Multi-Linguality, Multi-Granularity), tek bir model içinde üç farklı retrieval yöntemi destekliyor: dense embedding, sparse embedding (lexical) ve ColBERT tarzı multi-vector retrieval. Modelin MTEB benchmark'ındaki çok dilli sonuçları, özellikle Türkçe gibi İngilizce dışı dillerde OpenAI text-embedding-3-large'a yakın veya bazı görevlerde üstün.
1024 boyutlu dense vektör üretiyor. 8192 token context window'u var, bu da uzun chunk'ları destekliyor.
from FlagEmbedding import BGEM3FlagModel
model = BGEM3FlagModel("BAAI/bge-m3", use_fp16=True)
chunk_texts = ["RAG pipeline'ında chunking stratejisi...", "Embedding modeli seçimi..."]
embeddings = model.encode(
chunk_texts,
return_dense=True,
return_sparse=True,
return_colbert_vecs=False
)
dense_vecs = embeddings["dense_vecs"] # shape: (2, 1024)
sparse_vecs = embeddings["lexical_weights"] # dict listesiHybrid search: dense + sparse birlikte
Dense embedding semantik benzerliği yakalıyor ama spesifik terimleri kaçırabiliyor. "React 19 use hook" araması yaparken, dense model "React hooks" ile ilgili genel chunk'ları getirebiliyor, tam olarak use() hook'undan bahseden chunk'ı değil. Sparse retrieval (BM25 veya öğrenilmiş sparse vektörler) tam terim eşleşmesinde daha iyi.
BGE-M3'ün sparse çıktısını Milvus veya Qdrant gibi vektör veritabanlarında hybrid search için kullanmak mümkün. Milvus 2.4+ hybrid search'ü doğrudan destekliyor:
from pymilvus import (
Collection, connections, FieldSchema,
CollectionSchema, DataType, AnnSearchRequest,
RRFRanker
)
connections.connect("default", host="localhost", port="19530")
# Koleksiyon şeması: dense ve sparse vektör alanları
fields = [
FieldSchema(name="id", dtype=DataType.INT64, is_primary=True, auto_id=True),
FieldSchema(name="text", dtype=DataType.VARCHAR, max_length=4096),
FieldSchema(name="dense_vector", dtype=DataType.FLOAT_VECTOR, dim=1024),
FieldSchema(name="sparse_vector", dtype=DataType.SPARSE_FLOAT_VECTOR),
]
schema = CollectionSchema(fields)
collection = Collection("rag_chunks", schema)
# Hybrid search: iki ayrı arama isteği, RRF ile birleştirme
dense_req = AnnSearchRequest(
data=[query_dense_vec],
anns_field="dense_vector",
param={"metric_type": "IP", "params": {"nprobe": 16}},
limit=20
)
sparse_req = AnnSearchRequest(
data=[query_sparse_vec],
anns_field="sparse_vector",
param={"metric_type": "IP"},
limit=20
)
results = collection.hybrid_search(
reqs=[dense_req, sparse_req],
ranker=RRFRanker(k=60),
limit=10
)RRF (Reciprocal Rank Fusion) her iki sonuç listesinin sıralamasını birleştiriyor. k=60 parametresi RRF formülündeki düzeltme katsayısı. Pratikte 40-80 arası değerler iyi çalışıyor.
Hybrid search, saf dense search'e kıyasla özellikle teknik terimler, versiyon numaraları ve özel isimler içeren sorgularda recall'u belirgin artırıyor.
Reranking: geri dönen sonuçları iyileştirme
Retrieval aşaması geniş bir aday havuzu getiriyor. Bu havuzdaki sıralama mükemmel olmayabilir çünkü bi-encoder (embedding modeli) sorgu ve dokümanı bağımsız olarak kodluyor. Reranker, sorgu-doküman çiftini birlikte değerlendiren bir cross-encoder. Daha yavaş ama daha doğru.
Cross-encoder reranking
BGE-reranker-v2-m3 veya Cohere Rerank gibi modeller bu iş için tasarlanmış. İlk retrieval'dan dönen 20-50 chunk'ı reranker'dan geçirip en alakalı 3-5 tanesini LLM'e vermek standart bir yaklaşım.
from FlagEmbedding import FlagReranker
reranker = FlagReranker("BAAI/bge-reranker-v2-m3", use_fp16=True)
query = "React Server Components ile veri çekme"
candidate_chunks = [chunk.text for chunk in retrieval_results] # 20 chunk
# Sorgu-chunk çiftleri oluştur
pairs = [[query, chunk] for chunk in candidate_chunks]
scores = reranker.compute_score(pairs, normalize=True)
# Skora göre sırala, ilk 5'i al
ranked = sorted(zip(candidate_chunks, scores), key=lambda x: x[1], reverse=True)
top_chunks = [text for text, score in ranked[:5]]Reranking adımı latency ekliyor. 20 chunk'ı cross-encoder'dan geçirmek, GPU'da 50-150ms arası sürüyor. CPU'da bu süre 500ms'i aşabiliyor. Latency bütçen kısıtlıysa aday havuzunu daraltmak veya daha küçük bir reranker modeli kullanmak gerekiyor.
Reranking ne kadar fark yaratıyor?
Bunu ölçmeden karar vermek zor. Ama genel bir gözlem: reranking, retrieval precision'ı yüzde 10-25 arası artırabiliyor. Özellikle sorgu ile chunk arasındaki ilişki dolaylı olduğunda (kullanıcı farklı kelimelerle soruyor, cevap farklı terimlerle yazılmış) cross-encoder bu boşluğu kapatmada bi-encoder'dan iyi.
Değerlendirme: pipeline'ını nasıl ölçersin
Bir RAG pipeline'ını "iyi çalışıyor gibi" hissiyatıyla değerlendirmek tehlikeli. Değişiklik yaptığında neyin iyileşip neyin kötüleştiğini göremiyorsun.
Retrieval metrikleri
İki temel metrik var. Hit Rate (Recall@k): doğru chunk, ilk k sonuç içinde mi? MRR (Mean Reciprocal Rank): doğru chunk kaçıncı sırada?
Bunları hesaplamak için bir test seti lazım. 50-100 adet sorgu-cevap çifti oluştur, her biri için hangi chunk'ın doğru cevabı içerdiğini işaretle.
def hit_rate(results: list[list[str]], ground_truths: list[str], k: int = 5) -> float:
hits = 0
for retrieved, truth in zip(results, ground_truths):
if truth in retrieved[:k]:
hits += 1
return hits / len(ground_truths)
def mrr(results: list[list[str]], ground_truths: list[str]) -> float:
total = 0.0
for retrieved, truth in zip(results, ground_truths):
for i, doc_id in enumerate(retrieved):
if doc_id == truth:
total += 1.0 / (i + 1)
break
return total / len(ground_truths)End-to-end değerlendirme
Retrieval metrikleri tek başına yetmiyor çünkü doğru chunk'ı getirsen bile LLM yanlış cevap üretebiliyor. RAGAS kütüphanesi (v0.2+) bu amaçla dört metrik sunuyor: faithfulness (cevap, verilen bağlama sadık mı), answer relevancy (cevap soruya uygun mu), context precision ve context recall.
from ragas import evaluate
from ragas.metrics import faithfulness, answer_relevancy, context_precision
from datasets import Dataset
eval_data = {
"question": ["React 19'da use() hook ne işe yarar?"],
"answer": [rag_pipeline_answer],
"contexts": [[chunk1, chunk2, chunk3]],
"ground_truth": ["use() hook, promise ve context değerlerini doğrudan okumak için kullanılır."]
}
result = evaluate(
Dataset.from_dict(eval_data),
metrics=[faithfulness, answer_relevancy, context_precision]
)
print(result)Bu metrikleri her pipeline değişikliğinde (chunk boyutu değiştirme, embedding model değiştirme, reranker ekleme) çalıştırmak, kararlarını veriye dayandırmanı sağlıyor.
Pipeline'ı bir araya getirmek
Tüm adımları birleştiren minimal bir akış şöyle görünüyor:
- Dökümanları semantic veya recursive chunking ile parçala.
- BGE-M3 ile hem dense hem sparse vektör üret.
- Milvus veya Qdrant'ta hybrid search ile ilk 20 adayı getir.
- BGE-reranker-v2-m3 ile yeniden sırala, ilk 5'i al.
- Bu 5 chunk'ı LLM'e bağlam olarak ver.
Her adımın kendi ayar noktası var ve bunları birbirinden bağımsız test edebilirsin. Chunk boyutunu değiştirdiğinde retrieval metriklerinin nasıl değiştiğine bak. Reranker eklediğinde MRR farkını ölç.
Bir şeyi belirtmeden geçmeyeyim: bu pipeline, AI ajanlarına dost API tasarımı yazısında değindiğim konuyla kesişiyor. Eğer RAG pipeline'ını bir ajan sisteminin parçası olarak kullanıyorsan, retrieval adımını bir tool olarak tanımlaman ve idempotent tutman gerekiyor. Agentic coding yaklaşımıyla RAG'i bir subagent'a devretmek de yaygınlaşan bir desen.
Son olarak: RAG pipeline'ı kurmak bir kerelik iş değil. Yeni döküman ekledikçe, kullanıcı sorgularının dağılımı değiştikçe pipeline'ın davranışı da değişiyor. O yüzden değerlendirme setini güncel tut ve metriklerini düzenli çalıştır. Hissiyatla değil, sayılarla karar ver.