Putting Context into the Prompt: Citations, Context Budget and "I Don't Know" on Low Scores
Packing chunks as short id-tagged blocks within a token budget, getting the answer and source ids via structured output and validating the ids in code makes citations trustworthy; but a valid citation is not a correct answer. Because Turkish similarity scores overlap, a pre-model "I don't know" threshold only catches the obvious cases.
Version note: The code in this post was built and run with .NET 10, ASP.NET Core 10, Microsoft.Extensions.AI 10.11.0, Microsoft.ML.Tokenizers 2.0.1, OllamaSharp 5.5.2 and PostgreSQL 17 + pgvector 0.8.7; the models are gemma3:4b and nomic-embed-text on Ollama.
Guess First
By the end of this post we'll have an /ask endpoint: it returns the answer, the source list and a link for each source as JSON (JavaScript Object Notation). The model can only cite the source ids we put in the prompt, and code catches made-up ids. A school principal asks: "Okulumuz için 120 adet kitap alırsak ne kadar indirim alırız?" (if our school buys 120 books, what discount do we get?). The corporate sales contract is clear: 15% for 50–199 copies, 22% for 200 and more. The endpoint finds the right source and the citation is valid. Is the answer right?
$ 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
}
Right source, valid citation, wrong answer. The model read 22% from the right chunk but applied it to the wrong tier; 120 is less than 200. This post covers how we put chunks into the prompt, how we ask for and validate citations, and how hard it is to say "I don't know" without calling the model at all. And the output above shows from the start what none of that guarantees.
Packing Chunks as Id-Tagged Blocks
In post 14 of the LLM (Large Language Model) Fundamentals series we simply concatenated chunks as plain text and asked the model to stick to the context. In production every chunk needs an identity: the model must be able to say which block it read from, and we must be able to map that id to the real document and a link. The packer does four things:
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. Deduplicate: the same chunk may have arrived twice from different queries
List<Candidate> unique = ranked.DistinctBy(c => c.Id).ToList();
// 2. Budget: take as many as fit, starting from the most relevant
List<Candidate> selected = [];
int used = 0;
foreach (Candidate c in unique)
{
int cost = tokenizer.CountTokens(c.Content) + 20; // allowance for tags and metadata
if (selected.Count == maxChunks || used + cost > tokenBudget) break;
selected.Add(c);
used += cost;
}
// 3. Order: most relevant first, second most relevant last ("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-tagged blocks: the model only sees S1, S2...; we map them to the real chunk id
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('-');
}
The ids the model sees are deliberately short: S1, S2. Small models mangle long ids like kurumsal-satis-sozlesmesi#0 when copying them; we translate the short key back to the real id. A real pack:
$ 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>
The ordering rule works: the chunk that was second in retrieval went to the very end. But what should really catch your eye is S4: "Tedarikçiye İade Süreci" (the supplier return process) is a document only staff should see, and it made it into the context of a customer question. This post's pipeline has no authorization filter yet; that is exactly the subject of the next post.
Token Budget: Whose Tokenizer?
Whose tokens do you count the budget in? The model's own tokenizer isn't always reachable from .NET. The series' shared decision is to use the o200k_base encoding of Microsoft.ML.Tokenizers as a proxy. I measured how well the proxy holds by sending the same prompt to gemma3:4b and comparing with the real input token count Ollama reported:
$ 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
On these Turkish texts the difference is 1–2% and always in the same direction (gemma3 counts 5–7 tokens more each time; part of that is probably the special tokens the chat template adds). This holds for these two tokenizers and this text; as we saw in the LLM Fundamentals post on tokens, Turkish can be far more expensive on models with small vocabularies. Measure once with your own model and add the difference to the budget as a safety margin. The budget isn't set by the model's context window either: gemma3:4b accepts 128K tokens, but we cap at 1,200, because every extra chunk is both a cost and a candidate for distracting the model.
Measuring the "Lost in the Middle" Rule
Liu et al.'s 2023 "Lost in the Middle" study showed that, in long contexts, models use information at the beginning and the end better and miss what is in the middle; the results traced a U-shaped curve. The packer's ordering rule comes from there. To see whether it matters at our scale, I asked gemma3:4b 10 questions with the right chunk placed 1st, 5th and 10th in a 10-chunk context (checking whether the expected number appears in the answer):
$ 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
No effect: 8/10 in all three positions. The two missed questions (q09 and q18) were missed the same way in every position; in q18 the model gave the 12-month inactivity rule instead of the 24-month validity. The likely reason is scale: a context of roughly 1,000 tokens and 10 chunks is very short next to Liu et al.'s 20–30-document experiments. That doesn't make the rule wrong; it means you shouldn't rely on it at your scale without measuring. I apply it because it's cheap, but I expect the real gain from keeping the chunk count low.
Answer and Source List with Structured Output
We ask for the answer and the sources in separate fields, so we don't have to parse citations out of free text. GetResponseAsync<T>, which we saw in the LLM Fundamentals post on structured output, does the job here:
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();
// Threshold check BEFORE the model: if the best vector similarity is low, don't call the model at all
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 validation: the model may only cite the ids we gave it
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);
}
Two rules live in code, not in the model: invalid ids land in InvalidSourceIds and never reach the user as links, and an answer the model calls "answerable" but backs with no valid source is treated as unanswerable. A six-question run (threshold off, 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.
The three questions not covered by the documents were correctly rejected, and no made-up id appeared in this run. But there are three distinct failure classes. The first is formatting: gemma3:4b copied half a <source id= tag to the end of the return answer; the tag syntax in the context leaks into the answer. You need to clean the answer text before showing it, or reject the answer when you see that pattern. The second is confusing "no" with "I don't know": the answer to the gift card question is in the document ("a gift card can't be converted to cash"); the model cited the right source yet said answerable=false. It took a negative answer for "no answer". The third is the 22% from the opening: citation validation proves the id is valid, not that the claim is written in the source. Only the groundedness check we'll see in the measurement post can catch that.
Saying "I Don't Know" Before Calling the Model
The cheapest "I don't know" is the one said without calling the model at all: if the best vector similarity is below a threshold, return straight away. We left this as an idea in the LLM Fundamentals post on RAG (Retrieval-Augmented Generation); now let's try to pick the threshold from data. The top cosine similarity for 30 answerable questions and 8 questions the documents don't cover:
$ 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ı?
| Threshold | Answerable questions wrongly rejected | Unanswerable questions correctly rejected |
|---|---|---|
| 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 |
The two distributions overlap almost completely: answerable questions sit between 0.642 and 0.850, unanswerable ones between 0.643 and 0.736. "Kitap kulübünüz var mı?" (do you have a book club?, 0.736) scores higher than half of the real questions, because it resembles the book descriptions in the product catalog. We saw at the start of the series that nomic-embed-text squeezes Turkish scores into a narrow band. With this data a threshold can only catch the obvious cases: 0.65 rejects two unanswerable questions without the model and sacrifices one real question (q04). I used 0.65 in the endpoint; that's why the "do you have a café?" request returned in 24 ms with zero model tokens. The real "I don't know" decision stays with the model, through the answerable field in the structured output and the citation rule. A reranker's score may be a better signal, but as we saw in the previous post it needs calibration too.
Production Notes
Return citations to the user as links, but build the link from your own SourceRef table, not from the model's text. The model should never write a URL (Uniform Resource Locator); it should only pick a key such as S1. That cuts off made-up links and, in one go, the malicious links that arrive via the prompt injection we discuss in the next post.
With streaming, where citations go directly affects the UI (User Interface). You can't parse structured output until it's complete; if you leave the sources for the end, the user reads the answer without seeing any source. A practical approach: as soon as retrieval finishes, send the pack's source list as the first event, stream the answer text, send the validated citation list as the last event, and dim the unused sources in the UI. The mechanics of streaming with SSE (Server-Sent Events) are covered in the upcoming AI (Artificial Intelligence) with .NET series.
When you log each answer, log the pack too: which chunks, in what order, how many tokens. That's where the answer to "did the model misread, or did the wrong chunk arrive?" lives. For the 22% error above, the log shows the right chunk was given as S1 and the mistake was the model's.
Common Pitfalls
Trusting made-up citation ids. In this run gemma3:4b never produced an invalid id, but that's what the validation code is for: if the model says S7 and the pack only has four sources, that citation must never become a link.
Mistaking a valid citation for a correct answer. The 22% in the opening is the example. Citation validation is an existence check, not a correctness check.
Drowning the model in chunks. Putting 20 chunks in because the context window is large raises token cost and invites confusion between similar policies. The value of the previous post's reranker is that it moves the right chunk into the top three and lets you lower the chunk count.
Copying a raw similarity threshold from somewhere else. Every embedding model, language and corpus has a different score distribution; 0.65 came out of our table and may be meaningless in yours.
A Small Experiment
★☆☆ Add the line "Never copy the source tags into the answer" to Instructions and ask the return question again. Does the half <source tag disappear? If not, write a final step that cleans the answer text with a regex.
★★☆ Add an enum called Verdict to GroundedAnswer (Yes, No, NotInSources) and derive Answerable from it. Is the gift card question now counted as answerable?
★★☆ Turn off the packer's ordering rule (leave retrieval order) and repeat the position experiment with 20-chunk and 40-chunk contexts. At what length does a position effect start to show?
★★★ Build the threshold on the previous post's LLM reranker top score instead of vector similarity and redo the table on the same 38 questions. Does a simple rule combining both signals (e.g. reject when both are low) separate them better?
The context we packed also contained "Tedarikçiye İade Süreci", meant for staff only, ready to become a source for a customer question. In the next post we close that hole: putting ACL (Access Control List) and tenant information into the retrieval query's filter, seeing how "fetch first, hide in the prompt" leaks, and building defences against instructions hidden inside documents, i.e. indirect prompt injection.