Howwise RSS
AI & Automation6 min read

LangChain ConversationalRetrievalChain Custom Prompt and Memory

Short answer

To combine a LangChain ConversationalRetrievalChain custom prompt with memory, pass the memory object and put your QA prompt in combine_docs_chain_kwargs. For new code, use create_history_aware_retriever instead.

Key takeaways
  • ConversationalRetrievalChain uses two prompts: condense_question_prompt rewrites the follow-up, and the combine_docs prompt answers from context; you normally customise only the second.
  • A stuff-chain QA prompt must contain exactly {context} and {question}, and memory_key must be 'chat_history' to match the condense prompt.
  • RetrievalQA with memory retrieves badly on follow-ups like 'what about the second one?' because the raw follow-up is embedded.
  • The current pattern is create_history_aware_retriever + create_stuff_documents_chain + create_retrieval_chain, with history passed explicitly or via RunnableWithMessageHistory.
  • Key memory per session ID, otherwise every user shares one conversation.

Which chain should I use: RetrievalQA, ConversationalRetrievalChain or a history-aware chain?

The confusion comes from three generations of API that all look similar. The choice depends on whether you need follow-up questions to work and whether you are writing new code.

Short version: use the history-aware retrieval chain for anything new, keep ConversationalRetrievalChain only to maintain existing code, and use RetrievalQA only when every question stands alone.

ApproachMemoryFollow-up handlingStatus
RetrievalQANone built in (can be bolted on)Raw follow-up is embedded for retrieval, so it often retrieves the wrong chunksLegacy
ConversationalRetrievalChainConversationBufferMemory or similarRewrites follow-up into a standalone question firstLegacy, deprecated
create_history_aware_retriever + create_retrieval_chainExplicit chat_history, RunnableWithMessageHistory, or LangGraph checkpointerSame rewrite step, but as a visible, testable componentCurrent recommended

Why do I get memory or a custom prompt, but not both?

RetrievalQA is stateless: one query goes in, it retrieves, stuffs documents into a prompt, and returns an answer. It has no notion of earlier turns, so a custom prompt works but nothing is remembered.

ConversationalRetrievalChain adds a first step. An LLM call takes the chat history plus the new question and produces a standalone question, which is then used for retrieval. Only then does the answering step run.

That means two prompts exist, and people often edit the wrong one. The condense_question_prompt uses {chat_history} and {question} and only rewrites the query. The QA prompt (the combine-docs prompt) uses {context} and {question} and produces the visible answer. Your tone, language and 'only answer from context' rules belong in the QA prompt.

PromptVariablesJobPassed via
condense_question_prompt{chat_history}, {question}Turn a follow-up into a standalone questioncondense_question_prompt= argument of from_llm
QA / combine-docs prompt{context}, {question}Answer using retrieved chunkscombine_docs_chain_kwargs={'prompt': ...}
Tip: If your custom prompt seems to be ignored, you probably passed it as condense_question_prompt. That prompt's output is never shown to the user.

How do I add memory and a custom prompt to ConversationalRetrievalChain?

This is the legacy fix and it still works in versions that ship the old classes. In newer releases the legacy chains may live in the langchain-classic package, so adjust imports if you see ImportError.

The QA prompt must use exactly {context} and {question} for the default stuff chain. Other names raise a validation error about missing or extra input variables, or the context silently never reaches the model.

from langchain.chains import ConversationalRetrievalChain
from langchain.memory import ConversationBufferMemory
from langchain.prompts import PromptTemplate

QA_PROMPT = PromptTemplate(
    input_variables=['context', 'question'],
    template=(
        'You are a support assistant. Answer ONLY from the context below. '
        'If the answer is not in the context, say "I don\'t know" and do not guess.\n\n'
        'Context:\n{context}\n\nQuestion: {question}\nAnswer:'
    ),
)

memory = ConversationBufferMemory(
    memory_key='chat_history',   # must match the condense prompt
    return_messages=True,
    output_key='answer',         # needed when returning source documents
)

qa = ConversationalRetrievalChain.from_llm(
    llm,
    retriever=vectorstore.as_retriever(search_kwargs={'k': 4}),
    memory=memory,
    combine_docs_chain_kwargs={'prompt': QA_PROMPT},
    return_source_documents=True,
    verbose=True,
)

print(qa.invoke({'question': 'What is the refund window?'})['answer'])
print(qa.invoke({'question': 'And for digital items?'})['answer'])
Warning: One memory object holds one conversation. If you create it at module level in a web app, every user shares the same history. Create one chain and memory per session.

What is the current way: create_history_aware_retriever with a custom prompt?

The modern pattern splits the same two steps into explicit pieces. create_history_aware_retriever holds the rewrite prompt, create_stuff_documents_chain holds the QA prompt, and create_retrieval_chain joins them. Chat history is just a list of messages.

The QA prompt needs {context} (filled with documents) and {input} (the user message), plus a MessagesPlaceholder for history. The rewrite prompt needs a placeholder for history and {input}.

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_core.chat_history import InMemoryChatMessageHistory
from langchain_core.runnables.history import RunnableWithMessageHistory

retriever = vectorstore.as_retriever(search_kwargs={'k': 4})

# 1) rewrite prompt: follow-up -> standalone question
condense_prompt = ChatPromptTemplate.from_messages([
    ('system', 'Given the chat history and the latest user message, rewrite it as a '
               'standalone question. Do NOT answer it. Return it unchanged if already standalone.'),
    MessagesPlaceholder('chat_history'),
    ('human', '{input}'),
])
history_aware_retriever = create_history_aware_retriever(llm, retriever, condense_prompt)

