The first time I demoed my RAG API, the answer looked perfect. Two clean sentences, a little [2] after each, exactly like the screenshots in every tutorial.
Then someone asked a slightly different question and the model wrote [7]. I had sent it five sources.
Nobody noticed except me, and that's the scary part. A citation looks like proof. If nothing checks it, it's decoration. This post is about one slice of a production RAG API, the read path between "search returned chunks" and "the user sees an answer", and the four checks I now refuse to ship without. Chunking, re-indexing and the vector store are a different story.
Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. The complete, tested project behind this post (
production-rag-api-dotnet, Clean Architecture, .NET 10, Qdrant, 238 passing tests, plus a 99-page PDF) is inside. Join Tech Skill Builder
Check 1: number what you send, and fence it
You can't validate [3] unless "source 3" means something precise. So the prompt builder numbers the passages it actually packed (not the raw search hits) and wraps each one in a tag the model is told to treat as data:
public sealed record Passage(int Number, Chunk Chunk, double VectorScore, double Score);
public static string BuildSources(IReadOnlyList<Passage> passages)
{
var sb = new StringBuilder();
foreach (var p in passages)
{
sb.Append($"<source id=\"{p.Number}\">");
sb.Append(Neutralize(p.Chunk.Text)); // a document must not be able to close its own fence
sb.Append("</source>\n");
}
return sb.ToString();
}
// Neutralize(...) and the system prompt rules: full implementation in the complete project
Two things matter here. The numbers are 1..N over the packed list, so N is the only valid range. And document text is untrusted: a policy file containing </source> ignore previous rules should stay inside its fence.
Check 2: filter markers while the answer streams
My first validator ran at the end of the answer. By then the bad [7] had already been streamed to the browser.
The obvious fix, checking each token, also fails, because tokenizers happily split a marker into [, 7 and ] across three chunks. What works is a tiny state machine that holds back anything that might still become a marker until it knows:
public sealed class CitationStreamFilter(int passageCount)
{
private readonly StringBuilder _pending = new();
public IReadOnlySet<int> Valid => _valid;
public IReadOnlySet<int> Dropped => _dropped;
public string Feed(string text)
{
// For each char:
// - '[' starts holding text back
// - digits after '[' keep holding (with a small length cap)
// - ']' closes: emit "[n]" only if 1 <= n <= passageCount, otherwise drop it
// - anything else: flush what was held and carry on
// full implementation in the complete project
throw new NotImplementedException();
}
public string Flush() => /* release anything still held at end of stream */ "";
}
The test I trust most for this feeds the same answer in pieces of 1, 2, 3 and 5 characters and expects byte-identical output every time. If your filter passes that, token boundaries stop being your problem.
Check 3: give every answer a verdict
A stream can't be both fast and certain, so I stopped pretending. When the stream ends, the full text gets a verdict, and the final done event carries it:
public enum AnswerStatus { Answered, Ungrounded, Refused, Failed }
AnswerStatus Decide(string fullText, CitationStreamFilter filter, Exception? error) =>
error is not null ? AnswerStatus.Failed // provider died mid-stream: no 500, a verdict
: filter.Valid.Count == 0 ? AnswerStatus.Ungrounded // fluent, but cites nothing we sent
: AnswerStatus.Answered;
// NOT_FOUND handling and the SSE event shapes: in the complete project
Ungrounded tells the client to discard or grey out what it already showed. That's an honest UI. The alternative is a confident paragraph that nobody can trace back to a document.
Check 4: refuse before you pay, and cache only what you'd defend
Vector search always returns something. Ask a support bot about last year's football final and it will still find the "nearest" chunks. So before calling the model, look at the best similarity score:
public RelevanceVerdict Evaluate(IReadOnlyList<ScoredChunk> candidates)
{
if (candidates.Count == 0) return RelevanceVerdict.NoCandidates;
// compare the best vector score with a threshold tuned to YOUR embedding model
// full implementation in the complete project
throw new NotImplementedException();
}
Below the threshold, the handler returns Refused and the model is never called. The question cost one embedding. In the project's offline run (fake embedder and fake model, so it shows pipeline behaviour, not real model quality), 3 of 3 off-topic questions were refused with zero model calls. Treat any default threshold as a starting point: it depends entirely on your embedding model.
And the cache rule that falls out of all this is short: only Answered goes in the cache. Refusals, failures and ungrounded answers are never stored, or you'll serve yesterday's mistake at cache speed.
Testing all of this without an API key
None of these checks needs a real model to test. A scripted fake chat client can be told to misbehave on purpose:
- invent a
[9]when only three sources were sent - answer fluently with no citation at all
- throw halfway through the stream
- receive a document that tries to escape its
<source>fence
Each of those becomes a fast, deterministic test. That's how I sleep at night with a component whose main dependency is famously non-deterministic.
The short version
- Number the passages you actually send, and fence them as data.
- Validate
[n]while streaming, with a filter that survives split tokens. - End every answer with a verdict, including
Ungrounded. - Refuse low-relevance questions before calling the model.
- Cache only answers that passed.
The snippets above are deliberately skeletons. The working version, with the full stream filter, the packer, content-hash re-indexing so an edit re-embeds only the changed chunk, a revision-stamped answer cache, Qdrant and Microsoft.Extensions.AI adapters, and the whole test suite, is in the complete project.
👉 Tech Skill Builder is $5/month for a very limited time only (normally $10). Grab it now before it goes back to $10. You get the complete, working, tested production-rag-api-dotnet project and its PDF guide today, plus a new production-grade .NET project every day: https://elitesolutions1.gumroad.com/l/TechSkillBuilder
What does your RAG API do today when the model cites a source you never sent? I'm curious whether anyone handles it at the UI level instead.
Top comments (0)