Code RAG Bahçesi

Uçtan uca kurulmuş bir codebase RAG'i — İngilizce kod üzerinde Türkçe sorular, incremental sync ve ölçülmüş her karar.

Beyaz tahta daha geniş bir ekran istiyor — notlar sırayla aşağıda.

şema

Code RAG'in dört zor tarafı

Vektör arama, bir codebase RAG'inin kolay tarafı. Asıl zor olan dört şey bu sistemin her parçasını belirledi.

önce ne kırılır
birebir sembollerhandleAuthCallback
Türkçe → İngilizcedüzyazı vs. kod
bayatlamarepo değişti
"hayır" demekkNN asla hayır demez
ProblemNe yaptıkÖlçüm
Sembollersembol biçimli query'leri BM25'e yönlendir1.0 / 1.0 recall@8 / MRR
Türkçe sorularçok dilli embedder, isteğe bağlı LLM açıklamaları0.04 → 0.684 → 1.0 (alt küme)
Bayatlamapush'ta sha256 manifest diff'i38 değişen dosya 23 sn'de
Cevap yoküç skor bandı + negatif golden case'lerabstain 0.846, yanlış alarm 0.048

Her satır, yanında bir sayı olan bir karar. Bu bahçenin geri kalanı o sayıların hikâyesi — fikrimizi değiştirenler dâhil.

#rag#code-search
deney

Kullan-at deney

Gerçek sistemden önce bir kullan-at sistem vardı: MiniLM-L6-v2, küçük bir Milvus, 30 soru. Neredeyse her konuda yanılıyordu ve amaç da zaten buydu.

recall@5sembol query'sidüz cümle
dense (MiniLM)0.600.62
BM250.800.36
ikisinin RRF'i0.600.52

Gerçek sisteme ne taşındı

  • Semboller BM25 ister. Bir embedding'in QUEUE_NAMES hakkında söyleyecek hiçbir şeyi yok; leksik bir index'in ise her şeyi var.
  • Her zaman birleştirmek bedava değil. RRF, kaybeden kanalın en iyi tahminini tam güçle öne çıkardı ve iki kanalın da tek başına aldığı sonucun altında kaldı.
  • Türkçe soru, İngilizce kod: 0.04. Bir retrieval hatası değil — eşleşecek Türkçe metin yoktu, o kadar. İki çözüm ileri taşındı: çok dilli bir embedder ve seçenek olarak LLM'in yazdığı açıklamalar.
  • Yerel modeller kayar. qwen2.5 yük altında cümle ortasında Çinceye geçti ve bunu söylemedi. O günden beri üretilen her açıklamanın alfabesi kontrol ediliyor.
#baseline#bm25#multilingual
şema

Stack, ve neden

Tek process, tek collection, tek veritabanı dosyası. Her parça bir özellik listesine göre değil, dört zor probleme göre seçildi.

MCP · /mcpsearch_code · read_code · list_repos, aynı process
FastAPI + typer/search /ask, webhook'lar, CLI
Retrieveryönlendir → dense | BM25 → (RRF, rerank flag arkasında)
Milvus 2.6dense + BM25 sparse tek collection'da, repo_id partition key
BGE-M3 · tree-sitter · sha256 manifestembed · chunk · diff
SQLite + tek workerrepo'lar, dosyalar, job'lar, enrichment cache'i
tek process, tek collection, tek dosya
tek FastAPI process'iStreamable HTTPpushsorgurepo başına jobhybrid searchupsertmanifest · job'larAgentClaude Code · CursorGitHubwebhook · pollerMCP · /mcpsearch · read · listFastAPI/search /ask · CLIRetrieveryönlendir → dense | BM25Indexertree-sitter · BGE-M3Milvus 2.6dense + sparseSQLiterepo'lar · job'lar · cacheBackend3Veritabanı2Kuyruk1Dış2
ParçaNedenElenen
Milvus 2.6BM25 yerleşik bir Function → ikinci index yok; partition key repo filtrelerini ucuzlatıyorpgvector (BM25 yok), Qdrant (BM25 client tarafında), Elasticsearch (ayrı bir dünya)
BGE-M3, yerel, 1024dçok dilli: MiniLM'in 0.04 aldığı Türkçe düzyazıda 0.684; kod makineden dışarı çıkmıyorOpenAI / Voyage embedding'leri (kod dışarı çıkıyor; flag olarak duruyor)
tree-sitterchunk = kod birimi, atıf = file:line — sembolsabit pencereler, LLM ile chunking
sha256 manifestrename, mode, submodule uç durumları yok oluyor; yerel dizinler aynı yoldan geçiyorgit diff
webhook + pollerpush anında tazelik, artı bir emniyet ağıyalnız cron, yalnız webhook
SQLite + tek workertek dosya, Redis yok, repo başına tek bekleyen jobPostgres + Celery

