Faithfulness checking
Score whether an LLM response is grounded in the context you injected, and gate downstream actions on the score.
- A Recall client.
- An LLM that produces responses you want to verify (any provider — OpenAI, Anthropic, Google, local).
- Familiarity with Building context.
A guarded RAG loop that builds context from Recall, asks an LLM, scores faithfulness, and either commits to the answer or regenerates with stricter instructions.
Why a separate API for faithfulness
A faithful answer is one that doesn't fabricate beyond the context it was given. A hallucinated answer might be plausible-sounding, internally consistent, even useful — but it's not grounded in your data, and acting on it can be dangerous.
Faithfulness checking takes three inputs (the user's query, the context block that was injected, the LLM's answer) and returns a score and a verdict. The check itself is an LLM call — Recall keeps it isolated from the read pipeline so you can apply it selectively (it costs more than a cache hit on search).
The endpoint lives at POST /v1/search/check-faithfulness. The TypeScript SDK exposes this as alice.checkFaithfulness(...). See Step 2 below for both SDK and transport forms.
Step-by-step
Standard RAG-style flow. Hold on to the promptSection so you can pass it back into the faithfulness check.
const ctx = await recall.context({
query: userQuery,
recipe: 'decision_support',
maxTokens: 2000,
});
const answer = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{ role: 'system', content: 'Answer using the context. If unsupported, say so.\n\n' + ctx.promptSection },
{ role: 'user', content: userQuery },
],
});
const llmText = answer.choices[0]!.message.content!;ctx = recall.context.build(
query=user_query, recipe="decision_support", max_tokens=2000,
)
resp = openai.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system",
"content": "Answer using the context. If unsupported, say so.\n\n" + ctx["promptSection"]},
{"role": "user", "content": user_query},
],
)
llm_text = resp.choices[0].message.contentPass the query, the context (the same Markdown block you injected), and the LLM's response. The endpoint returns a score in [0.0, 1.0] and a verdict.
import { createClient, RecallConflictError } from '@arc-labs/recall';
const recall = createClient({ url: process.env.RECALL_URL, apiKey: process.env.RECALL_KEY });
const alice = recall.forUser(req.user.id);
// After your LLM generates a response using context memories:
let report;
try {
report = await alice.checkFaithfulness({
response: llmOutput,
contextMemoryIds: ctx.memoryIds,
config: { onHighRisk: 'warn' }, // or 'block' to throw RecallConflictError on low scores
});
} catch (e) {
if (e instanceof RecallConflictError && e.code === 'FAITHFULNESS_BLOCKED') {
// Score too low, don't show the response
return safeFallbackResponse();
}
throw e;
}
if (report.risk === 'high') {
console.warn('Low faithfulness score:', report.score, report.claims);
}# Python SDK: coming soon as a typed helper.
# For now, call the endpoint directly via the transport:
result = recall._transport.request(
"POST",
"/v1/search/check-faithfulness",
body={
"response": llm_output,
"contextMemoryIds": ctx["memoryIds"],
"config": {"onHighRisk": "warn"},
},
)
print(result["score"], result["verdict"], result.get("unsupportedClaims"))curl -X POST $RECALL_URL/v1/search/check-faithfulness \
-H "Authorization: Bearer $RECALL_API_KEY" \
-H 'Content-Type: application/json' \
-d "$(jq -n --arg q "$Q" --arg c "$CTX" --arg r "$ANS" \
'{query:$q, context:$c, response:$r}')"The response shape:
scorenumberoptionalverdict'faithful' | 'partial' | 'unfaithful'optionalunsupportedClaimsstring[]optionaltraceIdstringoptionallatencyMsnumberoptionalThree policies depending on what's at stake:
- High-stakes action (sending a payment, posting publicly) → require
verdict === 'faithful'(score ≥ 0.85). - Medium-stakes (drafting an email for human review) → accept
partial, surfaceunsupportedClaimsto the reviewer. - Low-stakes (chitchat reply) → skip the check entirely; the cost isn't worth it.
if (check.verdict === 'unfaithful') {
log.warn({ trace: check.traceId, claims: check.unsupportedClaims }, 'unfaithful response — regenerating');
return regenerateWithStricterPrompt(userQuery, ctx);
}
if (check.verdict === 'partial') {
// Either accept and flag, or regenerate. For human-in-the-loop, accept.
return { text: llmText, flagged: check.unsupportedClaims };
}
return { text: llmText, flagged: [] };When a check fails, regenerate with an explicit instruction to stay within the context. Often a single retry resolves it.
async function regenerateWithStricterPrompt(query: string, ctx: BuildContextResult) {
const stricter = await openai.chat.completions.create({
model: 'gpt-4o',
messages: [
{
role: 'system',
content: [
'Answer ONLY using facts from the context.',
'If the context does not contain the answer, say "I don\'t have that information."',
'Do not infer, do not extrapolate.',
'',
ctx.promptSection,
].join('\n'),
},
{ role: 'user', content: query },
],
temperature: 0.0,
});
return stricter.choices[0]!.message.content!;
}If the regeneration also fails the check, abort and surface the unsupported claims to the user — the model genuinely can't answer with what's in memory.
End-to-end faithfulness-guarded loop
from recall import Recall
import openai, os
recall = Recall(url=os.environ["RECALL_URL"], api_key=os.environ["RECALL_API_KEY"])
def answer(query: str, max_attempts: int = 2):
ctx = recall.context.build(query=query, recipe="decision_support", max_tokens=2000)
for attempt in range(max_attempts):
system = (
"Answer using the context. Be conservative."
if attempt == 0
else "Answer ONLY using context facts. Refuse if unsupported."
)
resp = openai.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": system + "\n\n" + ctx["promptSection"]},
{"role": "user", "content": query},
],
)
text = resp.choices[0].message.content
check = recall._transport.request("POST", "/v1/search/check-faithfulness", body={
"query": query, "context": ctx["promptSection"], "response": text,
})
if check["verdict"] in ("faithful", "partial"):
return {
"text": text,
"score": check["score"],
"trace_id": check["traceId"],
"unsupported_claims": check.get("unsupportedClaims", []),
}
# All attempts unfaithful — give up cleanly.
return {
"text": "I don't have enough information to answer reliably.",
"score": check["score"],
"trace_id": check["traceId"],
"unsupported_claims": check.get("unsupportedClaims", []),
}Cost and latency considerations
The check is a single LLM call — typically ~500 ms with a small model. You're trading that latency for safety; for high-stakes actions, it's almost always worth it.
To keep the cost manageable on a high-traffic agent:
- Skip the check on cached / repeat queries.
- Only run it when the read-pipeline result has
totalBeforePolicy <= 2(small evidence base — high hallucination risk). - Run it asynchronously when the action is reversible: emit the answer immediately, run the check in the background, and roll back / flag if it fails.
Log every faithfulness check's traceId next to your application's user-action ID. When you discover a wrong answer in the wild, the trace tells you which context the model saw, and the check verdict tells you whether the system caught it or let it through.
Was this page helpful?