Upstage AI Lab 4기

8/12 QA Engine 개발 Day2 | LangChain을 이용한 App 개발 PJT

Eddie_D 2024. 8. 14. 00:19

오늘의 프로젝트 기록

각자 어느 부분을 맡을 것인지 얘기했다. 나는 벡터 스토어 부분을 맡기로 했고, 그 부분을 포함해서 랭체인을 다시 공부하면서 코드를 연습했다. 첨부터 끝까지 한번 쭉 짜보긴 했는데 어째 성능이 좀 애매..? 

처음이니까 다 그런거지! 


 

목표: 올림픽 스포츠 규정에 관한 PDF를 참조하여 질문에 답변하는 QA 엔진 개발

 

RAG QA엔진을 만드는 과정은 대략적으로 이렇다.

1. 데이터 로드 : 데이터베이스가 될 것
2. 분할 : 데이터베이스를 청킹청킹 잘라준다.
3. 임베딩과 벡터스토어에 저장 : 숫자로 바꿔서 저장해준다. 
4. 리트리버 만들기 : 질문과 연관된 정보를 찾아올 아이
5. 프롬프트 만들기 : 사용자의 질문과 DB에서 찾은 정보를 조합해서 프롬프트로! 
6. 제너레이터 : 실제로 말을 생성할 애
7. 체인 : 위 기능들이 어떤 순서로 작동할지 체인을 만들어준다.  

 

 

스피드 스케이팅 규정에 관한 pdf를 찾아서 이걸 샘플로 작업해보았다.

앞부분에 Api 설정하는 부분은 랭체인 홈페이지의 quick start를 보고 따라했다. 

import getpass
import os

os.environ["OPENAI_API_KEY"] = getpass.getpass()

from langchain_openai import ChatOpenAI

model = ChatOpenAI(model="gpt-3.5-turbo")

 

1. 데이터 로드

pdf로 된 다큐먼트를 로드했다. PDF, 웹, 텍스트, csv 등 다양한 포맷의 데이터를 올릴 수 있다. 양이 많다고 항상 좋은 것만은 아니고, 데이터가 어느 정도 정제되어 있어야 대답의 질도 올라간다고 한다. 참고로 내가 올린 pdf. 국제빙상연맹에서 스피드스케이팅 특별규정 및 기술규칙에 대한 87페이지 분량의 문서다.  

speedskating.pdf
2.53MB

from langchain_community.document_loaders import PyPDFLoader

loader = PyPDFLoader("speedskating.pdf가 저장된 경로 붙여넣기")
speedskatepdf = loader.load()


2. 분할

Text splitters를 사용해서 단어나 문장, 페이지 등의 원하는 단위로 잘라줄 수 있다. Text splitters에는 여러 종류가 있다. 이때 자른 단위가 나중에 임베딩, 리트리브되므로 텍스트가 문맥을 유지할 수 있도록 알아서 센스있게 잘 잘라줘야 한다. 

from langchain_text_splitters import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=1000, 
    chunk_overlap=200,
    length_function=len,
    is_separator_regex=False
    )

텍스트 스플릿터 안에 입력값들을 보면 chunk size, chunk overlap이 있어서 사이즈와 겹쳐서 볼 문맥 정도를 조절할 수 있다. 텍스트 스플릿터 중에서 일반적으로 쓰는 것이 RecursiveCharacterTextSplitter(재귀적 문자 텍스트 분할)이라고 하길래 나도 이걸 썼다. 이 재귀적 분할 방식은 단락 - 문장 - 단어 순서로 재귀적으로 분할한다. 

speedskatesplits = text_splitter.split_documents(speedskatepdf)

텍스트 스플릿터로 분할한 pdf파일을 변수에 넣어주고 이제 이걸 임베딩할 차례. 

 

3. 임베딩과 벡터스토어에 저장

