Conversational RAG
The Core Problem
A retriever only sees the current query. In multi-turn chat the query is ambiguous without prior context:
User: What was revenue in Q3?
Bot: Revenue was $4.2M.
User: How did that compare to last year? <- "that" = Q3 revenue
Retrieving on "How did that compare to last year?" matches nothing. The retriever must see a reformulated, self-contained query.
Pipeline
[history + current_q] -> [condenser LLM] -> [standalone_q] -> [retriever] -> [answer LLM(history, standalone_q, docs)]
Two LLM calls per turn: one cheap (Haiku) for condensation, one standard for synthesis.
Query Condensation (Haiku)
from anthropic import Anthropic
client = Anthropic()
CONDENSE_PROMPT = """Given the conversation so far and the latest user message,
rewrite the latest message as a standalone question that can be understood without
the prior messages. Resolve pronouns and elliptical references. If the latest
message is already standalone, return it unchanged.
Conversation:
{history}
Latest message: {question}
Standalone question:"""
def condense(history: list[dict], question: str) -> str:
hist_str = "\n".join(f"{m['role']}: {m['content']}" for m in history[-8:])
msg = client.messages.create(
model="claude-haiku-4-5-20250929",
max_tokens=256,
messages=[{"role": "user", "content": CONDENSE_PROMPT.format(
history=hist_str, question=question)}],
)
return msg.content[0].text.strip()
Keep only the last 8 turns in the condenser prompt — older turns rarely affect coreference and inflate cost.
Follow-up vs New Query Routing
Not every turn needs retrieval. Classify first, retrieve only when needed.
from pydantic import BaseModel
from typing import Literal
from langchain_anthropic import ChatAnthropic
class TurnType(BaseModel):
kind: Literal["follow_up", "new_query", "chit_chat", "meta"]
needs_retrieval: bool
llm = ChatAnthropic(model="claude-haiku-4-5-20250929").with_structured_output(TurnType)
def classify(history, question) -> TurnType:
return llm.invoke(
f"Classify the latest message.\nHistory: {history[-4:]}\nLatest: {question}"
)
meta (e.g. "summarize our conversation", "what did you say earlier") does not require retrieval. chit_chat ("thanks!") either.
History Compaction Strategies
| Strategy | Keeps | Token Cost | Loses | Best For |
|---|---|---|---|---|
| Full transcript | Everything | Unbounded | Nothing | Short sessions |
| Sliding window | Last N turns | Bounded | Early context | Most chatbots |
| Summary buffer | Rolling summary + last N | Bounded | Detail in old turns | Long sessions with continuity |
| Vector memory | Retrieved past turns | Bounded | Linearity | Returning users |
| Hierarchical | Summary of summaries | Bounded | Recency | Very long sessions |
Sliding Window
def sliding_window(history: list[dict], n: int = 10) -> list[dict]:
return history[-n:]
Summary Buffer (LangChain 0.3+ via LangGraph)
from langgraph.graph import MessagesState, StateGraph
from langchain_core.messages import RemoveMessage, SystemMessage
SUMMARY_AFTER = 20 # messages
def maybe_summarize(state: MessagesState):
msgs = state["messages"]
if len(msgs) <= SUMMARY_AFTER:
return {}
to_summarize = msgs[:-8]
summary = llm.invoke(
[SystemMessage("Summarize the prior conversation in <= 300 words."),
*to_summarize]
).content
removals = [RemoveMessage(id=m.id) for m in to_summarize]
return {"messages": [SystemMessage(f"Summary so far: {summary}"), *removals]}
Vector Memory (retrieve relevant past turns)
from langchain_qdrant import QdrantVectorStore
from langchain_openai import OpenAIEmbeddings
mem_store = QdrantVectorStore.from_documents([], OpenAIEmbeddings(), collection_name="chat_mem")
def remember(session_id: str, turn: dict):
mem_store.add_texts([turn["content"]], metadatas=[{"session_id": session_id, **turn}])
def recall(session_id: str, query: str, k: int = 3):
return mem_store.similarity_search(query, k=k, filter={"session_id": session_id})
Best paired with a sliding window of the immediate turns — vector memory catches long-range callbacks while recency is handled verbatim.
LangChain 0.3+ Conversational Retrieval (LCEL)
ConversationalRetrievalChain is deprecated. The idiomatic pattern uses create_history_aware_retriever + create_retrieval_chain.
from langchain.chains import create_history_aware_retriever, create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_anthropic import ChatAnthropic
llm = ChatAnthropic(model="claude-sonnet-4-5-20250929")
condense_prompt = ChatPromptTemplate.from_messages([
("system", "Given the chat history and latest question, rewrite it as standalone."),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
])
history_aware_retriever = create_history_aware_retriever(llm, retriever, condense_prompt)
qa_prompt = ChatPromptTemplate.from_messages([
("system", "Answer using the context. Cite [source_id].\n\nContext:\n{context}"),
MessagesPlaceholder("chat_history"),
("human", "{input}"),
])
qa_chain = create_stuff_documents_chain(llm, qa_prompt)
rag_chain = create_retrieval_chain(history_aware_retriever, qa_chain)
result = rag_chain.invoke({"input": "How did that compare to last year?",
"chat_history": prior_messages})
LlamaIndex ChatEngine
from llama_index.core.chat_engine import CondensePlusContextChatEngine
from llama_index.core.memory import ChatMemoryBuffer
from llama_index.llms.anthropic import Anthropic
memory = ChatMemoryBuffer.from_defaults(token_limit=4000)
chat_engine = CondensePlusContextChatEngine.from_defaults(
retriever=index.as_retriever(similarity_top_k=5),
llm=Anthropic(model="claude-sonnet-4-5-20250929"),
memory=memory,
context_prompt=(
"Context:\n{context_str}\n\n"
"Answer using context; cite [id]. If insufficient, say so."
),
)
response = chat_engine.chat("How did that compare to last year?")
Modes:
condense_plus_context— condense + retrieve + answer (best default).context— retrieve on raw query only (misses coreference).condense_question— condense + retrieve, no chat history in answer prompt.react— ReAct with retrieval as a tool; handles multi-hop follow-ups.
Message History Stores
Redis (production default)
from langchain_community.chat_message_histories import RedisChatMessageHistory
def get_history(session_id: str):
return RedisChatMessageHistory(
session_id=session_id,
url="redis://localhost:6379/0",
ttl=60 * 60 * 24 * 7, # 7 day TTL
)
Set a TTL. Chat history is PII — expire it.
Postgres
from langchain_postgres import PostgresChatMessageHistory
import psycopg
conn = psycopg.connect("postgresql://...")
PostgresChatMessageHistory.create_tables(conn, "chat_history")
def get_history(session_id: str):
return PostgresChatMessageHistory("chat_history", session_id, sync_connection=conn)
Use Postgres when you need to query history (analytics, audit, compliance).
LangGraph Checkpointer (state + history together)
from langgraph.checkpoint.postgres import PostgresSaver
checkpointer = PostgresSaver.from_conn_string("postgresql://...")
checkpointer.setup()
app = graph.compile(checkpointer=checkpointer)
config = {"configurable": {"thread_id": session_id}}
app.invoke({"messages": [("human", question)]}, config=config)
Preferred for agentic conversational RAG — state survives restarts and can be replayed.
TypeScript (Vercel AI SDK)
import { streamText, convertToCoreMessages } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
export async function POST(req: Request) {
const { messages, sessionId } = await req.json();
const standalone = await condense(messages);
const docs = await retriever.search(standalone, 5);
const result = await streamText({
model: anthropic('claude-sonnet-4-5-20250929'),
system: `Answer using context. Cite [id].\n\nContext:\n${docs.map(d => `[${d.id}] ${d.text}`).join('\n\n')}`,
messages: convertToCoreMessages(messages),
});
return result.toDataStreamResponse();
}
Prompt Caching for History
History repeats token-for-token on every turn. Cache it.
client.messages.create(
model="claude-sonnet-4-5-20250929",
system=[
{"type": "text", "text": system_prompt, "cache_control": {"type": "ephemeral"}},
],
messages=[
*history,
{"role": "user", "content": [
{"type": "text", "text": f"Context:\n{ctx}\n\nQ: {question}"},
]},
],
max_tokens=1024,
)
Cache hits on the system prompt save ~90% on input tokens; effective for long system instructions or large persona blocks.
Anti-Patterns
| Anti-Pattern | Fix |
|---|---|
| Retrieving on the raw latest message | Condense first with history-aware rewriter |
| Passing full history to the retriever as the query string | Use standalone condensed query; keep history for answer LLM only |
| Unbounded chat history | Sliding window + summary buffer |
| Sonnet/Opus for condensation | Haiku is enough; condensation is straightforward |
| No session isolation in vector memory | Filter by session_id / user_id on every recall |
In-memory ChatMessageHistory in production | Redis or Postgres with TTL |
| Re-running retrieval for chit-chat | Classify turn first |
| No coreference handling | "it", "that", "last one" silently kill recall |
| History PII stored forever | TTL + right-to-erase handling |
| Retrieval context dumped verbatim every turn | Only include retrieved docs for the current turn |
Production Checklist
- Turn classifier routes follow-up, new query, chit-chat, meta
- History-aware retriever condenses before retrieval
- History compaction strategy chosen (sliding window default)
- Summary buffer triggers above N messages
- Session-scoped vector memory if sessions are long-running
- Redis or Postgres message store with TTL
- LangGraph checkpointer for agentic conversational flows
- Haiku for condensation, Sonnet for synthesis
- Prompt caching enabled on system + persona
- PII expiration + right-to-erase endpoint
- Tracing per turn (LangSmith/LangFuse) with condensed query logged
- Token budget monitored per turn; alert above threshold