langchain / / 2024. 10. 5. 11:29

[langchain] 검색 증강 생성(RAG) 애플리케이션 만들기

Langchain 공식문서의 내용을 정리한 것입니다. 내용 및 예제는 일부 변경하였지만 가능한 구조는 유지했습니다.

Build a Retrieval Augmented Generation (RAG) App - https://python.langchain.com/docs/tutorials/rag/


LLM(대형 언어 모델)에 의해 가능해진 가장 강력한 응용 프로그램 중 하나는 정교한 질문-응답(Q&A) 챗봇이다. 이 챗봇은 특정 출처 정보를 바탕으로 질문에 답하는 애플리케이션이다. 이러한 애플리케이션은 RAG(Retrieval Augmented Generation)라는 기술을 사용한다.

이 튜토리얼에서는 텍스트 데이터 소스를 기반으로 한 간단한 Q&A 애플리케이션을 만드는 방법을 보여준다. 진행하면서 일반적인 Q&A 아키텍처를 설명하고, 더 발전된 Q&A 기술에 대한 추가 자료도 강조할 것이다. 또한, LangSmith가 애플리케이션을 추적하고 이해하는 데 어떻게 도움을 줄 수 있는지도 살펴볼 것이다. 애플리케이션이 복잡해질수록 LangSmith의 유용성이 더욱 커질 것이다.

기본 검색에 익숙하다면 다양한 검색 기술에 대한 이 고급 개요도 관심이 있을 수 있다.

RAG란 무엇인가?

RAG는 LLM의 지식을 추가 데이터로 보강하는 기법이다.

LLM은 다양한 주제에 대해 추론할 수 있지만, 그 지식은 학습된 시점까지의 공개 데이터에 한정된다. 만약 비공개 데이터나 모델의 컷오프 날짜 이후에 도입된 데이터에 대해 추론할 수 있는 AI 애플리케이션을 구축하려면, 모델에 필요한 특정 정보를 추가하여 지식을 보강해야 한다. 적절한 정보를 가져와 모델 프롬프트에 삽입하는 과정을 RAG(Retrieval Augmented Generation)라고 한다.

LangChain에는 Q&A 애플리케이션 및 RAG 애플리케이션을 구축하는 데 도움이 되는 여러 구성 요소가 포함되어 있다.

참고: 여기서는 비정형 데이터를 위한 Q&A에 중점을 둔다. 구조화된 데이터를 위한 RAG에 관심이 있다면 SQL 데이터를 통한 질문/응답 튜토리얼을 확인하라.

개념

일반적인 RAG 애플리케이션은 두 가지 주요 구성 요소를 가지고 있다.

  1. 인덱싱: 데이터를 소스로부터 수집하고 인덱싱하는 파이프라인이다. 보통 이 과정은 오프라인에서 이루어진다.
  2. 검색 및 생성: 실제 RAG 체인으로, 런타임 시 사용자의 쿼리를 받아 인덱스에서 관련 데이터를 검색한 후, 이를 모델에 전달한다.

원시 데이터부터 답변까지의 전체 시퀀스는 다음과 같다.

인덱싱

  • 로드: 먼저 데이터를 로드해야 한다. 이 작업은 Document Loaders를 사용하여 수행된다.
  • 분할: 텍스트 분할기는 큰 문서를 더 작은 청크로 나눈다. 이는 데이터를 인덱싱하거나 모델에 전달할 때 유용하다. 큰 청크는 검색이 더 어렵고, 모델의 제한된 컨텍스트 크기(window)에 맞지 않기 때문이다.
  • 저장: 나눈 청크를 나중에 검색할 수 있도록 저장하고 인덱싱할 장소가 필요하다. 이를 위해 보통 VectorStoreEmbeddings 모델을 사용한다.

