Skip to main content
The retrieval step of a RAG pipeline ranks passages by how much their wording resembles the query, and hands the top few to a language model. These may include noisy or irrelevant passages, or worse yet, may lump together contradicting facts, prompt injections, or model instructions together with what is nominally evidence to assist with generating an answer. This cookbook adds classifying questions as a second stage between retrieval and generation. For each retrieved passage, send TypeSafe one request carrying multiple questions about the query–passage pair: is it relevant, does it state something usable in an answer, does it contradict something the query takes for granted, and is it trying to instruct the model. The answers to those questions decide what happens to each passage, with simple branching logic: add it to the prompt as evidence, add it to the prompt as conflicting information, or drop it. Evidence and conflicts arrive in separate blocks, so the generator can react appropriately. To exercise the pipeline, we run it over some tricky questions against real auth documentation full of pages that read alike, and a planted passage carrying a prompt injection. Two questions contain false assumptions, which are flagged before being handed to the model generating answers. The sections below build it end to end: corpus, retrieval, the classifying questions, the routing, the prompt, and finally the answers.

Setup

Set TYPESAFE_API_KEY, ANTHROPIC_API_KEY and OPENAI_API_KEY. We use TypeSafe to score each retrieved passage, OpenAI to embed the corpus for the search step, and Claude to write the final answer out of whatever survives the scoring. None of the three needs a key to reproduce this page. json_cache.json ships with the cookbook and replays every recorded call, so a re-render costs nothing. Delete the file to run the pipeline live instead. The numbers here came out of jev-1.12 and claude-sonnet-5 on 2026-08-27.

Load the docs corpus

The corpus file corpus.json holds 81 passages. We copied 80 of them straight from the Supabase auth docs at commit 2440b06, one passage per heading, verbatim and used under Apache 2.0: https://github.com/supabase/supabase/tree/2440b06/apps/docs/content/guides/auth Each passage carries id, title, text and source_type, and every request sends all four. Near-misses fill the set. Rotation, expiry, sessions and signing keys each get their own page, and those pages read alike. Refresh-token rotation and JWT signing-key rotation are different things described in nearly the same words. We wrote the last one ourselves, forum-injection, marked community_forum: it reads as an ordinary forum answer until its final paragraph, which is an instruction aimed at the model. We also wrote two of the six queries to state a premise the docs contradict, so the injection and conflict routes both have something to catch.

Retrieve the top passages

Rank the passages by cosine similarity over embeddings, using text-embedding-3-small at 256 dimensions, and keep the best TOP_K = 12 for each query. Short vectors keep the shipped cache small, and the embedding calls are cached with everything else, so the vectors travel inside json_cache.json.
The 12 passages retrieved for the first query:
The forum post carrying the injected instruction, forum-injection, ranks 1st at 0.584. The passage that refutes the premise, sessions-01, ranks 7th at 0.509. All 12 scores fall between 0.584 and 0.455, a spread too narrow to separate the passage that corrects the query from the one trying to hijack the answer.

Ask four questions about each passage

Put the query and one passage in the state together, so every question is about the pair rather than the passage alone. Shape:
Use the same four questions for every query. Only the state changes between calls. Four Nouls, and what each answer drives:
  • is_relevant: the relevance floor.
  • contains_answer_evidence: include, or drop.
  • contradicts_query_premise: promotes to the conflict block.
  • contains_prompt_injection: excludes outright.
None of the four asks whether to include the passage. That call sits in the code below, where changing it means editing a number instead of rewording a question.

Route each passage in code

Every answer comes back as a probability, and there are plenty of ways to turn four of them into one decision. A plain run of comparisons worked here. Test the four probabilities against their thresholds in a fixed order and stop at the first match. That match labels the passage, and the label decides what happens to it: evidence in the prompt, a conflict in the prompt, or dropped. The tests, in order:
  1. contains_prompt_injection > 0.70 -> exclude
  2. contradicts_query_premise > 0.70 -> conflicting_evidence
  3. is_relevant < 0.45 -> exclude
  4. contains_answer_evidence > 0.55 -> include
  5. otherwise exclude
Injection comes first because it is a security decision, not an evidence one. The contradiction test comes before the evidence test because a passage that denies the query’s premise usually states something usable too; tested the other way round, it would land in the accepted block instead of the conflict one.
We picked these four numbers for this corpus. Treat them as a starting point, not defaults. Moving one is cheap: THRESHOLDS holds all four and route() reads only the stored answers, so re-routing every passage costs no API calls.
The premise-contradiction question scores sessions-01 at 0.92 and sends it to the conflict block. Relevance reads 0.49 and answer evidence 0.51, so those two alone would have dropped it. Similarity ranked forum-injection first and its relevance clears the floor at 0.71. The injection score of 0.99 is what drops it. Nothing reaches the prompt as evidence, which is right for a question built on a false premise. Below, the same table for a query the docs do answer.
Four passages reach the evidence block here, and the answer below cites all four. The rows print in retrieval order, which shows the reshuffle: ranks 2, 3 and 4 all read Lifetime of a signing key, the wrong kind of lifetime in almost the query’s own words, and all three score 0.08 or less on relevance. Three of the four that made it sat 8th, 9th and 11th. forum-injection is excluded again at 0.99.
Note - treat the injection question as a filter, not a security boundary. It is one layer: the generator prompt still has to treat passages as untrusted text, and a passage scoring under the threshold still reaches the prompt.
One request per passage, so cost scales with k. Nothing batches passages into one request, because each question is about one pair.

Build the prompt from the accepted evidence

TypeSafe scores the passages and the routing labels them. An LLM still writes the answer, here claude-sonnet-5. Keep accepted and conflicting evidence in separate blocks. Two blocks let the answer push back. Merge them into one and the generator has no way to tell a passage that answers the query from one that denies its premise.
Two answers follow. The first belongs to the false-premise query, the second to an ordinary question whose retrieved passages included the injected instruction.
The first answer arrived with an empty accepted block and one conflicting passage. It opens with “I don’t have sufficient accepted evidence”, names the conflict, and quotes sessions-01 on refresh tokens never expiring rather than inventing a 30-day setting. The second had 4 accepted passages and no conflict, and cites all four. Nothing of the injected instruction reaches the text.

Compare the six queries

output Each bar holds the 12 passages retrieved for one query, 72 in all. At least two thirds of every bar is excluded. Only the two false-premise queries route anything to conflict, and two queries accept nothing at all: the one about a 30-day expiry, and how are refresh tokens rotated?

Open it in the playground

Open the link below to re-run one call live: the first query against the passage that routed to the conflict block, plus the four questions.
Open the query + passage and its four questions →