Sistem dünyayla HTTP üzerinden konuşuyor ve index'lediği repo'lara asla yazmıyor. Personal access token'lar git header'ı olarak taşınıyor ve her hata mesajından temizleniyor.

#milvus#bge-m3#tree-sitter#architecture
şema

İçerik hash'iyle incremental sync

Bir push saniyeler sürmeli, baştan sona bir re-index değil. Değişiklik tespiti git diff ile değil, içerik hash'i ile.

bir push neyi tetikler
pushwebhook ya da poll
jobrepo başına tekilleştirilmiş
fetch + reset
sha256 diffmanifest'e karşı
sil + yeniden yazyalnız değişen path'ler
manifestgüncellendi

Neden diff değil hash

  • Rename'ler, mode değişiklikleri, submodule'ler, force-push'lar: dosya içeriğini karşılaştırınca hiçbiri özel durum değil.
  • git repo'su olmayan yerel bir dizin de birebir aynı yoldan geçiyor.
  • Manifest, read_code için de tek doğruluk kaynağı — agent yalnızca gerçekten index'lenmiş dosyaları okuyabiliyor.

Sıralamayla gelen çökme güvenliği

Bir dosyanın manifest satırı, chunk'ları silinmeden önce kaldırılıyor ve yeni chunk'lar yazıldıktan sonra geri konuyor. Yarı yolda öldürülen bir job, dosyayı "index'lenmemiş" görünür bırakıyor; bir sonraki sync onu baştan yapıyor, o kadar. Hiçbir şey asla yarım hâlde durmuyor.

#webhook#manifest#freshness
deney

Query biçimine göre yönlendir

İki retriever, bir regex. Sembole benzeyen bir query BM25'e gidiyor; bir cümle dense index'e. Hybrid fusion ve reranking flag arkasında duruyor — ikisi de ölçüldü, ikisi de kaybetti.

AyarRecall@8MRRTR düzyazı R@8p50
yalnız BM250.4050.2400.2632 ms
hybrid (dense + BM25 → RRF)0.7860.6040.68440 ms
yalnız dense0.7860.6780.68432 ms
auto: sembol → BM25, düzyazı → dense ✓0.7860.6900.68434 ms

Her zaman hybrid neden kaybediyor

RRF sıraları topluyor. Bir kanalın elinde işe yarar bir şey olmadığında — QUEUE_NAMES için dense, "auth nasıl çalışıyor" için BM25 — en iyi tahmini yine tam güçle öne çıkıyor ve listenin tepesini kirletiyor. Yönlendirme, tahmin yürütecek olan kanalı devreden çıkarıyor.

Sembol sayılan ne

İçinde bir sınır olan tek bir token: camelCase, snake_case, kebab-case, a.b.c, a/b, A::B ya da ALLCAPS. Bilerek dar tutuldu — bir false positive gerçek bir soruyu BM25'e gönderiyor, bu ölçülebilir biçimde kötü; bir false negative ise yalnızca küçük bir kazanımdan vazgeçiyor. Semboller BM25'te 1.0 / 1.0, dense'te 1.0 / 0.875 alıyor.

Masada bir LLM router vardı. Bir regex aynı MRR kazanımını bedavaya getirdi ve sıfır latency ekliyor.

#routing#bm25#dense#rrf
deney

Zarar veren reranker

Ders kitabı şöyle der: 40 getir, cross-encoder ile rerank et, 8 tanesini ver. Bu corpus'ta ders kitabı yanılıyordu ve sayılar bunu söyledi.

AyarRecall@8MRRTR düzyazıp50
auto + dense (rerank yok)0.7860.6900.68434 ms
hybrid, k=40 aday0.9520.89539 ms
hybrid + bge-reranker-v2-m30.7620.5080.5794389 ms
auto + bge-reranker-v2-m30.7620.5140.5792050 ms
  • Kazanılacak alan gerçek. Recall@40 0.95 — doğru chunk neredeyse her zaman aday havuzunda. İyi bir reranker'ın kazanacak 17 puanı var.
  • Bu reranker o puanları harcadı. MRR 0.690'dan 0.514'e düştü, en çok da Türkçe dilim geriledi. Üstüne query başına iki ila dört saniye ekledi.
  • "Cevap yok" kapısı olarak daha da kötüydü. 12/12 negatifi yakaladı — ve 42 gerçek cevabın 12'sini çöp diye işaretledi. %29 yanlış alarm oranı.

