“Bilmiyorum” Diyebilen Yapay Zekâ
Refusal ve citation sözleşmesini prompt’a değil, .NET 10 koduna yazmak.
Bir önceki yazımda bir cümle kurdum: "Modelin verdiği her cevabın arkasında ya doğrulanabilir bir kaynak vardır ya da açık bir refusal vardır. Üçüncü bir seçenek yoktur."
Sonra özelden bazı yorumlar geldi. Bir kısmı "katılıyorum"du. Bir kısmı da şuna benziyordu: "Tamam ama bunu nereye yazıyoruz peki? Sistem prompt'una mı?"
İşte bu yazı serisinin çıkış noktası tam olarak burası: sözleşme, sistem prompt'unda yazan şey değildir. Çünkü model için sistem prompt'u çoğu zaman bağlayıcı bir mekanizma değil, takip edilmesi beklenen bir talimattır. Sözleşme dediğimiz şey, ihlal edildiğinde sistemi durdurabilen kuraldır. Bunu kodda zorunlu hâle getirmiyorsak aslında bir sözleşme değil, sadece bir iyi niyet beyanı yazmış oluruz.
Bu yazıda, önceki bölümde bahsettiğim gerçek kodun içine giriyoruz. Hangi sözleşme nerede duruyor, neden orada duruyor ve ihlal edildiğinde sistem ne yapıyor; bunları kod üzerinden anlatacağım.
Azure tarafı bir sonraki yazının konusu; bu yazıda özellikle Azure'a girmeyeceğim. Çünkü iyi bir sözleşme Azure'a, AWS'e ya da herhangi bir bulut sağlayıcısına bağlı olmadan da anlamlı olmalıdır.
Bölüm 1Domain sözleşmeyi tanımlar, Application uygular
İlk soru şu: bu sözleşme hangi katmana ait?
İlk cevap genelde Application olur. Çoğu .NET geliştiricisi refusal mantığını handler'ın içine yazar ve akışı oradan yönetir. Bu yaklaşım tamamen yanlış değildir ama önemli bir şeyi kaçırır.
Application katmanı sözleşmenin sahibi değildir. Onun görevi sözleşmeyi uygulamak ve sonucu orkestre etmektir. Sözleşmenin kendisi Domain'de durmalıdır. Çünkü "hangi durumda cevap verilir, hangi durumda reddedilir, hangi durumda sistem durur?" soruları teknik akıştan önce iş kuralıdır.
Pratik karşılığı şu:
public sealed record AssistantAnswer
{
public required string AnswerText { get; init; }
public required IReadOnlyList<Citation> Citations { get; init; }
public required ConfidenceLevel ConfidenceLevel { get; init; }
public required RiskClass RiskClass { get; init; }
public required bool EscalationRequired { get; init; }
public required bool Refused { get; init; }
public string? RefusalReason { get; init; }
public static AssistantAnswer Grounded(
string answerText,
IReadOnlyList<Citation> citations,
ConfidenceLevel confidenceLevel,
RiskAssessment riskAssessment)
{
ArgumentNullException.ThrowIfNull(citations);
if (citations.Count is 0)
{
throw new UngroundedAnswerException();
}
return new AssistantAnswer { /* ... */ };
}
public static AssistantAnswer RefusedAnswer(string reason, RiskAssessment riskAssessment) =>
new()
{
AnswerText = reason,
Citations = [],
ConfidenceLevel = ConfidenceLevel.Low,
RiskClass = riskAssessment.RiskClass,
EscalationRequired = riskAssessment.EscalationRequired,
Refused = true,
RefusalReason = reason
};
public void EnsureCitationInvariant()
{
if (!Refused && Citations.Count is 0)
{
throw new UngroundedAnswerException();
}
}
}Kodda özellikle dikkat edilmesi gereken üç nokta var.
Birincisi, AssistantAnswer iki yerden üretiliyor: Grounded(...) ve RefusedAnswer(...). Burada şunu istemiyorum: kodun herhangi bir yerinde biri gelsin, new AssistantAnswer { ... } yazsın, citation boş kalsın ve bu cevap API'den dışarı çıksın. Çünkü o noktada sözleşme bozulmuş olur.
O yüzden nesneyi üretmenin normal yolu factory metotları. Grounded(...) kullanıyorsanız citation vermek zorundasınız; boş citation listesiyle geçmeye çalışırsanız exception fırlatılır. RefusedAnswer(...) tarafında ise citation beklemiyorum; çünkü refusal zaten kaynaklı bir cevap değil, güvenli bir yönlendirme cevabı.
Yani sistemde iki geçerli durum var: ya kaynaklara dayalı, citation içeren bir cevap üretirsiniz ya da bilinçli olarak refusal dönersiniz. Benim burada kapatmak istediğim üçüncü yol şu: cevap var ama citation yok. Canlı sistemlerde en tehlikeli durumlardan biri bence bu. Sistem cevap veriyor gibi görünür ama cevabın arkasında doğrulanabilir bir kaynak yoktur.
İkincisi, EnsureCitationInvariant() metodu. Domain invariant dediğimiz şeyi burada çok büyütmeye gerek yok; aslında tek cümlelik bir kuraldan bahsediyoruz: refused olmayan bir cevabın en az bir citation'ı olmalı.
Bu kontrolü sadece handler'ın iyi niyetine bırakmak istemiyorum. Çünkü handler değişir, mapping değişir, response modeli değişir. Bir refactor sırasında citation yanlışlıkla boş kalabilir. O yüzden Application katmanı, orchestrator'ın sonunda bu metodu çağırıyor. Cevap refusal değilse ve citation yoksa, sistem response dönmeden önce patlıyor.
Bence sözleşme dediğimiz şey tam olarak bu: "lütfen citation ekleyelim" değil; citation yoksa cevap dışarı çıkmasın. Tam bir kesinlik hâli.
Üçüncüsü: refusal'ı Result<T>.Failure yapmadım. Bu, küçük görünen ama sistem davranışını ciddi etkileyen bir karar. Çünkü refusal bir hata değil.
Kullanıcı "ilaç dozu nedir?" diye soruyor ve sistem yeterli kaynak bulamadığı için "Bunu yanıtlayamam, sizi klinik ekibe yönlendiriyorum" diyorsa, sistem bozulmuş olmuyor. Tam tersine, yapması gereken şeyi yapıyor.
Bunu Failure olarak modellersem, API tarafı bunu teknik bir hata gibi ele almaya başlar: 500 döner, frontend kırmızı bir hata gösterir, kullanıcı da sistem çöktü sanır. Ama ortada çöken bir şey yok; sistem güvenli davranıyor. Bu yüzden refusal da başarılı bir AssistantAnswer. API 200 döner. Frontend bunu hata gibi değil, kontrollü bir yönlendirme mesajı gibi gösterir. Kullanıcı da teknik bir hata görmek yerine doğru aksiyona yönlendirilir.
Bunun bedeli var mı? Var. Test yazarken biraz daha fazla nesne kuruyorsunuz. Bazen AssistantAnswer üretmek için RiskAssessment gibi bağlı modelleri de hazırlamanız gerekiyor; yani geliştiriciye küçük bir yük biniyor (çok şükür, artık bunları LLM'lere yazdırabiliyoruz).
Bu bedeli ödemeye razı olmalıyız. Çünkü karşılığında şunu kazanıyoruz: sistemde citation'sız ama cevap veriyormuş gibi görünen bir AssistantAnswer üretmek neredeyse imkânsız hâle geliyor (hiçbir şey %100 kesin değildir; en fazla %99,9'dur). Domain'in koruduğu sınır da tam olarak bu.
Bölüm 2Orchestrator: kısa görünen akışın gerçek hayattaki karşılığı
İlk yazıda orchestrator'ı özellikle sade göstermiştim: risk sınıflandır → ara → üret → audit. Kâğıt üzerinde sekiz satırlık bir akış gibi duruyor. Ama gerçek koda baktığınızda bu yapı yaklaşık 120 satıra çıkıyor.
Bu bir şişkinlik mi? Bence değil. Çünkü buradaki her ek satır, sistemin bir yerinde "burası böyle çalışmalı" dediğimiz sözleşmenin karşılığı. Kod uzamış gibi görünse de her blok bir sınır çiziyor: kimlik nereden alınır, risk ne zaman hesaplanır, model ne zaman çağrılır, hangi durumda cevap reddedilir, hangi karar audit'e yazılır.
public async ValueTask<Result<AssistantAnswer>> HandleAsync(
AssistantQuery request,
CancellationToken ct)
{
ArgumentNullException.ThrowIfNull(request);
// Kimlik request body'sinden değil, IUserContextProvider'dan gelir.
// Body'de userId/roles olsa bile retrieval filtresi buradan beslenir.
request = request with
{
UserId = userContextProvider.UserId,
Roles = userContextProvider.Roles,
Location = userContextProvider.Location
};
var validation = await queryValidator.ValidateAsync(request, ct).ConfigureAwait(false);
if (!validation.IsValid)
{
throw new InvalidAssistantQueryException(validation.ToString("; "));
}
var startTimestamp = Stopwatch.GetTimestamp();
var mode = agentOptions.Value.Mode;
metrics.RecordProviderMode(mode);
var risk = await riskClassifier.ClassifyAsync(request, ct).ConfigureAwait(false);
metrics.RecordRiskClass(risk.RiskClass, mode);
var chunks = await knowledgeSearchService.SearchAsync(request, risk, ct).ConfigureAwait(false);
metrics.RecordRetrievalCount(chunks.Count, mode);
// REFUSAL #1 — Kaynak yoksa modele hiç gitme.
if (chunks.Count is 0)
{
var refused = AssistantAnswer.RefusedAnswer(NoSourceRefusalReason, risk);
await WriteAuditAsync(request, refused, chunks.Count, startTimestamp, ct);
return Result<AssistantAnswer>.Success(refused);
}
var template = await promptProvider.GetAsync(AnswerTemplateId, ct);
var messages = BuildMessages(template, request, chunks);
var chatResponse = await chatClient.GetResponseAsync(messages, cancellationToken: ct);
// REFUSAL #2 — Model JSON şemasını bozduysa cevabı reddet.
if (!ChatResponseParser.TryParse(chatResponse.Text, out var envelope) || envelope is null)
{
var refused = AssistantAnswer.RefusedAnswer(MalformedResponseReason, risk);
await WriteAuditAsync(request, refused, chunks.Count, startTimestamp, ct);
return Result<AssistantAnswer>.Success(refused);
}
// REFUSAL #3 — Model kendisi "yapamam" diyorsa bunu structured olarak ilet.
if (envelope.Refused)
{
var refused = AssistantAnswer.RefusedAnswer(envelope.RefusalReason ?? envelope.AnswerText, risk);
await WriteAuditAsync(request, refused, chunks.Count, startTimestamp, ct);
return Result<AssistantAnswer>.Success(refused);
}
// REFUSAL #4 — Model uydurma citation ID döndüyse cevabı reddet.
var citationValidation = CitationValidator.Validate(envelope.Citations, chunks);
if (citationValidation.Outcome is not CitationValidationOutcome.Valid)
{
var refused = AssistantAnswer.RefusedAnswer(InvalidCitationReason, risk);
await WriteAuditAsync(request, refused, chunks.Count, startTimestamp, ct);
return Result<AssistantAnswer>.Success(refused);
}
var chunkLookup = chunks.ToDictionary(chunk => chunk.ChunkId, StringComparer.Ordinal);
var citations = envelope.Citations
.Select(id => chunkLookup[id].ToCitation())
.ToArray();
var answer = AssistantAnswer.Grounded(
envelope.AnswerText,
citations,
MapConfidenceLevel(envelope.Confidence),
risk);
answer.EnsureCitationInvariant();
await WriteAuditAsync(request, answer, chunks.Count, startTimestamp, ct);
return Result<AssistantAnswer>.Success(answer);
}Şimdi bu kodu biraz irdeleyelim.
Sıralama tesadüf değil. IRiskClassifier, retrieval'dan önce çalışıyor; çünkü IKnowledgeSearchService.SearchAsync(query, risk, ct) metodu risk bilgisini parametre olarak alıyor. Bu şu anlama geliyor: soru düşük riskliyse daha az chunk getirebilirsiniz. Ama soru yüksek riskliyse daha fazla kaynak aramanız, daha sıkı filtrelemeniz ve cevabı daha kontrollü üretmeniz gerekir.
Yani risk → retrieval ilişkisi teknik bir detay değil, doğrudan iş kuralı. Bu yüzden orchestrator'ın içinde açıkça görünmesini istiyorum. Başka bir yere sakladığınızda, sistemin en kritik kararlarından biri okunamaz hâle geliyor.
Bir diğer önemli satır şu:
request = request with
{
UserId = userContextProvider.UserId,
Roles = userContextProvider.Roles,
Location = userContextProvider.Location
};Bu satır küçük görünüyor ama güvenlik açısından çok kritik. Handler, request body'den gelmiş olabilecek userId, roles veya location değerlerine güvenmiyor; bunları IUserContextProvider üzerinden yeniden yazıyor. Yani kullanıcı API'ye şuna benzer bir body gönderse bile:
{
"question": "...",
"roles": ["supervisor"]
}retrieval tarafı bu role bilgisini kullanmıyor. Sistem sadece authentication context'inden gelen role bilgisini dikkate alıyor.
"Body'de bu alanların gelmesine zaten izin vermiyoruz" diyebilirsiniz. Doğru: DTO tarafında JsonUnmappedMemberHandling.Disallow var ve böyle bir request 400 ile dönüyor. Ama buraya ikinci bir kalkan daha ekledik. JsonUnmappedMemberHandling.Disallow tam bir JSON schema validator değildir; sadece beklenmeyen alanları reddeder. Required alanlar, nullability, enum eşlemesi ve response parse davranışı ayrıca test edilmelidir.
Ben bunu defense-in-depth olarak görüyorum. İlk katman request modelinde, ikinci katman application akışında. Kimlikten türeyen veri body'den değil, sistemin güvenilir context'inden gelmeli.
Kodda dört ayrı refusal noktası var:
- Kaynak yoksa modele hiç gitmiyoruz. Modelin elinde kaynak yoksa vereceği cevap ya genel bilgi olacak ya da tahmin. Bu sistemde ikisi de istemediğimiz şeyler. O yüzden chunk bulunamadığında doğrudan
no_source_refusalüretiyoruz. - Model JSON formatını bozarsa cevabı yine reddediyoruz. Modelin metin üretmiş olması yeterli değil; ondan belirli bir şemaya uyan structured bir response bekliyoruz. Şema bozulduysa response güvenilir değildir.
- Model kendisi "bu soruyu yanıtlayamam" diyorsa bunu hata gibi değil, structured refusal olarak taşıyoruz. Modelin refusal kararını serbest metin içinde kaybetmek yerine sistemin anlayacağı bir
RefusedAnswernesnesine çeviriyoruz. - Model citation uydurduysa cevabı reddediyoruz. Modelin döndürdüğü citation ID'leri gerçekten retrieval'dan gelen chunk'ların içinde yoksa, bu cevap dışarı çıkmamalı.
Bunların her biri ayrı bir RefusalReason ile audit'e yazılıyor. Bence bu çok önemli. Çünkü üretimde sadece "halüsinasyon oranımız şu kadar" demek pek bir şey ifade etmiyor. Onun yerine şunu görebilmek çok daha değerli:
Dört refusal, dört ayrı aksiyon
| Refusal | Sebep kodu | Ne oldu | Model çağrıldı mı? | Artıyorsa şuna bak… |
|---|---|---|---|---|
| #1 | no_source_refusal | Bilgi tabanında kaynak yok. | Hayır | Bilgi tabanın eksik olabilir. |
| #2 | malformed_response | Model beklenen JSON şemasına uymadı. | Evet | Prompt template’e ya da response şemasına. |
| #3 | model_self_refusal | Model cevap vermemeyi seçti. | Evet | Risk politikasını ya da prompt davranışını gözden geçir. |
| #4 | invalid_citation | Model geçersiz bir citation döndürdü. | Evet | Model kaynak id’lerini doğru kullanmıyor olabilir. |
Bu ayrım aksiyonu değiştirir. Aynı "cevap dönmedi" durumu aslında dört farklı sebepten kaynaklanabilir. Bunları aynı hata kovasına atarsanız neyi düzelteceğinizi de bilemezsiniz. (Bu biraz da system design tarafına kayıyor; çok daha derin bir konu olduğu için bu kısma dalmıyorum.)
Bir diğer bilinçli tercih audit tarafında: audit her durumda yazılıyor. Sadece başarılı cevaplarda değil, refusal durumlarında da. Çünkü reddetmek de sistem açısından bir karardır; hatta bazı sektörlerde en çok açıklanması gereken karar bu olabilir.
Altı ay sonra biri çıkıp "Bu kullanıcıya neden cevap vermediniz?" diye sorduğunda sistemin şunu söyleyebilmesi gerekir: şu correlation ID ile gelen istekte şu risk sınıfı oluştu, retrieval şu kadar chunk döndürdü, model şu çıktıyı verdi ve sistem şu refusal sebebiyle cevabı reddetti. Bunu söyleyemiyorsanız refusal sadece ekranda görünen güvenli bir mesaj olarak kalır; denetlenebilir bir karar olmaz.
Kodda audit yazımı birkaç yerde tekrar ediyor; dikkatinizi çekmiştir. Bu küçük bir DRY ihlali mi? Evet. Bunu bir helper'a çıkarmak mümkün mü? Evet, denedim. Ama bu örnekte helper'a çıkardığımda orchestrator'ın okunabilirliği düştü. Her refusal yolunda ne olduğunu açıkça görmek benim için daha değerli. O yüzden burada küçük tekrarı bilinçli olarak kabul ediyorum. Biraz yoğurt yeme stilim diyebiliriz.
Bir de ConfigureAwait(false) tercihi var. Application katmanında her await sonrasında kullanıyorum; çünkü bu katman bir anlamda kütüphane gibi davranmalı ve hangi host tarafından çağrıldığını varsaymamalı. API katmanında ise bunu özellikle yazmıyorum; ASP.NET Core'un varsayılan davranışı bu ihtiyacı büyük ölçüde karşılıyor. Ama Application katmanı host katmanından bağımsız kalmalı.
Sizi çok fazla koda ve .NET'e boğmadan, konuyu kısa bir diyagramla özetleyeyim:
Orchestrator: sorudan cevaba, dört refusal kapısından geçerek
- 01Soru gelir
- 02Kimlik UserContext’ten basılıruserId, roller, lokasyon. Asla request body’den değil.
- 03Validation geçti mi?400 · InvalidAssistantQueryException
- 04Risk sınıflandırmaRisk, aramaya parametre olarak gider
- 05Bilgi arama
- 06Chunk var mı?#1 · no_source_refusal
- 07IChatClient çağrısı
- 08JSON parse başarılı mı?#2 · malformed_response
- 09Model kendisi reddetti mi?#3 · model_self_refusal
- 10Citation’lar chunk’larda var mı?#4 · invalid_citation
- 11Grounded answerEnsureCitationInvariant()
- 12AuditHer yolda yazılır
- 13200 OK · AssistantAnswer
{
"refused": false,
"citations": ["CHK-001", "CHK-004"],
"confidenceLevel": "High",
"riskClass": "Low"
}Bütün kontroller geçti; EnsureCitationInvariant() son sınırı bir kez daha kontrol etti.
{
"title": "Geçersiz asistan sorgusu",
"status": 400
}Bu bir asistan cevabı bile değil. Akış risk, arama ve modelden önce durur.
no_source_refusal{
"refused": true,
"refusalReason": "no_source_refusal",
"citations": [],
"riskClass": "Medium"
}Chunk yok, model çağrısı da yok. Refusal #1 tek bir token harcanmadan devreye girer.
malformed_response{
"refused": true,
"refusalReason": "malformed_response",
"citations": [],
"riskClass": "Medium"
}Model bonus_info diye bir alan ekledi. Tanımsız alanlara izin yok; cevap parse edilmiş sayılmaz.
model_self_refusal{
"refused": true,
"refusalReason": "model_self_refusal",
"citations": [],
"riskClass": "Medium"
}Model cevap veremeyeceğini söyledi. Bu karar serbest metinde kaybolmuyor, yapılandırılmış veri olarak taşınıyor.
invalid_citation{
"refused": true,
"refusalReason": "invalid_citation",
"citations": [],
"riskClass": "Medium"
}Model CHK-009 döndürdü. Retrieval yalnızca CHK-001…CHK-004 getirmişti.
Her karar noktası, sistemin başka bir güvenlik sınırını temsil ediyor:
- Validation geçmezse bu artık bir asistan cevabı değildir; doğrudan 400 dönen hatalı bir request'tir.
- Kaynak yoksa modele gitmeyiz;
no_source_refusaldöneriz. - Model beklenen JSON şemasına uymazsa
malformed_responsedöneriz. - Model kendisi cevap veremeyeceğini söylüyorsa bunu
model_self_refusalolarak structured şekilde taşırız. - Model citation uydurursa
invalid_citationile cevabı durdururuz.
Ancak bütün bu kontroller geçilirse grounded bir cevap üretiriz ve en sonda EnsureCitationInvariant() ile son sınırı tekrar kontrol ederiz. Hangi yoldan gelmiş olursak olalım, en sonda audit yazılır ve API 200 OK ile bir AssistantAnswer döner.
Burada özellikle şunu vurgulamak istiyorum: refusal, sistemin hata verdiği anlamına gelmiyor. Refusal da başarılı bir asistan sonucudur, çünkü sistem beklenen güvenlik davranışını göstermiştir. Hata olan şey; kaynak yokken cevap üretmek, citation uydurmak veya parse edilemeyen model çıktısını kullanıcıya göstermektir.
Bölüm 3Modelin sözü yetmez: citation sistem tarafından doğrulanmalı
Bir önceki yazıda şunu söylemiştim: citation'ı modelin dil becerisine bırakmamak gerekiyor. Citation, modelin "ben bunu şu kaynaktan aldım" demesiyle değil, sistemin bunu doğrulamasıyla geçerli sayılmalı.
Bu cümlenin .NET tarafındaki karşılığı aslında küçük bir sınıf: CitationValidator. Ama o sınıfa gelmeden önce bu kararın nedenini anlatmam lazım.
Bir önceki yazıdaki ilk denemede mock chat client şöyle çalışıyordu: model retrieve edilen chunk listesini alıyor, cevap metnine [1], [2] gibi işaretler koyuyordu. Ben de cevabı parse edip bu işaretleri listedeki index'lerle eşleştiriyordum. Test yazdım, geçti. İlk bakışta gayet mantıklı görünüyordu.
Sonra biraz ters bir senaryo denedim. Retrieve edilen chunk listesini iki elemana düşürdüm, ama mock'a üç chunk varmış gibi davranmasını söyledim. Model cevapta [3] yazdı. Benim parse kodum da doğal olarak chunks[2]'yi okumaya çalıştı ve IndexOutOfRangeException patladı.
Hata mesajını görünce asıl problemi fark ettim: modelin metin içinde yazdığı citation'a güvenmek, bir liste index'ine körlemesine güvenmek kadar kırılgan bir şey. Çünkü model gerçekten hangi kaynağa dayandığını bilmek zorunda değil; sadece doğru formatta konuşuyor gibi görünebilir. Üç chunk gönderirsiniz, dördüncüye atıf yapar. Ya da eğitim verisinde benzerini gördüğü için [Document_47] gibi hiç var olmayan bir kaynak ID'si uydurur.
O noktada text-marker yaklaşımını bıraktım ve structured bir citation alanına geçtim. Artık modelden cevap metninin içine [1], [2] gibi işaretler sıkıştırmasını beklemiyorum. Modelden ayrı bir citations listesi istiyorum. Sonra bu listedeki ID'lerin gerçekten retrieval'dan gelen chunk'lar içinde olup olmadığını sistem kontrol ediyor. Böylece kontrolsüz bir IndexOutOfRangeException yerine kontrollü bir sonuç elde ediyorum:
public static CitationValidationResult Validate(
IReadOnlyList<string> citations,
IReadOnlyList<RetrievedChunk> retrievedChunks)
{
if (citations.Count is 0)
{
return new CitationValidationResult(CitationValidationOutcome.Empty);
}
var whitelist = retrievedChunks
.Select(chunk => chunk.ChunkId)
.ToHashSet(StringComparer.Ordinal);
var unknown = citations
.Where(id => !whitelist.Contains(id))
.ToArray();
return unknown.Length > 0
? new CitationValidationResult(CitationValidationOutcome.Unknown, unknown)
: new CitationValidationResult(CitationValidationOutcome.Valid);
}Orchestrator da bunu gördüğünde cevabı dışarı çıkarmıyor, refusal'a düşürüyor.
Bu hikâye, bu makale için yazdığım PoC kodunda başıma geldi; ama bence RAG sistemi yazan herkesin bir noktada karşılaşacağı bir problem. Modelin citation üretmesiyle, o citation'ın gerçekten cevabı desteklemesi aynı şey değil. Wallat ve arkadaşlarının Correctness is not Faithfulness in RAG Attributions çalışması, doğru görünen bir citation'ın modelin gerçekten o belgeye dayandığı anlamına gelmediğini ve bazı durumlarda citation faithfulness eksiklerinin %57'ye kadar çıkabildiğini gösteriyor. Buchmann ve Gurevych ise citation failure problemini ayrıca tanımlayıp bu davranışı azaltmaya yönelik yöntemler öneriyor. Ortada citation var; ama citation gerçekten cevabın kaynağı mı, orası tartışmalı.
Bu arada modelden beklediğim şey düz metin değil, belirli bir JSON zarfı:
internal sealed record AssistantAnswerEnvelope
{
public required string AnswerText { get; init; }
public required IReadOnlyList<string> Citations { get; init; }
public string? Confidence { get; init; }
public bool Refused { get; init; }
public string? RefusalReason { get; init; }
}Bu zarfa fazladan bir property gelirse parser bunu kabul etmiyor. Mesela model cevaba bonus_info diye ekstra bir alan ekledi. İlk bakışta masum görünebilir, ama benim açımdan bu iki ihtimalden birini gösterir: ya prompt beklediğim gibi çalışmadı ya da biri modeli başka bir formata yönlendirmeye çalıştı. İkisi de güvenli cevap üretmek için sıkıntılı. Bu yüzden JsonSerializerOptions.UnmappedMemberHandling = Disallow kullanıyorum. Model sadece bizim beklediğimiz alanları dönebilir; fazlası gelirse response parse edilmiş sayılmaz ve orchestrator bunu malformed_response refusal yoluna taşır.
CitationValidator'ın yaptığı şey aslında çok basit: modelin döndürdüğü envelope.Citations listesi, retrieval'dan gelen retrievedChunks.Select(c => c.ChunkId) listesinin içinde olmak zorunda. Bir tane bile yabancı ID varsa sonuç Unknown. Orchestrator da bunu gördüğünde cevabı kullanıcıya göstermiyor. Çünkü modelin verdiği cevap doğru görünebilir, dili düzgün olabilir, hatta kendinden emin olabilir. Ama citation ID'si sistemin gerçekten getirdiği chunk'lar arasında yoksa, o cevap benim için geçerli değildir.
Burada küçük ama önemli bir detay var: StringComparer.Ordinal. Chunk ID karşılaştırmasında kültüre duyarlı bir karşılaştırma kullanmak istemiyorum. CHK-001 ile chk-001 aynı şey değil. Bu ID'leri kullanıcı üretmiyor, sistem üretiyor; dolayısıyla büyük/küçük harf duyarlılığı burada bir tercih değil, zorunluluk.
Text-marker vs. structured citation
| Senaryo | Text-marker yaklaşımı | Structured citation |
|---|---|---|
| 1Model [5] yazar ama sadece 3 chunk vardır | IndexOutOfRangeException ya da boş citationchunks[4] → IndexOutOfRangeException | Unknown sonucu → refusal[5] whitelist’te yok |
| 2Model [1] yazar ama cevap aslında chunk 1 ile desteklenmez | Görünürde kanıt var sanılır | Bu da yakalanmaz. Semantic kontrol ayrı bir problem. |
| 3Model [DOC_NEW] gibi bir id uydurur | Marker pattern eşleşmeyebilir; cevap citation’sız dışarı çıkabilir | Whitelist’te yok → refusal |
| 4Model citation alanını boş döndürür | Düz metin cevap güvenilir sanılabilir | Empty sonucu → refusalcitations: [] |
| 5Model JSON yerine düz metin döndürür | Parse mantığı bozulabilirJsonSerializer.Deserialize → exception | TryParse false → malformed_response refusalTryParse(…) → false |
Bu tabloda özellikle ikinci satırı saklamıyorum: CitationValidator semantik doğruluğu çözmüyor. Yani "bu chunk gerçekten bu iddiayı destekliyor mu?" sorusunun cevabını vermiyor. Onun yaptığı şey daha temel ama çok kritik: modelin verdiği citation ID'leri gerçekten sistemin getirdiği chunk'lar içinde var mı, bunu kontrol ediyor.
Bu iki problemi birbirine karıştırmamak gerekiyor. Birincisi yapısal doğrulama: model var olmayan bir kaynağa atıf yapmasın. İkincisi semantik doğrulama: var olan kaynak gerçekten cevabı destekliyor mu? Bu yazıda anlattığım kısım birincisi.
Bir önceki yazıda önce sistemin sınırlarını kurmaya odaklanmıştım: hangi cevap dışarı çıkabilir, hangisi durdurulmalı, hangi durumda refusal dönülmeli? Semantik kalite, faithfulness skoru ve ayrı bir grounding değerlendirmesi bunun üzerine kurulacak ikinci katman. (Bu kısım biraz yapay zekânın derinlikleri; ileride motivasyonumu kaybetmezsem değiniriz.)
Özetle benim için sıralama şu: önce sistemin yapısı doğru olacak, sonra cevabın kalitesi daha derin metriklerle ölçülecek. Çünkü yapı yanlışsa kalite metriği de sizi kurtarmaz. Model var olmayan bir kaynağa atıf yapabiliyorsa, cevabın ne kadar akıcı olduğu artık ikinci planda kalır.
Bölüm 4Audit, kime ne sorulduğunu değil, sistemin ne karar verdiğini kaydeder
Yine biraz genel mimari tarafına gireceğiz ama önemli.
Audit log deyince akla bazen şu geliyor: "Her şeyi kaydedelim, lazım olursa sonra bakarız." Bence bu çok tehlikeli bir yaklaşım. Çünkü audit log sınırsız bir veri çöplüğü değildir. Özellikle kişisel veri içerebilecek sistemlerde, "belki lazım olur" diye ham kullanıcı sorusunu yıllarca saklamak doğru bir karar değil. Altı ay sonra biri çıkıp "Bu kullanıcının ham sorusunu neden hâlâ tutuyorsunuz?" diye sorduğunda, sadece teknik olarak değil, hukuki ve operasyonel olarak da cevap verebilmek gerekir.
Bizim bu soruya cevabımız şu: tutmuyoruz. Kod tarafında bunun karşılığı iki küçük metot:
public static string ComputeQuestionHash(string question)
{
ArgumentNullException.ThrowIfNull(question);
var bytes = SHA256.HashData(Encoding.UTF8.GetBytes(question));
return Convert.ToHexString(bytes);
}
public static string BuildQuestionPreview(string question)
{
ArgumentNullException.ThrowIfNull(question);
var redacted = SensitiveNumberRedactor.Redact(question);
return redacted.Length <= 80
? redacted
: string.Concat(redacted.AsSpan(0, 80), "…");
}Birinci metot sorunun SHA-256 hash'ini üretiyor. Sonuç 64 karakterlik, deterministik ve geri çevrilemeyen bir değer. Deterministik olması önemli: aynı soru tekrar gelirse aynı hash oluşur. Böylece audit tarafında "aynı soru kaç kez soruldu?" gibi bir soruya cevap verebilirsiniz; ama hash'ten geriye dönüp kullanıcının gerçek sorusunu okuyamazsınız.
İkinci metot sorunun kısa bir önizlemesini üretiyor. Ama bunu yapmadan önce SensitiveNumberRedactor devreye giriyor: TC kimlik numarası, kart numarası veya benzeri hassas örüntüler maskeleniyor. Sonra metin 80 karaktere indiriliyor. Yani audit kaydında elimizde iki şey oluyor:
QuestionHash→ aynı soruyu takip edebilmek için,QuestionPreview→ sorunun kabaca neyle ilgili olduğunu anlamak için.
Ham soru ise audit'e yazılmıyor. Bu ayrım benim için kritik; çünkü audit uzun süre saklanabilir, örneğin 7 yıl. Ama ham kullanıcı sorusunu içeren request log'ları çok daha kısa süre tutulmalıdır, örneğin 24 saat veya 30 gün.
Log vs. audit
Uzun süreli karar kaydı
- QuestionHash · SHA-256
- QuestionPreview · maskeli, 80 karakter
- RiskClass · RefusalReason · RetrievalCount
- Ham soru asla
Kısa süreli debugging desteği
- Ham soru, request scope içinde
- ör. Application Insights
- Saklama süresi dolunca silinir
Yazıdaki örnek saklama süreleri, aynı ölçekte
Bu ikisini karıştırırsanız audit sistemi zamanla bir kişisel veri arşivine dönüşür. O noktada da audit'in güvenlik faydası, veri saklama riskine dönüşmeye başlar.
Bir diğer önemli karar da audit yazımının nasıl ele alındığı. Kodda IAuditEventSink.WriteAsync çağrısı try/catch içinde çalışıyor:
try
{
await auditEventSink.WriteAsync(auditEvent, ct).ConfigureAwait(false);
}
catch (OperationCanceledException)
{
throw;
}
catch (Exception ex)
{
logger.LogWarning(ex, "Audit write failed for correlation {CorrelationId}; continuing.",
auditEvent.CorrelationId);
metrics.RecordAuditWriteFailed(mode);
}Buradaki soru şu: audit yazılamazsa kullanıcıya cevap dönmeli miyiz? Mesela SQL veya Elastic geçici olarak çökmüş. Sistem cevabı üretmiş, refusal ya da grounded cevap kararını vermiş, ama audit event yazılamamış.
Bu durumda iki yaklaşım var. İlki audit-or-fail: audit yazılamıyorsa cevap da dönme; sistem kararını kayda alamıyorsa kullanıcıya 503 dön. Bu yaklaşım, özellikle regülasyonun çok ağır olduğu sistemlerde mantıklı olabilir; çünkü denetlenemeyen bir kararın dışarı çıkmasını istemezsiniz. İkincisi best-effort: audit yazılamasa bile kullanıcıya cevap dön, ama bu durumu sessiz geçme. Log'a warning yaz, metriği artır, observability tarafında alarm üret.
Audit'i bu şekilde tutmanın bir bedeli var: debugging zorlaşır. Çünkü audit'te ham soru yok. Bir kullanıcı "bot bana saçma bir cevap verdi" dediğinde audit tablosuna gidip kullanıcının tam sorusunu okuyamazsınız; sadece hash'i, maskelenmiş önizlemeyi, risk sınıfını, refusal sebebini, chunk sayısını ve correlation ID'yi görürsünüz. Bu bilinçli bir tercih.
Bunun çözümü audit'i kirletmek değil, kısa süreli log sistemini doğru tasarlamak. Ham soru gerekiyorsa request scope içinde kısa retention'lı bir log'a yazılabilir; mesela Application Insights tarafında 24 saat veya 30 gün saklanır, süre dolunca silinir. Audit ise uzun süre kalır ama ham soru taşımaz. Benim burada çizmeye çalıştığım çizgi şu:
Audit, kullanıcının tam olarak ne yazdığını saklamak için değil; sistemin hangi kararı neden verdiğini kanıtlamak için vardır.
Audit kaydı ne anlatır?
Dokuz alan, tek bir soru için sistemin verdiği kararın tüm izini oluşturur. Bir adımın üzerine gelin ya da dokunun.
Hangi alan hangi adımdan gelir
Audit kaydı · örnek
- CorrelationId0HN4Q7K2B1M9RBu isteğe ait tüm log ve servis kayıtlarını birleştirir.
- QuestionHash9F2C51…A7E1Sorunun SHA-256 hash’i. Aynı soru aynı hash’i üretir; tekrarları sayabilirsin.
- QuestionPreview“TC’m ***********, randevum ne…”Maskelenmiş, 80 karaktere kısaltılmış. Konuya dair kaba bir fikir.
- RiskClassMediumSistemin hangi risk politikasıyla hareket ettiği.
- RetrievalCount0Retrieval’ın kaç chunk bulduğu.
- RefusalReasonno_source_refusalCevap reddedildiyse, neden reddedildiği.
- ProviderModeDevCloudÇağrının arkasında hangi sağlayıcının olduğu.
- Latency412 msToplam yanıt süresi; performans ve SLA takibi için.
- Timestamp2026-05-18T09:41:07ZKaydın ne zaman yazıldığı (UTC).
- Model çağrıldı mı?RefusalReason = no_source ise hiç çağrılmadı.
- Neden reddedildi?RefusalReason bunu açıkça söyler.
- Audit yazımı başarılı mı?Değilse: metrics.RecordAuditWriteFailed() ve bir uyarı logu.
Bence güvenli yapay zekâ sistemlerinde audit'in görevi tam olarak bu olmalıdır: kullanıcıyı izlemek değil, sistemin kararını denetlenebilir hâle getirmek.
Bölüm 5Bir sözleşmenin gerçekten sözleşme olduğu nasıl anlaşılır?
Bence cevabı basit: ihlali test edilebiliyorsa sözleşmedir. Test edilemiyorsa, sadece bir niyet beyanıdır.
"Citation zorunlu olsun", "kaynak yoksa cevap dönmeyelim", "model citation uydurursa reddedelim" gibi cümleler tek başına mimari karar sayılmaz. Bunların gerçekten sistem davranışına dönüştüğünü ancak testlerle kanıtlayabilirsiniz.
Repoda şu an 70'in üzerinde test var. Hepsini buraya koymayacağım (seri ilerledikçe projeyi GitHub'da public yapacağım), ama bu yazının ana tezini doğrudan doğrulayan yedi testi özellikle göstermek istiyorum:
Yedi test, yazıdaki yedi cümle
HandleAsync_NoRetrievedChunks_ReturnsRefusedAnswer→ Kaynak yoksa cevap yok.Handler_ModelReturnsUnknownCitation_ReturnsRefusal→ Model hayali bir chunk ID uydursa bile sistem reddeder.Handler_DoesNotUseTextMarkerOnly_AsGroundingProof→ Cevap metnindeki [1] grounding kanıtı sayılmaz. Sadece structured citation dikkate alınır.Handler_PromptInjectionAttemptInUserMessage_DoesNotOverrideSystemPrompt→ Kullanıcı “önceki kuralları unut” dese bile sistem prompt’u korunur.AssistantAnswer_GroundedFactory_WithEmptyCitations_ThrowsUngroundedAnswerException→ Domain invariant compile-time’da değil, runtime’da garanti altına alınır.Audit_Question_StoredAsHashAndPreview_NotRaw→ Ham soru audit’e yazılmaz. Hash ve maskelenmiş preview yazılır.Audit_SqlOutage_IncrementsAuditWriteFailedMetric→ Best-effort audit gerçekten çalışır: SQL kesintisinde sayaç artar, cevap yine döner.
Bu testlerin her biri aslında makaledeki bir cümleyi doğruluyor. İlk yazıda "refusal contract" ve "citation contract" derken kastettiğim şey tam olarak buydu. Bunlar sadece prompt'a yazılmış kurallar değil; kodda karşılıkları var. İhlal edildiklerinde sistemin ne yapacağı belli. Ve en önemlisi, bu davranış test edilebiliyor. Test geçmiyorsa sözleşme yok demektir.
Bölüm 6Sıradaki faz: Azure'a taşıma
Bu yazıda bilinçli olarak Azure tarafına girmedim. Çünkü bence iyi bir mimari şurada belli olur: context değiştiğinde sözleşmeler hâlâ çalışıyor mu?
Bir önceki yazıda sistemi mock tabanlı kurmuştum. IKnowledgeSearchService'in arkasında MockKnowledgeSearchService vardı ve bellekteki chunk'lar üzerinde basit bir keyword eşleştirmesi yapıyordu. Bu ilk bakışta oyuncak gibi görünebilir; ama amaç zaten Application ve Domain katmanlarındaki sözleşmeleri Azure'a, OpenAI'a ya da herhangi bir dış servise bağlanmadan test edebilmek.
Sonraki adımda bu interface'in arkasına AzureSearchKnowledgeService gelecek; yani bilgi arama tarafı Azure AI Search'e bağlanacak. Keyword search, vector search ve semantic ranker gibi production'a daha yakın retrieval mekanizmaları devreye girecek. MockChatClient'ın yerine de Azure OpenAI tarafındaki chat client register edilecek. Hedef aynı: vendor değişsin, handler değişmesin.
Çünkü Microsoft.Extensions.AI.IChatClient zaten bu ayrımı yapabilmek için anlamlı bir abstraction sunuyor. Application katmanı "hangi provider?" sorusuyla ilgilenmemeli. Provider OpenAI olmuş, Azure OpenAI olmuş, ileride başka bir model olmuş; Domain ve Application katmanlarındaki sözleşmeler bundan etkilenmemeli.
Kapanış
İlk yazıda bir kavram ortaya atmıştım: sistem sözleşmesi. Bu yazıda ise o sözleşmenin gerçekte nereye yazıldığını göstermeye çalıştım. Application katmanındaki orchestrator'ın 120 satırına yazdık. Domain katmanındaki tek bir invariant metoduna yazdık. CitationValidator içindeki 15 satırlık kontrole ve son olarak dört ayrı refusal noktasına yazdık.
Ama hepsinin ortak noktası aynı:
Sözleşme dediğiniz şey, ihlal edildiğinde sistemi durdurabilmeli. Bunu yapamıyorsa artık sözleşme değildir; en fazla iyi niyetli bir dilektir.
Air Canada'nın chatbot yüzünden ödediği tazminat, Mata v. Avianca davasındaki yaptırım, Anthropic tarafındaki mahkeme özrü… Bu örneklerin hepsinde ortak problem aslında çok benzerdi: model emin olmadığı yerde duramadı, bilmediğini söyleyemedi; ya da sistem, modelin ürettiği cevabı yeterince sorgulamadı.
Güvenli cevap akışı: model konuşur, sistem karar verir
- Kullanıcı sorusu alınırkimlik, bağlam, lokasyon
- Validasyonteknik ve kural olarak uygun mu?
- Risk sınıflandırmadüşük / orta / yüksek
- Bilgi aramadoğru kaynaklarda arama
no_source_refusal- Model çağrısısistem prompt’u + soru + chunk’lar → JSON envelope
- JSON parse başarılı mı?
- Refusal #2
malformed_response - Model “veremem” dedi mi?
- Refusal #3
model_self_refusal - Citation’ların hepsi retrieval’dan gelen chunk’larda mı?
- Refusal #4
invalid_citation
- Grounded (kanıtlı)
- Geçerli citation’larla
- Confidence bilgisiyle
- Audit kaydı yazılır
Sözleşme geçmezse, gerekçesiyle birlikte refusal döner.
- Sınır koy Doğru soruyu modele gönder.
- Kontrol et Modelin cevabını sözleşmelere göre denetle.
- Cevap ver Sözleşmeler geçerse dışarı çıkar.
Kanıtlar ve kaynaklar
Yasal vakalar ve olaylar
- Moffatt v. Air Canada, 2024 BCCRT 149
- Mata v. Avianca, Inc., 678 F. Supp. 3d 443 (S.D.N.Y. 2023). Yaptırım kararı: 22 Haziran 2023.
- Concord Music Group v. Anthropic PBC (N.D. Cal., Mayıs 2025). TechCrunch
Akademik çalışmalar
- Buchmann, Gurevych. Citation Failure: Definition, Analysis and Efficient Mitigation (arXiv 2510.20303, Ekim 2025)
- Wallat ve arkadaşları. Correctness is not Faithfulness in RAG Attributions (citation post-rationalization bulgusu, %57'ye varan oran)
- Stanford RegLab, Magesh ve ekibi. Hallucination-Free? Assessing the Reliability of Leading AI Legal Research Tools (Mayıs 2024)
.NET ve Microsoft.Extensions.AI
- .NET 10 duyurusu (11 Kasım 2025)
- Microsoft.Extensions.AI IChatClient API
- Stephen Toub — GitHub Discussion #5498
- JsonUnmappedMemberHandling referansı
Bu makaledeki fikirler, mimari yaklaşım ve teknik değerlendirmeler bana aittir. Görsel üretimi, yazım düzenleme, kodlama ve formatlama süreçlerinde yapay zekâ destekli araçlardan yararlanılmıştır.