직접 만들어보는 RAG

RAG를 직접 구성하여 LLM을 커스터마이즈해봅시다.
신은성's avatar
Sep 06, 2024
직접 만들어보는 RAG

RAG 동작 방식

RAG는 'LLM'하면 빼놓지 않고 함께 나오는 키워드입니다. LLM의 단점은 모르는 정보에 대해서는 틀린 답변을 내놓는다는 점인데, RAG는 LLM에 추가 정보를 제공하여 모델이 학습하지 않은 정보에 대해서도 답변할 수 있도록 합니다. LLM의 학습을 위해서 만만치 않은 비용이 든다는 사실을 감안하면, RAG는 목적에 맞게 LLM을 활용할 수 있는 굉장히 효과적인 방법이라고 할 수 있습니다. 이 글에서는 RAG를 직접 만들어보면서 어떻게 동작하는지 이해해봅시다.

먼저 RAG의 구성도는 다음과 같습니다.

우선 언어모델이 필요합니다. 이 언어모델은 어플리케이션이 돌아가는 엔진과 같은 역할이라고 할 수 있습니다. 사용자의 인풋을 처리하여 마치 사람이 직접 답변을 한 것과 같은 아웃풋을 제공합니다.

그리고 RAG를 위해 필요한 또 다른 장치는 데이터베이스입니다. 언어모델이 학습하지 못 했지만 질문에 대답하기 위해 필요한 정보들을 참고할 수 있는 저장소라고 할 수 있습니다. 언어모델은 텍스트를 숫자 형식으로 인식하기 때문에 그에 맞춰 벡터 형식으로 저장됩니다. 그래서 이 벡터 데이터 타입에 특화된 데이터베이스를 벡터 데이터베이스라고 부릅니다.

이제 사용자 인풋이 들어오면, 언어모델과 벡터 데이터베이스를 사용하여, LLM이 학습하지 못한 정보를 처리하는 RAG를 구현해볼 겁니다.

직접 RAG를 만들어봅시다!

어떤 정보를 벡터 데이터베이스에 담을 수 있을까요?

평소에 관심있는 주제에 대한 내용이면 좋고, 거기다가 LLM이 학습하지 못한 정보면 더 좋을 겁니다. 보통 LLM이 학습하지 못한 정보는 굉장히 마이너한 내용이거나 지속적으로 업데이트되는 내용입니다. 그럼 저는 평소에 관심있는 '부동산' 관련 정보를 담아보려고 합니다.

아래 설명 드릴 내용들은 깃헙 리포지토리에서 코드를 확인할 수 있습니다.

단계 1. 데이터 모으기

우선 텍스트 데이터를 긁어모으기 위해서 네이버 api를 활용합니다. '부동산' 키워드를 사용해서 10개 정도의 데이터를 긁어옵니다. 주기적으로 데이터를 수집한다던지, 검색 키워드를 늘린다던지 개선할 수 있을 것 같지만, 우선 RAG를 구현해보기에는 충분한 듯합니다.

단계 2. 데이터를 벡터 데이터베이스에 저장하기

이제 모은 데이터를 데이터베이스에 저장하고 LLM과 연동시켜 인풋을 처리하는 파이프라인을 만들 예정입니다. 이를 위해 langchain이라는 라이브러리를 활용할 예정입니다. LangChain은 LLM을 활용하기 위해 필요한 컴포넌트들과 이를 조립하여 활용하는 방법들을 제공하는 프레임워크라고 생각하시면 됩니다.

기본적으로 Langchain은 LLM, Messages, Prompt templates, output parsers, vector stores, agent 등과 같은 추상화된 컴포넌트들을 제공합니다. 그리고 이 모든 컴포넌트들은 Runnable이라는 클래스를 상속하고, 1개 이상의 Runnable로 구성된 "Chain"으로 연계되어 사용될 수 있습니다.

예를 들어, 유저 인풋을 미리 지정해둔 프롬프트에 집어넣은 후에 LLM에 넘기고 나온 결과를 정해진 형식으로 받고 싶다면, 프롬프팅, LLM, 결과 파싱 각 단계가 Runnable로 구성되어 동작합니다.

(LLM의 활용방법은 아직도 계속 탐구되는 중이며, 그에 맞춰 발빠르게 새로운 컴포넌트가 생겨나기도 사라지기도 합니다. 프레임워크 컴포넌트를 모두 익히는 것보다는 필요한 부분을 찾아서 습득하는 걸 추천합니다.)

그럼 다시 돌아와서 모은 데이터를 데이터베이스에 저장하는 단계부터 살펴보겠습니다. LLM이 급부상함에 따라 여러 벡터 데이터베이스들이 등장하였는데, 그 중 무료이면서 손쉽게 접근할 수 있는 Chroma라는 벡터 데이터베이스를 선택합니다.

텍스트 형식인 현재의 데이터를 언어모델이 인식하는 벡터로 변환해주어야합니다. 엠베딩 모델을 사용해서 텍스트를 벡터로 변환해주고, 벡터 데이터베이스에 넣어줍시다.

