RAG 시리즈 (18): 대화형 RAG — 다회차 대화에서의 대명사 문제
요약
본 글은 기존 RAG(Retrieval-Augmented Generation) 시스템이 단일 회차 질문에만 최적화되어 있어, 실제 대화 상황에서 발생하는 '대명사'나 생략된 주어 문제에 취약하다는 점을 지적합니다. 이를 해결하기 위해 검색 단계 이전에 LLM을 사용하여 현재 질문과 전체 대화 이력을 결합하고, 독립적이고 자기 완결적인(self-contained) 형태로 질문을 재작성하는 'History-Aware Retriever' 패턴을 제안합니다. 이 과정은 LangChain의 `RunnableBranch`와 커스텀 체인을 통해 구현됩니다.
핵심 포인트
- 기존 RAG는 단일 회차, 독립적 쿼리라는 숨겨진 가정에 의존한다.
- 실제 대화에서는 '그것(it)', '그중 하나(which one)' 같은 대명사 사용으로 인해 검색 시스템이 주어를 파악하기 어렵다.
- 해결책은 LLM을 사용하여 질문과 대화 이력을 결합하고, 독립적이고 완전한 쿼리로 재작성하는 것이다 (Query Rewriting).
- 재작성된 질문을 이용해 벡터 검색을 수행하며, 이는 LangChain의 `RunnableBranch`와 커스텀 체인으로 구현될 수 있다.
- LLM 출력 길이 제한(예: 임베딩 모델의 512 토큰)을 고려하여 재작성된 질문에서 첫 번째 줄만 추출하는 절단(truncation) 단계가 포함되었다.
단일 회차(Single-Turn) RAG의 숨겨진 가정
지금까지 이 시리즈의 모든 글은 한 가지 유형의 질문만을 다루어 왔습니다. 즉, 문서를 검색하고 답변을 생성하는 독립적이고 자기 완결적인(self-contained) 쿼리입니다. 실제 대화는 그렇게 작동하지 않습니다. "RAGAS가 무엇인가요?"라고 물은 후, 사용자는 자연스럽게 대화를 이어갑니다.
1회차: RAGAS가 무엇인가요?
2회차: 그것의 네 가지 핵심 지표는 무엇인가요?
3회차: 그중 개선하기 가장 어려운 것은 무엇이며, 그 이유는 무엇인가요?
1회차는 괜찮습니다. 2회차의 "그것(Its)"은 RAGAS를 가리킵니다. 3회차의 "그중 하나(Which one)"는 2회차에서 언급된 네 가지 지표를 가리킵니다. 인간에게 지시 대상(referent)은 명확합니다. 하지만 검색 시스템(retrieval system)에게 "그것의 네 가지 핵심 지표는 무엇인가요?"는 주어가 없는 쿼리입니다. 벡터 검색(vector search)은 "its four metrics"와 의미론적으로 유사한 문서를 찾으려 할 것이며, 이는 무엇이든 될 수 있습니다. 이것이 단일 회차 RAG의 숨겨진 가정입니다: 모든 질문은 독립적이고 완전하다는 것입니다. 후속 질문이 나타나는 순간, 이 가정은 깨집니다.
대화 이력을 인식하는 검색기(History-Aware Retriever): 검색 전 재작성하기
해결 방법은 간단합니다. 검색을 수행하기 전에, 한 번의 LLM 호출을 사용하여 현재 질문을 대화 이력(conversation history)과 결합하고, 이를 독립적이고 자기 완결적인 질문으로 재작성(rewrite)하는 것입니다. 그런 다음 재작성된 질문을 검색에 사용합니다.
1회차: RAGAS가 무엇인가요? → 직접 검색 (이력 없음)
2회차: 그것의 네 가지 지표는 무엇인가요? ↓ 1회차 이력과 결합
"RAGAS 프레임워크의 네 가지 핵심 지표는 무엇인가요?" ↓ 재작성된 질문을 사용하여 검색
3회차: 그중 개선하기 가장 어려운 것은 무엇인가요?
↓ 1회차+2회차 대화 기록과 결합: "RAGAS의 네 가지 지표 중 개선하기 가장 어려운 것은 무엇이며, 그 이유는 무엇인가요?" ↓ 재작성된 질문을 사용하여 검색
LangChain은 이 패턴을 위해 create_history_aware_retriever를 제공하지만, LLM의 장황한 출력이 임베딩 모델(embedding model)의 512-토큰 제한을 초과하는 것을 방지하기 위해, 이 구현에서는 절단(truncation) 단계를 포함하여 체인(chain)을 수동으로 구축합니다:
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnableBranch, RunnableLambda
def _extract_standalone_question(text: str) -> str:
"""
첫 번째 줄만 유지합니다. — LLM의 장황한 출력이 임베딩 모델의 512-토큰 입력 제한을 초과하는 것을 방지합니다.
"""
lines = [l.strip() for l in text.strip().split("\n") if l.strip()]
question = lines[0] if lines else text
return question[:400] # 하드 캡(hard cap)
_contextualize_chain = (
CONTEXTUALIZE_PROMPT | llm | StrOutputParser() | RunnableLambda(_extract_standalone_question)
)
# 대화 기록이 없으면 → 직접 검색; 대화 기록이 있으면 → 먼저 재작성
history_aware_retriever = RunnableBranch(
(
lambda x: not x.get("chat_history"),
(lambda x: x["input"]) | retriever,
),
_contextualize_chain | retriever,
)
아키텍처 (The Architecture)
Contextualize 프롬프트 (The Contextualize Prompt)
CONTEXTUALIZE_PROMPT = ChatPromptTemplate.from_messages([
(
"system",
"대화 기록과 최신 질문을 고려하여, 질문을 독립적이고 자기 완결적인(self-contained) 질문으로 재작성하세요.\n\n"
"요구사항:\n"
"- 모든 대명사(it, this, these, which one 등)를 구체적인 명사로 교체할 것\n"
"- 생략된 주어나 목적어를 채워 넣을 것\n"
"- 설명 없이 재작성된 질문만 출력할 것\n\n"
"만약 질문이 이미 완전하고 독립적이라면, 변경하지 말고 그대로 반환하세요."
),
])
), MessagesPlaceholder ( "chat_history" ), ( "human" , "{input}" ), ]) 주목할 만한 세 가지 설계 결정 사항:
- "질문만 출력할 것" — 이 명시적인 제약 조건이 없다면, LLM은 자신의 추론 과정을 설명하게 되어 임베딩 모델 (embedding model)이 처리할 수 있는 범위를 훨씬 초과하는 출력을 생성합니다.
- 시스템과 사용자 메시지 사이에 배치된 히스토리 — MessagesPlaceholder("chat_history")는 해당 위치에서 전체 메시지 목록으로 확장됩니다.
- 변경 없는 통과 조건 — 1회차 질문이거나 의미적으로 완전한 질문은 재작성할 필요가 없습니다. LLM에게 탈출구를 제공합니다.
Full ConvRAG 체인
1단계: 히스토리를 인식하는 검색 (History-aware retrieval)
history_aware_retriever = ... # 위 내용 참조
2단계: 검색된 문서 + 대화 히스토리를 사용하여 답변 생성
ANSWER_PROMPT = ChatPromptTemplate.from_messages([
("system", "당신은 RAG 기술 전문가입니다. 참고 자료를 바탕으로 답변하세요.\n" "참고 자료:\n" "{context}"),
MessagesPlaceholder("chat_history"), # 히스토리는 생성 과정에도 정보를 제공합니다
("human", "{input}"),
])
qa_chain = create_stuff_documents_chain(llm, ANSWER_PROMPT)
rag_chain = create_retrieval_chain(history_aware_retriever, qa_chain)
3단계: 세션 기반 히스토리 관리
store: dict[str, ChatMessageHistory] = {}
def get_session_history(session_id: str) -> ChatMessageHistory:
if session_id not in store:
store[session_id] = ChatMessageHistory()
return store[session_id]
conv_rag = RunnableWithMessageHistory(
rag_chain,
get_session_history,
input_messages_key="input",
history_messages_key="chat_history",
output_messages_key="answer",
)
각 session_id는 격리된 대화 히스토리에 매핑됩니다. RunnableWithMessageHistory는 각 invoke 이전에 히스토리를 자동으로 주입하고, 이후에 새로운 Q&A 쌍을 추가합니다.
질문 재작성 결과
세 가지 테스트 대화, 각 턴의 재작성 출력을 보여줍니다.
2턴 후속 질문: [RAGAS 후속 질문]
원본: 그것의 네 가지 핵심 지표는 무엇인가요?
재작성됨: RAGAS 프레임워크의 네 가지 핵심 지표는 무엇인가요?
[Vector DB 후속 질문] 원본: Which one is best for production? (어느 것이 프로덕션에 가장 좋나요?)
재작성됨: Among the common vector databases (Chroma, Pinecone, Milvus, Qdrant), which is most suitable for a production environment? (일반적인 벡터 데이터베이스(Chroma, Pinecone, Milvus, Qdrant) 중에서 프로덕션 환경에 가장 적합한 것은 무엇인가요?)
[Advanced RAG 후속 질문] 원본: What about Graph RAG and Agentic RAG? (Graph RAG와 Agentic RAG는 어떤가요?)
재작성됨: What problems do Graph RAG and Agentic RAG each solve? (Graph RAG와 Agentic RAG는 각각 어떤 문제를 해결하나요?)
"Its"는 "in the RAGAS framework"로 변환됩니다. "Which one"은 첫 번째 턴(Turn 1)에서 언급된 데이터베이스의 전체 목록으로 확장됩니다. 이러한 모호성 해소(disambiguation)가 없다면, 이 질문들은 쓰레기(garbage)와 같은 검색 결과를 생성합니다. 모호성을 해소하면 올바른 문서를 검색합니다.
검색 비교: 2번째 턴(Turn 2)에서의 실제 이야기
"What are its four core metrics?"로 직접 검색하는 것과 "What are the four core metrics in the RAGAS framework?"로 검색하는 것의 비교:
Baseline 검색 (원본: "What are its four core metrics?"):
doc1: RAG 핵심 워크플로우: Retrieval(검색) → Augmentation(증강) → Generation(생성). RAG는 2020년 Meta AI에 의해 소개되었습니다...
doc2: 문서 청킹(Document chunking) 전략은 RAG 검색 품질에 영향을 미칩니다: 고정 크기 청킹(fixed-size chunking, chunk_size=512-1024)은 일반적인 사례에 적합합니다...
ConvRAG 검색 (재작성됨: "What are the four core metrics in RAGAS?"):
doc1: RAGAS는 RAG 시스템을 위해 특별히 설계된 평가 프레임워크로, 2023년 Es 등이 소개했습니다. 네 가지 핵심 지표: 1. context_recall... 2. context_precision...
doc2: 임베딩 모델(Embedding models)은 텍스트를 벡터로 변환하여 의미론적 검색(semantic retrieval)의 품질 상한선을 결정합니다...
Baseline은 RAG 소개와 청킹 전략을 검색합니다. 둘 다 RAG에 관한 내용이지만, RAGAS 지표를 포함하고 있지는 않습니다. ConvRAG는 RAGAS 문서를 직접 검색합니다. 이 차이는 미미한 수준이 아니라 질적인 차이입니다.
RAGAS 지표: 흥미로운 역전 현상 ====================================================================== RAGAS 지표 비교 (Baseline vs Conversational RAG) ====================================================================== 지표 Baseline ConvRAG 차이(Delta) ────────────────────────────────────────────────────────────── context_recall 0.667 0.400 ↓-0.267 ◀ context_precision 0.880 0.870 →-0.010 faithfulness 1.000 1.000 →+0.000 answer_relevancy 0.432 0.430 →-0.002 ====================================================================== 참고: 각 3회차 대화의 마지막 턴(Turn 3)에서 평가됨
ConvRAG의 context_recall (문맥 재현율)은 Baseline보다 0.267 낮습니다. 이는 직관에 어긋나는 결과입니다. 왜 "더 나은 검색 (better retrieval)"이 더 적은 관련 문맥을 생성할까요? 그 답은 RAGAS가 실제로 무엇을 평가했는가에 있습니다.
평가는 각 대화의 3번째 턴(Turn 3)에서 수행되었습니다:
"어떤 지표를 개선하는 것이 가장 어렵습니까, 그리고 그 이유는 무엇입니까?"
"만약 우리 팀이 이제 막 RAG를 시작한다면, 어떤 데이터베이스를 선택해야 합니까?"
"이 네 가지 기술 사이의 진화적 관계는 무엇입니까?"
이 3번째 턴의 질문들은 그 자체로 의미론적(semantically)으로 완결되어 있습니다. 대화 이력이 없더라도, 이 질문들에 대해 직접 검색을 수행하면 올바른 문서를 찾아낼 수 있습니다. Baseline은 정확히 그렇게 작동하며, 잘 작동합니다.
ConvRAG는 3번째 턴의 질문을 가져와 누적된 이력을 포함하여 다시 작성(rewriting)합니다. "이 네 가지 기술 사이의 진화적 관계는 무엇입니까"라는 질문은 "Self-RAG, CRAG, Graph RAG, 그리고 Agentic RAG 사이의 진화적 관계는 무엇입니까"로 바뀔 수 있습니다. 이는 의미론적으로는 더 풍부해지지만, 변경된 문구로 인해 검색 결과가 약간 다른 문서에 도달하게 되어 context_recall을 감소시킬 수 있습니다.
RAGAS는 Conversational RAG의 핵심 가치를 포착하지 못했습니다. 그 가치는 2번째 턴(Turn 2)에 있습니다. 즉, 대명사 모호성 해소 (pronoun disambiguation)를 통해 실패할 뻔한 검색을 올바른 검색으로 바꾸는 데 있습니다. RAGAS는 질문이 이력 없이도 우연히 잘 작동했던 3번째 턴을 평가했습니다. 실험 설계가 Baseline 시나리오에 유리하게 작용하여, ConvRAG의 진정한 기여도를 가려버렸습니다.
이것은 이 시리즈에서 반복되는 주제입니다: 메트릭은 자신이 측정하는 것만 측정합니다. 항상 질문해야 합니다—그 메트릭이 실제로 어떤 시나리오를 테스트했는가? 무엇을 놓쳤는가?
| 사용 시점 | 대화형 RAG (Conversational RAG) | 시나리오 기반 RAG (Scenario Baseline RAG) |
|---|---|---|
| 모든 질문이 독립적임 | ✅ | 직접 검색(Direct retrieval), 비용 낮음 |
| 대명사( |
주요 결과:
- 대명사 모호성 해소(Pronoun disambiguation)가 핵심 문제임 — "그것의 네 가지 지표는 무엇인가요?(what are its four metrics?)"라는 질문은 완전히 무관한 문서를 검색함; 2단계(Turn 2) 검색 비교를 통해 이러한 격차를 명확히 확인할 수 있음
- 질문 재작성(Question rewriting)은 효과적임 — GLM-4-flash는 "그것의 네 가지 지표는 무엇인가요?"를 "RAGAS 프레임워크의 네 가지 핵심 지표는 무엇인가요?(what are the four core metrics of the RAGAS framework?)"로 정확하게 재작성함; 모호성 해소 품질이 견고함
- RAGAS는 역전 현상을 보임 — ConvRAG의 context_recall이 더 낮게 나타남 (0.400 vs 0.667), 이는 3단계(Turn 3) 테스트 질문들이 그 자체로 의미론적으로 완전했기 때문임; 해당 특정 질문들에 대해서는 직접 검색(Direct retrieval)이 우연히 잘 작동함
- 지표와 시나리오의 가치가 여기서 가장 극명하게 갈림 — 대화형 RAG(Conversational RAG)의 가치는 "대명사 후속 질문이 실패하는" 시나리오에 있으나, RAGAS는 이를 테스트하지 않음; 따라서 수치가 실제 이점을 반영하지 못함
본 시리즈를 관통하며: Self-RAG는 "검색해야 하는가?(should we retrieve?)"를 물었고, CRAG는 "우리가 검색한 것이 충분히 좋은가?(is what we retrieved good enough?)"를 물었으며, Graph RAG는 관계적 추론(Relational reasoning)을 처리했고, Agentic RAG는 이들을 하나의 결정 루프(Decision loop)로 통합했으며, 이제 Conversational RAG는 시간적 차원(Temporal dimension)을 처리하여 각 질문이 이전 내용을 인지하도록 만듭니다 — 각각의 방식은 시스템이 올바르게 처리할 수 있는 시나리오의 범위를 확장합니다.
참고 문헌
LangChain Conversational RAG Documentation
RunnableWithMessageHistory API Reference
RAGAS: Automated Evaluation of Retrieval Augmented Generation (Es et al., 2023)
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기