Brainby arc-labs/docs
Guide

Faithfulness checking

Score whether an LLM response is grounded in the context you injected, and gate downstream actions on the score.

Prerequisites
  • A Recall client.
  • An LLM that produces responses you want to verify (any provider — OpenAI, Anthropic, Google, local).
  • Familiarity with Building context.
What you'll build

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.content

Pass 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:

ParameterTypeRequired
scorenumberoptional
verdict'faithful' | 'partial' | 'unfaithful'optional
unsupportedClaimsstring[]optional
traceIdstringoptional
latencyMsnumberoptional

Three 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, surface unsupportedClaims to 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?

On this page