11. 대화형 RAG: 검색에 메모리 추가하기
10장에서 우리는 RAG 시스템의 검색 품질을 향상시켰습니다. 하지만 우리 RAG 시스템에는 아직 한 가지 제약이 있습니다. 모든 질문을 개별적으로 처리한다는 점입니다. 사용자가 "환불 정책이 어떻게 되나요?"라고 물으면, 우리 시스템은 문서에서 질문과 관련된 내용을 찾아서 답변합니다. 그리고 그다음 질문이 들어오면, 이전 대화 내용을 전혀 기억하지 못한 채 새로운 질문에 대한 답변만 합니다.
실제 대화에서 이것이 왜 문제가 되는지 보겠습니다. 사용자가 "환불 정책이 어떻게 되나요?"라고 물은 다음, "디지털 제품도 해당되나요?"라고 후속 질문을 합니다. 이 후속 질문은 이전 턴의 "환불 정책"을 전제로 하고 있지만, 질문 자체에는 그 정보가 없습니다. 만약 "디지털 제품도 해당되나요?"를 그대로 검색 쿼리로 사용한다면, 리트리버는 "디지털 제품"에 관련된 엉뚱한 정보(예를 들어, 디지털 제품의 가격이나 사양 등)를 가져오게 되고, 결국 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."
)이 시스템 프롬프트의 핵심은 "대화 히스토리를 참고하여 후속 질문을 완전한 질문으로 재작성하라"는 것입니다. 이 프롬프트에는 두 가지 중요한 지시사항이 포함되어 있습니다.
첫째, "Do NOT answer the question, just reformulate it." 질문에 답하지 말고, 재작성만 하라는 지시입니다. 이 지시가 없으면 LLM은 질문을 재작성하는 대신 답변을 하려고 합니다. 여기서 우리가 원하는 것은 답변이 아니라 대화 히스토리가 없어도 이해할 수 있는 완전한 질문으로 바꾸는 것입니다.
둘째, "otherwise return it as is." 재작성이 필요 없으면 그대로 반환하라는 지시입니다. 이 지시가 없으면 LLM이 불필요하게 질문을 다른 말로 바꿔서 원래 의미나 범위가 달라질 수 있습니다.
이제 이 시스템 프롬프트를 사용해서 실제로 후속 질문을 재작성해 보겠습니다.
messages = [
SystemMessage(content=system_prompt),
# 대화 히스토리
HumanMessage(content="환불 정책이 어떻게 되나요?"),
AIMessage(content="모든 물리적 제품은 구매 후 30일 이내에 전액 환불이 가능합니다."),
# 후속 질문
HumanMessage(content="디지털 제품도 해당되나요?"),
]
response = llm.invoke(messages)
print(response.content)출력:
디지털 제품도 환불이 가능한가요?LLM은 대화 히스토리를 읽고 "환불 정책"에 대해 묻는 것을 파악하여, 완전한 질문으로 재작성했습니다. 이 재작성된 질문으로 검색하면 사용자의 의도에 맞는 문서를 찾을 수 있습니다.
다음 섹션에서는 이 재작성 단계를 RAG 파이프라인에 통합하여, 재작성 → 검색 → 답변 생성이 하나의 호출로 동작하는 대화형 RAG를 구축하겠습니다.
11.2) 대화형 RAG 구축하기
앞에서 우리는 대화 히스토리와 후속 질문을 LLM에게 전달하여 완전한 질문으로 재작성하는 방법을 배웠습니다. 이제 이 재작성 단계를 RAG 파이프라인에 통합하여, 재작성 → 검색 → 답변 생성이 하나의 호출로 동작하는 대화형 RAG를 구축하겠습니다.
LangChain은 대화형 RAG를 구축하기 위한 체인 유틸리티(create_history_aware_retriever, create_retrieval_chain 등)를 제공하고 있지만, 이 함수들은 현재 langchain-classic 패키지에 있으며 2026년 12월에 지원이 종료될 예정입니다. LangChain 공식 문서에서는 이 함수들 대신 에이전트를 사용하는 방식을 권장하고 있습니다.
따라서 이 장에서는 에이전트를 사용하여 대화형 RAG를 구현하겠습니다. 에이전트에 대한 자세한 내용은 Part V(15~17장)에서 다루므로, 여기서는 대화형 RAG 구현에 필요한 정도로만 간단히 소개하고 넘어갑니다.
11.2.1) 여기서 사용할 에이전트 구성 요소
5장에서 우리는 에이전트의 핵심 개념을 잠깐 살펴보았습니다. LLM이 사용자의 요청을 분석하여 어떤 도구를 사용할지 결정하면, 시스템이 그 결정을 실행하는 방식이었습니다. 당시에는 이 과정을 직접 구현했지만, LangChain은 이를 간편하게 해주는 API를 제공합니다. 여기서 사용할 세 가지 구성 요소를 간단히 소개하겠습니다.
@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) 멀티턴 대화 실행하기
에이전트를 실행하여 후속 질문이 올바르게 처리되는지 확인하겠습니다.
# thread_id는 대화를 구분하는 식별자입니다
# 같은 thread_id를 사용하면 같은 대화로 계속 이어집니다.
thread_config = {"configurable": {"thread_id": "1"}}
# --- 턴 1: 완전한 질문 ---
response1 = agent.invoke(
{"messages": [{"role": "user", "content": "환불 정책이 어떻게 되나요?"}]},
thread_config,
)
print("Q: 환불 정책이 어떻게 되나요?")
print("A:", response1["messages"][-1].content)
# --- 턴 2: 이전 턴에 의존하는 후속 질문 ---
response2 = agent.invoke(
{"messages": [{"role": "user", "content": "디지털 제품도 해당되나요?"}]},
thread_config,
)
print("\nQ: 디지털 제품도 해당되나요?")
print("A:", response2["messages"][-1].content)출력:
Q: 환불 정책이 어떻게 되나요?
A: 모든 물리적 제품은 구매 후 30일 이내에 전액 환불이 가능합니다.
원본 영수증 또는 주문 확인 이메일이 필요하며, 제품은 원래 포장 상태이고 사용되지 않은 상태여야 합니다.
30일 이후에는 스토어 크레딧으로만 반품이 가능합니다.
Q: 디지털 제품도 해당되나요?
A: 디지털 제품(소프트웨어 라이선스, 전자책, 온라인 강좌)은 다운로드 또는 액세스 링크가 활성화되면 환불이 불가능합니다.
다만 기술적 문제가 발생하면 7일 이내에 지원팀에 연락하여 교체 또는 환불을 받을 수 있습니다.두 번째 턴에서 "디지털 제품도 해당되나요?"를 전달했지만, 에이전트는 대화 히스토리를 보고 이것이 환불 정책에 대한 후속 질문이라는 것을 파악하여 환불 정책 문서에서 디지털 제품 관련 내용을 정확히 찾아왔습니다.
잠깐! 이 에이전트에는 11.1에서 배운 쿼리 재작성 단계가 없는데, 어떻게 후속 질문이 제대로 처리된 걸까요? LLM은 도구(
@tool데코레이터가 붙은 함수)를 호출할 때 도구의 파라미터를 직접 생성합니다.retrieve_context에 넘기는 사용자 질문도 LLM이 만드는데, 이때 대화 히스토리 전체를 참고하여 후속 질문을 완전한 질문으로 바꿔 넣은 것입니다. 별도의 쿼리 재작성 단계를 두지 않았지만, 도구 호출 과정에서 쿼리 재작성이 일어난 것입니다.또한 대화 히스토리를 수동으로 관리하지 않았다는 점에 주목하세요.
InMemorySaver가thread_id별로 대화 내역을 자동으로 관리합니다.
다음 섹션에서는 대화가 길어질수록 히스토리가 커지면서 발생하는 문제와 그 해결 방법을 다루겠습니다.
11.3) 길어지는 대화 관리하기
앞에서 구축한 대화형 RAG는 처음에는 잘 동작하지만, 대화가 길어지면 문제가 생길 수 있습니다. 8장에서 배운 것처럼, LLM에는 한 번에 처리할 수 있는 최대 입력 크기가 정해져 있습니다. 시스템 프롬프트, 대화 히스토리, 검색된 문서, 사용자 질문 모두가 이 안에 들어가야 합니다.
대화가 길어져서 히스토리의 크기가 커지면, 최대 입력 크기를 초과하게 되면서 API 호출이 실패할 수 있습니다. 또한 토큰 개수 단위로 요금이 부과되므로, 히스토리가 커질수록 매 호출마다 비용도 증가합니다. 따라서 대화 히스토리의 크기를 적절히 관리해야 합니다.
8장에서 우리는 이 문제를 슬라이딩 윈도우로 해결했습니다. 가장 최근 N개의 메시지만 유지하고 오래된 메시지를 버리는 방식이었습니다. 에이전트 환경에서도 같은 개념을 적용할 수 있습니다. create_agent는 미들웨어(middleware) 기능을 지원하는데, 미들웨어란 LLM을 호출하기 전에 메시지를 가공하는 중간 처리 단계입니다. 이 미들웨어를 사용해 오래된 히스토리를 잘라낼 수 있습니다.
11.3.1) 미들웨어로 히스토리 제한하기
@before_model 데코레이터는 11.2에서 본 @tool과 비슷한 역할을 합니다. @tool이 함수를 에이전트가 사용할 수 있는 도구로 변환했던 것처럼, @before_model은 함수를 LLM 호출 전에 실행되는 미들웨어로 변환합니다. 변환된 미들웨어를 create_agent의 middleware 파라미터에 등록하면 LLM 호출 전에 실행됩니다.
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) 슬라이딩 윈도우의 트레이드오프
오래된 메시지를 잘라내면 에이전트는 그 내용을 더 이상 참조할 수 없습니다. 사용자가 열 턴 전에 물었던 내용을 다시 언급하면, 에이전트는 그 맥락을 알 수 없는 것입니다. 이것은 슬라이딩 윈도우 방식의 근본적인 한계입니다.
오래된 대화 내용을 보존해야 하는 경우에는, 오래된 메시지를 삭제하는 대신 LLM이 생성한 요약으로 교체하는 방법이 있습니다. LangChain은 이를 위한 SummarizationMiddleware를 제공하며, 에이전트와 그래프 아키텍처를 본격적으로 다루는 Part V(15장~)에서 다룹니다.