검색 및 생성

  • 검색: 사용자 입력을 받으면, Retriever를 사용하여 저장소에서 관련된 청크를 검색한다.
  • 생성: ChatModel / LLM이 질문과 검색된 데이터를 포함한 프롬프트를 사용하여 답변을 생성한다.

설치

pip install --quiet --upgrade langchain langchain-community langchain-chroma

pip install -qU langchain-openai

미리보기

이 가이드에서는 웹사이트의 콘텐츠에 대해 질문에 답하는 앱을 만들어보자. 사용할 사이트는 네이버 뉴스 한국을 뜨고싶은 이들, 왜?…"불안한 경제, 낮은 임금"으로, 이 게시물의 내용에 대해 질문할 수 있도록 해준다.

이를 위해 약 20줄의 코드로 간단한 인덱싱 파이프라인과 RAG 체인을 만들 수 있다.

from dotenv import load_dotenv
from langchain_core.prompts import PromptTemplate

load_dotenv()

from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini")

import bs4
from langchain_chroma import Chroma
from langchain_community.document_loaders import WebBaseLoader
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
from langchain_openai import OpenAIEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter

loader = WebBaseLoader(
    web_paths=("https://n.news.naver.com/mnews/article/029/0002905111",),
    bs_kwargs=dict(
        parse_only=bs4.SoupStrainer(class_=("media_end_head_headline", "newsct_article _article_body"))
    ),
)
docs = loader.load()

text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
splits = text_splitter.split_documents(docs)
vectorstore = Chroma.from_documents(documents=splits, embedding=OpenAIEmbeddings())

retriever = vectorstore.as_retriever()
prompt_template = """You are an assistant for question-answering tasks. Use the following pieces of retrieved context to answer the question. If you don't know the answer, just say that you don't know. Use three sentences maximum and keep the answer concise.

Question: {question} 

Context: {context} 

Answer:
"""
prompt = PromptTemplate.from_template(prompt_template)


def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)


rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

result = rag_chain.invoke("한국을 뜨고 싶은 이유가 뭐야?")
print(result)

응답결과

한국을 떠나고 싶은 이유는 불안정한 경제 상황과 낮은 임금이 주요 요인으로 꼽히고 있습니다. 또한 정치적 불신과 불평등한 사회에 대한 불만족도 그 이유에 포함됩니다. 많은 사람들이 직업과 학업 기회를 이유로 해외 이주를 고려하고 있습니다.

세부 설명

위 코드를 단계별로 살펴보면서 무슨 일이 일어나는지 자세히 이해해 보자.

1. 인덱싱: 로드 (Load)

먼저 블로그 게시물의 내용을 로드해야 한다. 이를 위해 DocumentLoaders를 사용할 수 있다. DocumentLoaders는 데이터를 소스로부터 로드하여 Document 목록을 반환하는 객체이다. Document는 page_content(str)와 metadata(dict)로 구성된 객체이다.

이번 경우에는 WebBaseLoader를 사용한다. 이 로더는 urllib을 사용해 웹 URL에서 HTML을 로드하고, BeautifulSoup을 통해 HTML을 텍스트로 파싱한다. bs_kwargs를 통해 BeautifulSoup 파서에 매개변수를 전달하여 HTML을 텍스트로 파싱하는 방식을 커스터마이즈할 수 있다(자세한 내용은 BeautifulSoup 문서를 참조). 이 경우, "media_end_head_headline", "newsct_body" 클래스가 있는 HTML 태그만 유의미하므로 나머지 태그는 모두 제거한다.

loader = WebBaseLoader(
    web_paths=("https://n.news.naver.com/mnews/article/029/0002905111",),
    bs_kwargs=dict(parse_only=bs4.SoupStrainer(class_=("media_end_head_headline", "newsct_article _article_body"))),
)
docs = loader.load()
len(docs[0].page_content)
print(docs[0].page_content[:500])

실행결과

39
한국을 뜨고싶은 이들, 왜?…"불안한 경제, 낮은 임금"


