The answer I refused to let the AI give

Sep 2, 2026 | ai-reliability-engineering

Reading Time: 6 minutes
The answer I refused to let the AI give

There is one line in this system that took longer to agree on than any other, and it is a number.

public double ConfidenceThreshold { get; set; } = 0.70;

That is from a client system my team at Aditi built this year. A staffing firm runs a structured joining call with every new employee. An AI takes the call, walks the new hire through the formalities, and answers their questions. A member of the HR team sits on the call the whole time, watching.

The number decides when the AI is allowed to speak.

Above 0.70, it answers the new hire directly. Below 0.70, it says nothing at all, and the question goes to the human on the call instead.

Most of the argument about this system was about that number. Almost none of it was about the model.

The question that has no good answer

Here is the situation the threshold exists for.

It is somebody’s first morning. They are nervous in the specific way people are nervous on a first day, and they ask a question about their notice period, or their probation terms, or when their insurance actually starts. The knowledge base has three documents that are adjacent to the answer and none that contain it.

There are exactly three things a system can do here.

It can guess. This is the default behaviour of every language model you have ever used, and it is catastrophic in this context, because the person asking has no way to evaluate the answer. They are new. They have no idea what is normal. They will write it down and act on it, and six weeks later somebody in HR will be explaining why the thing they were told on day one was wrong.

It can refuse everything it is not certain about. Safe, and useless. A system that answers four questions in twenty is a system nobody uses twice.

Or it can know the difference, and hand the hard ones to a person who is already sitting right there.

We built the third one. The interesting part is not that decision, which is obvious once stated. It is what the third one costs to implement honestly.

What I got wrong for about two years

Before this project, when a client asked me whether we could add AI answering to their product, I scoped it as a retrieval problem. Get the documents in, chunk them well, embed them, search them properly, and the answers follow.

I was wrong, and I was wrong in a way that is easy to miss because retrieval quality is measurable and improving it feels like progress. You can spend a month on chunking strategy and watch your relevance scores climb and ship a system that is still dangerous, because retrieval quality and answer confidence are different quantities and I had been treating them as one.

Good retrieval tells you that these five chunks are the most similar things in the corpus. It tells you nothing about whether the answer is in them. A question with no answer anywhere in the knowledge base still returns five confident-looking chunks, because something is always the nearest neighbour of something.

I now ask a different first question on these engagements. Not “what do you want it to answer”. It is “what happens when it does not know, and who finds out”.

That question changes the architecture. The first one does not.

The mechanism

The pipeline is four steps, and the ordering of the last two is the whole design.

The question is embedded, using a small embedding model. The vector store returns the top five chunks. Those chunks go to the model along with the new hire’s own profile, because “when does my probation end” is not answerable without knowing who is asking. The model returns two things, not one: an answer, and a confidence.

Then the branch:

if (confidence < _ragOptions.ConfidenceThreshold)
{
    await _notifier.FlagQuestionAsync(callIdStr, question, confidence, ct);
    return new AnswerQuestionResponse(callId, question, Answer: null, Confidence: confidence, Flagged: true);
}

Note what is returned on the low-confidence path. Answer: null. Not a hedged answer, not “I am not sure, but”. The answer is discarded before it reaches the caller, which means it can never reach the voice channel, which means the new hire never hears it.

That matters more than it looks. A system that says “I think it might be thirty days, but please confirm” has still put the number thirty in a nervous person’s head. They will remember thirty. They will not remember the hedge. Discarding the answer entirely is the only version of this that actually works, and it costs you the ability to show a partial result, which is a real cost and worth paying.

The flag goes out over SignalR to the HR dashboard, carrying the question and the confidence score. The person on the call sees it and answers in their own voice.

There is one more detail, and it is the one I would keep if I could keep only one. The question is pushed to the HR feed before the retrieval runs:

// Surface the question in the HR feed even before we know the answer
await _notifier.TranscriptLineAsync(callIdStr, "Joiner", question, ct);

The human sees the question at the same moment the machine does. Not after the pipeline decides it cannot cope. By the time the flag arrives, HR has already read the question and is often already forming the answer. The handoff feels like a colleague stepping in, rather than a system giving up.

That is a one-line change with no technical difficulty in it at all, and it is most of the difference between a handoff that feels smooth and one that feels like a failure.

Why 0.70

Because the correct threshold is a business decision wearing a technical costume, and it should be argued about by the people who carry the consequence.

Set it at 0.95 and the AI defers constantly. The human ends up running the call manually with an expensive avatar sitting on top, and within a fortnight somebody asks why they are paying for this.

Set it at 0.50 and the model answers nearly everything, including the things it should not, and you have built the guessing machine.

0.70 is where it landed for this client, on this content, with a person present on every call. That last clause is the one that moves the number most. The threshold is not a property of the model. It is a property of the model plus the knowledge base plus what happens on the far side of the handoff. A system with no human on the call would need a much higher threshold and a much better answer for what it does when it defers.

Which is why the number is configuration, not a constant:

// Bound from the Rag configuration section (Rag__ConfidenceThreshold etc.)

It is bound from configuration and can be changed per environment without a deployment. That was deliberate. I did not want the value of that number to be a decision only a developer could revisit.

What this generalises to

Very little of this is about onboarding, and none of it is about avatars.

Any time you put a language model in front of a live system where being wrong has a cost, you are making the same three decisions, whether or not you notice:

What is the threshold, and who owns it. If nobody owns it, it is 0.0 and your system guesses. That is the default and it is a choice you made by not making it.

What happens below the threshold. This is the part that gets skipped, and it is the part that determines whether the feature is trustworthy. Deferring to a human who is not there is the same as guessing, just slower.

Does the partial answer get discarded, or shown. Showing it is more comfortable to build and it undoes the entire mechanism.

I have started asking clients the second question before we talk about models at all. Where does the uncertain case go, and is anybody there to receive it. If there is no good answer, the threshold discussion is theatre and we should be scoping something else.

A system that knows when to stay quiet is worth more than one that always has something to say. That is true of software and it was true of colleagues long before any of this.