Bir reranker, corpus'un hakkında bir hipotezdir. Onu bir hipotez gibi test et.

#rerank#cross-encoder#latency
deney

Türkçe soru, İngilizce kod

Kullanıcılar Türkçe soruyor; kod, yorumlar ve commit mesajları İngilizce. Metin o köprüyü kurmadıkça bir bi-encoder'da bu boşluğu kapatacak hiçbir şey yok.

TR düzyazı recall@8 — MiniLM4%
TR düzyazı recall@8 — BGE-M368%
TR düzyazı MRR — alt küme, enrichment yok78%
TR düzyazı MRR — alt küme, enriched93%

Birinci adım: çok dilli bir embedder

Tek başına BGE-M3, Türkçe düzyazıyı 0.04'ten 0.684'e taşıdı. Aynı corpus'ta İngilizce düzyazı 0.842'de; yani ~16 puanlık bir fark kaldı.

İkinci adım: eksik metni yaz

Contextual retrieval, koda uygulanmış hâli: dosya başına bir LLM çağrısı, her chunk için 2–3 cümlelik Türkçe bir açıklama döndürüyor. Açıklama yalnızca embed edilen / BM25'e giren metne giriyor — modele gösterilen chunk gerçek kaynak olarak kalıyor. chunk hash'i + model ile cache'leniyor; bir re-index asla iki kez ödemiyor.

Aynı 46 dosya / 423 chunkRecall@8MRRTR düzyazı MRRyanlış weak-match
enrichment yok0.9290.8390.7780.119
enriched (yerel qwen3.5:9b)1.0000.9120.9320.048

Negatifler değişmedi (abstain iki durumda da 0.923) — açıklamalar alakasız sorularda güven üretmedi — ve skor kalibrasyonu yerinden oynamadı.

#multilingual#contextual-retrieval#enrichment
şema

Chunk bir kod birimidir

Bir chunk; bir fonksiyon, sınıf, metot, interface, tip ya da export edilmiş bir sabit — sınırları süslü parantez sayarak değil, gerçek bir parser'dan geliyor.

chunker kuralları
parsetree-sitter
birimler≤ 2000 B
bölbüyük sınıf → üyeler
birleştirküçükler ≥ 200 B
headeryalnız index için

Dört kural

  • Büyük kapsayıcılar üyelerine bölünüyor. 5 KB'lık bir sınıf, bir header chunk'ı artı metot başına bir chunk oluyor; her biri parent = class taşıyor.
  • Küçük şeyler bir komşuyla birleşiyor. Tek satırlık tipler, kısa const'lar, import blokları ve doc yorumları bir sonraki birime yapışıyor.
  • Hiçbir şey ~2000 byte'ı (≈ 500 token) aşmıyor. Model 8192 kabul ediyor ama uzun bir chunk'ın embedding'i ortalamaya çekilip hiçbir şey anlatmaz oluyor. Tek başına aşırı büyük bir fonksiyon satır satır pencereleniyor ve sembolünü koruyor.
  • Metin temiz kalıyor. "Ben neyim, nerede yaşıyorum" header'ı — dosya, sembol, parent, import'lar — yalnızca embed edilen ve BM25'e giren metnin başına ekleniyor. Atıflar gerçek kaynağı gösteriyor.

BM25 için identifier'lar

Standart analyser handleAuthCallback'i tek token olarak tutuyor; yani "auth callback" ona hiç dokunmuyor. Her chunk'ın identifier'ları küçük harfli alt kelimelere bölünüp index'lenen metnin sonuna ekleniyor.

#chunking#tree-sitter
deney

AST chunk'ları vs. düz pencereler

Sözdizimi farkında chunking gerçekte ne kazandırıyor? Aynı repo'yu iki kez index'ledik — bir kez tree-sitter birimleriyle, bir kez aynı boyutta boş satır pencereleriyle — ve aynı 42 soruyu çalıştırdık.

ChunkerRecall@8MRREN düzyazıTR düzyazısembol R / MRR
tree-sitter birimleri0.7860.6900.842 / 0.7440.684 / 0.5701.0 / 1.0
boş satır pencereleri0.7620.5980.895 / 0.7370.632 / 0.5180.75 / 0.32