프레플리, 한국인 국내 생활 만족도 및 해외 이주 의향 조사



프레플리 설문조사. 한국을 떠나고 싶은 이유    한국인 3명 중 1명은 5년 이내 한국을 떠나 해외에서 거주하고 싶다는 의향을 갖고 있는 것으로 나타났다. 가장 가고 싶은 나라로는 미국과 호주, 캐나다 등 영미권 국가가 상위권에 올랐다. 해외 이주를 꿈꾸는 이유로는 직업과 학업 등을 주로 꼽았다. 글로벌 온라인 영어 과외 플랫폼인 프레플리는 26일 설문 조사기관 센서스와이드에 의뢰해 18세 이상 한국인 1500여명을 대상으로 해외 이주 의향 및 국내 생활의 만족도 설문조사를 진행한 결과를 공개했다. 응답자 중  28.3%(424명)는 5년 이내에 해외에 이주하고 싶다고 답변했다. 특히 Z세대는 38.05%가 5년 내 이주를 희망해 가장 높은 수치를 보였다. 이중 43.43%는 해외 장기 거주 의사가 있다고 했고, 44.25%는 시간이 지날수록 해외 이주에

2. 인덱싱: 분할 (Split)

로드한 문서는 3,780자 이상이다. (사실 이번 예제는 그렇게 긴 내용은 아니다. 하지만 일반적으로는 이 이상이 될 수 있다). 이는 많은 모델의 컨텍스트 크기에 맞추기에는 너무 긴 길이이다. 전체 게시물을 컨텍스트 크기에 맞출 수 있는 모델의 경우에도, 모델은 매우 긴 입력에서 정보를 찾는 데 어려움을 겪을 수 있다.

이를 처리하기 위해 문서를 임베딩 및 벡터 저장을 위한 청크로 분할한다. 이렇게 하면 런타임에 뉴스 게시물에서 가장 관련성이 높은 부분만 검색할 수 있다.

이번 경우에는 문서를 1000자 청크로 나누고 각 청크 간에 200자의 중첩을 둔다. 중첩은 중요한 맥락과 관련된 진술이 분리되는 가능성을 완화하는 데 도움이 된다. 우리는 RecursiveCharacterTextSplitter를 사용하여 일반적인 구분자(예: 줄 바꿈)를 사용하여 문서를 재귀적으로 분할하고, 각 청크가 적절한 크기가 될 때까지 반복한다. 이는 일반적인 텍스트 사용 사례에 권장되는 텍스트 분할기이다.

add_start_index=True로 설정하여 각 분할된 문서가 원본 문서 내에서 시작되는 문자 인덱스를 메타데이터 속성 “start_index”로 보존한다.

from langchain_text_splitters import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000, chunk_overlap=200, add_start_index=True
)
splits = text_splitter.split_documents(docs)

print(len(splits))
print(len(splits[1].page_content))
print(splits[1].metadata)
6
995
{'source': 'https://n.news.naver.com/mnews/article/029/0002905111', 'start_index': 71}

3. 인덱싱: 저장

이제 6개의 텍스트 청크를 인덱싱하여 실행 시 검색할 수 있도록 해야 한다. 가장 일반적인 방법은 각 문서의 분할된 내용을 임베딩하고, 이러한 임베딩을 벡터 데이터베이스(또는 벡터 저장소)에 삽입하는 것이다. 분할된 문서들을 검색하고자 할 때, 텍스트 검색 쿼리를 임베딩하고, “유사도” 검색을 수행하여 쿼리 임베딩과 가장 유사한 임베딩을 가진 저장된 분할 문서를 식별한다. 가장 간단한 유사도 측정 방법은 코사인 유사도(cosine similarity)로, 임베딩 쌍(고차원 벡터들) 간 각도의 코사인을 측정하는 방식이다.

from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings

vectorstore = Chroma.from_documents(documents=splits, embedding=OpenAIEmbeddings())

더 알아보기

