langchain / / 2024. 9. 20. 19:46

[langchain] RAG에서 sources를 리턴하는 방법

Q&A 애플리케이션에서 사용자가 답변을 생성할 때 사용된 출처를 보여주는 것이 중요할 때가 있다. 이를 가장 간단하게 구현하는 방법은 체인이 각 생성 과정에서 검색된 문서를 반환하는 것이다. 일반적인 RAG 방식을 사용하면 답변 결과만을 리턴하여 source 문서를 확인할 수 없다. 그래서 소스 문서를 같이 리턴하도록 하는 방법을 알아보자.

방법은 아래와 같이 3가지 방법이 있다.

  1. create_retrieval_chain을 사용하는 방법
  2. 간단한 LCEL 구현을 사용하는 방법
  3. 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으로 생성된 것과 유사한 체인을 구성한다. 이 체인은 다음과 같은 방식으로 작동하는 딕셔너리를 만든다.

  1. 입력 쿼리를 포함한 딕셔너리로 시작하고, 검색된 문서를 "context" 키에 추가한다.
  2. 쿼리와 컨텍스트를 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 원시 구성 요소들로 이루어져 있기 때문에 확장이 간단하다.

  1. 모델의 도구 호출 기능을 사용하여 답변과 출처 목록으로 구성된 구조화된 출력을 생성한다. 응답에 대한 스키마는 아래의 AnswerWithSources TypedDict로 나타낸다.
  2. 이 시나리오에서는 딕셔너리 출력을 기대하므로 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년 국민여행조사', '문화체육관광부 발표']}
}

참고

반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유