RAG 챗봇 만들기 8 - 검색과 응답 생성
처음에 검색된 문서를 그냥 LLM에게 던졌더니 답변이 두서없이 나왔다. 프롬프트 템플릿을 제대로 구성하니 훨씬 깔끔해졌다. “다음 문서를 참고해서 답변하라”고 명시하는 것이 생각보다 중요하다.
오늘은 드디어 RAG의 핵심인 검색 + 응답 생성을 다룬다. 지금까지 준비한 것을 연결해서 실제로 대화할 수 있게 만들어본다.
RAG 체인 구성
검색과 응답 생성을 연결한 것을 “RAG 체인”이라고 한다.
[사용자 질문]
↓
[벡터 저장소에서 관련 문서 검색]
↓
[검색된 문서 + 질문을 프롬프트로 구성]
↓
[LLM이 답변 생성]
↓
[답변 반환]
기본 RAG 체인 만들기
필요한 것들 준비
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.chains import RetrievalQA
# LLM
llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
# 임베딩 모델
embeddings = OpenAIEmbeddings()
# 벡터 저장소 (이전에 만든 것)
vectorstore = Chroma(
persist_directory="./chroma_db",
embedding_function=embeddings
)
# Retriever 생성
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
RetrievalQA 체인
# RAG 체인 생성
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff", # 문서를 하나로 합쳐서 전달
retriever=retriever,
return_source_documents=True # 출처 문서도 반환
)
# 질문하기
query = "연차 휴가는 며칠이에요?"
result = qa_chain.invoke({"query": query})
print(f"질문: {query}")
print(f"\n답변: {result['result']}")
print(f"\n참고 문서:")
for doc in result['source_documents']:
print(f"- {doc.page_content[:100]}...")
실행 결과:
질문: 연차 휴가는 며칠이에요?
답변: 연차 휴가는 매년 15일이 부여됩니다.
참고 문서:
- 제15조(연차휴가) 연차유급휴가는 매년 1월 1일에 15일이 일괄 부여된다...
프롬프트 커스터마이징
기본 프롬프트를 변경해서 답변 스타일을 조절할 수 있다.
커스텀 프롬프트 템플릿
from langchain.prompts import PromptTemplate
template = """다음 문서들을 참고해서 질문에 답변해주세요.
문서에 없는 내용은 "해당 정보를 찾을 수 없습니다"라고 말해주세요.
답변은 친절하고 명확하게 해주세요.
참고 문서:
{context}
질문: {question}
답변:"""
prompt = PromptTemplate(
template=template,
input_variables=["context", "question"]
)
# 커스텀 프롬프트로 체인 생성
qa_chain = RetrievalQA.from_chain_type(
llm=llm,
chain_type="stuff",
retriever=retriever,
chain_type_kwargs={"prompt": prompt},
return_source_documents=True
)
한국어 최적화 프롬프트
korean_template = """당신은 회사 규정에 대해 답변하는 HR 도우미입니다.
아래 문서를 참고하여 직원의 질문에 답변해주세요.
- 문서에 있는 내용만 답변하세요
- 모르는 건 모른다고 솔직히 말하세요
- 존댓말을 사용하세요
- 관련 조항이 있으면 함께 알려주세요
참고 문서:
{context}
직원 질문: {question}
답변:"""
prompt = PromptTemplate(
template=korean_template,
input_variables=["context", "question"]
)
LCEL로 RAG 체인 만들기
최신 LangChain 방식인 LCEL(LangChain Expression Language)로 만들면 더 유연하다.
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
# 프롬프트
prompt = ChatPromptTemplate.from_template("""
다음 문서를 참고해서 질문에 답변해주세요.
문서:
{context}
질문: {question}
답변:""")
# 문서를 텍스트로 변환하는 함수
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
# RAG 체인 구성
rag_chain = (
{
"context": retriever | format_docs,
"question": RunnablePassthrough()
}
| prompt
| llm
| StrOutputParser()
)
# 실행
answer = rag_chain.invoke("병가는 며칠까지 쓸 수 있어요?")
print(answer)
실행 결과:
병가는 연간 60일까지 사용하실 수 있습니다.
다만, 60일을 초과하는 경우에는 무급휴직으로 전환될 수 있으니
HR팀에 문의해주시기 바랍니다.
대화 기록 유지하기
이전 대화를 기억하게 만들 수 있다.
from langchain.memory import ConversationBufferMemory
from langchain.chains import ConversationalRetrievalChain
# 메모리 설정
memory = ConversationBufferMemory(
memory_key="chat_history",
return_messages=True,
output_key="answer"
)
# 대화형 RAG 체인
conv_chain = ConversationalRetrievalChain.from_llm(
llm=llm,
retriever=retriever,
memory=memory,
return_source_documents=True
)
# 첫 번째 질문
result1 = conv_chain.invoke({"question": "연차는 며칠이에요?"})
print(f"Q: 연차는 며칠이에요?")
print(f"A: {result1['answer']}\n")
# 두 번째 질문 (이전 대화 참조)
result2 = conv_chain.invoke({"question": "그럼 이월은 되나요?"})
print(f"Q: 그럼 이월은 되나요?")
print(f"A: {result2['answer']}")
실행 결과:
Q: 연차는 며칠이에요?
A: 연차는 매년 15일이 부여됩니다.
Q: 그럼 이월은 되나요?
A: 미사용 연차는 다음 해로 이월되지 않습니다.
단, 연말 3일 이내 미사용분은 수당으로 정산됩니다.
전체 코드
지금까지 만든 것을 합치면 다음과 같다:
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.chains import ConversationalRetrievalChain
from langchain.memory import ConversationBufferMemory
from langchain.prompts import PromptTemplate
class RAGChatbot:
def __init__(self, db_path: str = "./chroma_db"):
# LLM
self.llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
# 벡터 저장소
embeddings = OpenAIEmbeddings()
vectorstore = Chroma(
persist_directory=db_path,
embedding_function=embeddings
)
# 메모리
self.memory = ConversationBufferMemory(
memory_key="chat_history",
return_messages=True,
output_key="answer"
)
# RAG 체인
self.chain = ConversationalRetrievalChain.from_llm(
llm=self.llm,
retriever=vectorstore.as_retriever(search_kwargs={"k": 3}),
memory=self.memory,
return_source_documents=True
)
def chat(self, question: str) -> dict:
"""질문하고 답변 받기"""
result = self.chain.invoke({"question": question})
return {
"answer": result["answer"],
"sources": [doc.page_content[:100] for doc in result["source_documents"]]
}
def clear_memory(self):
"""대화 기록 초기화"""
self.memory.clear()
# 사용
chatbot = RAGChatbot()
# 대화
response = chatbot.chat("연차 규정 알려줘")
print(f"답변: {response['answer']}")
print(f"출처: {response['sources']}")
운영자 실전 노트
실제 프로젝트 진행하며 겪은 문제
- 환각 현상 심각: 프롬프트에 “문서에 없으면 ‘모르겠습니다’ 답변” 명시 안 하니 거짓 정보 생성 → 명시적 제약 추가로 80% 개선
- k=3이 항상 최적은 아님: 특정 도메인에서는 k=5가 더 나은 결과 → 도메인별로 실험 필수
- 프롬프트 길이 초과: 청크 크기가 크고 k=5일 때 토큰 한도 초과 에러 → 청크 크기 줄이거나 k 값 조정
- 대화 맥락 손실: ConversationBufferMemory가 길어지면 응답 느려짐 → ConversationSummaryMemory로 교체
이 경험을 통해 알게 된 점
- 프롬프트 엔지니어링이 핵심: 역할, 제약사항, 출력 형식 명시가 품질의 80% 결정
- k값은 실험으로 결정: 도메인마다 최적값이 다르므로 A/B 테스트 필요
- 온도(temperature) 0이 정확: RAG는 창의성보다 정확성이 중요하므로 0 권장
마무리
프롬프트 템플릿을 어떻게 구성하느냐에 따라 답변 품질이 크게 달라진다. “문서에 없으면 모른다고 답변하라”고 명시하면 환각(hallucination)을 상당히 줄일 수 있다. 일반적으로 프롬프트에 역할(예: HR 도우미), 제약사항(문서 기반만), 출력 형식을 명시하는 것이 좋다.
다음 편에서는 Streamlit으로 웹 UI를 만든다. 터미널이 아닌 브라우저에서 채팅할 수 있게 된다.
RAG 챗봇 만들기 시리즈:
← 블로그 목록으로