11. 会話型RAG: 検索にメモリを追加する
第10章では、RAGシステムの検索品質を改善しました。しかし、まだ1つの制約が残っています。それは、すべての質問が個別に扱われるという点です。ユーザーが「返金ポリシーは何ですか?」と尋ねると、システムは関連する内容をドキュメントから見つけて回答します。次の質問が来ると、システムは前回の会話の記憶を一切持たないまま、その質問に回答します。
これが実際の会話でなぜ問題になるのか見てみましょう。ユーザーが「返金ポリシーは何ですか?」と尋ね、続けて「それはデジタル製品にも適用されますか?」とフォローアップ質問をします。このフォローアップ質問は前のターンの「返金ポリシー」の文脈を前提としていますが、質問文そのものにはそのような情報が一切含まれていません。「それはデジタル製品にも適用されますか?」をそのまま検索クエリとして使うと、レトリーバーは「デジタル製品」に関する無関係な情報(例えば価格や仕様)を取得してしまい、RAGシステムはユーザーの意図に合わない回答を生成してしまいます。
この章では、この問題を解決する方法を学びます。曖昧なフォローアップ質問を完全な質問に書き換える方法を学び、その技術を使って会話型RAGシステムを構築し、会話が長くなるにつれて会話履歴を管理する方法を扱います。
11.1) フォローアップ質問を完全な質問に書き換える
冒頭で見たように、フォローアップ質問は前の会話の文脈の上に成り立っているため、人は多くの情報を省略しがちです。その結果、フォローアップ質問はそれ単体では不完全であることがよくあります。これをどう解決すればよいでしょうか?
第8章では、各メッセージとともに会話履歴を渡すことで、LLMが会話の文脈を理解できるようにする方法を学びました。ここでも同じアプローチを適用できます。フォローアップ質問を会話履歴とともにLLMに渡し、文脈を反映した完全な質問に書き換えるよう依頼します。例えば、「それはデジタル製品にも適用されますか?」というフォローアップ質問は、会話履歴とともに「デジタル製品は返金の対象ですか?」に書き換えられます。この書き換えられた質問を使えば、デジタル製品の返金ポリシーに関する適切なドキュメントを検索で見つけられます。
この技術をクエリ書き換え(query rewriting)と呼びます。そのためのシステムプロンプトを作成しましょう。
from langchain_openai import ChatOpenAI
from langchain_core.messages import SystemMessage, HumanMessage, AIMessage
llm = ChatOpenAI(model="gpt-5-mini")
system_prompt = (
"Given a chat history and the latest user question "
"which might reference context in the chat history, "
"formulate a standalone question "
"which can be understood without the chat history. "
"Do NOT answer the question, just reformulate it if needed "
"and otherwise return it as is."
)このシステムプロンプトの核心となる指示は「チャット履歴を使ってフォローアップ質問を完全な質問に書き換える」ことです。2つの具体的な指示が重要です。
1つ目は、「Do NOT answer the question, just reformulate it.」です。これはLLMに、質問に回答するのではなく、質問を書き換えるだけにするよう伝えています。この指示がないと、LLMは質問を書き換える代わりに回答しようとする傾向があります。ここで求めているのは回答ではなく、チャット履歴なしでも理解できる完全な質問です。
2つ目は、「otherwise return it as is.」です。これはLLMに、書き換えが不要な場合は質問をそのままにしておくよう伝えています。これがないと、LLMは不必要に質問を言い換えてしまい、元の意味や範囲を変えてしまう可能性があります。
それでは、このシステムプロンプトを使って、実際にフォローアップ質問を書き換えてみましょう。
messages = [
SystemMessage(content=system_prompt),
# チャット履歴
HumanMessage(content="What is your refund policy?"),
AIMessage(content="All physical products may be returned within 30 days of purchase for a full refund."),
# フォローアップ質問
HumanMessage(content="Does that apply to digital products too?"),
]
response = llm.invoke(messages)
print(response.content)出力:
Are digital products eligible for a refund?LLMは会話履歴を読み、この質問が「返金ポリシー」についてのものであることを認識し、完全な質問に書き換えました。この書き換えられた質問で検索すれば、ユーザーの意図に合致するドキュメントが返ってきます。
次のセクションでは、この書き換えステップをRAGパイプラインに統合し、書き換え、検索、回答生成がすべて1回の呼び出しで行われるようにします。
11.2) 会話型RAGの構築
前のセクションでは、会話履歴をLLMに渡すことで、フォローアップ質問を完全な質問に書き換える方法を学びました。ここでは、この書き換えステップをRAGパイプラインに統合し、書き換え → 検索 → 回答生成がすべて1回の呼び出しで行われる会話型RAGを構築します。
LangChainは会話型RAGを構築するためのチェーンユーティリティ(create_history_aware_retriever、create_retrieval_chainなど)を提供していますが、これらの関数はlangchain-classicパッケージに含まれており、このパッケージは2026年12月にサポート終了を迎えます。現在、公式のLangChainドキュメントでは、代わりにエージェントを使うことが推奨されています。
そこで、この章ではエージェントを使って会話型RAGを実装します。エージェントについては第V部(第15〜17章)で詳しく扱うので、ここでは会話型RAGの実装に必要なものだけを紹介します。
11.2.1) ここで使うエージェントのコンポーネント
第5章では、エージェントの核心となる概念を簡単に見ました。LLMがユーザーのリクエストを分析し、どのツールを使うかを決定すると、システムがその決定を実行します。当時はこのプロセスを手動で実装しましたが、LangChainはこれをはるかにシンプルにするAPIを提供しています。ここで使う3つのコンポーネントを簡単に紹介します。
@tool: 通常のPython関数を、エージェントが使えるツールに変換するデコレータです。エージェントは、ユーザーのリクエストに基づいて、登録されたツールの中から適切なツールを自律的に選択して呼び出します。
create_agent: LLM、ツールのリスト、システムプロンプトを受け取ってエージェントを作成する関数です。エージェントの決定・実行のフローを内部で処理します。
InMemorySaver: 会話履歴を自動的に管理するチェックポインターです。会話をthread_idごとに整理するので、同じthread_idでエージェントが呼び出されると、前回の会話履歴を自動的に読み込みます。
11.2.2) 検索ツールの作成
まず、第10章で構築したベクトルストア検索を、エージェントが使えるツールに変換しましょう。
from langchain.tools import tool
from langchain_openai import OpenAIEmbeddings
from langchain_chroma import Chroma
# 第10章で構築したベクトルストアに接続します
embedding_model = OpenAIEmbeddings(model="text-embedding-3-small")
vector_store = Chroma(
persist_directory="data/chroma_db",
collection_name="company_docs",
embedding_function=embedding_model,
)
@tool
def retrieve_context(query: str):
"""クエリに関連する内容をドキュメントから検索します。"""
retrieved_docs = vector_store.similarity_search(query, k=3)
serialized = "\n\n".join(
f"Source: {doc.metadata['source']}\nContent: {doc.page_content}"
for doc in retrieved_docs
)
return serialized@toolデコレータは、retrieve_context関数をエージェントが使えるツールに変換します。エージェントは、ユーザーの質問に基づいて、このツールを呼び出すかどうかを自律的に決定します。
11.2.3) エージェントの作成
検索ツール、システムプロンプト、チェックポインターをcreate_agentに渡してエージェントを作成します。
from langchain.agents import create_agent
from langgraph.checkpoint.memory import InMemorySaver # langchain とともに自動でインストールされます
agent = create_agent(
model="gpt-5-mini",
tools=[retrieve_context],
system_prompt=(
"You are a helpful assistant that answers questions about company policies. "
"Use the retrieve_context tool to search for relevant information. "
"If the retrieved context does not contain relevant information, "
"say that you don't know. "
"Keep the answer concise, three sentences maximum."
),
checkpointer=InMemorySaver(),
)model: エージェントが使用するLLMです。tools: エージェントが利用できるツールのリストです。先ほど作成したドキュメント検索ツール(retrieve_context)を登録します。system_prompt: エージェントの動作指示です。エージェントに、会社のポリシーに関する質問に答えるために検索ツールを使うこと、そして取得した文脈に関連情報がない場合はわからないと答えることを伝えます。checkpointer: 会話履歴を自動的に管理します。InMemorySaver()は会話をメモリに保存し、第8章で手動で管理していた会話履歴を自動的に扱います。
エージェントはユーザーの質問を受け取ると、会話履歴を参照し、ベクトルストアでのドキュメント検索が必要かどうかを判断します。必要であれば、retrieve_contextツールを呼び出して関連するドキュメントを取得し、LLMを通じて回答を生成します。会話履歴はInMemorySaverによって自動的に管理されます。
11.2.4) マルチターン会話の実行
実際に2ターンの会話を実行して、フォローアップ質問を正しく処理できるか確認しましょう。
# thread_id は会話を区別する識別子です
# 同じ thread_id を使うと同じ会話を継続します
thread_config = {"configurable": {"thread_id": "1"}}
# --- ターン1: 完全な質問 ---
response1 = agent.invoke(
{"messages": [{"role": "user", "content": "What is your refund policy?"}]},
thread_config,
)
print("Q: What is your refund policy?")
print("A:", response1["messages"][-1].content)
# --- ターン2: ターン1に依存するフォローアップ質問 ---
response2 = agent.invoke(
{"messages": [{"role": "user", "content": "Does that apply to digital products too?"}]},
thread_config,
)
print("\nQ: Does that apply to digital products too?")
print("A:", response2["messages"][-1].content)出力:
Q: What is your refund policy?
A: All physical products may be returned within 30 days of purchase for a full refund.
The original receipt or order confirmation email is required, and items must be in
their original packaging and unused condition.
After 30 days, returns are accepted for store credit only.
Q: Does that apply to digital products too?
A: Digital products (software licenses, e-books, online courses) are non-refundable
once the download or access link has been activated.
However, if you experience technical issues preventing access, you can contact support
within 7 days for a replacement or refund.2ターン目では「Does that apply to digital products too?」を渡しましたが、エージェントは会話履歴からこれが返金ポリシーに関するフォローアップ質問であることを認識し、返金ポリシーのドキュメントからデジタル製品のセクションを正確に取得しました。
ちょっと待ってください — このエージェントには、セクション11.1で学んだクエリ書き換えのステップがありません。それなのに、なぜフォローアップ質問が正しく処理されたのでしょうか? LLMはツール(
@toolデコレータが付いた関数)を呼び出す際、ツールの引数を自ら生成します。retrieve_contextに渡すユーザーの質問もLLMが生成しますが、その際に会話履歴全体を参照し、フォローアップ質問を完全な質問に書き換えてから呼び出しを行ったのです。専用の書き換えステップを設けていないにもかかわらず、ツール呼び出しの過程でクエリ書き換えが行われたということです。また、会話履歴を手動で管理しなかった点にも注目してください。
InMemorySaverがthread_idごとに会話履歴を自動的に管理します。
次のセクションでは、会話が長くなり履歴が大きくなるにつれて生じる問題と、その解決方法を扱います。
11.3) 長い会話の管理
私たちが構築した会話型RAGは最初はうまく動作しますが、会話が長くなるにつれて問題が生じることがあります。第8章で学んだように、LLMには1回の呼び出しで処理できる最大入力サイズがあります。システムプロンプト、会話履歴、取得したドキュメント、そしてユーザーの質問は、すべてこの制限内に収まる必要があります。
会話が長くなると、会話履歴がより多くのトークンを占めるようになり、やがて最大入力サイズを超えてAPI呼び出しが失敗します。また、トークン単位で課金されるため、呼び出しのたびにコストも増加します。つまり、会話履歴のサイズを管理する必要があるということです。
第8章では、この問題をスライディングウィンドウで解決しました。最新のN件のメッセージだけを保持し、古いものを破棄するという方法です。同じ考え方がエージェント環境にも当てはまります。create_agentはミドルウェアをサポートしています。ミドルウェアとは、LLMが呼び出される前にメッセージを変更できる処理ステップです。ミドルウェアを使って古い履歴を切り詰めることができます。
11.3.1) ミドルウェアによる履歴の制限
@before_modelデコレータは、セクション11.2で見た@toolデコレータと同様に動作します。@toolが関数をエージェントが使えるツールに変換するのと同じように、@before_modelは関数を、各LLM呼び出しの前に実行されるミドルウェアに変換します。変換されたミドルウェアは、create_agentのmiddlewareパラメータに登録することで有効になります。
from langchain.agents import create_agent, AgentState
from langchain.agents.middleware import before_model
from langchain.messages import RemoveMessage
from langgraph.graph.message import REMOVE_ALL_MESSAGES
@before_model
def trim_old_messages(state: AgentState, runtime) -> dict | None:
"""各LLM呼び出しの前に古いメッセージを削除します。"""
messages = state["messages"]
# メッセージが十分に少ない場合は何もしません
if len(messages) <= 10:
return None
# システムメッセージ(最初)と最新の10件のメッセージだけを保持します
return {
"messages": [
RemoveMessage(id=REMOVE_ALL_MESSAGES),
messages[0], # システムメッセージ
*messages[-10:], # 最新の10件のメッセージ(5ターン分)
]
}AgentStateはエージェントの状態データを保持するオブジェクトで、state["messages"]にはこれまでの会話メッセージのリストが含まれます。ミドルウェアの戻り値が、この会話リストをどのように変更するかを決定します。
Noneを返すと、既存のエージェントの状態データは変更されません。- 辞書を返すと、その内容が既存のメッセージリストに適用されます。上記のコードでは、
RemoveMessage(id=REMOVE_ALL_MESSAGES)がまず既存のすべてのメッセージを削除し、その後システムメッセージと最新の10件のメッセージだけを追加し直します。その結果、これらのメッセージだけがLLMに渡されます。
このミドルウェアをエージェントに登録します。
agent = create_agent(
model="gpt-5-mini",
tools=[retrieve_context],
system_prompt=(
"You are a helpful assistant that answers questions about company policies. "
"Use the retrieve_context tool to search for relevant information. "
"If the retrieved context does not contain relevant information, "
"say that you don't know. "
"Keep the answer concise, three sentences maximum."
),
checkpointer=InMemorySaver(),
middleware=[trim_old_messages], # ミドルウェアを登録します
)これはセクション11.2のエージェントにmiddleware=[trim_old_messages]を追加したものです。これで、会話がどれだけ長くなっても、最近のメッセージだけがLLMに渡されます。
11.3.2) スライディングウィンドウのトレードオフ
古いメッセージが切り詰められると、エージェントはその内容を参照できなくなります。ユーザーが10ターン前に尋ねたことを持ち出しても、エージェントにはその文脈を知る術がありません。これはスライディングウィンドウのアプローチの根本的な制約です。
古い会話内容を保持する必要がある場合、代替手段として、古いメッセージを削除する代わりにLLMが生成した要約で置き換える方法があります。LangChainはこの目的のためにSummarizationMiddlewareを提供しており、エージェントとグラフのアーキテクチャを掘り下げる第V部(第15章以降)で扱います。