RAG란 무엇인가
RAG는 Retrieval Augmented Generation으로 저장소에 저장된 데이터를 기반으로 유저가 입력한 명령(프롬프트)에 부합하는 답변을 생성하는 AI 시스템 구조이다. 우리가 흔히 사용하는 ChatGPT, Claude와 같은 LLM모델을 목적과 용도에 맞게 직접 커스텀, 최적화 해서 사용할 수 있다는 큰 장점이 있다.
왜 쓰는 건데?
1. 기존에 학습된 데이터라는 검색 범위의 한계성 극복
먼저 GPT-4o 기준으로는 기존에 학습된 데이터만 사용하는 것이 아니라, 유저가 요청한 정보가 최신 정보일 경우 기존에 학습된 데이터 뿐만 아니라 웹 검색을 통해서도 정보를 가져오는 것을 확인할 수 있었다(불과 3시간 전의 뉴스 속보에 대해 질문했는데 질문에 정확하게 답하는 것을 보고 깜짝 놀랐다).
하지만 구모델의 GPT-3.5의 경우 최신 정보일 경우 GPT가 20XX년도 X월 까지의 데이터만 학습하였기 때문에 답할 수 없다는 답변을 준다. 이처럼 LLM 모델의 학습 데이터의 범위를 벗어나는 정보(예를 들어 사내 내부 문서, 개인 문서) 인 경우 LLM의 사용이 제한적이기 때문에 RAG를 통해 유저가 직접 해당 정보를 학습(train)시키고 필요할 경우 답변을 손쉽게 얻을 수 있다.
2. 토큰 제한으로 인한 제한 극복
LLM 모델을 사용하기 위해서는 토큰(token)이 요구된다. 유저의 프롬프트/질문 입력(input), 답변 출력(output), 모델의 답변 생성(reasoning) 시 토큰이 소모된다. 입력, 출력, 답변 생성의 양에 따라 당연히 토큰 소모 수가 비례한다. 자세한 내용은 OPENAI의 공식문서에 잘 나와있으니 살펴보자.
유저의 입력 프롬프트가 너무 길어지거나, 지나치게 복잡한 답변을 요구하거나, 너무 긴 답변을 요구하게 되면 토큰 제한 수에 걸려서 답변이 제대로 생성되지 않거나 응답이 중간에 끊어질 수 있다. 따라서 긴 문서를 기반으로 검색을 시도하거나 답변을 생성하는데에 무리가 있을 수 있다. RAG를 통해 범위 내에서 검색에 필요한 정보만 가져와서 활용을 할 수 있으므로 토큰을 절약할 수 있을 뿐만 아니라 토큰 제한으로 인한 답변 생성 제한 또한 피할 수 있다는 장점이 있다.
실제로 지난 프로젝트 때 openai api를 활용했었는데, 토큰 제한 때문에 너무 긴 문서 기반으로 답변 생성이 불가능했다(pdf 문서 몇백페이지짜리였던 것 같다). 토큰 소모량도 줄이려고 프롬프트도 영어로 작성하고 별 짓을 다했던 기억이 있다.
3. 정확도 향상 및 hallucination(환각) 방지
GPT 성능이 나날이 향상돼서 요즘은 덜하다만 위의 짤 처럼 옛날에는 거짓된 정보에 기반한 답변을 주는 경우가 허다했다. 이는 LLM이 답변을 생성 시에 기존에 학습한 데이터를 기반으로 유저가 입력한 문장의 '다음에 올 단어'를 확률에 기반해 추론해서 답변을 생성하는 방법을 사용하기 때문이다. 그렇기 때문에 앞서 언급한 최신 정보에 대한 답변은 모델 자체적으로 생성할 수 없고, 또한 잘못된 정보를 사실처럼 말하는 hallucination(환각) 현상이 발생할 수 있는 것이다.
RAG는 데이터베이스에 저장된 신뢰할 수 있는 정보를 기반으로 답변을 생성하기 때문에 정확도가 높아지고 hallucination을 줄일 수 있다는 장점이 있다.
RAG의 동작 과정에 대해 간략하게 짚고 넘어가자.
1. Retrieval(정보 가져오기)
정보를 가져오는 과정이다. 유저가 입력한 요청을 기반으로 RAG에서 사용중인 데이터베이스와 요청이 연관성이 있는지 파악한 후에, 연관성이 있다면 문서의 일부분을 필터링 해서 가져오는 것이다. 이 때 연관성 있는 문서를 가져오기 위해 유저가 입력한 데이터를 임베딩 모델을 통해서 벡터 값으로 치환한 후에 이를 비교하는 유사도 검증 과정을 거치게 된다.
유사도 검증, 임베딩, 벡터 값 등은 조금만 있다가 자세히 알아보자.
2. Augmentation(가져온 정보 보강하기)
retrieval을 통해 가져온 문서를 기반으로 프롬프트와 문서를 결합하여 답변을 개선하는 과정이다.
예를 들어 유저가 입력한 질문이 "XX대학교 산공과 16학번 김아무개가 누구야?" 면
이를 질문을 "XX대학교 산업공학과 16학번 김아무개에 대한 최신 인적사항(이름, 학번, 입학일 등)을 알려줘." 로 바꾸고 retrieval 단계에서 검색한 문서를 함께 제공하는 식이다.
이를 통해 단순히 검색된 문서를 LLM 모델에 전달하는 것이 아니라 프롬프트+검색된 문서를 함께 제공해서 생성 답변의 품질을 높인다.
3. Generation(답변 생성하기)
augmentation 단계에서 보강된 정보를 기반으로 LLM이 최종 답변을 생성하는 과정이다. 이렇게 생성된 답변은 최종적으로 사용자에게 전달된다.
Langchain을 활용하여 RAG 구축하기
먼저 구현하기 앞서서 langchain이 무엇인지에 대해 알아보자.
Langchain이란
langchain이란 LLM을 쉽게 사용하기 위한 프레임워크이다. 일상생활에서 대부분의 사람들이 GPT/Claude 등의 LLM을 단순히 질의응답 용도로 사용하는 경우가 많은데 외부 데이터 검색, 문서 요약, RAG, 데이터베이스 연동 등 다양한 용도로 활용이 가능하다. 다양한 용도로 활용하기 위한 기능들을 제공하는 것이 바로 Langchain이다.
이 Langchain을 활용해서 LLM 연동, 프롬프트 엔지니어링, QA 체인 구축을 간편하게 하여 RAG를 보다 쉽게 구축해보자.
⚾️Langchain과 RAG를 활용하여 야구 규칙에 대한 질문에 답하는 챗봇을 만들어보자!
1. 벡터 데이터베이스 구축 작업
RAG에서 단계를 소개할 때 첫 번째 단계를 retrieval(데이터 가져오기)라고 썼지만, 유저의 요청을 기반으로 DB에 저장된 데이터를 가져오기 위해서는 당연하게 데이터/문서를 저장하는 과정이 선행되어야 한다. RAG에서는 검색에 사용될 데이터를 벡터 값으로 치환하여 저장하고 이는 벡터(vector) 데이터베이스에 저장된다.
텍스트/이미지와 같은 데이터를 굳이 벡터로 변환해서 저장하는 이유는 앞서 언급한 것과 같이 Retrieval 과정에서 유저가 입력한 요청과 데이터베이스의 문서가 연관성이 있는지 판단하고 저장된 문서와 사용자의 요청 질문 간의 유사도를 측정하여 유사한 정도에 따라 문서의 일부분을 retrieve 하기 위해서이다.
✅벡터 데이터베이스:
이미지, 텍스트 등의 정형/비정형 데이터는 벡터 임베딩(embedding)을 통해서 벡터 값으로 변환될 수 있다. 벡터 값은 기존 객체의 의미를 내포하는 긴 숫자 목록의 형태를 띈다(예를 들어 강아지를 벡터화하면 [1.0, 3.92, -5.94...] 이런 형태로 변환된다). 이런 벡터 값들을 저장하는 데이터베이스가 벡터 데이터베이스이다.
저장할 문서 전처리 및 저장하기(Embedding/Chunking)
먼저 필요한 패키지를 설치해주자. langchain, upstage 관련 패키지를 설치한다. LLM은 일정량의 무료 토큰을 제공하는 upstage로 결정했다(🚨파이썬 가상환경 버전은 3.11.11로 pyenv를 활용하여 가상환경 설정을 진행했다).
%pip install docx2txt langchain-community langchain langchain-upstage python-dotenv
사용할 벡터 데이터베이스인 pinecone 패키지도 설치한다.
%pip install langchain-pinecone
야구 규칙에 대한 질문을 답하기 위해서 우리가 데이터베이스에 저장할 문서는 KBO 2024년 야구규칙 문서이다. pdf 문서로 다운 받을 수 있는데 마크다운이나 json 형태인 텍스트 형태의 파일로 저장하는 것이 편리하다.
텍스트 파일로 저장할 경우 파일 전체를 구조화하기 쉽기 때문에(제목, 소제목, 문단 등) chunking 작업이 용이하고 임베딩 과정을 통해 벡터 값을 도출하기 위해서는 pdf 형태보다는 문서의 구조를 온전하게 유지하는 json/markdown 형태가 편리하다. pdf 파일을 마크다운으로 변환해보자.
import pypdf
def extract_text_from_pdf(pdf_path):
with open(pdf_path, "rb") as file:
reader = pypdf.PdfReader(file)
text = "\n".join([page.extract_text() for page in reader.pages if page.extract_text()])
return text
def save_as_markdown(text, markdown_path):
with open(markdown_path, "w", encoding="utf-8") as file:
file.write(text)
pdf_path = "baseball_rules.pdf"
markdown_path = "baseball_rules.md"
text = extract_text_from_pdf(pdf_path)
save_as_markdown(text, markdown_path)
md 파일로 변환했으니 우리가 저장한 데이터를 chunking 과정을 거쳐서 덩어리로 분리해서 저장해야 한다. 앞서 설명했듯이 RAG를 사용하는 장점 중 하나가 사용되는 토큰 수를 절약하고 최대 토큰 수 제한을 피해서 LLM을 활용할 수 있다는 점이다. 이를 활용하기 위해서 문서 쪼개기 작업인 chunking을 하는 것이다.
chunking 방식에도 여러가지가 있지만 이번에는 간단하게 고정된 크기로 청킹을 진행해보자.
%pip install unstructured markdown # 마크다운 관련 패키지 설치
from langchain_community.document_loaders import UnstructuredMarkdownLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1500, # 하나의 chunk가 가지는 토큰 수
chunk_overlap=200, # chunk간의 중복되는(겹치게 되는) 토큰 수(overlap을 주면서 우리가 원하는 문서를 가져오는 정확도를 조금 더 높임)
)
loader = UnstructuredMarkdownLoader('./baseball_rules.md') # Load the document
documents = loader.load()
split_docs = text_splitter.split_documents(documents) # Split the document into chunks
chunking 작업이 끝나면 전처리한 데이터를 벡터 데이터베이스인 pinecone에 저장한다. 그러기 위해서는 pinecone에서 저장소 역할을 하는 index를 먼저 생성해야 한다. index 생성 시에 아래와 같이 인덱스의 설정 단계에서 dimension과 metric을 설정한다. 임베딩 모델에 따라 조금씩 다를 수 있지만, 대부분 임베딩 모델은 4096 dimensions 정도이기 때문에 4096으로 설정했다.
pinecone 저장소에 인덱스 생성이 끝나면 앞서 생성했던 문서들을 임베딩 과정을 거쳐 인덱스에 저장한다.
from langchain_upstage import UpstageEmbeddings
from langchain_pinecone import PineconeVectorStore
from pinecone import Pinecone
import os
pinecone_api_key = os.getenv("PINECONE_API_KEY") # .env 파일에 저장한 PINECONE_API_KEY
pc = Pinecone(api_key=pinecone_api_key) # Pinecone API 연결
index_name = "baseball-rules-index"
embedding = UpstageEmbeddings(model="solar-embedding-1-large") # 임베딩 모델 로드
# 문서를 임베딩 과정을 거쳐서 Pinecone에 저장
database = PineconeVectorStore.from_documents(documents=split_docs, embedding=embedding, index_name=index_name)
해당 과정까지 완료하면 벡터 데이터베이스에 문서가 잘 저장된 것을 확인할 수 있다.
2. 필요한 문서를 가져오기 위한 Retriever 구현
RAG에서 첫 번째 단계인 사용자 요청에 부합하는 올바른 정보를 가져오는 retriever를 구현해보자. 앞서 설명한 RAG의 장점들인 토큰 절약과 토큰 수 제한에 따른 제약, hallucination 방지를 통한 답변 품질 및 정확도 향상을 위해서는 retriever의 역할이 매우 중요하며 이를 잘 구현하는 데에서 개발자의 실력이 갈린다.
위의 사진에서 빨간색 테두리 범위 내의 단계가 retrieval 과정에 해당한다.
1. 사용자가 요청(Prompt + Query)
2. 벡터 DB를 검색(Query)
3. 요청과 연관성이 있는 정보들만 제공(Relevant info for enhanced context)
해당 과정에서 hallucination을 피하고 사용자의 주제와 관련이 있는 문서의 일부분만 제공하기 위해서 핵심적으로 사용되는 기술이 similarity search(유사성 검색)이다.
유사성/유사도 검색
유사성 검색이란 두 벡터의 계산 값을 토대로 유사한 정도에 따라 결과 값을 도출하는 검색 기법이다. 단 일반적인 키워드 기반 검색과는 다르게 데이터의 의미적 유사성을 고려하여 검색을 한다(키워드 검색이 정확한 키워드가 검색 범위 내에 존재하는지 확인하는 것이라면, 유사성 검색은 키워드가 완전히 같지 않더라도 의미가 비슷하면 검색이 가능하다).
dog를 검색해보면 실제로 의미상 연관성이 있는 cat, cow, rat, dogs, bird 등이 검색 결과로 나오는 것을 확인할 수 있다.
이 유사도 검색에 사용되는 기법은 여러가지가 있지만 3가지만 살펴보자.
1. Euclidean distance(L2 거리)
공간상 두 점 사이의 직선 거리를 계산해서 벡터의 비유사성을 측정한다. 값이 작을 수록 더 유사하다.
✅단순하고 직관적이기 때문에 적용하고 이해하기 쉽다는 장점이 있다.
❌고차원에서는 점 사이의 거리가 균일해지는 특성이 있어 효율성이 떨어진다는 단점이 있다.
2. Dot product(내적)
두 벡터의 방향과 크기를 고려해서 유사도를 측정한다. 값이 클 수록 더 유사하다.
✅직관적이고 이해하기 쉽고 계산이 빨라 성능상으로 효율적이라는 장점이 있다.
❌벡터 크기에 민감하다. 비교되는 벡터의 크기가 커질 경우 결과값의 차이 또한 크게 차이가 나기 때문에 유사도를 정확하게 나타내지 못할 수도 있다는 단점이 있다.
3. Cosine similarity(코사인 유사도)
벡터 간의 각도를 계산하여 유사도를 측정한다. 값이 1에 가까울 수록 유사하다.
✅벡터 크기의 영향을 받지 않기 때문에 문장의 길이가 다르더라도 유사도를 쉽고 빠르게 판단할 수 있다는 장점이 있다.
❌벡터의 크기를 무시하기 때문에 중요한 정보가 누락될 수 있다.
아래의 두 문장에서 B가 추가 정보를 포함함에도 불구하고, 두 문장이 동일한 문장이라고 판단할 위험성이 존재한다.
A: 고양이는 포유류과 동물입니다.
B: 고양이는 포유류과 동물입니다. 포유류과 동물은 새끼를 낳아 젖을 먹여 키웁니다.
이 외에도 여러가지 유사도 검색 방식 모두 각 장단점이 존재하므로 목적에 부합하는 검색 방식을 쓰는 것이 현명하다.
우리 프로젝트에서의 유사도 검색의 목적은 hallucination을 줄이기 위해 문서 내에서 비슷한 의미를 지니는 일부분을 가져오는 것이 목적이기 때문에 문장의 길이(벡터의 크기)는 고려할 필요가 없다. 따라서 cosine similarity를 활용한다(실제로 cosine similarity가 보편적으로 사용되는 유사도 검색 방식이기도 하다).
유사성/유사도 검색 적용하기
similarity_search를 통해 유사도 검색이 가능하다. k 값을 통해 불러올 chunk의 갯수를 조절 가능하다.
query = "1루, 2루의 주자가 동시에 도루하는걸 뭐라고 하나요?"
# 유사도 검색(저장한 chunk들 중에서 query와 가장 유사한 chunk를 찾아낸다.) k값 조절을 통해 얼마나 많은 결과를 가져올지 결정 가능하다.
retrieved_docs = database.similarity_search(query=query, k=4)
질문인 query와 가장 유사한 chunk 네 개를 문서 내에서 불러오는 것을 확인할 수 있다.
3. Augmentation을 통해 정보 보강하여 질문의 품질 높이기
augmentation에서는 유저의 질문(query) + prompt를 결합하여 질문의 품질을 높이고 retrieval 단계에서 가져온 문서 chunk들(enhanced context)을 LLM에 함께 제공해서 최대한 원하는 답변을 얻을 수 있도록 하는 것이 목적이다. 아래의 그림에서 4번 과정에 해당한다.
프롬프트를 통해 유저의 질문(query)를 좀 더 세밀하고 구체적으로 변환하여 보완하고 질문을 필터링 하는 것도 가능하다(예를 들어 야구 챗봇이 축구 핸드볼 파울에 대한 정보는 없으므로 답변을 하지 않는 것).
from langchain_upstage import ChatUpstage
llm = ChatUpstage() # Upstage Chat 모델 로드
langchain에서는 ChatPromptTemplate이라는 LLM 채팅 모델을 위한 프롬프트 관리 도구를 제공한다. 해당 클래스를 이용하여 채팅 LLM 모델이 유연하게 작동할 수 있도록 프롬프트를 구성할 수 있다. 아래와 같이 유저의 질문 품질 향상을 위한 용어/단어 구체화, 질문 필터링 또한 가능하다.
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
# 사용자의 모호한 용어를 변환
dictionary = [
"공 -> 야구공",
"선수 -> 야구선수",
"경기장 -> 야구장"
]
prompt = ChatPromptTemplate.from_template(f"""
만약에 야구 규칙과 관련된 질문이 아니라면 "🚫야구 규칙과 관련된 질문에만 답변이 가능합니다." 라는 답변을 리턴해주세요.
사용자의 질문을 확인하고 dictionary를 참고해서 용어를 변경해주세요.
만약에 변경할 필요가 없다고 판단되면 변경하지 않고 질문을 그대로 리턴해주세요.
사전: {dictionary}
질문: {{query}}
""")
parsing_chain = prompt | llm | StrOutputParser() # Runnable chaining
pipe operator(|) 를 사용하여 체이닝을 통해 prompt -> llm -> StrOutputParser()를 차례로 순차적으로 동작시킨다. 체이닝을 통해 각 컴포넌트를 동작시킨 parsing_chain을 invoke를 통해 주제와 관련이 있는 쿼리, 관련이 없는 쿼리를 넘겨주고 동작시킨다.
irrelevant_query = "축구에서 공을 손으로 만지면 어떻게 되나요?"
relevant_query = "1루 2루 주자가 동시에 도루하는 것을 뭐라고 하나요?"
irre_query = parsing_chain.invoke({"query": irrelevant_query})
rel_query = parsing_chain.invoke({"query": relevant_query})
답변에 불필요한 정보가 조금 포함되어 있지만 일단은 답변을 성공적으로 도출하는 것을 확인할 수 있다.
체이닝을 통한 RAG 구성해보기
지금까지의 과정을 정리해보면 아래와 같다.
1. 문서 벡터 DB에 저장
2. 유저가 쿼리 입력
3. 해당 쿼리와 문서사이의 유사도 검색 통해 연관성 있는 문서 불러오기(retrieve)
4. 유저가 입력한 쿼리 + 프롬프트 + 불러온 문서 데이터
5. 채팅 LLM 모델에 데이터 넘겨줘서 답변 도출
해당 과정을 단계별로 함수화 해보자. 3번과정, 4번과정, 5번과정을 모두 체인으로 변화해서 해당 체이닝을 통해 결과를 도출할 수 있도록 코드를 작성했다.
# 주제와 관련 없는 질문을 필터링 하는 체인
def get_filter_chain():
filter_prompt = ChatPromptTemplate.from_template(f"""
유저의 질문이 야구와 관련된 질문인지 확인하고, 야구와 관련된 질문일 경우에는 "YES" 라는 답변을, 그렇지 않은 경우에는 "NO" 라는 답변을 리턴해주세요.
질문: {{question}}
""")
filtering_chain = filter_prompt | llm | StrOutputParser()
return filtering_chain
# 유저의 쿼리 수정 및 변환을 위한 체인
def get_dictionary_chain():
dictionary = [
"베이스에 있는 선수를 나타내는 표현 -> 주자",
"타석에 있는 선수를 나타내는 표현 -> 타자",
"마운드 위에서 공을 던지는 선수를 나타내는 표현 -> 투수",
"뭐라고 하나요?, 뭐라고 부르나요 -> 를 뜻하는 용어"
]
dictionary_prompt = ChatPromptTemplate.from_template(f"""
사용자의 질문을 확인하고 사전을 참고하여 사용자의 질문을 변경해주세요.
만약 변경할 필요가 없다고 판단되면 변경하지 않고 질문을 그대로 리턴해주세요.
사전: {dictionary}
질문: {{question}}
""")
dictionary_chain = dictionary_prompt | llm | StrOutputParser()
return dictionary_chain
# augmentation을 통한 쿼리+프롬프트+컨텍스트를 활용하여 답변 도출을 하기 위한 체인
from langchain.chains.combine_documents import create_stuff_documents_chain
def get_qa_chain():
system_prompt = ("""
당신은 최고의 KBO 리그 야구 규칙 전문가입니다.
아래의 문서를 참고해서 사용자의 질문에 대한 답변을 해주세요.
답변 시에 출처에 대해서도 명확하게 밝혀주시고, 사용자가 이해하기 쉽게 설명해주세요.
답변의 길이는 2-3줄 정도로 제한해주세요.
\n\n
문서: {context}
""")
qa_prompt = ChatPromptTemplate.from_messages(
[
("system", system_prompt),
("human", "{input}")
]
)
# 문서를 참고해서 사용자의 질문에 대한 답변을 해주는 chain 생성
question_answer_chain = create_stuff_documents_chain(llm, qa_prompt)
return question_answer_chain
아래의 최종 코드를 작성시킬 경우 답변 결과가 잘 나오는 것을 확인할 수 있다.
user_query = "1루, 2루 주자가 동시에 도루하는 것을 뭐라고 하나요?"
filtered_chain = get_filter_chain()
dictionary_chain = get_dictionary_chain()
qa_chain = get_qa_chain()
retriever = get_retriever()
filtered_result = filtered_chain.invoke({"question": user_query})
if filtered_result == "YES":
retrieved_documents = retriever.invoke(user_query)
dictionary_result = dictionary_chain.invoke({"question": user_query})
qa_result = qa_chain.invoke({"input": dictionary_result, "context": retrieved_documents})
print(qa_result)
else:
print("🚫야구규칙에 관한 질문에만 답변할 수 있습니다.")
References
https://aws.amazon.com/what-is/retrieval-augmented-generation/
https://platform.openai.com/docs/models/gpt-4o
https://projector.tensorflow.org/
https://stackoverflow.blog/2024/12/27/breaking-up-is-hard-to-do-chunking-in-rag-applications/
https://blog.dailydoseofds.com/p/5-chunking-strategies-for-rag
'AI' 카테고리의 다른 글
[AI] RAG와 LangChain을 활용한 Chatbot 구현(3) (0) | 2025.02.21 |
---|---|
[AI] RAG와 LangChain을 활용한 Chatbot 구현(2) (0) | 2025.02.14 |