Q&A 애플리케이션에서 사용자가 답변을 생성할 때 사용된 출처를 보여주는 것이 중요할 때가 있다. 이를 가장 간단하게 구현하는 방법은 체인이 각 생성 과정에서 검색된 문서를 반환하는 것이다. 일반적인 RAG 방식을 사용하면 답변 결과만을 리턴하여 source 문서를 확인할 수 없다. 그래서 소스 문서를 같이 리턴하도록 하는 방법을 알아보자.
방법은 아래와 같이 3가지 방법이 있다.
- create_retrieval_chain을 사용하는 방법
- 간단한 LCEL 구현을 사용하는 방법
- response model을 사용하는 방법
기본 구조
우선 vectore store는 chroma db를 사용할 것이며, 관련 라이브러리 설치 및 환경세팅하는 방법은 제외한다.
샘플 데이터 임베딩
아래 news.txt파일을 임베딩하여 chroma에 입력한다.
[news.txt]
40대 직장인 한모 씨는 올해 여름휴가를 예년보다 앞당겨 6월에 쓰기로 했다. 평소 7~8월에 휴가를 다녀온 그는 "어딜 가도 사람 많고 더운 7~8월을 피해 6월에 다녀오려 한다. 마침 항공권도 구했고 비용도 성수기보다는 싸서 큰 마음 먹고 결정했다"고 말했다.
...
...
...
이처럼 무더운 여름을 피해 여행을 떠나는 이들이 늘고 있다.
인터파크트리플은 최대 3만 원까지 할인받을 수 있는 쿠폰을 지급한다. 야놀자는 최대 5만원 할인 등 쿠폰 팩을, 마이리얼트립은 추가 3만원 할인이 가능한 1+1 쿠폰 등을 선보인다. 쿠팡은 와우회원 가입자만 지역 호텔 예약시 최대 25%의 추가 할인 혜택을 준다.
업계 관계자는 "통상 기업들 휴가가 시작되는 3분기 폭발적으로 증가하는 여행 수요에 대응하기 위해 여행사들도 캠페인을 벌인다. 올해는 정부 지원까지 더해지면서 시기가 좀 더 앞당겨지는 추세"라고 말했다.
from dotenv import load_dotenv
from langchain_community.document_loaders import TextLoader
from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import CharacterTextSplitter
load_dotenv()
embeddings = OpenAIEmbeddings()
loader = TextLoader("./data/news.txt")
documents = loader.load()
text_splitter = CharacterTextSplitter(chunk_size=200, chunk_overlap=0)
docs = text_splitter.split_documents(documents)
# 데이터 추가
Chroma.from_documents(docs, embeddings, persist_directory="./chroma_db")
CharacterTextSplitter를 사용하고 chunk size는 200으로 한다.
1. create_retrieval_chain을 사용하는 방법
from dotenv import load_dotenv
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain.chains.retrieval import create_retrieval_chain
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
load_dotenv()
embeddings = OpenAIEmbeddings()
vector_store = Chroma(
persist_directory="./chroma_db",
embedding_function=embeddings,
)
query = "한모 씨의 여름 휴가는 언제야?"
model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
retriever = vector_store.as_retriever()
#
template = """Answer the question based only on the following context:
{context}
Question: {input}
"""
prompt = ChatPromptTemplate.from_template(template)
question_answer_chain = create_stuff_documents_chain(model, prompt)
rag_chain = create_retrieval_chain(retriever, question_answer_chain)
result = rag_chain.invoke(
{
"input": query,
}
)
print(result)
실행결과
{
'input': '한모 씨의 여름 휴가는 언제야?',
'context': [Document(metadata={'source': './data/news.txt'}, page_content='40대 직장인 한모 씨는 올해 여름휴가를 예년보다 앞당겨 6월에 쓰기로 했다. 평소 7~8월에 휴가를 다녀온 그는 "어딜 가도 사람 많고 더운 7~8월을 피해 6월에 다녀오려 한다. 마침 항공권도 구했고 비용도 성수기보다는 싸서 큰 마음 먹고 결정했다"고 말했다.'), Document(metadata={'source': './data/news.txt'}, page_content="6월이 '이른' 여행 성수기로 떠오르는 셈이다. 올해도 6월 여행을 떠나는 관광객이 늘어날 것으로 예상된다. 현충일(6월6일) 이튿날에 하루만 휴가를 사용하면 연이어 나흘을 쉴 수 있는 황금연휴가 있는 데다 여행업계도 각종 할인 프로그램을 내놓으면서다."), Document(metadata={'source': './data/news.txt'}, page_content="3일 문화체육관광부가 발표한 '2023년 국민여행조사'에 따르면 관광·휴양 목적으로 여행을 떠나는 국내 관광여행 횟수는 2023년 6월 2122만회로 전년 동월(2022년 6월 2044만회) 대비 3.8% 증가했다. 반면 여름휴가 성수기인 7~8월은 각각 2203만회(0.7% 증가), 2316만회(0.9% 감소)로 1년 전에 비해 소폭 증가하거나 감소한 것으로 나타났다."), Document(metadata={'source': './data/news.txt'}, page_content="특히 6월 한 달간 진행되는 '대한민국 숙박 세일페스타'는 국내 여행 수요를 이끌어 낼 것으로 보인다. 앞서 지난 2월과 3월 배포한 숙박 할인권은 여행 지출액 약 862억원, 지역 관광객 약 48만명 유발 효과를 낸 것으로 집계됐다. G마켓의 경우 전년 동월 대비 국내 여행 판매량이 숙박 할인권이 배포된 2월(97% 증가)과 3월(90% 증가)에 거의 2배 뛰었다.")],
'answer': '한모 씨의 여름 휴가는 6월에 계획되어 있습니다.'
}
여기서 "context
"는 LLM이 "answer"를 생성할 때 사용한 출처를 포함하고 있다.
2. 간단한 LCEL 구현을 사용하는 방법
아래에서는 create_retrieval_chain
으로 생성된 것과 유사한 체인을 구성한다. 이 체인은 다음과 같은 방식으로 작동하는 딕셔너리를 만든다.
- 입력 쿼리를 포함한 딕셔너리로 시작하고, 검색된 문서를 "context" 키에 추가한다.
- 쿼리와 컨텍스트를 RAG 체인에 전달한 후 결과를 딕셔너리에 추가한다.
from dotenv import load_dotenv
from langchain_chroma import Chroma
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
load_dotenv()
embeddings = OpenAIEmbeddings()
vector_store = Chroma(
persist_directory="./chroma_db",
embedding_function=embeddings,
)
query = "한모 씨의 여름 휴가는 언제야?"
# docs = vector_store.similarity_search(query)
# print(docs)
model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
retriever = vector_store.as_retriever()
#
template = """Answer the question based only on the following context:
{context}
Question: {input}
"""
prompt = ChatPromptTemplate.from_template(template)
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
rag_chain_from_docs = (
{
"input": lambda x: x["input"],
"context": lambda x: format_docs(x["context"]),
}
| prompt
| model
| StrOutputParser()
)
retrieve_docs = (lambda x: x["input"]) | retriever
chain = RunnablePassthrough.assign(context=retrieve_docs).assign(
answer=rag_chain_from_docs
)
result = chain.invoke(
{
"input": query,
}
)
print(result)
실행결과
{
'input': '한모 씨의 여름 휴가는 언제야?',
'context': [Document(metadata={'source': './data/news.txt'}, page_content='40대 직장인 한모 씨는 올해 여름휴가를 예년보다 앞당겨 6월에 쓰기로 했다. 평소 7~8월에 휴가를 다녀온 그는 "어딜 가도 사람 많고 더운 7~8월을 피해 6월에 다녀오려 한다. 마침 항공권도 구했고 비용도 성수기보다는 싸서 큰 마음 먹고 결정했다"고 말했다.'), Document(metadata={'source': './data/news.txt'}, page_content="6월이 '이른' 여행 성수기로 떠오르는 셈이다. 올해도 6월 여행을 떠나는 관광객이 늘어날 것으로 예상된다. 현충일(6월6일) 이튿날에 하루만 휴가를 사용하면 연이어 나흘을 쉴 수 있는 황금연휴가 있는 데다 여행업계도 각종 할인 프로그램을 내놓으면서다."), Document(metadata={'source': './data/news.txt'}, page_content="3일 문화체육관광부가 발표한 '2023년 국민여행조사'에 따르면 관광·휴양 목적으로 여행을 떠나는 국내 관광여행 횟수는 2023년 6월 2122만회로 전년 동월(2022년 6월 2044만회) 대비 3.8% 증가했다. 반면 여름휴가 성수기인 7~8월은 각각 2203만회(0.7% 증가), 2316만회(0.9% 감소)로 1년 전에 비해 소폭 증가하거나 감소한 것으로 나타났다."), Document(metadata={'source': './data/news.txt'}, page_content="특히 6월 한 달간 진행되는 '대한민국 숙박 세일페스타'는 국내 여행 수요를 이끌어 낼 것으로 보인다. 앞서 지난 2월과 3월 배포한 숙박 할인권은 여행 지출액 약 862억원, 지역 관광객 약 48만명 유발 효과를 낸 것으로 집계됐다. G마켓의 경우 전년 동월 대비 국내 여행 판매량이 숙박 할인권이 배포된 2월(97% 증가)과 3월(90% 증가)에 거의 2배 뛰었다.")],
'answer': '한모 씨의 여름 휴가는 6월에 계획되어 있습니다.'
}
3. response model을 사용하는 방법
지금까지는 검색 단계에서 반환된 문서를 최종 응답까지 그대로 전달했다. 하지만 이것만으로는 모델이 답변을 생성할 때 어떤 정보의 부분집합을 활용했는지 명확하게 보여주지 않을 수 있다. 아래에서는 출처를 모델 응답에 구조화하여, 모델이 답변을 생성할 때 어떤 특정 컨텍스트를 참조했는지 보고할 수 있도록 하는 방법을 보여준다.
위의 LCEL 구현은 Runnable
원시 구성 요소들로 이루어져 있기 때문에 확장이 간단하다.
- 모델의 도구 호출 기능을 사용하여 답변과 출처 목록으로 구성된 구조화된 출력을 생성한다. 응답에 대한 스키마는 아래의
AnswerWithSources
TypedDict
로 나타낸다. - 이 시나리오에서는 딕셔너리 출력을 기대하므로
StrOutputParser()
를 제거한다.
from typing import TypedDict, Annotated, List
from dotenv import load_dotenv
from langchain_chroma import Chroma
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
load_dotenv()
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
class AnswerWithSources(TypedDict):
"""An answer to the question, with sources."""
answer: str
sources: Annotated[
List[str],
...,
"List of sources used to answer the question",
]
embeddings = OpenAIEmbeddings()
vector_store = Chroma(
persist_directory="./chroma_db",
embedding_function=embeddings,
)
query = "한모 씨의 여름 휴가는 언제야?"
model = ChatOpenAI(model="gpt-4o-mini", temperature=0)
retriever = vector_store.as_retriever()
template = """Answer the question based only on the following context:
{context}
Question: {input}
"""
prompt = ChatPromptTemplate.from_template(template)
rag_chain_from_docs = (
{
"input": lambda x: x["input"],
"context": lambda x: format_docs(x["context"]),
}
| prompt
| model.with_structured_output(AnswerWithSources)
)
retrieve_docs = (lambda x: x["input"]) | retriever
chain = RunnablePassthrough.assign(context=retrieve_docs).assign(
answer=rag_chain_from_docs
)
result = chain.invoke({"input": query})
print(result)
실행결과
{
'input': '한모 씨의 여름 휴가는 언제야?',
'context': [Document(metadata={'source': './data/news.txt'}, page_content='40대 직장인 한모 씨는 올해 여름휴가를 예년보다 앞당겨 6월에 쓰기로 했다. 평소 7~8월에 휴가를 다녀온 그는 "어딜 가도 사람 많고 더운 7~8월을 피해 6월에 다녀오려 한다. 마침 항공권도 구했고 비용도 성수기보다는 싸서 큰 마음 먹고 결정했다"고 말했다.'), Document(metadata={'source': './data/news.txt'}, page_content="6월이 '이른' 여행 성수기로 떠오르는 셈이다. 올해도 6월 여행을 떠나는 관광객이 늘어날 것으로 예상된다. 현충일(6월6일) 이튿날에 하루만 휴가를 사용하면 연이어 나흘을 쉴 수 있는 황금연휴가 있는 데다 여행업계도 각종 할인 프로그램을 내놓으면서다."), Document(metadata={'source': './data/news.txt'}, page_content="3일 문화체육관광부가 발표한 '2023년 국민여행조사'에 따르면 관광·휴양 목적으로 여행을 떠나는 국내 관광여행 횟수는 2023년 6월 2122만회로 전년 동월(2022년 6월 2044만회) 대비 3.8% 증가했다. 반면 여름휴가 성수기인 7~8월은 각각 2203만회(0.7% 증가), 2316만회(0.9% 감소)로 1년 전에 비해 소폭 증가하거나 감소한 것으로 나타났다."), Document(metadata={'source': './data/news.txt'}, page_content="특히 6월 한 달간 진행되는 '대한민국 숙박 세일페스타'는 국내 여행 수요를 이끌어 낼 것으로 보인다. 앞서 지난 2월과 3월 배포한 숙박 할인권은 여행 지출액 약 862억원, 지역 관광객 약 48만명 유발 효과를 낸 것으로 집계됐다. G마켓의 경우 전년 동월 대비 국내 여행 판매량이 숙박 할인권이 배포된 2월(97% 증가)과 3월(90% 증가)에 거의 2배 뛰었다.")],
'answer': {'answer': '한모 씨의 여름 휴가는 6월에 계획되어 있다.',
'sources': ['2023년 국민여행조사', '문화체육관광부 발표']}
}