from langchain_openai import OpenAIEmbeddings

import chromadb

from langchain_chroma import Chroma

embedding = OpenAIEmbeddings()

persist_directory = 'db'

persistent_client = chromadb.PersistentClient()

vectordb = Chroma.from_texts([

    "최근 서울 강남 3구를 중심으로 아파트 신고가 행진이 이어지고 있습니다.정부가 강력한 대출 규제 카드를 꺼내 들었지만, 대출의존도가 높은 서민들이 주로 영향을 받으며 양극화 현상이 심화되고 있다는 지적이 나옵니다.김수강 기자입니다. ...(중략)",

],

        client=persistent_client,

        embedding=embedding, # 엠베딩을 위해 사용할 모델

        persist_directory=persist_directory # vector db 저장위치 설정

)

단계 2. 벡터데이터베이스와 언어모델을 연동한 Chain 구성하기

이제 벡터 데이터베이스를 LLM이 접근할 수 있도록 구성해야합니다. 앞서 언급한 것처럼, RAG 과정도 Runnable 객체들을 연계하여 chain을 구성하는 방식으로 구현할 수 있습니다.

사용자의 인풋을 미리 지정해둔 프롬프트로 변환하고, 벡터 데이터베이스도 Retriever로 변환해줍니다. 여기서 신경써야할 부분은 자연스러운 대화를 위해 현시점의 사용자의 인풋뿐만 아니라 언어 모델이 이해할 수 있는 문맥으로 채팅 기록을 함께 제공해야한다는 점입니다.

retriever = vectorstore.as_retriever()

_inputs = RunnableMap(

    standalone_question=RunnablePassthrough.assign(

        chat_history=lambda x: formatchat_history(x["chat_history"])

    )

    | CONDENSE_QUESTION_PROMPT

    | ChatOpenAI(temperature=0)

    | StrOutputParser(),

)

_context = {

    "context": lambda x: x['standalone_question'] | retriever | combinedocuments,

    "question": lambda x: x['standalone_question'],

}

conversational_qa_chain = (

inputs | context | ANSWER_PROMPT | ChatOpenAI() | StrOutputParser()

)

(`_inputs` 객체는 사용자 인풋을 받아 정의해둔 프롬프트로 포맷하고, 채팅 기록을 추가하여 문맥을 제공합니다. _context 객체는 사용자 인풋을 벡터데이터베이스를 참고하여 언어모델이 이해할 수 있는 문맥으로 변환합니다. 그리고 다시 체이닝되어 conversational_qa_chain을 구성합니다. 이런 유연한 연계방식이 가능한 이유는 RAG과정이 Runnable과 Chain으로 추상화되었기 때문입니다.)

이렇게 언어모델과 벡터 데이터베이스를 붙인 RAG chain이 완성되었습니다.

채팅 UI 만들기

이제 이 RAG를 사용할 수 있는 UI를 만들어주어야합니다.

Streamlit이라는 라이브러리를 활용하여 간단하게 구성해봅시다.

# Display chat messages from history on app rerun

for message in st.session_state.messages:

    with st.chat_message(message['role']):

        st.markdown(message['content'])

# React to user input

if prompt := st.chat_input('what is up?'):

    with st.chat_message('user'):

        st.markdown(prompt)

    # Add user message to chat history

    st.session_state.messages.append({'role': "user", 'content': prompt})

    with st.chat_message('assistant'):

        message_placeholder = st.empty()

        stream = st.session_state.rag.invoke(

            {"question": prompt,

             "chat_history": [(msg['role'], msg['content'])

                              for msg in st.session_state.messages

                              if msg['role'] == 'user'

                              ]})

        response = st.write(stream)

    st.session_state.messages.append({'role': "assistant", 'content': response})

(streamlit은 UI 구성을 위한 컴포넌트들을 제공합니다. 그리고 페이지 내에서 상태 보존이 가능한 session_state를 활용하여 채팅 기록과 RAG chain을 저장해둡니다. 사용자가 질문을 입력시 session_state에 저장된 rag를 호출하여 결과를 도출합니다.)

그럼 RAG를 활용한 챗봇이 완성되었습니다. 미리 넣어준 최신 뉴스에 대해서 인식하고 필요한 정보를 제공해주는 걸 확인할 수 있습니다. 이미 제공되어있는 프레임워크와 라이브러리를 활용하니 수월하게 개발이 가능했습니다.

마무리

물론 이 상태로 상용화할 수 있는 건 아닙니다. 서비스 확장에 따라 추가될 수 있는 기능들, 답변의 정확도와 퀄리티, 언어모델 운영이나 호출에 대한 비용적인 측면, 그리고 사용자 경험에 큰 영향을 줄 수 있는 속도(스트리밍) 측면에서도 고려할 요소들이 아직 많이 남아 있습니다.

다음 글에서는 우리가 만든 챗봇을 직접 사용해보면서 개선해보려고 합니다.

Share article
Subscribe to our newsletter

직계약으로 끝까지 책임지는 매칭 플랫폼, 스파르타빌더스