Adaptive-RAG — Classify the Question, Dispatch the Strategy¶
What problem does this solve?¶
RAG systems usually treat every query the same: retrieve five chunks, stuff them, answer. That is wasteful for trivia the model already knows, insufficient for multi-hop questions, and miscalibrated for the wide range of complexities real users send. Adaptive-RAG classifies the question first and dispatches to the right retrieval strategy.
The published technique (Jeong et al., NAACL 2024) uses three classes: no retrieval (model answers from world knowledge), single-step retrieval (vanilla RAG), multi-step retrieval (iterative — retrieve, re-query based on what we found, retrieve again). The classifier picks one; the pipeline runs accordingly. Total cost is lower than uniform RAG because simple questions skip retrieval entirely.
Where it came from¶
Adaptive-RAG was published at NAACL 2024 by Soyeong Jeong and colleagues at KAIST. Their classifier was a fine-tuned DistilBERT-class model trained on a labelled question complexity dataset. The headline result: 22% lower latency at similar quality, with selective gains on multi-hop questions (HotpotQA, MuSiQue) thanks to the multi-step branch. By late 2025 the prompt-engineered variant — ask the chat model to classify — had become dominant in production for the same reason as Self-RAG: it does not require a separately trained model and works on any base LLM. We use the prompt variant here.
Where it fits in the RAG landscape¶
Adaptive-RAG sits before retrieval. Self-RAG and CRAG sit after retrieval:
- Adaptive-RAG (this recipe) decides strategy before retrieving. Cheapest, runs once per query.
- Self-RAG (Recipe 24) filters each retrieved passage. Runs k times.
- CRAG (Recipe 25) scores retrieval as a whole, triggers fallback. Runs k+1 times.
All three compose. Production systems route with Adaptive-RAG, retrieve, then run Self-RAG over the survivors, then CRAG-style fallback if everything fails. The pre-routing in Adaptive-RAG is the single biggest cost saver — most queries skip the expensive post-retrieval reflection entirely.
When to use it (and when not to)¶
Use Adaptive-RAG when your traffic is heterogeneous. Public-facing assistants, multi-domain agents, anywhere users send a mix of "what is X" trivia, "how do I do Y" how-tos, and "compare X and Y across documents" multi-hop questions. The classifier turns those into different code paths at low cost. Skip it when traffic is uniform. A single-domain Q&A bot with consistent question shapes does not benefit — every query hits the same branch. Skip it also when the classifier is unreliable on your domain. If your classifier mis-routes 30 % of queries to the wrong branch, you are worse than vanilla RAG. Evaluate the classifier on a labelled slice before shipping.
The intuition¶
Three intuitions:
Cheapest decisions first. Routing on a single LLM call (a few hundred tokens) is far cheaper than retrieving, reranking, generating, then realising the question was trivia. Pre-routing pays back the most when traffic is mixed.
Multi-hop is a structural problem. Some questions genuinely need iterative retrieval — retrieve, see the answer references X, retrieve about X, combine. A single-shot retrieval on those questions retrieves passages adjacent to the answer but missing the bridging fact. The multi-hop branch is what makes Adaptive-RAG genuinely lift quality on HotpotQA-class corpora.
Three classes is the sweet spot. Two classes (retrieve / don't) is too coarse for production. Four+ classes invite confusion. The Adaptive-RAG paper landed on three after a lot of measurement.
Architecture¶
flowchart TB
Q[User question] --> CLS{Classifier:
complexity?}
CLS -->|NO_RETRIEVAL| A[Direct LLM
answer]
CLS -->|SIMPLE| SR[Single-shot
retrieval]
CLS -->|MULTI_HOP| MR[Iterative
retrieve-and-refine]
SR --> SG[LLM answer]
MR --> MR2{Done?}
MR2 -->|no| MR
MR2 -->|yes| MG[Compose answer]
A --> OUT[Return]
SG --> OUT
MG --> OUT
References¶
- 📄 Adaptive-RAG: Learning to Adapt Retrieval-Augmented LLMs through Question Complexity — Jeong et al., NAACL 2024.
- 📚 LangGraph Adaptive RAG tutorial — Reference implementation as a stateful graph.
- 📄 HotpotQA dataset — Standard multi-hop QA benchmark Adaptive-RAG targets.
- 💻 MuSiQue multi-hop QA — Harder multi-hop benchmark with controllable hop count.
- 📄 Self-RAG (cousin technique) — Recipe 24 — per-passage filtering.
- 📄 CRAG (cousin technique) — Recipe 25 — post-retrieval scoring and fallback.
Setup¶
Pick a provider via the PROVIDER env var; everything below is provider-agnostic. The default is Nebius. Tracing is off by default in published notebooks so the outputs are clean — flip COOKBOOK_TRACING=phoenix to send spans to a local Phoenix UI.
import os
os.environ.setdefault('PROVIDER', 'nebius')
os.environ.setdefault('COOKBOOK_TRACING', 'off')
from cookbook.providers import LLMClient
from cookbook.tracing import init_tracing
client = LLMClient()
print(f'Provider: {client.provider} | Chat model: {client.chat_model}')
print(init_tracing())
Provider: nebius | Chat model: meta-llama/Llama-3.3-70B-Instruct Tracing disabled.
Build the Pipeline, Step by Step¶
Step 1 — Index the corpus¶
We use the Wikipedia superconductors subset. It is many short documents, perfect for multi-hop questions that span pages.
from cookbook.corpora import load_wikipedia_superconductors
from cookbook.chunkers import sentence_window
from cookbook.stores import QdrantBackend
docs = list(load_wikipedia_superconductors())
chunks = sentence_window(docs, sentences_per_chunk=4, overlap=1)
vectors = client.embed([c.text for c in chunks])
store = QdrantBackend('adapt', dim=len(vectors[0]))
store.add([c.text for c in chunks], vectors, ids=[c.chunk_id for c in chunks])
print(f'Indexed {len(chunks)} chunks.')
C:\Users\faree\Desktop\rag\rag-cookbook-2026\.venv\Lib\site-packages\tqdm\auto.py:21: TqdmWarning: IProgress not found. Please update jupyter and ipywidgets. See https://ipywidgets.readthedocs.io/en/stable/user_install.html from .autonotebook import tqdm as notebook_tqdm
Indexed 72 chunks.
Step 2 — Build the classifier¶
One prompt, three labels. We constrain the output and parse the first token. Keeping the prompt short matters — this call runs on every query.
CLASSIFY = (
'Classify the following question into exactly one of three classes:\n'
' NO_RETRIEVAL — trivia or common knowledge the model already knows.\n'
' SIMPLE — a single look-up in a domain knowledge base would suffice.\n'
' MULTI_HOP — needs combining facts from multiple documents to answer.\n\n'
'Reply with exactly one word from NO_RETRIEVAL, SIMPLE, or MULTI_HOP.\n\n'
'Question: {q}\nAnswer:'
)
def classify(question: str) -> str:
out = client.chat(CLASSIFY.format(q=question)).strip().upper()
for label in ('NO_RETRIEVAL', 'MULTI_HOP', 'SIMPLE'):
if label in out:
return label
return 'SIMPLE' # safe default
probes = [
'What year did World War II end?',
'What is the Meissner effect?',
'How did the discovery of YBCO and the later development of high-pressure hydride superconductors together change what counts as room-temperature superconductivity?',
]
for p in probes:
print(f' {classify(p):14s} {p}')
NO_RETRIEVAL What year did World War II end? NO_RETRIEVAL What is the Meissner effect? MULTI_HOP How did the discovery of YBCO and the later development of high-pressure hydride superconductors together change what counts as room-temperature superconductivity?
Step 3 — Build the three branches¶
Each branch is one function. The NO_RETRIEVAL branch is just a direct chat call. SIMPLE is vanilla RAG. MULTI_HOP is the interesting one — we let the model propose a follow-up question after seeing the first retrieval, then retrieve again.
def branch_no_retrieval(question: str) -> tuple[str, list[str]]:
answer = client.chat(f'Answer concisely from your own knowledge: {question}')
return answer, []
def branch_simple(question: str, k: int = 5) -> tuple[str, list[str]]:
qv = client.embed([question])[0]
hits = store.search(qv, top_k=k)
contexts = [h.text for h in hits]
prompt = 'Use these passages:\n' + '\n\n'.join(contexts) + f'\n\nQuestion: {question}\nAnswer:'
return client.chat(prompt), contexts
def branch_multi_hop(question: str, max_hops: int = 3) -> tuple[str, list[str]]:
history, contexts = [], []
follow_up = question
for hop in range(max_hops):
partial, ctx = branch_simple(follow_up, k=4)
history.append(partial)
contexts.extend(ctx)
rewrite = client.chat(
'Given the partial answers below, what specific FOLLOW-UP question would close the remaining gap? '
'Reply with just the question, or the single word DONE if complete.\n\n'
+ '\n'.join(history) + f'\n\nOriginal: {question}'
).strip()
if rewrite.upper().startswith('DONE') or len(rewrite) < 5:
break
follow_up = rewrite
final = client.chat(
'Combine the partial answers below into a single coherent response.\n\n'
+ '\n\n'.join(history) + f'\n\nOriginal question: {question}\nFinal answer:'
)
return final, contexts
print('Three branches defined.')
Three branches defined.
Step 4 — The dispatcher¶
Tie classifier output to branch. This is the entire "adaptive" part. Everything else is vanilla RAG primitives we built earlier.
def adaptive(question: str) -> tuple[str, list[str], str]:
cls = classify(question)
if cls == 'NO_RETRIEVAL':
ans, ctx = branch_no_retrieval(question)
elif cls == 'MULTI_HOP':
ans, ctx = branch_multi_hop(question)
else:
ans, ctx = branch_simple(question)
return ans, ctx, cls
for q in [
'What is the boiling point of liquid nitrogen?',
'What is the Meissner effect?',
'How do the discoveries of YBCO and high-pressure hydride superconductors together change the practical meaning of \"room temperature\" superconductivity?',
]:
ans, _, cls = adaptive(q)
print(f'[{cls}] {q}')
print(f' -> {ans[:200]}')
print()
[NO_RETRIEVAL] What is the boiling point of liquid nitrogen? -> -77.1°C or -109.8°F at standard atmospheric pressure. [NO_RETRIEVAL] What is the Meissner effect? -> The Meissner effect is the expulsion of magnetic fields from a superconductor when it is cooled below its critical temperature, causing it to behave as a perfect diamagnet. [MULTI_HOP] How do the discoveries of YBCO and high-pressure hydride superconductors together change the practical meaning of "room temperature" superconductivity? -> The discoveries of YBCO and high-pressure hydride superconductors, such as lanthanum decahydride, have significantly changed the practical meaning of "room temperature" superconductivity. Prior to the
Step 5 — The cookbook contract: answer_question¶
Same shape as every other recipe. Strips the classification label since the contract is (answer, contexts) only.
def answer_question(question: str) -> tuple[str, list[str]]:
ans, ctx, _ = adaptive(question)
return ans, ctx
ans, ctx = answer_question('Compare BCS theory with the standard explanation of high-temperature superconductivity.')
print(ans)
BCS theory is a microscopic theory that explains many thermodynamic and electromagnetic properties of superconductors, describing superconductivity as a microscopic effect caused by a condensation of pairs of electrons known as Cooper pairs. In contrast, the standard explanation of high-temperature superconductivity is still an active area of research and debate, and a complete understanding of the phenomenon has not yet been achieved. The BCS theory, which was originally developed to explain conventional superconductivity at lower temperatures, is generally considered to be inadequate for explaining high-temperature superconductivity. While BCS theory can explain the superconducting mechanism of some materials, such as MgB2, it is not sufficient to explain the behavior of high-temperature superconductors, which are mostly type-II superconductors. High-temperature superconductivity is often associated with cuprate superconductors, which are a family of materials made of layers of copper oxides alternating with layers of other metal oxides. Current research and theories propose that high-temperature superconductivity may involve more complex mechanisms, such as strong electron correlations, unconventional pairing mechanisms, magnetic fluctuations, and other exotic mechanisms. Some of the key theories and models that attempt to explain high-temperature superconductivity include the resonating valence bond (RVB) theory, the t-J model, and the spin-fermion model. These theories and models have contributed significantly to our understanding of high-temperature superconductivity, but a complete and consistent theory that explains all the experimental observations is still lacking. In summary, BCS theory provides a well-established explanation for conventional superconductivity, but it is not sufficient to explain high-temperature superconductivity, which remains an active area of research and debate.
Look Inside¶
Inspect — classifier behaviour across a small battery¶
Run several questions and see how they classify. The classifier is the foundation of everything; if it mis-routes, every branch breaks.
battery = [
'What is the freezing point of water in Celsius?',
'Define superconductivity in one sentence.',
'How does the Meissner effect distinguish a superconductor from a perfect conductor?',
'What materials connect the discovery of high-temperature superconductivity to current applications in MRI?',
'In what decade was BCS theory proposed and how did it relate to earlier London-equation work?',
]
for q in battery:
print(f' {classify(q):14s} {q}')
NO_RETRIEVAL What is the freezing point of water in Celsius? NO_RETRIEVAL Define superconductivity in one sentence. NO_RETRIEVAL How does the Meissner effect distinguish a superconductor from a perfect conductor? MULTI_HOP What materials connect the discovery of high-temperature superconductivity to current applications in MRI? MULTI_HOP In what decade was BCS theory proposed and how did it relate to earlier London-equation work?
Inspect — what does the multi-hop trace look like?¶
Manually drive the multi-hop branch on a question that needs it and print every hop. Useful for debugging when the final answer is wrong — usually the failure is in the follow-up question, not the retrieval.
q = 'What materials connect the discovery of high-temperature superconductivity to applications in MRI and particle accelerators?'
history = []
follow_up = q
for hop in range(3):
partial, _ = branch_simple(follow_up, k=4)
history.append((follow_up, partial))
next_q = client.chat(
'Given the partial answers, what FOLLOW-UP question best closes the gap? Or reply DONE.\n\n'
+ '\n'.join(p for _, p in history) + f'\n\nOriginal: {q}'
).strip()
if next_q.upper().startswith('DONE'):
break
follow_up = next_q
for i, (qq, ans) in enumerate(history):
print(f'Hop {i}: {qq[:80]}')
print(f' -> {ans[:200]}')
print()
Hop 0: What materials connect the discovery of high-temperature superconductivity to ap
-> Superconducting magnets, made from coils of superconducting wire, connect the discovery of high-temperature superconductivity to applications in MRI and particle accelerators. These magnets can produc
Inspect — token budget per branch¶
How many LLM calls does each branch make? Cost-aware design.
from cookbook import _cache
def measure(q: str):
before = _cache.stats()['entries']
_ = adaptive(q)
after = _cache.stats()['entries']
return after - before
for q in [
'What is the freezing point of water?', # NO_RETRIEVAL
'What is the Meissner effect?', # SIMPLE
"How did Bednorz and Mueller's discovery, combined with later YBCO work, change practical superconductivity?", # MULTI_HOP
]:
calls = measure(q)
print(f' {classify(q):14s} {calls:2d} new calls {q[:60]}')
NO_RETRIEVAL 0 new calls What is the freezing point of water?
NO_RETRIEVAL 0 new calls What is the Meissner effect?
MULTI_HOP 0 new calls How did Bednorz and Mueller's discovery, combined with later
Inspect — sensitivity to the classifier prompt¶
Slightly different classifier prompts produce different routing. Print the same question against two prompt variants and look at the disagreement.
alt_prompt = (
'Is the following question best answered from world knowledge alone (skip retrieval), '
'from a single corpus lookup, or by combining multiple sources?\n'
'Reply with one of NO_RETRIEVAL, SIMPLE, or MULTI_HOP.\n\nQuestion: {q}\nAnswer:'
)
def classify_alt(question: str) -> str:
out = client.chat(alt_prompt.format(q=question)).strip().upper()
for label in ('NO_RETRIEVAL', 'MULTI_HOP', 'SIMPLE'):
if label in out:
return label
return 'SIMPLE'
for q in [
'What is the boiling point of liquid nitrogen?',
'How is BCS theory connected to superconducting magnets used in MRI?',
]:
print(f' default={classify(q):14s} alt={classify_alt(q):14s} {q[:60]}')
default=NO_RETRIEVAL alt=NO_RETRIEVAL What is the boiling point of liquid nitrogen? default=MULTI_HOP alt=MULTI_HOP How is BCS theory connected to superconducting magnets used
Run It¶
End-to-end on a representative multi-hop question.
q = 'How did the discovery of YBCO and the more recent high-pressure hydride superconductors together change the practical meaning of \"room-temperature\" superconductivity?'
ans, ctxs = answer_question(q)
print('=== Adaptive-RAG answer ===')
print(ans)
print()
print(f'(used {len(ctxs)} contexts)')
=== Adaptive-RAG answer === The discovery of YBCO and the more recent high-pressure hydride superconductors, such as lanthanum decahydride, have significantly changed the practical meaning of "room-temperature" superconductivity. Prior to the discovery of YBCO in 1986, superconductivity was thought to be limited to very low temperatures, near absolute zero. The discovery of YBCO, which becomes superconducting at 93 K, raised the temperature threshold for superconductivity and redefined what was considered "high-temperature" superconductivity. The more recent discovery of high-pressure hydride superconductors, such as lanthanum decahydride, which becomes superconducting at 250 K (approximately -23°C), has further pushed the boundaries of what is considered "room-temperature" superconductivity. While 250 K is still below the typical definition of room temperature (around 20-25°C), it is much closer to ambient temperatures than previous superconducting materials. Together, these discoveries have expanded the definition of "room-temperature" superconductivity to include temperatures that are significantly higher than previously thought possible. They have also raised hopes that superconducting materials can be developed that can operate at true room temperatures, without the need for cooling, which could have significant practical applications in fields such as energy transmission, medical devices, and transportation. The development of superconducting materials that can operate at true room temperatures without the need for cooling could enable a wide range of practical applications, including widespread adoption of magnetic levitation transportation systems, more efficient and compact power transmission and distribution, advanced medical imaging and diagnostics, more efficient and powerful electric motors and generators, advanced energy storage and grid management systems, quantum computing and simulation, and advanced materials processing and manufacturing. To achieve this goal, researchers are likely exploring various materials and conditions to achieve higher critical temperatures, such as investigating new material compositions and structures, exploring the effects of high pressure on superconducting materials, studying the properties of existing high-temperature superconductors, and developing new fabrication techniques. Overall, the discovery of YBCO and high-pressure hydride superconductors has changed the practical meaning of "room-temperature" superconductivity from being an unrealistic goal to a potentially achievable target, with significant advances being made towards developing materials that can operate at temperatures closer to ambient conditions. (used 12 contexts)
Side by Side: Vanilla Baseline vs This Technique¶
On a multi-hop question, vanilla retrieval often returns chunks from the right area but misses the bridging fact. Adaptive-RAG's multi-hop branch retrieves twice and composes. Show both.
from cookbook.baselines import vanilla_pipeline
q = 'How did the discovery of YBCO and the more recent high-pressure hydride superconductors together change the practical meaning of \"room-temperature\" superconductivity?'
base = vanilla_pipeline(q, corpus='wikipedia-superconductors', top_k=5)
ours_ans, ours_ctx = answer_question(q)
import pandas as pd
pd.DataFrame([
{'pipeline': 'vanilla', 'preview': base.answer[:200], 'n_contexts': len(base.contexts)},
{'pipeline': 'adaptive', 'preview': ours_ans[:200], 'n_contexts': len(ours_ctx)},
])
| pipeline | preview | n_contexts | |
|---|---|---|---|
| 0 | vanilla | The passages do not contain a direct answer to... | 5 |
| 1 | adaptive | The discovery of YBCO and the more recent high... | 12 |
Knobs to Turn¶
Five knobs:
- Classifier prompt. The whole pipeline pivots on it. Test on a labelled slice (50–100 questions you have hand-routed) before relying on it.
max_hopsin the multi-hop branch. Default 3. Higher means deeper multi-hop coverage but longer latency. Cap at 5 in production; runaway loops are a real failure mode.- Classifier model. A small fast model is fine. The Adaptive-RAG paper uses DistilBERT; in cloud terms
gpt-4o-minior Groq Llama-3.3-70b-instant are cheap and fast. SIMPLEbranchk. 5 is the cookbook default; tune for your corpus.- Multi-hop follow-up prompt. The most fragile part of multi-hop is the rewrite. If the model proposes a too-general follow-up, the second retrieval returns the same chunks. Tighten the prompt to demand specificity.
Evaluate on a Slice¶
Run the recipe's answer_question over a small slice of the hand-curated eval set. Full RAGAS metrics are exercised in recipes/09-evaluation-and-production/ragas-triad-eval.ipynb; here we just print a quick spot-check table so you can eyeball whether the technique is on track.
from cookbook.corpora import load_eval_questions
from cookbook.eval import EvalSample
qs = load_eval_questions()
qs = [q for q in qs if q['corpus'] == 'wikipedia-superconductors']
samples = []
for row in qs[:5]:
answer, contexts = answer_question(row['question'])
samples.append({
'question': row['question'],
'expected': row['answer'],
'actual': answer[:200],
'contexts_retrieved': len(list(contexts)),
})
import pandas as pd
pd.DataFrame(samples)
| question | expected | actual | contexts_retrieved | |
|---|---|---|---|---|
| 0 | Who first observed superconductivity, and in w... | Heike Kamerlingh Onnes observed it in mercury ... | The passage does not explicitly state who firs... | 5 |
| 1 | What is the Meissner effect? | The complete expulsion of magnetic flux from a... | The Meissner effect is the expulsion of magnet... | 0 |
| 2 | Distinguish Type-I from Type-II superconductors. | Type-I has a single critical field above which... | Type-I and Type-II superconductors can be dist... | 5 |
| 3 | What does BCS theory explain? | It explains conventional superconductivity thr... | BCS (Bardeen-Cooper-Schrieffer) theory explain... | 0 |
| 4 | What is a Cooper pair? | Two electrons bound together by phonon exchang... | A Cooper pair is a pair of electrons that are ... | 0 |
Closing Thoughts¶
Three places Adaptive-RAG falls down:
- Misclassification at the boundary. A question that is
SIMPLEbut classifier callsNO_RETRIEVALwill be answered from world knowledge and silently miss the corpus's specific facts. Catch this with periodic eval-set sampling. - Multi-hop runaway. If the follow-up rewrite keeps proposing minor variations, the loop never returns. The hop cap and the
DONEshortcut both matter. - Latency tail. Multi-hop queries take 3–5x longer than simple ones. If your latency SLO is tight, route multi-hop to a separate async path or refuse for synchronous use.
Adaptive-RAG is the cheapest way to handle heterogeneous traffic. Stack it under Self-RAG (Recipe 24) for the SIMPLE branch, and CRAG (Recipe 25) for the MULTI_HOP branch's final answer-grounding step. The composition is what makes a production system feel polished.