Dürüstçe okumak

  • Recall bir soru kadar oynadı. MRR 0.09 oynadı — ve neredeyse tamamı sembol query'leri (0.32 → 1.0) artı Türkçe dilim.
  • İngilizce düzyazıda düz pencereler eşit ya da daha iyiydi.
  • Yani AST chunking daha fazlasını bulmuyor. Doğru parçayı tepeye koyuyor ve adını biliyor. Bu sahip olmaya değer; ama 20 puanlık bir kaldıraç değil.

Literatür de aynı fikirde

cAST makalesi aynı boyuttaki satır pencerelerine karşı +1 ila +4 puan bildiriyor. Bağımsız kontrollü bir tekrar ≈ 0 buldu; bir üçüncüsü sabit pencereleri hafif önde buldu. Naif "fonksiyon başına bir chunk" ise güvenilir biçimde daha kötü (−4 ila −6 puan) — değer, küçük kardeşleri birleştirmekte ve boyutu sınırlamakta; sözdizimsel sınırın kendisinde değil. Reranking ve LLM'in yazdığı bağlam retrieval'ı 3–10 kat daha fazla oynatıyor.

#ablation#chunking#cast
not

Dil kapsamı tablo değil, kural

Cazip tasarım bir tabloydu: uzantı → dil, klasör → rol (controllers/ = controller), semboller için dil başına bir regex. Bunun yerine production sistemlerinin ne yaptığına baktık ve bir tablonun ne kazandıracağını ölçtük.

Herkes ne yapıyor