임베딩(Embeddings): 텍스트를 임베딩으로 변환하는 텍스트 임베딩 모델을 감싸는 래퍼.

벡터 저장소(VectorStore): 임베딩을 저장하고 쿼리하는 데 사용되는 벡터 데이터베이스를 감싸는 래퍼.

이로써 파이프라인의 인덱싱 부분이 완료되었다. 이제 우리는 게시물의 청크 내용을 포함하는 쿼리가 가능한 벡터 저장소를 갖추게 되었다. 사용자의 질문이 주어지면, 이상적으로는 그 질문에 답하는 게시물의 일부를 반환할 수 있어야 한다.

4. 검색 및 생성(Retrieval and Generation): 검색

이제 실제 애플리케이션 로직을 작성해 보자. 우리는 사용자 질문을 받아 그 질문과 관련된 문서를 검색하고, 검색된 문서와 초기 질문을 모델에 전달하여 답변을 반환하는 간단한 애플리케이션을 만들고자 한다.

먼저 문서를 검색하는 로직을 정의해야 한다. LangChain에서는 문자열 쿼리를 주면 관련 Documents를 반환하는 인덱스를 감싸는 Retriever 인터페이스를 정의한다.

가장 일반적인 유형의 RetrieverVectorStoreRetriever로, 벡터 저장소의 유사도 검색 기능을 활용하여 문서 검색을 수행한다. 어떤 VectorStoreVectorStore.as_retriever() 메서드를 통해 쉽게 Retriever로 변환할 수 있다.

retriever = vectorstore.as_retriever(search_type="similarity", search_kwargs={"k": 4})

retrieved_docs = retriever.invoke("한국을 뜨고 싶은 이유가 뭐야?")

print(len(retrieved_docs))
print(retrieved_docs[0].page_content)

실행결과

4
프레플리 설문조사. 한국을 떠나고 싶은 이유    한국인 3명 중 1명은 5년 이내 한국을 떠나 해외에서 거주하고 싶다는 의향을 갖고 있는 것으로 나타났다. 가장 가고 싶은 나라로는 미국과 호주, 캐나다 등 영미권 국가가 상위권에 올랐다. 해외 이주를 꿈꾸는 이유로는 직업과 학업 등을 주로 꼽았다. 글로벌 온라인 영어 과외 플랫폼인 .....

더 알아보기

벡터 저장소는 검색에 자주 사용되지만, 검색을 수행하는 다른 방법들도 있다.

Retriever: 텍스트 쿼리를 주면 관련 문서를 반환하는 객체

  • MultiQueryRetriever: 검색 적중률을 높이기 위해 입력된 질문의 변형을 생성한다.

  • MultiVectorRetriever: 역시 검색 적중률을 높이기 위해 임베딩의 변형을 생성한다.

  • Max marginal relevance: 중복된 컨텍스트가 전달되지 않도록 검색된 문서들 중에서 관련성과 다양성을 선택한다.

  • Self Query Retriever와 같이 메타데이터 필터를 사용하여 벡터 저장소 검색 중 문서를 필터링할 수 있다.

5. 검색 및 생성: 생성

이제 질문을 받아 관련 문서를 검색하고, 프롬프트를 생성하여 모델에 전달한 후 출력을 파싱하는 체인으로 모든 것을 결합해 보자.

우리는 gpt-4o-mini OpenAI 채팅 모델을 사용할 것이지만, 어떤 LangChain LLM 또는 ChatModel도 대체할 수 있다.

아래는 구현체이다.

from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough


def format_docs(docs):
    return "\n\n".join(doc.page_content for doc in docs)


rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
    | llm
    | StrOutputParser()
)

for chunk in rag_chain.stream("What is Task Decomposition?"):
    print(chunk, end="", flush=True)