# 2) QA prompt: your custom system prompt
qa_prompt = ChatPromptTemplate.from_messages([
    ('system', 'Answer only from the context. If it is not there, say "I don\'t know".\n\n{context}'),
    MessagesPlaceholder('chat_history'),
    ('human', '{input}'),
])
qa_chain = create_stuff_documents_chain(llm, qa_prompt)
rag = create_retrieval_chain(history_aware_retriever, qa_chain)

# 3) memory keyed by session
store = {}
def get_history(session_id: str):
    return store.setdefault(session_id, InMemoryChatMessageHistory())

chat = RunnableWithMessageHistory(
    rag, get_history,
    input_messages_key='input',
    history_messages_key='chat_history',
    output_messages_key='answer',
)

cfg = {'configurable': {'session_id': 'user-42'}}
chat.invoke({'input': 'What is the refund window?'}, config=cfg)
res = chat.invoke({'input': 'And for digital items?'}, config=cfg)
print(res['answer'])
Tip: For agents or multi-step apps, a LangGraph checkpointer (MemorySaver in development, a database-backed saver in production) does the same job, with a thread_id in the config playing the role of session_id.

Can I add memory to RetrievalQA, and what goes wrong?

Yes, mechanically. Put {history} in the prompt and pass the memory through chain_type_kwargs. The input_key must be 'question' so the memory knows which field is the user turn.

The catch is retrieval. RetrievalQA embeds the raw user text. A follow-up such as 'what about the second one?' has no searchable meaning on its own, so the retriever returns irrelevant chunks and the model, which can see the history but not the right documents, either hallucinates or says it does not know.

Memory in the prompt fixes how the answer reads, not what gets retrieved. Only a rewrite step fixes retrieval, which is exactly what ConversationalRetrievalChain and the history-aware retriever add.

from langchain.chains import RetrievalQA
from langchain.memory import ConversationBufferMemory
from langchain.prompts import PromptTemplate

prompt = PromptTemplate(
    input_variables=['history', 'context', 'question'],
    template='History:\n{history}\n\nContext:\n{context}\n\nQuestion: {question}\nAnswer:',
)
qa = RetrievalQA.from_chain_type(
    llm, retriever=retriever,
    chain_type_kwargs={
        'prompt': prompt,
        'memory': ConversationBufferMemory(input_key='question', memory_key='history'),
    },
)

What do the common errors mean and how do I fix them?

Most failures come from variable names and output keys, not from the model.

SymptomCauseFix
One output key expected, got dict_keys(['answer', 'source_documents'])return_source_documents=True gives two outputs, and memory cannot choose which to saveSet output_key='answer' on the memory
Validation error about missing or extra input variablesQA prompt does not use {context} and {question} exactlyRename variables to match the chain, and set input_variables accordingly
Memory has no effect, or KeyError on chat_historymemory_key differs from the placeholder name in the promptUse 'chat_history' everywhere
History is a string, not messages, in a chat promptreturn_messages left FalseSet return_messages=True for message placeholders
Answers ignore documentsContext variable not in the prompt, or poor rewritten questionCheck prompt text and print the standalone question
Users see each other's conversationsSingle shared memory or store keyKey history by session or thread ID

How should I choose a memory type and debug the final prompt?

Every stored turn is re-sent on each call, so cost and latency grow with conversation length, and eventually you hit the context window. Pick the memory that fits the conversation shape.

Buffer keeps everything: simplest and exact, but unbounded. Window keeps the last k turns (k of 3 to 6 is a common starting point): predictable cost, but loses early facts. Summary compresses old turns with an extra LLM call: bounded size, but adds cost and can drop details. In the modern API, windowing is just slicing the message list or using trim_messages before the call.

To debug, first print what the model actually sees. In the legacy chain set verbose=True. In the modern chain, attach a callback or use LangSmith tracing, or run the pieces separately.

  1. Invoke the history_aware_retriever alone with a sample history and a vague follow-up, and read the documents returned.
  2. Check that the rewritten standalone question names the subject explicitly; if it does not, tighten the condense prompt with 'do not answer, only rewrite'.
  3. Print the formatted QA prompt (qa_prompt.invoke({...})) and confirm {context} is populated.
  4. Open two sessions with different IDs and confirm neither sees the other's turns.
  5. Lower temperature to 0 for the rewrite step if the standalone questions drift.

Common questions

Should I customise condense_question_prompt or the QA prompt?

Customise the QA prompt for tone, format, language and refusal behaviour, because it produces the answer users read. Change condense_question_prompt only when rewritten questions are poor, for example when it loses product names or answers instead of rewriting.

Is ConversationBufferMemory deprecated?

Yes, the legacy memory classes and chains are deprecated in favour of passing messages explicitly, RunnableWithMessageHistory or LangGraph persistence. They still run in versions that include them, but new projects should not build on them.

Can I use a different, cheaper model for the rewrite step?

Yes. The rewrite step only needs to produce a short standalone question, so a small fast model works. Pass that model to create_history_aware_retriever and your stronger model to create_stuff_documents_chain.

How do I return source documents in the modern chain?

create_retrieval_chain already returns a context key containing the retrieved Document objects next to answer. With RunnableWithMessageHistory, keep output_messages_key='answer' so only the answer text is stored in history.

Why does my chatbot answer from general knowledge despite my prompt?

The instruction is probably too weak or placed after the context. State the rule in the system message, add the exact fallback sentence to use, and set temperature to 0. Also check that retrieval actually returned relevant chunks, since an empty context invites guessing.

Related topics:langchainragconversational-retrievalchat-memoryprompt-templates