GitHub code search, Sourcegraph, Cursor, aider, Continue, Sweep, Qodo, Greptile — hepsi dil kapsamını bir parser ekosistemine devrediyor (upstream tags.scm ile tree-sitter grammar'ları ya da universal-ctags), grammar'dan bağımsız bir boyut sınırıyla chunk'lıyor, evrensel bir satır penceresi fallback'i tutuyor ve path + sembolü metadata olarak taşıyor. Hiçbirinde framework'e özel mantık yok. Hiçbiri klasör→rol taksonomisi tutmuyor.

ÖneriKararNeden
uzantı → dil tablosubir kuralla değiştirildipakette 371 grammar geliyor; uzantı adı bir grammar adıysa (.lua .vue .razor .zig) doğrudan çalışıyor; küçük bir alias tablosu (.ts .cs .kt) import anında pakete karşı doğrulanıyor
regex sembol çıkarıcılarhayırtree-sitter 20+ dil için sembol, tür ve parent'ı zaten veriyor
klasör → rol etiketlerihayırrol, path'in deterministik bir fonksiyonu ve path zaten embed edilen metnin içinde; golden set'teki 11 kaçağın hiçbirini düzeltmedi
vendor adı atlama listeleri (jquery, bootstrap…)hayırrepo başına bir ignore dosyası genelleşiyor; bir isim listesi tek bir şirketi kodluyor
framework başına kurallar (Next.js, Vue, Razor)hayırroute dosyaları route'u zaten path'lerinde taşıyor; Razor blokları grammar'dan kendiliğinden çıkıyor

Elle yazılı tuttuklarımız, ve neden

  • Bilerek düz: json, yaml, css, html — grammar'ları var, ama pencereler eşit ölçüldü ve bir parser yalnızca çökme yüzeyi.
  • Hiç index'lenmeyenler: csv, tsv, diff, po, pem — onlar için de grammar var.
  • Grammar var, parser reddedildi: sql (üretilen grammar migration dosyalarında segfault veriyor) ve cobol (dengesiz parantezlerde 300 sn'yi aşıp takılıyor). İkisi de her grammar'ı kendi subprocess'inde dejenere girdiyle çalıştıran bir smoke test'le bulundu — bir çökme indexer'ı değil, çocuk process'i öldürüyor.

Artık bir dil eklemek sıfır satıra mal oluyor. Ablation'a göre bu eksenin tavanı topu topu bir soruluk recall — yani sıfır, doğru fiyat.

#languages#tree-sitter#survey
şema

Hayır demek: üç bant

En yakın komşu aramasının "yakın bir şey yok" diye bir kavramı yok. Bir codebase'e beş yıldızlı bir tatil köyüne gidip gitmediğini sor, sekiz chunk döndürür (en iyi skor 0.366). Halüsinasyon tam orada başlıyor.

en yüksek dense skor üzerinde CRAG tarzı bantlar
0.45'in altıatıldı, sayıldı
0.45 – 0.55weak-match notuyla döndü
0.55 ve üstünormal

Neden tek eşik değil

Bu model ve corpus'ta cosine gri bölgeyi ayırmıyor: gerçek cevapların top-1 dense medyanı 0.636, minimumu 0.526; alakasız soruların medyanı 0.524, maksimumu 0.587. Üst üste biniyorlar. Tek bir kesim ya gerçek cevapları atıyor ya da çöpü içeri alıyor.

Kapı13 negatifte abstain42 pozitifte yanlış alarmek latency
0.55 altına not0.8330.0480
taban 0.45 + not 0.55 ✓0.8460.0480
reranker skoru 0.05 altı1.0000.286+550 ms

Taban, golden set'ten hiçbir şey eksiltmedi ve tatil köyü sorusunu boş bir sonuca çevirdi. Hâlâ sızan iki negatif, repo'da gerçekten benzer kod olan sorular — orada karar okuyucuya ait.

#abstention#crag#calibration
not

Aynı process'te MCP

Tüketici bir agent — Claude Code, Cursor — o yüzden retriever, aynı FastAPI process'i içinde /mcp'ye mount edilmiş üç MCP tool'u olarak açılıyor.

tool'lar
search_codequery, repo, path prefix'i, kategori
read_codeindex'lenmiş dosya, satır aralığı
list_repostazelik

Tasarım kararları

  • Aynı process, aynı retriever. Embedding modelini ikinci kez yükleyen bir stdio sidecar yok; bloklayan işler (embedding, Milvus, disk) thread'lerde çalışıyor ki MCP oturumunun event loop'u akmaya devam etsin.
  • Çıktı talimat değil, veri. Agent'a dönen kod, güvenilmeyen içerik olarak sarılıyor.
  • Sinyaller hit'lerle birlikte gidiyor. Weak-match bandı, DOC etiketi, "N aday atıldı", repo başına son index zamanı.
  • read_code yalnızca index'lenmiş dosyaları okuyor. Path manifest'te olmak zorunda — yani agent node_modules ya da .env okuyamıyor ve var olmayan bir dosya için "o dosya var" diye kandırılamıyor. Diskteki dosya index'lenen hash'le artık eşleşmiyorsa dilim stale işaretiyle dönüyor.
TuzakÇözüm
mount edilen alt uygulamanın lifespan'i hiç çalışmıyorsession_manager.run() üst uygulamanın lifespan'ini sarıyor
transport yabancı Host header'larını 421 ile reddediyor (DNS rebinding)config üzerinden ek host'lar
/mcp → /mcp/ bir 307; redirect'ten kaçınan client'lar POST body'sini kaybediyorbunun yerine saf ASGI bir middleware path'i yeniden yazıyor

Agent adayları ve kanıtı alıyor, sonra okuyor. Son sözü söylemeye çalışan retrieval, agent adına halüsinasyon gören retrieval'dır.

#mcp#agent#signals
not

Ölç, yoksa olmamıştır

Repo'nun kuralı: evals/results/ altında bir JSON olmadan hiçbir retrieval flag'inin default'u değişmez. "Daha iyi gibi" bir sonuç değil.

harness
golden42 pozitif + 13 negatif
çalıştırauto · k=8
puanlaRecall@8 · MRR · abstain · false-weak
ledgerREADME tablosu

Golden set 19 İngilizce düzyazı soru, 19 Türkçe, 4 sembol ve cevabı repo'da olmayan 13 soru. Her koşu ayrıca p50/p95 ve bir kalibrasyon bloğu basıyor. Repo'ya özel set'ler git dışında kalıyor — içlerinde dahili path'ler var.

Sayıların devirdiği inançlar

İnançÖlçümKarar
dense + BM25'i her zaman birleştirMRR 0.604 vs yönlendirmeli 0.690query biçimine göre yönlendir
cross-encoder reranker işe yararMRR 0.690 → 0.514, +2–4 snflag, default kapalı
reranker 'cevap yok' kapısı olabilir%29 yanlış alarmyerine üç cosine bandı
klasör → rol etiketleri embedding'leri akıllandırır11 kaçağın 0'ını düzelttiyapılmadı
daha çok dil = büyük kazançAST vs pencereler: 1 soru, MRR +0.09genel kural, dil başına sıfır kod
Türkçe daha iyi bir eşleştirici istereşleştirme değil, metin eksikti: 0.04 → 0.684 → 0.932çok dilli embedder + isteğe bağlı enrichment

Tablonun yarısı emin olduğumuz şeyler. Tablonun var olma sebebi de bu.

#evals#golden-set#ledger
snippet

Tuzaklar

Her biri bir öğleden sonraya mal olan ve artık bir config'de ya da bir test'te tek satır olan şeyler.

Bunların hiçbiri bir benchmark'ta görünmüyor. Hepsi production'da gece 2'de görünüyor.