If you’re evaluating llamaindex simplechatengine vs contextchatengine for a production system, the decision comes down to whether you need conversation history to influence retrieval. SimpleChatEngine treats every turn as independent; ContextChatEngine rewrites the user’s latest message using chat history before querying your index. That single architectural difference cascades into latency, token cost, retrieval quality, and failure modes. Here’s the breakdown.
Core architecture
SimpleChatEngine is a thin wrapper around an LLM call with a system prompt. It maintains an in-memory ChatMemoryBuffer (or whatever memory implementation you inject) and stuffs the last N messages into the context window alongside your system prompt. No index, no retriever, no reranking. The prompt template looks roughly like:
from llama_index.core.chat_engine import SimpleChatEngine
from llama_index.core.memory import ChatMemoryBuffer
from llama_index.llms.openai import OpenAI
llm = OpenAI(model="gpt-4o-mini")
memory = ChatMemoryBuffer.from_defaults(token_limit=3000)
engine = SimpleChatEngine.from_defaults(
llm=llm,
memory=memory,
system_prompt="You are a helpful assistant with access to internal docs.",
)
ContextChatEngine adds a retrieval step. Before calling the LLM, it takes the user’s latest message plus recent history, runs a condense question prompt through the LLM to produce a standalone query, hits your vector index (or any BaseRetriever), fetches top-k nodes, then feeds those nodes plus the full conversation history into a final response synthesis prompt.
from llama_index.core.chat_engine import ContextChatEngine
from llama_index.core import VectorStoreIndex
from llama_index.core.memory import ChatMemoryBuffer
from llama_index.llms.openai import OpenAI
llm = OpenAI(model="gpt-4o-mini")
index = VectorStoreIndex.from_documents(docs)
memory = ChatMemoryBuffer.from_defaults(token_limit=3000)
engine = ContextChatEngine.from_defaults(
llm=llm,
retriever=index.as_retriever(similarity_top_k=4),
memory=memory,
system_prompt="You are a support agent. Use the retrieved context to answer.",
)
The condense prompt is the critical piece. Default template:
Given the following conversation and a follow up question, rephrase the follow up question to be a standalone question.
Chat History:
{chat_history}
Follow Up Input: {question}
Standalone question:
If the user asks “What about pricing?” after discussing Enterprise tiers, the condense step rewrites it to “What is the pricing for Enterprise tiers?” — which your retriever can actually match.
Retrieval quality and hallucination surface
SimpleChatEngine has zero retrieval. It hallucinates freely unless your system prompt and context window happen to contain the right facts. Use it only when:
- The model’s parametric knowledge is sufficient (general coding help, creative writing, reasoning tasks)
- You’re prototyping and haven’t built an index yet
- You explicitly want the model to not ground in external data
ContextChatEngine grounds responses in retrieved nodes. The synthesis prompt receives {context_str} (concatenated node text) and {chat_history}. This reduces hallucination on domain-specific questions but introduces new failure modes:
- Condense drift: If the condense LLM misinterprets a vague follow-up (“and the other one?”), the rewritten query retrieves irrelevant chunks.
- Context overflow: Retrieved nodes + full history + system prompt can exceed the model’s context window. LlamaIndex truncates history first, then nodes, but you lose either conversation continuity or grounding.
- Stale retrieval: The condense step uses the same LLM as synthesis. If that model is weak at query rewriting (e.g., a small local model), retrieval quality tanks.
Latency and token economics
| Dimension | SimpleChatEngine | ContextChatEngine |
|---|---|---|
| LLM calls per turn | 1 | 2 (condense + synthesis) |
| Input tokens per turn | system + history + user | condense(history + user) + synthesis(system + history + retrieved) |
| Output tokens per turn | response only | condense query + response |
| Retrieval latency | 0 | vector search + optional rerank |
| Typical p50 latency (gpt-4o-mini, 4k context) | ~400ms | ~900-1400ms |
| Cost per 1k turns (est., gpt-4o-mini pricing) | ~$0.15 | ~$0.45-0.65 |
The second LLM call dominates. If you’re serving user-facing chat with SLAs under 1s, SimpleChatEngine is safer. ContextChatEngine can be optimized:
- Use a smaller/faster model for condense (e.g.,
gpt-4o-minifor condense,gpt-4ofor synthesis) - Cache condensed queries for repeated follow-ups
- Lower
similarity_top_kto 2-3 if your chunks are dense - Disable condense entirely for single-turn queries (detect via history length)
# Custom condense with a cheaper model
from llama_index.core.chat_engine import CondenseQuestionChatEngine
from llama_index.llms.openai import OpenAI
cheap_llm = OpenAI(model="gpt-4o-mini")
expensive_llm = OpenAI(model="gpt-4o")
engine = CondenseQuestionChatEngine.from_defaults(
llm=expensive_llm, # synthesis
condense_llm=cheap_llm, # query rewriting
retriever=index.as_retriever(similarity_top_k=3),
memory=memory,
)
Note: ContextChatEngine is an alias for CondenseQuestionChatEngine in current LlamaIndex versions. The class hierarchy is BaseChatEngine → CondenseQuestionChatEngine → ContextChatEngine (deprecated alias). Use CondenseQuestionChatEngine directly for the condense_llm parameter.
Memory and conversation continuity
Both engines use ChatMemoryBuffer by default, which implements a token-aware sliding window. You can swap in ChatSummaryMemoryBuffer to summarize older turns instead of dropping them:
from llama_index.core.memory import ChatSummaryMemoryBuffer
memory = ChatSummaryMemoryBuffer.from_defaults(
llm=llm,
token_limit=4000,
summary_token_limit=500,
)
With SimpleChatEngine, summarization preserves high-level context but loses detail — fine for chit-chat, bad for multi-step debugging where the user references “that error from three messages ago.”
With ContextChatEngine, memory interacts with retrieval in subtle ways. The condense prompt sees the full memory buffer (summarized or raw). If you use ChatSummaryMemoryBuffer, the condense LLM receives a summary of early turns plus raw recent turns. This usually works well, but verify your condense prompt handles summarized history gracefully. The default template doesn’t distinguish; you may want a custom template:
from llama_index.core.prompts import PromptTemplate
custom_condense = PromptTemplate(
"Conversation summary (older turns):\n{summary}\n\n"
"Recent conversation:\n{recent_history}\n\n"
"Follow Up Input: {question}\n"
"Standalone question:"
)
engine = CondenseQuestionChatEngine.from_defaults(
llm=llm,
retriever=retriever,
memory=memory,
condense_prompt=custom_condense,
)
Streaming and UX
Both engines support stream_chat and astream_chat. The difference is what streams:
- SimpleChatEngine streams the final response token-by-token. Clean UX.
- ContextChatEngine streams only the synthesis step. The condense + retrieval phase blocks before the first token appears. Users see a 500-1500ms blank pause, then streaming starts.
If that pause is unacceptable, you have two options:
- Show a “thinking…” indicator during the condense+retrieve phase (easy, honest)
- Run condense+retrieve speculatively while the user types (hard, requires frontend cooperation)
# Async streaming with progress callback
from llama_index.core.callbacks import CallbackManager, LlamaDebugHandler
debug = LlamaDebugHandler()
callback_manager = CallbackManager([debug])
engine = ContextChatEngine.from_defaults(
llm=llm,
retriever=retriever,
memory=memory,
callback_manager=callback_manager,
)
# In your handler, inspect debug.get_llm_inputs_outputs() to time each phase
response = await engine.astream_chat("What's the refund policy?")
async for token in response.async_response_gen():
print(token, end="", flush=True)
Customization surface
SimpleChatEngine exposes:
system_promptmemoryimplementationprompt_template(the final prompt fed to LLM)llm
ContextChatEngine adds:
retriever(anyBaseRetriever— vector, BM25, hybrid, kg, custom)condense_prompt/condense_llmresponse_synthesis_mode(compact,refine,tree_summarize,simple_summarize)node_postprocessors(rerankers, filters, metadata extraction)verboselogging for debugging retrieval
The response_synthesis_mode matters when retrieved context exceeds the synthesis window. compact (default) stuffs as many nodes as fit, then calls LLM once. refine iterates: answer with first node, then refine with each additional node — slower but handles arbitrary context length. tree_summarize builds a hierarchical summary. Choose based on your chunk size and token budget.
from llama_index.core.postprocessor import LLMRerank
reranker = LLMRerank(
choice_batch_size=5,
top_n=2,
llm=llm,
)
engine = ContextChatEngine.from_defaults(
llm=llm,
retriever=index.as_retriever(similarity_top_k=10),
node_postprocessors=[reranker],
response_synthesis_mode="compact",
memory=memory,
)
Failure modes and debugging
SimpleChatEngine fails quietly: confident wrong answers. Add verbose=True to see the exact prompt sent to the LLM.
ContextChatEngine fails visibly but opaquely. Common issues:
| Symptom | Likely cause | Fix |
|---|---|---|
| Irrelevant retrieval | Condense prompt too generic | Custom condense prompt with domain examples |
| “I don’t know” despite relevant docs | Synthesis prompt ignores context | Check response_synthesis_mode; verify context_str in prompt |
| Truncated answers mid-sentence | Context window exceeded | Reduce similarity_top_k, shorten chunks, increase token_limit |
| Condense hallucinates entities | Weak condense LLM | Use stronger condense_llm or few-shot condense prompt |
| Latency spikes | Vector index cold start / large top-k | Warm index, lower similarity_top_k, add caching |
Enable verbose=True and callback_manager with LlamaDebugHandler to trace every LLM call, retrieval, and prompt. Log the condensed query and retrieved node IDs for every turn — this is the single most valuable debug artifact.
from llama_index.core.callbacks import LlamaDebugHandler, CallbackManager
debug = LlamaDebugHandler()
callback_manager = CallbackManager([debug])
engine = ContextChatEngine.from_defaults(
llm=llm,
retriever=retriever,
memory=memory,
callback_manager=callback_manager,
verbose=True,
)
response = engine.chat("How do I reset my API key?")
# After the call:
for event in debug.get_llm_inputs_outputs():
print(f"Prompt: {event.prompt}")
print(f"Response: {event.response}")
print("---")
# Retrieve the condensed query and nodes
condense_events = [e for e in debug.get_llm_inputs_outputs() if "standalone question" in e.prompt.lower()]
print("Condensed query:", condense_events[0].response if condense_events else "N/A")
When to reach for something else
Both engines are single-turn reasoning loops. They don’t support:
- Tool use / function calling — use
OpenAIAgentorReActAgent - Multi-step planning — use
ReActAgentorFunctionCallingAgent - Structured output enforcement — use
OpenAIAgentwith Pydantic output parser - Parallel retrieval across multiple indexes — build a custom retriever or use
RouterRetriever
If your use case is “chat over docs” with occasional tool calls (lookup order status, create ticket), start with OpenAIAgent and give it a QueryEngineTool wrapping your index. The agent decides when to retrieve vs. when to call tools. SimpleChatEngine and ContextChatEngine are retrieval-only; they cannot act.
Which to choose
SimpleChatEngine when:
- Parametric knowledge is enough (coding assistant, brainstorming, general Q&A)
- You need sub-500ms p99 latency
- You’re building a prototype and haven’t indexed data yet
- Conversation history is purely for tone/context, not factual grounding
- Cost per conversation must stay minimal
ContextChatEngine (CondenseQuestionChatEngine) when:
- Users ask follow-ups that require retrieval (“What about the Enterprise plan?” after discussing pricing)
- Hallucination on domain facts is unacceptable (support, legal, medical, internal tools)
- You have a quality vector index and retriever already
- You can tolerate 1-2s latency for the first token
- You need reranking, hybrid search, or metadata filtering on retrieved context
Neither when:
- You need tool use, structured output, or multi-step reasoning →
OpenAIAgent/ReActAgent - You need to route across multiple indexes conditionally → custom router +
QueryEngineTool - You’re building a RAG pipeline with complex post-processing (citation extraction, conflict detection) → build a custom
QueryEngineand wrap in an agent
The llamaindex simplechatengine vs contextchatengine decision is fundamentally a retrieval question. If the user’s next question depends on something you must look up, use ContextChatEngine. If the model can answer from its weights plus a system prompt, SimpleChatEngine is faster, cheaper, and simpler. Don’t add retrieval infrastructure until you have a measured need for it.