임베딩은 텍스트를 숫자로 변환해주는 작업인데, 청킹해준 데이터를 AI가 잘 알아먹을 수 있도록 임베딩해준다. 문서의 의미를 벡터 형태(숫자 배열)로 표현한다. (청크로 쪼개놓은 정보랑 사용자가 질문한 내용의 유사도를 계산하기 위해서 사용된다고...)

from langchain_chroma import Chroma
from langchain_openai import OpenAIEmbeddings
vectorstore = Chroma.from_documents(
	documents=speedskatesplits, 
    	embedding=OpenAIEmbeddings()
    )

일단 OpenAIEmbedding을 썼는데, 실제 플젝에서는 업스테이지의 임베딩을 써야한다. 임베딩 모델에도 종류가 다양하다. 벡터DB, 벡터인덱스, 벡터스토어는 엄밀히 말하면 모두 다른 말이다. 하지만 이런 실습에서는 하는 역할이 같아서 그냥 같은 말처럼 쓰인다.

임베딩한 청크들을 모아서 저장하는 곳이 Vectorstore이다. 생성된 임베딩 벡터들을 관리하는 곳이라고 생각하면 된다. 벡터스토어에는 크게 Chroma와 FAISS가 있다. 뭐가 다른건지는 잘 모르겠지만 일단 크로마를 사용했다.(튜토리얼에서 이걸 쓰길래) 

 


4. 리트리버

retriever = vectorstore.as_retriever(
    search_type = "mmr",
    search_kwargs = {"k":5})
    
def merge_docs(retrieved_docs):
	return "\n\n".join([d.page_content for d in retrieved_docs])

리트리버(검색기)는 사용자가 질문을 했을 때 벡터스토어에 가서 그 질문과 연관된 정보(임베딩된 청크 단위)를 검색해서 들고 온다. 리트리버를 정의해줄 때 어떤 서치 타입을 쓸 건지, 검색해서 관련된 정보는 몇 개(k)까지 가져올 것인지를 선택할 수 있다. 만약에 n개의 정보를 가져왔다면 이걸 하나로 합쳐야 한다. 리트리버의 서치 타입의 디폴트값은 mmr(maxiaml marginal relevance)이다. 

밑에 merge_docs 함수가 리트리브된 정보들을 하나로 모은 것이다. 


5. 프롬프트, 제너레이터, 체인 

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



prompt = hub.pull("rlm/rag-prompt")

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

리트리버로 가져온 데이터와 사용자가 입력한 질문을 조합해서 프롬프트로 만들어준다. 허브에 검증된 프롬프트 템플릿이 있는데 이걸 써줘도 된다. (직접 입력해줄 수도 있다.)

제너레이터는 프롬프트를 LLM 모델(chatGPT)에 전달하고, 출력을 받아온다. 제너레이터는 LLM이 데이터를 순차적으로 처리하게 해준다. (?)

여러 기능을 순서대로 작동할 수 있게 체인을 건다. 순서는 (리트리버가 가져온 정보 = context)와 (사용자의 질문 = query)를 합쳐서 하나의 프롬프트로 만들고 -> LLM 모델에 넘기고 -> 결과를 파서로 가져온다. (리트리버가 가져온 정보 = context)와 (사용자의 질문 = query)를 합칠 때에 RunnableParallel라는 메서드를 써서 하나의 딕셔너리로 묶어준다. 그리고 사용자의 질문을 받은 그대로 전달하기 위해서는 RunnablePassthrough를 사용한다. 

 

결과

rag_chain.invoke("스피드 스케이팅에 대해 알려줘.")
답변

'스피드 스케이팅은 스케이트를 사용하여 경기를 하는 스포츠로, 선수의 안전을 위해 특정 규칙이 있습니다. 세계 선수권대회나 월드컵 대회에서는 선수의 이름이 국가명 옆에 표시될 수 있습니다. 스케이트는 선수의 육체적 능력을 최대로 사용하게 하기 위해 구성되어 있으며, 선수와 스케이트 사이에는 아무런 연결이 없어야 합니다.'