멀티턴 대화형 RAG 챗봇 구현하기 - 대화 맥락 이어가기
지난 글에서 Hybrid Search로 검색 정확도를 끌어올리는 방법을 다뤘습니다. 그런데 지금까지 만든 RAG 챗봇은 질문 하나마다 완전히 독립적으로 답변하는 구조였습니다. 실제 대화는 이전 맥락을 이어받아야 하는 경우가 많습니다. 이번 글에서는 이전 대화 내용을 기억하고 이어가는 멀티턴 RAG 챗봇을 구현합니다.
1. 왜 단일턴 구조로는 부족한가
단일턴 RAG는 매 질문을 독립적인 것으로 취급하기 때문에, 대화가 이어질수록 문맥을 놓치는 문제가 생깁니다.
[사용자] 재택근무는 몇 회까지 가능해?
[챗봇] 재택근무는 팀장 승인 하에 주 2회까지 가능합니다.
[사용자] 승인은 누구한테 받아?
[단일턴 챗봇] "승인"이라는 단어만으로 벡터 검색
→ 재택근무 얘기인지, 연차 얘기인지, 출장 얘기인지 알 수 없음
→ 엉뚱한 문서를 검색해오거나 애매한 답변
두 번째 질문 "승인은 누구한테 받아?"는 사람이 보면 당연히 재택근무 얘기라는 걸 알지만, 챗봇은 이전 대화를 모르기 때문에 이 질문만 놓고 검색을 시도합니다.
2. 멀티턴 RAG의 핵심 아이디어 - 질문 재구성
멀티턴 대화의 핵심은 검색을 실행하기 전에, 이전 대화 맥락을 반영해서 사용자의 질문을 독립적으로 이해 가능한 형태로 먼저 바꿔주는 단계를 추가하는 것입니다.
[대화 이력]
사용자: 재택근무는 몇 회까지 가능해?
챗봇: 재택근무는 팀장 승인 하에 주 2회까지 가능합니다.
[현재 질문]
승인은 누구한테 받아?
[질문 재구성 후]
재택근무 승인은 누구에게 받아야 하나요?
이렇게 재구성된 질문으로 벡터 검색을 수행하면, 이전과 동일한 검색 파이프라인을 그대로 재사용하면서도 문맥이 반영된 정확한 검색이 가능해집니다.
3. 질문 재구성을 LLM으로 처리하기
질문 재구성 자체도 LLM 호출로 처리합니다. 별도의 복잡한 로직 없이, 대화 이력과 현재 질문을 넘겨서 "독립된 질문으로 다시 써달라"고 요청하면 됩니다.
@Service
@RequiredArgsConstructor
public class QueryRewriteService {
private final ChatClient chatClient;
public String rewrite(List<ChatMessage> history, String currentQuestion) {
if (history.isEmpty()) {
return currentQuestion;
}
String historyText = history.stream()
.map(m -> (m.isUser() ? "사용자: " : "챗봇: ") + m.getContent())
.collect(Collectors.joining("\n"));
String prompt = """
아래는 이전 대화 내용과 사용자의 새 질문이다.
새 질문이 이전 대화를 참고해야만 이해되는 표현(대명사, 생략된 주어 등)을 포함한다면,
이전 대화 맥락을 반영해서 독립적으로 이해 가능한 완전한 질문으로 다시 써라.
맥락 없이도 이미 완전한 질문이라면 그대로 반환하라.
질문 외의 다른 말은 하지 마라.
[이전 대화]
%s
[새 질문]
%s
""".formatted(historyText, currentQuestion);
return chatClient.prompt().user(prompt).call().content().trim();
}
}
4. 대화 이력 저장하기
대화 이력은 세션(또는 사용자) 단위로 관리해야 합니다. 짧은 TTL을 두고 Redis에 저장하면, DB까지 갈 필요 없이 빠르게 이전 대화를 불러올 수 있습니다.
@Service
@RequiredArgsConstructor
public class ChatHistoryService {
private final RedisTemplate<String, String> redisTemplate;
private final ObjectMapper objectMapper;
private static final Duration HISTORY_TTL = Duration.ofMinutes(30);
private static final int MAX_HISTORY_SIZE = 6;
public List<ChatMessage> getHistory(String sessionId) {
String json = redisTemplate.opsForValue().get("chat:history:" + sessionId);
if (json == null) return List.of();
return deserialize(json);
}
public void appendMessage(String sessionId, ChatMessage message) {
List<ChatMessage> history = new ArrayList<>(getHistory(sessionId));
history.add(message);
if (history.size() > MAX_HISTORY_SIZE) {
history = history.subList(history.size() - MAX_HISTORY_SIZE, history.size());
}
redisTemplate.opsForValue().set(
"chat:history:" + sessionId, serialize(history), HISTORY_TTL);
}
}
MAX_HISTORY_SIZE로 최근 대화 몇 턴만 유지하도록 제한합니다. 대화가 길어질수록 재구성 프롬프트에 넣을 텍스트가 늘어나 비용과 지연이 커지기 때문에, 최근 3턴 안팎으로 제한하는 것이 일반적입니다.
5. 전체 흐름 조합하기
질문 재구성, 대화 이력 저장, 기존 검색·생성 파이프라인을 하나로 엮습니다.
@Service
@RequiredArgsConstructor
public class MultiTurnRagChatService {
private final QueryRewriteService queryRewriteService;
private final ChatHistoryService chatHistoryService;
private final HybridSearchRepository hybridSearchRepository;
private final ChatClient chatClient;
public String answer(String sessionId, String question) {
List<ChatMessage> history = chatHistoryService.getHistory(sessionId);
String standaloneQuestion = queryRewriteService.rewrite(history, question);
List<SearchResult> results = hybridSearchRepository.hybridSearch(
embed(standaloneQuestion), standaloneQuestion, 5);
String context = results.stream()
.map(SearchResult::content)
.collect(Collectors.joining("\n\n"));
String promptText = """
다음 문서를 참고해서 질문에 답변해줘. 문서에 없는 내용은 답하지 마.
[문서]
%s
[질문]
%s
""".formatted(context, standaloneQuestion);
String answer = chatClient.prompt().user(promptText).call().content();
chatHistoryService.appendMessage(sessionId, ChatMessage.user(question));
chatHistoryService.appendMessage(sessionId, ChatMessage.assistant(answer));
return answer;
}
}
여기서 중요한 점은, 검색과 답변 생성에는 재구성된 standaloneQuestion을 쓰지만, 대화 이력에는 사용자가 실제로 입력한 원본 질문(question)을 저장한다는 것입니다. 사용자 화면에는 재구성된 문장이 아니라 실제로 입력한 문장이 그대로 남아야 자연스럽습니다.
6. 재구성 단계가 추가한 비용 고려하기
멀티턴 구조는 질문마다 LLM 호출이 하나 더 늘어난다는 트레이드오프가 있습니다.
[단일턴] 질문 → 검색 → LLM 호출 1회(답변 생성)
[멀티턴] 질문 → 질문 재구성(LLM 호출 1회) → 검색 → LLM 호출 1회(답변 생성)
이 추가 호출 비용을 줄이기 위해, 첫 턴(대화 이력이 없는 경우)은 재구성 단계를 아예 건너뛰도록 앞서 작성한 rewrite 메서드에서 이미 분기해두었습니다. 또한 재구성용 LLM 호출은 답변 생성보다 훨씬 짧고 간단한 작업이라, gpt-4o-mini처럼 비용이 낮은 모델을 별도로 지정해서 사용하는 것도 좋은 방법입니다.
7. 정리
- 멀티턴 RAG의 핵심은 검색 전에 이전 대화 맥락을 반영해 현재 질문을 독립적으로 이해 가능한 형태로 재구성하는 단계를 추가하는 것입니다.
- 대화 이력은 세션 단위로 Redis에 짧은 TTL과 최근 턴 수 제한을 두고 관리하면 충분합니다.
- 재구성 단계가 LLM 호출을 하나 늘리는 만큼, 첫 턴은 재구성을 생략하고 저비용 모델을 활용하는 방식으로 비용을 관리할 수 있습니다.
다음 글에서는 사용자마다 접근 가능한 문서가 다른 경우, 즉 권한에 따라 검색 대상을 제한하는 RAG 접근 제어를 다뤄보겠습니다.
'RAG를 활용한 LLM 서비스 구축' 카테고리의 다른 글
| STEP 19 - RAG 시스템 부하 테스트와 모니터링 (0) | 2026.07.15 |
|---|---|
| STEP 18 - 권한 기반 RAG 접근 제어 (0) | 2026.07.14 |
| STEP 16 - Hybrid Search (0) | 2026.07.13 |
| STEP 15 - 임베딩 모델 교체 시 벡터 데이터 마이그레이션하기 (0) | 2026.07.13 |
| STEP 14 - RAG 챗봇 트래픽 대응하기 (0) | 2026.07.10 |