Task Decomposition is a process where a complex task is broken down into smaller, more manageable steps or parts. This is often done using techniques like "Chain of Thought" or "Tree of Thoughts", which instruct a model to "think step by step" and transform large tasks into multiple simple tasks. Task decomposition can be prompted in a model, guided by task-specific instructions, or influenced by human inputs.

LCEL을 분석하여 그 동작 방식을 이해해 보자.

첫 번째로, 각 구성 요소(예: retriever, prompt, llm 등)는 Runnable의 인스턴스이다. 이는 이들이 sync 및 async .invoke, .stream, .batch와 같은 동일한 메서드를 구현하고 있다는 뜻이며, 이러한 메서드 덕분에 이들을 쉽게 연결할 수 있다. 이들은 RunnableSequence라는 또 다른 Runnable로 | 연산자를 통해 연결될 수 있다.

LangChain은 | 연산자와 함께 사용될 때 특정 객체들을 자동으로 Runnable로 변환한다. 여기에서 format_docsRunnableLambda로 변환되고, "context""question"이 포함된 딕셔너리는 RunnableParallel로 변환된다. 중요한 것은 각 객체가 Runnable이라는 점이다.

이제 입력된 질문이 이러한 Runnable을 통해 어떻게 흐르는지 추적해 보자.

앞서 보았듯이, prompt의 입력은 "context""question" 키를 가진 딕셔너리여야 한다. 따라서 이 체인의 첫 번째 요소는 입력된 질문으로부터 이를 계산할 Runnable들을 생성한다.

  • retriever | format_docs는 질문을 retriever를 통해 전달하여 Document 객체를 생성하고, 그런 다음 format_docs로 전달해 문자열을 생성한다.
  • RunnablePassthrough()는 입력된 질문을 변경 없이 그대로 전달한다.
chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | prompt
)

그렇다면 chain.invoke(question)은 추론을 위한 준비가 완료된 형식화된 프롬프트를 생성하게 된다. (참고: LCEL로 개발할 때는 이렇게 서브 체인을 테스트하는 것이 실용적일 수 있다.)

체인의 마지막 단계는 추론을 실행하는 llm과, LLM의 출력 메시지에서 문자열 내용을 추출하는 StrOutputParser()이다.

이 체인의 각 단계를 LangSmith trace를 통해 분석할 수 있다.

내장 체인(Built-in chains)

원하는 경우, LangChain은 위의 LCEL을 구현하는 편의 함수를 포함한다. 우리는 두 개의 함수를 구성한다.

create_stuff_documents_chain은 검색된 컨텍스트가 프롬프트와 LLM에 어떻게 전달되는지를 지정한다. 이 경우, 우리는 내용을 프롬프트에 "채워넣을" 것이다. 즉, 요약이나 다른 처리 없이 모든 검색된 컨텍스트를 포함하게 된다. 이는 위에서 언급한 rag_chain을 대부분 구현하며, 입력 키는 contextinput이고, 검색된 컨텍스트와 쿼리를 사용하여 답변을 생성한다.

create_stuff_documents_chain에 대해 더 알고 싶으면 아래 글을 확인하라.
[langchain] create_stuff_documents_chain은 무엇인가?

create_retrieval_chain은 검색 단계를 추가하고, 검색된 컨텍스트를 체인으로 전달하여 최종 답변과 함께 제공한다. 입력 키는 input이며, 출력에는 input, context, answer가 포함된다.

from langchain.chains import create_retrieval_chain
from langchain.chains.combine_documents import create_stuff_documents_chain
from langchain_core.prompts import ChatPromptTemplate

system_prompt = (
    "You are an assistant for question-answering tasks. "
    "Use the following pieces of retrieved context to answer "
    "the question. If you don't know the answer, say that you "
    "don't know. Use three sentences maximum and keep the "
    "answer concise."
    "\n\n"
    "{context}"
)

prompt = ChatPromptTemplate.from_messages(
    [
        ("system", system_prompt),
        ("human", "{input}"),
    ]
)


