Prompt'a Context Koymak: Citation, Context Bütçesi ve Düşük Skorda "Bilmiyorum"
Chunk'ları kısa id'li bloklar halinde, token bütçesine sığdırıp prompt'a koymak, cevabı ve kaynak id'lerini structured output ile almak ve id'leri kodla doğrulamak citation'ı güvenilir kılar; ama geçerli citation doğru cevap demek değil. Türkçe skorlar üst üste bindiği için modelden önce "bilmiyorum" eşiği yalnız bariz durumları yakalıyor.
Sürüm notu: Bu yazıdaki kodlar .NET 10, ASP.NET Core 10, Microsoft.Extensions.AI 10.11.0, Microsoft.ML.Tokenizers 2.0.1, OllamaSharp 5.5.2 ve PostgreSQL 17 + pgvector 0.8.7 ile derlenip denenmiştir; modeller Ollama'da gemma3:4b ve nomic-embed-text.
Önce Tahmin Et
Bu yazının sonunda bir /ask endpoint'imiz olacak: cevabı, kaynak listesini ve her kaynağın linkini JSON (JavaScript Object Notation) olarak dönecek. Model yalnız prompt'ta verdiğimiz kaynak id'lerine atıf yapabilecek, uydurma id'leri kod yakalayacak. Bir okul müdürü soruyor: "Okulumuz için 120 adet kitap alırsak ne kadar indirim alırız?" Kurumsal satış sözleşmesinde cevap net: 50–199 adet için %15, 200 ve üzeri için %22. Endpoint doğru kaynağı buluyor ve citation'ı geçerli. Sence cevap doğru mu?
$ curl -s -X POST localhost:19760/ask -H 'Content-Type: application/json' \
-d '{"question":"Okulumuz için 120 adet kitap alırsak ne kadar indirim alırız?"}' | python3 -m json.tool --no-ensure-ascii
{
"answerable": true,
"answer": "Okulunuz için 120 adet kitap alırsanız, liste fiyatı üzerinden %22 indirim alırsınız. Çünkü 200 adet ve üzeri siparişlerde indirim oranı %22'dir [S1].",
"citations": [
{
"id": "kurumsal-satis-sozlesmesi#0",
"docId": "kurumsal-satis-sozlesmesi",
"section": "1. Toplu alım indirimi",
"url": "/help/kurumsal-satis-sozlesmesi#1--toplu-alım-indirimi"
}
],
"invalidSourceIds": [],
"topScore": 0.7386473857550583,
"promptTokens": 553,
"elapsedMs": 5694
}
Kaynak doğru, citation geçerli, cevap yanlış. Model %22'yi doğru chunk'tan okudu ama yanlış eşiğe uyguladı; 120, 200'den küçük. Bu yazı, chunk'ları prompt'a nasıl koyduğumuzu, citation'ı nasıl istediğimizi ve doğruladığımızı, modele hiç gitmeden "bilmiyorum" demenin ne kadar zor olduğunu anlatıyor. Ve yukarıdaki çıktı, bütün bunların neyi garanti etmediğini en baştan gösteriyor.
Chunk'ları Id'li Bloklar Halinde Paketlemek
LLM (Large Language Model) Temelleri serisinin 14. yazısında chunk'ları düz metin olarak art arda ekleyip modelden context'e dayanmasını istemiştik. Production'da her chunk'ın bir kimliği olmalı: model hangi bloktan okuduğunu söyleyebilmeli, biz de o id'yi gerçek dokümana ve linke eşleyebilmeliyiz. Paketleyici dört iş yapıyor:
public sealed record SourceRef(string Key, string ChunkId, string DocId, string Section, string Url);
public sealed record PackedContext(string Text, IReadOnlyList<SourceRef> Sources, int Tokens);
public sealed class ContextPacker(Tokenizer tokenizer)
{
public PackedContext Pack(IReadOnlyList<Candidate> ranked, int tokenBudget, int maxChunks)
{
// 1. Duplicate temizliği: aynı chunk farklı sorgulardan iki kez gelmiş olabilir
List<Candidate> unique = ranked.DistinctBy(c => c.Id).ToList();
// 2. Bütçe: en alakalıdan başlayarak sığdığı kadar al
List<Candidate> selected = [];
int used = 0;
foreach (Candidate c in unique)
{
int cost = tokenizer.CountTokens(c.Content) + 20; // etiket ve metadata payı
if (selected.Count == maxChunks || used + cost > tokenBudget) break;
selected.Add(c);
used += cost;
}
// 3. Sıralama: en alakalı başa, ikinci en alakalı sona ("lost in the middle")
List<Candidate> ordered = [];
for (int i = 0; i < selected.Count; i++)
{
if (i % 2 == 0) ordered.Insert(ordered.Count - i / 2, selected[i]);
else ordered.Insert(i / 2 + 1, selected[i]);
}
// 4. Id'li bloklar: model yalnız S1, S2... görür, gerçek chunk id'sini biz eşleriz
List<SourceRef> sources = [];
List<string> blocks = [];
foreach (Candidate c in ordered)
{
string key = $"S{sources.Count + 1}";
string docId = c.Id[..c.Id.IndexOf('#')];
sources.Add(new SourceRef(key, c.Id, docId, c.Section, $"/help/{docId}#{Slug(c.Section)}"));
blocks.Add($"<source id=\"{key}\" section=\"{SecurityElement.Escape(c.Section)}\">\n{c.Content}\n</source>");
}
return new PackedContext(string.Join("\n", blocks), sources, used);
}
static string Slug(string s) => string.Concat(s.ToLowerInvariant()
.Select(ch => char.IsLetterOrDigit(ch) ? ch : '-')).Trim('-');
}
Modelin gördüğü id'ler bilerek kısa: S1, S2. kurumsal-satis-sozlesmesi#0 gibi uzun id'leri küçük modeller kopyalarken bozar; kısa anahtarı biz gerçek id'ye çeviriyoruz. Gerçek bir paket:
$ dotnet run -- pack
ranked: iade-politikasi#0, tedarikci-iade-sureci#1, iade-politikasi#3, kurumsal-satis-sozlesmesi#3
packed (442 tokens): S1=iade-politikasi#0, S2=iade-politikasi#3, S3=kurumsal-satis-sozlesmesi#3, S4=tedarikci-iade-sureci#1
<source id="S1" section="İade süresi ve koşullar">
İade ve Değişim Politikası > İade süresi ve koşullar
Satın aldığın basılı kitabı teslim tarihinden itibaren 21 gün içinde iade edebilirsin. ...
</source>
...
<source id="S4" section="İade penceresi">
Tedarikçiye İade Süreci > İade penceresi
Tedarikçilerle yapılan sözleşmeye göre hasarlı veya satılmayan kitaplar teslim alındıktan sonra 90 gün içinde tedarikçiye iade edilebilir. ...
</source>
Sıralama kuralı çalışıyor: retrieval'da ikinci olan chunk en sona gitti. Ama asıl dikkat çekmesi gereken S4: "Tedarikçiye İade Süreci" yalnız personelin görmesi gereken bir doküman ve müşteri sorusunun context'ine girdi. Bu yazının pipeline'ında henüz yetki filtresi yok; bir sonraki yazının konusu tam olarak bu.
Token Bütçesi: Hangi Tokenizer?
Bütçeyi kimin token'ıyla sayacaksın? Modelin kendi tokenizer'ı her zaman .NET'ten erişilebilir değil. Serinin ortak kararı Microsoft.ML.Tokenizers'ın o200k_base encoding'ini proxy olarak kullanmak. Proxy'nin ne kadar tuttuğunu, aynı prompt'u gemma3:4b'ye gönderip Ollama'nın raporladığı gerçek input token sayısıyla karşılaştırarak ölçtüm:
$ dotnet run -- budget
q01: o200k estimate 449 tokens, gemma3 actual 454 tokens, sources iade-politikasi#0,iade-politikasi#3,kurumsal-satis-sozlesmesi#3,tedarikci-iade-sureci#1
q09: o200k estimate 430 tokens, gemma3 actual 437 tokens, sources kargo-ve-teslimat#1,sss#4,kargo-ve-teslimat#5,kargo-ve-teslimat#2
q28: o200k estimate 491 tokens, gemma3 actual 497 tokens, sources kurumsal-satis-sozlesmesi#0,sadakat-programi#1,iade-politikasi#0,personel-indirimi#0
Bu Türkçe metinlerde fark yüzde 1–2 ve hep aynı yönde (gemma3 her seferinde 5–7 token daha fazla sayıyor; bunun bir kısmı muhtemelen chat template'inin eklediği özel token'lar). Bu sonuç bu iki tokenizer ve bu metin için geçerli; LLM Temelleri'nin token yazısında gördüğümüz gibi küçük vocabulary'li modellerde Türkçe çok daha pahalıya gelebilir. Kendi modelinle bir kez ölç, farkı güvenlik payı olarak bütçeye ekle. Bütçeyi belirleyen şey de modelin context window'u değil: gemma3:4b 128K token'ı kabul ediyor, ama biz 1.200 token'la sınırlıyoruz, çünkü her fazla chunk hem maliyet hem de modelin dikkatini dağıtacak bir aday.
"Lost in the Middle" Kuralını Ölçmek
Liu ve arkadaşlarının 2023 tarihli "Lost in the Middle" çalışması, uzun context'lerde modellerin bilgiyi başta ve sonda daha iyi kullandığını, ortadakini kaçırdığını gösterdi; sonuçlar U şeklinde bir eğri çiziyordu. Paketleyicideki sıralama kuralı buradan geliyor. Bu kuralın bizim ölçeğimizde işe yarayıp yaramadığını görmek için 10 soruyu, doğru chunk'ı 10 chunk'lık bir context'in 1., 5. ve 10. sırasına koyarak gemma3:4b'ye sordum (cevapta beklenen sayının geçmesine baktım):
$ dotnet run -- position
q01 gold@1 + tokens=1096 Basılı bir kitabı teslim tarihinden itibaren 21 gün içinde iade edebilirsin.
q01 gold@5 + tokens=1096 Basılı bir kitabı teslim tarihinden itibaren 21 gün içinde, hasarlı veya yıpranmamış olması koşuluyla iade edebilirsiniz.
q01 gold@10 + tokens=1096 Basılı bir kitabı teslim tarihinden itibaren 21 gün içinde iade edebilirsiniz.
...
q18 gold@1 - tokens=897 Hesabında 12 ay boyunca hiç alışveriş yapılmazsa tüm puanlar silinir.
...
correct: gold@1 8/10, gold@5 8/10, gold@10 8/10
Etki yok: üç konumda da 8/10. Kaçırılan iki soru (q09 ve q18) her konumda aynı şekilde kaçırıldı; q18'de model 24 aylık geçerliliği değil 12 aylık hareketsizlik kuralını söyledi. Bunun sebebi büyük ihtimalle ölçek: yaklaşık 1.000 token'lık, 10 chunk'lık bir context Liu ve arkadaşlarının 20–30 dokümanlık deneylerinin yanında çok kısa. Bu, kuralın yanlış olduğunu değil, senin ölçeğinde etkisini ölçmeden ona güvenmemen gerektiğini gösteriyor. Ucuz bir kural olduğu için uyguluyorum, ama asıl kazancı chunk sayısını düşük tutmaktan bekliyorum.
Structured Output ile Cevap ve Kaynak Listesi
Cevap ile kaynakları ayrı alanlarda istiyoruz; böylece citation'ı serbest metinden parse etmek zorunda kalmıyoruz. LLM Temelleri'nin structured output yazısında gördüğümüz GetResponseAsync<T> burada işimizi görüyor:
public sealed record GroundedAnswer(
[property: Description("False if the sources do not contain the answer")] bool Answerable,
[property: Description("Answer in Turkish, based only on the sources")] string Answer,
[property: Description("Ids of the sources used, e.g. S1")] string[] Sources);
public async Task<AskResult> AskAsync(string question, CancellationToken ct = default)
{
Stopwatch sw = Stopwatch.StartNew();
// Eşik kontrolü modelden ÖNCE: en iyi vector benzerliği düşükse modeli hiç çağırma
ReadOnlyMemory<float> q = await embedder.EmbedQueryAsync(question);
(_, double topScore) = await retriever.TopVectorAsync(q);
if (topScore < minScore)
{
return new AskResult(false, NoAnswer, [], [], topScore, 0, sw.ElapsedMilliseconds);
}
List<Candidate> candidates = await retriever.HybridAsync(question, 10);
PackedContext context = packer.Pack(candidates, tokenBudget: 1200, maxChunks: 4);
ChatResponse<GroundedAnswer> response = await chat.GetResponseAsync<GroundedAnswer>(
[
new ChatMessage(ChatRole.System, Instructions),
new ChatMessage(ChatRole.User, $"{context.Text}\n\nQuestion: {question}")
], new ChatOptions { Temperature = 0, Seed = 42 }, cancellationToken: ct);
GroundedAnswer answer = response.Result;
// Citation doğrulama: model yalnız verdiğimiz id'lere atıf yapabilir
Dictionary<string, SourceRef> byKey = context.Sources.ToDictionary(s => s.Key);
string[] invalid = answer.Sources.Where(s => !byKey.ContainsKey(s)).ToArray();
List<Citation> citations = answer.Sources.Where(byKey.ContainsKey).Distinct()
.Select(k => byKey[k]).Select(s => new Citation(s.ChunkId, s.DocId, s.Section, s.Url)).ToList();
bool answerable = answer.Answerable && citations.Count > 0;
return new AskResult(answerable, answerable ? answer.Answer : NoAnswer, citations, invalid,
topScore, (int)(response.Usage?.InputTokenCount ?? 0), sw.ElapsedMilliseconds);
}
İki kural kodda, modelde değil: geçersiz id'ler InvalidSourceIds'e düşer ve kullanıcıya link olarak gitmez; "cevaplanabilir" dediği halde hiç geçerli kaynak göstermeyen cevap da cevaplanamaz sayılır. Altı soruluk bir koşu (eşik kapalı, minScore = 0):
$ dotnet run -- batch ...
Q: Bir kitabı kaç gün içinde iade edebilirim?
answerable=True top=0,772 tokens=510 4336 ms invalid=[]
A: Basılı bir kitabı satın aldıktan sonra teslim tarihinden itibaren 21 gün içinde iade edebilirsiniz. <source id=
cites: iade-politikasi#0
Q: Hediye kartımı nakde çevirebilir miyim?
answerable=False top=0,766 tokens=513 4641 ms invalid=[]
A: Bu konuda dokümanlarımızda bir bilgi bulamadım.
cites: hediye-karti#2
Q: Okulumuz için 120 adet kitap alırsak ne kadar indirim alırız?
answerable=True top=0,739 tokens=553 5330 ms invalid=[]
A: Okulunuz için 120 adet kitap alırsanız, liste fiyatı üzerinden %22 indirim alırsınız. ...
cites: kurumsal-satis-sozlesmesi#0
Q: Mağazanızda kafe var mı?
answerable=False top=0,643 tokens=501 4196 ms invalid=[]
A: Bu konuda dokümanlarımızda bir bilgi bulamadım.
Q: Kitap kulübünüz var mı?
answerable=False top=0,736 tokens=458 3655 ms invalid=[]
A: Bu konuda dokümanlarımızda bir bilgi bulamadım.
Q: Hangi kargo firmasıyla çalışıyorsunuz?
answerable=False top=0,687 tokens=529 3305 ms invalid=[]
A: Bu konuda dokümanlarımızda bir bilgi bulamadım.
Üç dokümanda olmayan soru doğru şekilde reddedildi ve bu koşuda hiç uydurma id çıkmadı. Ama üç ayrı hata sınıfı var. Birincisi biçim: gemma3:4b iade cevabının sonuna yarım bir <source id= etiketi kopyaladı; context'teki etiket sözdizimi cevaba sızıyor. Cevap metnini kullanıcıya göstermeden önce temizlemen ya da bu kalıbı görünce cevabı reddetmen gerekiyor. İkincisi "hayır" ile "bilmiyorum"un karışması: hediye kartı sorusunun cevabı dokümanda var ("Hediye kartı nakde çevrilemez"); model doğru kaynağı gösterdiği halde answerable=false dedi. Olumsuz cevabı "cevap yok" sandı. Üçüncüsü açılıştaki %22: citation doğrulaması id'nin geçerli olduğunu kanıtlar, iddianın kaynakta yazdığını değil. Onu ancak bir sonraki ölçüm yazısında göreceğimiz groundedness kontrolü yakalayabilir.
Modele Gitmeden "Bilmiyorum" Demek
En ucuz "bilmiyorum" modeli hiç çağırmadan söylenendir: en iyi vector benzerliği bir eşiğin altındaysa direkt cevap dön. LLM Temelleri'nin RAG (Retrieval-Augmented Generation) yazısında bunu bir fikir olarak bırakmıştık; şimdi eşiği veriden seçmeye çalışalım. 30 cevaplanabilir soru ve dokümanda olmayan 8 soru için en yüksek cosine benzerliği:
$ dotnet run -- scores
answerable 0,642 q04 odeme-secenekleri#2
answerable 0,655 q11 urun-katalogu#12
answerable 0,656 q21 odeme-secenekleri#1
...
answerable 0,850 q23 sss#0
unanswerable 0,643 u01 sss#4 Mağazanızda kafe var mı?
unanswerable 0,644 u06 sss#4 Mağazanız pazar günü kaçta kapanıyor?
...
unanswerable 0,736 u07 urun-katalogu#12 Kitap kulübünüz var mı?
| Eşik | Yanlışlıkla reddedilen cevaplanabilir soru | Doğru reddedilen cevaplanamaz soru |
|---|---|---|
| 0,60 | 0/30 | 0/8 |
| 0,65 | 1/30 | 2/8 |
| 0,70 | 7/30 | 4/8 |
| 0,72 | 14/30 | 7/8 |
| 0,75 | 24/30 | 8/8 |
İki dağılım neredeyse tamamen üst üste biniyor: cevaplanabilir sorular 0,642–0,850, cevaplanamazlar 0,643–0,736 aralığında. "Kitap kulübünüz var mı?" (0,736) gerçek soruların yarısından daha yüksek skor alıyor, çünkü ürün kataloğundaki kitap anlatımlarına benziyor. nomic-embed-text'in Türkçede skorları dar bir banda sıkıştırdığını seriyi başında da görmüştük. Bu veriyle eşik yalnız bariz durumları yakalayabilir: 0,65 iki cevaplanamaz soruyu modelsiz reddediyor, bir gerçek soruyu (q04) feda ediyor. Ben endpoint'te 0,65 kullandım; "Mağazanızda kafe var mı?" isteği bu yüzden 24 ms'de, sıfır model token'ıyla döndü. Asıl "bilmiyorum" kararı ise structured output'taki answerable alanı ve citation kuralıyla modele kalıyor. Bir reranker'ın skoru daha iyi bir sinyal olabilir, ama önceki yazıda gördüğümüz gibi o da kalibrasyon ister.
Üretimde Dikkat
Citation'ları kullanıcıya link olarak dön, ama linki modelin metninden değil, kendi SourceRef tablondan üret. Model hiçbir zaman URL (Uniform Resource Locator) yazmamalı; yalnız S1 gibi bir anahtar seçmeli. Bu, hem uydurma linkleri hem de bir sonraki yazıda konuşacağımız prompt injection'la gelen kötü niyetli linkleri tek hamlede keser.
Streaming'de citation'ın yeri UI'ı (User Interface) doğrudan etkiler. Structured output tamamlanmadan JSON'u parse edemezsin; kaynakları sona bırakırsan kullanıcı cevabı okurken kaynak göremez. Pratik bir çözüm: retrieval bittiği anda paketteki kaynak listesini ilk event olarak gönder, cevap metnini akıt, sonunda doğrulanmış citation listesini son event olarak gönder ve UI'da kullanılmayan kaynakları soluklaştır. SSE (Server-Sent Events) ile streaming'in mekaniği ilerideki .NET ile Yapay Zeka serisinde.
Her cevabı loglarken paket içeriğini de logla: hangi chunk'lar, hangi sırayla, kaç token. Yanlış bir cevapta "model mi yanlış okudu, yoksa yanlış chunk mı geldi" sorusunun cevabı oradadır. Yukarıdaki %22 hatasında log, doğru chunk'ın S1 olarak verildiğini ve hatanın modelde olduğunu gösteriyor.
Sık Düşülen Tuzaklar
Uydurma citation id'sine güvenmek. Bu koşuda gemma3:4b hiç geçersiz id üretmedi, ama doğrulama kodu bunun için var: model S7 derse ve pakette yalnız dört kaynak varsa o atıf linke dönüşmemeli.
Geçerli citation'ı doğru cevap sanmak. Açılıştaki %22 bunun örneği. Citation doğrulaması bir varlık kontrolüdür, doğruluk kontrolü değil.
Modeli çok fazla chunk'la boğmak. Context window büyük diye 20 chunk koymak hem token maliyetini artırır hem de birbirine benzeyen politikalar arasında karışıklığa davet çıkarır. Önceki yazıdaki reranker'ın değeri, doğru chunk'ı ilk üçe taşıyıp chunk sayısını düşürmene izin vermesi.
Ham benzerlik eşiğini başka bir yerden kopyalamak. Her embedding modelinin, her dilin ve her corpus'un skor dağılımı farklıdır; 0,65 bizim tablomuzdan çıktı, senin tablonda anlamsız olabilir.
Küçük Bir Deneme
★☆☆ Instructions'a "Never copy the source tags into the answer" satırını ekle ve iade sorusunu tekrar sor. Yarım <source etiketi kayboluyor mu? Kaybolmuyorsa cevap metnini bir regex ile temizleyen bir son adım yaz.
★★☆ GroundedAnswer'a Verdict diye bir enum ekle (Yes, No, NotInSources) ve Answerable'ı buradan türet. Hediye kartı sorusu artık "cevaplanabilir" mi sayılıyor?
★★☆ Paketleyicinin sıralama kuralını kapatıp (retrieval sırasıyla bırak) position deneyini 20 chunk'lık ve 40 chunk'lık context'lerle tekrarla. Hangi uzunlukta konum etkisi görünmeye başlıyor?
★★★ Eşiği vector benzerliği yerine önceki yazıdaki LLM reranker'ın en yüksek skoruyla kur ve aynı 38 soruda tabloyu yeniden çıkar. İki sinyali birleştiren basit bir kural (ör. ikisi birden düşükse reddet) daha iyi ayırıyor mu?
Paketlediğimiz context'te personelin görmesi gereken "Tedarikçiye İade Süreci" de vardı ve müşteri sorusuna kaynak olarak girmeye hazırdı. Bir sonraki yazıda bunu kapatıyoruz: ACL (Access Control List) ve tenant bilgisini retrieval sorgusunun filtresine koymak, "önce getir, sonra prompt'ta gizle" yaklaşımının nasıl sızdırdığını görmek ve dokümanların içine gizlenmiş talimatlara, yani indirect prompt injection'a karşı savunma kurmak.