question_answer_chain = create_stuff_documents_chain(llm, prompt)
rag_chain = create_retrieval_chain(retriever, question_answer_chain)

response = rag_chain.invoke({"input": "한국을 뜨고 싶은 이유가 뭐야?"})
print(response["answer"])
한국을 떠나고 싶은 이유로는 불안정한 경제 상황과 낮은 임금이 주요한 요인으로 나타났습니다. 또한 정치적 불신과 불평등한 사회에 대한 불만족도 큰 영향을 미치고 있습니다. 많은 이들이 직업 기회나 학업을 위해 해외 이주를 고려하고 있습니다.

응답 소스(sources)

Q&A 애플리케이션에서는 사용자에게 답변을 생성하는 데 사용된 출처를 보여주는 것이 종종 중요하다. LangChain의 내장된 create_retrieval_chain은 검색된 출처 문서를 "context" 키를 통해 출력으로 전달한다.

for document in response["context"]:
    print(document)
    print()
page_content='프레플리 설문조사. 한국을 떠나고 싶은 이유    한국인 3명 중 1명은 5년 이내 한국을 떠나 해외에서 거주하고 싶다는 의향을 갖고 있는 것으로 나타났다. 가장 가고 싶은 나라로는 미국과 호주, 캐나다 등 영미권 국가가 ......... 성별 간 응답에' metadata={'source': 'https://n.news.naver.com/mnews/article/029/0002905111', 'start_index': 71}
page_content='한국을 뜨고싶은 이들, 왜?…"불안한 경제, 낮은 임금".............. 해외 이주 의향 조사' metadata={'source': 'https://n.news.naver.com/mnews/article/029/0002905111', 'start_index': 0}
------------------
page_content='프레플리 설문조사. 이주하고 싶은 국가    ..................' metadata={'source': 'https://n.news.naver.com/mnews/article/029/0002905111', 'start_index': 2623}
------------------
page_content='경제 상황(32.89%), 낮은 임금(25%)과 같은 경제적 상황에 .............. 뒤를 이어' metadata={'source': 'https://n.news.naver.com/mnews/article/029/0002905111', 'start_index': 871}

더 알아보기

모델 선택

ChatModel: LLM 기반의 채팅 모델. 일련의 메시지를 입력받아 메시지를 반환한다.

LLM: 텍스트를 입력받아 텍스트를 반환하는 LLM. 문자열을 입력받아 문자열을 반환한다.

여기에서 로컬로 실행되는 모델과 함께하는 RAG에 대한 가이드를 확인하라.

프롬프트 사용자 정의
위에서 보았듯이, 프롬프트 허브에서 프롬프트(예: 이 RAG 프롬프트)를 로드할 수 있다. 프롬프트는 쉽게 사용자 정의할 수도 있다.

from langchain_core.prompts import PromptTemplate

template = """Use the following pieces of context to answer the question at the end.
If you don't know the answer, just say that you don't know, don't try to make up an answer.
Use three sentences maximum and keep the answer as concise as possible.
Always say "thanks for asking!" at the end of the answer.

{context}

Question: {question}

Helpful Answer:"""
custom_rag_prompt = PromptTemplate.from_template(template)

rag_chain = (
    {"context": retriever | format_docs, "question": RunnablePassthrough()}
    | custom_rag_prompt
    | llm
    | StrOutputParser()
)

rag_chain.invoke("한국을 뜨고 싶은 이유가 뭐야?")
한국을 떠나고 싶은 이유로는 불안정한 경제 상황과 낮은 임금이 주요한 요인으로 나타났습니다. 또한 정치적 불신과 불평등한 사회에 대한 불만족도 큰 영향을 미치고 있습니다. 많은 이들이 직업 기회나 학업을 위해 해외 이주를 고려하고 있습니다.
반응형
  • 네이버 블로그 공유
  • 네이버 밴드 공유
  • 페이스북 공유
  • 카카오스토리 공유