RAG(Retrieval-Augmented Generation)는 LLM(Large Language Model)의 출력을 최적화하여 응답을 생성하기 전에 학습 데이터 소스 외부의 신뢰할 수 있는 knoledge data를 참조하도록 하는 Process입니다. LLM 은 방대한 양의 데이터를 기반으로 학습되며 수십억 개의 파라미터를 사용하여 질문에 대한 답변, 언어 번역, 문장 완성과 같은 Downstream task 작업에 대한 독창적인 결과를 생성합니다. RAG는 이미 강력한 LLM의 기능을 특정 도메인이나 조직의 내부 지식 기반으로 확장하므로 모델을 다시 학습시킬 필요가 없다고는 하지만 현재 시점에서 Hybrid RAG (RAG + Finetunning)을 같이 사용해서 성능을 극대화하는 방법과 RAFT(RAG 형식처럼 파인튜닝) 등 다양한 기법이 나오고 있습니다. 이는 LLM 결과를 개선하여 다양한 상황에서 관련성, 정확성 및 유용성을 유지하기 위한 비용 효율적인 접근 방식입니다.
위논문을 기반하여, 제가 직접 개발하면서 느낀 점, 다른 사람들의 공유, 정보들을 합쳐서 조금 더 잘할 수 있는 가이드가 될 수 있게 적어봤습니다.
Problem serving
Hallucination : 언어모델의 고질적인 문제, 이것을 해결하고자 RAG가 등장하였지만, 오 정보로 사실과는 다른 결과 값을 반환할 여지가 충분히 많다. 사용자의 쿼리에 따라 결과가 많이 달라질 수도 있다.
Poor Data Retrieval : Context로 레트리버 된 정보가 잘못된 정보라면? 그 어떤 성능이 좋은 PLM 이어도 오답을 내뱉을 것이다. 그렇기 때문에 db에서 정보를 추출하는 역량 또한 중요하다.
Expensive Cost : 현재 OpenLLM 은 아직 Context 길이도 길지가 않고 문맥 또한 이해를 잘하지 못하기 때문에 '거인의 어깨' 빅테크의 Model ChatGPT, Claude, Gemini 등 기존에 잘 나온 PLM을 쓰는 것이 RAG 성능을 극대화하는 것이다. 그러나 본질적으로 사용자가 많아지면 많아질수록, 쓰면 쓸수록 비용이 많이 나오게 된다.
Data Preprocessing : 도메인 특화로 RAG를 구성한다면, Prompt engineering과 그에 맞는 전처리가 분명 필요할 것이다. 그러나 이것은 매우 어렵고, 도전적인 일이라 할 수 있다.
Engineering : 이건 나의 문제 일 수 있지만, 이 RAG 아키텍처를 구현하고자 하는 개발환경에 맞게 개발을 하는 것 또한 만만치 않은 일이라고 생각을 했다.
Try to Challenge : ML에서 params 를 변경하면서 Gradient를 최적화를 해본 경험이 있다면, 이것 또한 그것과 같다. 경우의 수 처럼 많은것을 시도하고 도전 하는 일은 매우 어렵다.
Relative Work
현재 기점으로 다양한 시도들이 많이 이루어지고 있다.
프레임워크 : Langchain, llamaindex, sementic kernel, Big Tech Cloud(Azure, Aws, Copilot)
Vector DB : Faiss, Chroma , milvus, pinecon 등에 Graph db를 통한 (초개인화)
다양한 방법론, 프롬프팅기법, LLMops, Embedding, Multi LLM
다양하게 연구가 이루어지고 있고, 알아야 할 것들이 생각보다 많다.
RAG : Survey에서는 RAG를 단계별로 다루고 있으며, 기존의 우리가 간단하게 ChatPDF와 같이 간단하게 영역별 문제를 고려하지 않은 RAG를 Navie RAG 라하고, 여기서 영역별로 성능을 개선하기 위해 여러시돟를 한 방법을 Advanced RAG라고 하였다. 현재 우리는 Advanced RAG 단계에서 개발을 하고 있다고 볼 수 있다. Modular RAG는 모두 AI로 자동 파이프라인화 된 단계라고 볼 수 있다. 이런 Advanced RAG에서는 어떤 점이 중요한지 살펴볼 필요가 있다.
DATA , LLM PipeLine
결국 어떤 Task 던 딥러닝이던 모델이 어떻던? Data-Centric 관점에서 Data가 중요하다. 어떻게 Extract, Load, Transform, Run 할지 파이프라인을 구성해야 할 것이다. 또한 언어모델이기 때문에 Prompt 도 관리가 필요할 것이다.
ELT Pipeline(Extract, Load, Transform)
데이터 전처리만큼 후처리도 중요하고 LLMops 환경을 구축하기 위해선 반드시 필요한 부분이다. 이 부분에서는 임베딩과 Chunk에 대한 실험들이 많이 선행되고, 또한 그것을 관리할 수 있도록 스프레드시트로 눈으로 관리를 하는 것이 좋겠지만 어렵다면 Log를 남겨서 시각화를 하던지 어쨌든 많은 시도를 해야 한다. 각자 환경이 다 다르기 때문에 무작정 mlops를 적용보다는 적정 가용한 인원과 자원을 파악하고 Step by Step으로 진행햐아 할 듯하다.
결국 똑같다 ML 의 주기 Trash in Trash out / Clean data 가 제일 중요한 요소다.
Prompt Engineering
어떻게 보면 가스라이팅 단계다. LLM 이 일을 잘할 수 있도록 Guide 역할을 해주는 방법이기 때문에, 도메인 특화 챗봇을 만들고 싶다면 그에 알맞은 Prompt engineering 기법이 필요할 것이다. 물론 AGI 시대에는 Prompt engineering 마저 개떡같이 쿼리를 날려도 찰떡같이 AI 가 해결해 줄 거라고 하고 Modular RAG 도 어떻게 보면 그 개념이 아닌가 생각이 든다. 그러나 아직은 언어모델이 Prompt에 따라 response 되는 결과가 천지 차이기 때문에 반드시 필요한 부분이다.
Command-R
RAFT
현재 Command-R이라는 Cohere에서 RAG 특화된 모델도 내놓았고 벤치마크 또한 기존의 gpt3.5 보다 RAG에선 최적화되어 있다고 하고, RAFT는 모델이 주어진 질문과 관련된 문서들 사이에서 질문에 도움이 되지 않는 문서들(혼란 문서라고 불림)을 무시하도록 훈련하는 방법이다.
prompt = """
insturction : Context 를 기반하여 QnA 를 답하세요. {question}
Context : " {context}
answer:
"""
이런 구조의 Prompt 구조가 대체적으로 많이 쓴다. 그렇다면 저 구조자체를 활용해서 Instruction fine-tunning 을 진행한다면 더 RAG 전용 QA 답변을 잘 하고 문맥을 잘 이해하지 않을까 생각해봤다.
Langchain의 이름처럼 Prompt를 나눠서 쪼개서 Query에 대한 효율적인 처리를 하고, 명확하게 Output을 내야 할 때는 하나만 처리하는 것이 가장 효율적인 Chaining이라고 할 수 있다. 랭체인을 쓰면 여러 체인들을 엮어서 쓰게 된다. 당연히 이러면 복잡도는 올라가게 되고 비용은 더 나오게 될 것이다. 그러나 그만큼 할루시네이션이 줄고 신뢰성이 올라갈 수 있다.
Prompt는 도메인에 따라 구성을 다르게 해야겠지만 여러 번 돌려보고 스프레드시트로 프롬프트 버전을 관리하는 것이 좋다고 생각을 했다. 즉 템플릿을 관리하는 것이다. 뭐 똑같은 소리지만 간결하고 직관적인 프롬프트를 구성하고, few-shot , CoT와 같은 방법론을 쓰고 잘 안된다면 그 부분을 나눠서 처리해 보고, 또한 빅테크의 어깨를 계속 타야 된다. 좋은 모델이 나오면 왜 안 쓸까? 당연히 써야 된다. 반드시 여러 모델을 비교해 보고 테스트해봐야 한다.
+ 추가로 Model 에게 Prompt 를 던져 줄 때 Context 가 나오는 위치가 앞이냐 뒤냐 에 따라 성능도 달라질 수 있다니 여러모로 많이 시도를 해봐야한다. 내 경험상 Context 가 앞 인 경우에 더 성능이 좋았음.
Chunking
RAG를 잘하기 위해서는 Context-Rich 하게 데이터를 잘 추출하는 것이 핵심이다. 랭체인에 Docs를 최근에 따라 쳐보면서 여러 Chunking 모듈이 존재하는 것을 알았다. Tik-token 단위, HuggingFace 토큰단위, 문자열 단위, NLTK 단위, KONLPY, Fixed sized, 다양하게 Text data 들을 청킹 할 수 있다. 그래서 우리는 이에 맞춰서 사용을 해야 한다.
대체적으로 Recursive Chunking(재귀적인 청킹)을 활용해서 개발을 많이 하는 것으로 보인다.
Chuking 하는 모듈도 중요하고 그 후 테크닉이 또 있다.
- Overrapping이다. 요즘 트렌드는 짧고 오버랩핑을 하는 방식이라고 한다. Overrap 은 텍스트가 Chunk 가 되면 앞의 일부 문자를 같이 가져와서 출력시켜서 연관성, 문맥의 이해도를 높여주는 방식이라 할 수 있다.
- Sentence-window retrieval :추출된 데이터의 위아래로 정보를 더 가져와서 Context Rich 하도록
- Auto Merging : chuk를 나누고, 제일 선택이 많이 되는 것의 전체를 합쳐서 가져오는 것
- Parent Child Chunking 이란 부모자식 청킹은 GPT의 Context window의 책을 한 권을 청킹 해야 하는데 , 책을 다 정보로 청킹 하지 않고 넣으면 당연히 좋을 것이다. 요즘 GPT-4-turbo는 가능은 하지만, 현실적으로 매번 토큰이 책 한 권만큼 호출이 되면 비용부담이 엄청 클 것이다. 이 방법은 또 다른 DB를 구축해 부모, 자식을 인덱싱 해서 DB에도 저장하고 인덱싱 된 데이터를 Vector DB에 저장을 해서 레트리버를 할 때 비교를 해보고 가져오는 방법이라 할 수 있다.
- Summary Chunking 은 청킹 된 단위의 정보를 요약해서 정보를 넣어 저장하는 것이다. 당연히 정보의 유실이 있을 수밖에 없다.
- Extract candidate 방식은 N개의 요약과 같은 내용을 콘텍스트로 제공 후 N개의 질문으로 임베딩 인덱싱을 하는 것이다. QnA 챗봇에서 효율적이라고 하는데 말 만들어서는 사실 조금 어렵다.
- Meta Tagging 을 통해서 청크단위에 Meta tag 등 부가정보를 추가해서 임베딩하는 방법도 많이 쓴다.
굳이 위 모듈을 내가 만들어서 쓴다? 어차피 LLM 파이프라인 구축 때 한 번에 처리하고 추가적인 처리나 Update를 할 때나 사용하기 때문에 Framework에 의존을 해도 되지 않을까 생각을 한다. Langchain 도 많은 모듈을 지원하고, LLama index 에도 지원을 하니 찾아보고 입맛에 맞는 것을 사용하면 좋을 듯하다.
또 하나의 팁은 Chunk 를 Top_K를 활용해서 여러개로 Inference 를 해보아도 좋아진다고 들은적 있다.
Embedding
임베딩 모델 또한 매우 중요하다. 그랩님은 따로 임베딩 파인튜닝은 고려하지 않았다고 했는데, 나중에 시도해본다고 했는데, 예전에 딥러닝 플레이그라운드에서 네이버 테크리더분은 임베딩 파인튜닝을 하셨다고 했다. 물론 테크기업은 리속스가 많으니깐 상관은 없겠지만 현실적으로 당장은 어려운일이다. 일단 최대한 성능이 좋은 MTEB 를 토대로 모델을 여러개를 써보면서 체크해보면서 어떤게 우리 데이터와 적합한지도 테스트를 해보는 것 또한 분명 필요하다.
대용량 RAG 를 써야하는 경우 OpenAI Embedding 또한 리소스 소모가 많이 될 것이다. 그래서 이때 오픈소스 임베딩 모델을 사용해야 할 것이고, 또한 제일 중요한 것은 Caching 기법이다. ./Cache/ file 을 만들어 두어서, 임베딩을 할때 같은 데이터라면 다시 임베딩을 하지 않고 파일에서 내보내서 쓸 수 있게 해주는 절약관리 프로세스가 필요하다.
from langchain.embeddings import CacheBackedEmbeddings
underlying_embeddings = OpenAIEmbeddings()
store = LocalFileStore("./cache/")
cached_embedder = CacheBackedEmbeddings.from_bytes_store(
underlying_embeddings, store, namespace=underlying_embeddings.model
)
위처럼 임베딩 store 를 적용해서 Langchain 에서 쓸 수 있다. 시간 절약도 된다는 점은 덤이다. 저는 E5를 좋아합니다.
Search
서치는 Chunking 한 데이터로부터 쿼리에 대한 Retrieval Context에 가져올 때 검색하는 방법론이라고 할 수 있다.
일반적으로 검색엔진에서 많이 쓰는 전통적인 방법인 Keyword와, ML에서 많이 쓰이는 코사인 유사도, 그리고 이 둘을 결합한 Hybrid 가 보편적으로 많이 쓰인다.
Keyword : bm25, elastic, SPLADE
Semantic : Embedding method (MMR, cosine similarity)
Hybrid(RAG-fusion) : keyword + Semantic (Elastic search, Open search, Weaviate, Ensemble)
Langchain에서 Ensemble retrieval로 알고 있었는데 제일 많이 쓴다고 한다. 실제로 내가 RAG Search를 했을 때도 성능이 가장 좋았던 것 같다.
from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever
from langchain_community.vectorstores import FAISS
from langchain_openai import OpenAIEmbeddings
doc_list_1 = [
"I like apples",
"I like oranges",
"Apples and oranges are fruits",
]
# initialize the bm25 retriever and faiss retriever
bm25_retriever = BM25Retriever.from_texts(
doc_list_1, metadatas=[{"source": 1}] * len(doc_list_1)
)
bm25_retriever.k = 2
doc_list_2 = [
"You like apples",
"You like oranges",
]
embedding = OpenAIEmbeddings()
faiss_vectorstore = FAISS.from_texts(
doc_list_2, embedding, metadatas=[{"source": 2}] * len(doc_list_2)
)
faiss_retriever = faiss_vectorstore.as_retriever(search_kwargs={"k": 2})
# initialize the ensemble retriever
ensemble_retriever = EnsembleRetriever(
retrievers=[bm25_retriever, faiss_retriever], weights=[0.5, 0.5]
)
이런 식으로 예로 들어 2개씩 keyword, embedding search를 할 때 앙상블 Weight를 적절히 주어서 Reranking을 할 수가 있다.
Query Transform
사실 이 부분에서 제일 많이 삽질하고 많이 찾아봤던 부분 같다. 지금이야 많은 방법론들이 나왔지만 GPT3.5와 open LLM으로 RAG를 붙여보면 복수의 질문을 이해를 정말 못한다. 그래서 위의 Searching을 할 때도 한 개에 대한 정보만 가져오다 보니 할루시네이션이 유발이 되곤 한다. Self-RAG 논문과, Corrective-RAG(자가수정) 즉 모델에게 Agent 형식으로 수정을 하는 방법도 있지만, 이 또한 시간과 리소스가 많이 소요된다. 그래서 Decomposition , Step-back Prompting, Hyde, Sub query 등 다양한 방법론이 있다.
Rephrasing : query rephrase하고 두개의 질문을 Embedding Vector 넣어두면 그 질문이 똑같지는 않다.
Router Query Engine : Metadata 를 통해서 선택자가 결정을 하고 다양하게 쓰임새가 변할 수 있다.
HyDe : HyDE는 LLM이 생성한 문장이 사용자의 질의보다 임베딩 공간에서 더 가까울 수 있다는 가정에 기반한 방법론으로 즉, 이런 쿼리가 들어오겠다 미리 예측하고 임베딩해서 인덱스를 색인해서 내보내는 방법론이다.
Sub-question query engine : 다양한 쿼리를 적용해 보는 전략으로 llamaindex에서 사용이 가능하다.
Single Decomposion : 단일 단계 쿼리 분해 기능은 복잡한 질문을 데이터 컬렉션에서 관련 정보를 추출하는 데 특화된 더 단순한 질문으로 변환하도록 설계함 원래 질문을 더 작고, 더 집중된 부쿼리로 나눔으로써, 모델은 각각의 부답변(sub-answers)을 제공할 수 있으며, 이들은 전체적으로 원래 질문의 복잡성을 해결하는 데 기여한다.
Multi Decomposition : 쿼리를 분해하는 것이다. "나 오늘 나갈 건데 저녁메뉴 추천해 주고, 영화도 뭐 볼지 추천해 줘"라는 쿼리가 있으면 ['나 오늘 나갈 거야', '저녁메뉴 추천해 줘'. '영화도 뭐 볼까?']처럼 쿼리를 나눠서 처리 후 합치는 방식이라고 할 수 있다. 이 방법은 실제로 Dacon에서 LLM 성능 개선하기 위해 복수쿼리 질문을 유용하게 처리하기 위해 썼었는데 성능개선이 많이 되었었다.
Stepback Prompting : 더 추상적인 레벨로 Prompting을 올리는 것이다. Langchain에서 예시를 하나 보면은
from langchain_core.runnables import RunnablePassthrough
step_back_and_original = RunnablePassthrough.assign(step_back=step_back)
step_back_and_original.invoke({"question": question})
{'question': 'Gemini Pro와 벡터스토어 및 덕덕고 검색과 같은 도구를 사용하여 LangGraph 에이전트를 구축했습니다. 이벤트 스트림에서 LLM 호출만 얻으려면 어떻게 해야 하나요',
'step_back': 'Gemini Pro, 벡터스토어, 덕덕고 검색과 같은 외부 도구를 사용하여 구축한 에이전트가 생성한 이벤트 스트림에서 LLM 호출을 추출하기 위해 LangGraph에서 제공하는 구체적인 방법이나 함수는 무엇인가요?'}
이런 식으로 LLM을 통해서 한번 prompt를 가다듬는 작업이라고 생각하면 된다.
순차적으로 읽어봐도 Decomposition, stepback을 잘 결합해서 쓰는 것이 제일 좋아 보인다.
Post-Retrieval Process
후처리 Retrieval Process는 RAG에서 매우 중요한 단계로, 데이터베이스에서 검색된 중요한 문맥을 질의와 결합하여 LLM에 입력하는 과정이다. 예를 들어, 좋은 답변을 얻기 위해선 여러 데이터를 넣어야 하지만, 검색 결과로 나온 모든 문서들을 한꺼번에 LLM에 제공하는 것은 비효율적이며, LLM의 콘텍스트 윈도(context window) 크기를 초과할 수도 있다. 이 문제를 Lost in the middle이라 한다. 이 문제는 어느 정도 해결이 되었다고는 하지만 사실 OpenLLM 은 큰 숙제다. rotary PE를 써서 긴 콘텍스트의 정보를 기억하게 끔 하고 있고, 그냥 빅테크의 어깨를 타면 된다. 여기선 두 가지 정도의 후처리 방법론이 있다.
Re-rank : 어차피 모듈이 알아서 해주겠지만, 검색을 통해 추출한 데이터를 Re-ranking을 통해서 데이터를 추출해야 한다. decompose처럼 쿼리 재분합을 하기 전에 벡터임베딩에서 복수의 질문에서 앞에 위치한 지문의 쿼리가 적재가 되어서 처리하느라 애먹었다. 순위가 밀려서 결국 레트리버를 할 때 1,2순위의 답변을 레트리버를 주는데 앞지문에 대한 정보뿐이었다.
결국 Querytransform - rerank는 묶여서 간다고 생각한다. 그래서 여러 데이터를 앙상블 해주면 당연히 더 많은 정보를 알기 때문에 사용자가 원하는 답변에 더 도달할 수 있기 때문에 re-rank는 중요하다. 이미 랭체인, 라마인덱스 heystack에서 잘 구현이 되어있으니 꼭 사용하는 것을 추천한다.
또 따로 LLM을 통해서 Re-rank 모델을 구축해서 사용자 입맛에 맞게 프롬프팅을 넣어서 "나는 { } 쿼리들의 순위를 조정할 거야, 내가 중요시하는 것은 ~~~ 이야" 이런 식으로 커스텀을 해서도 충분히 사용이 가능하다.(랭체인에 이미 존재함)
Prompt Compression : 검색된 정보들 중 온전한 데이터는 없을 것이다. 정보들 중 중요한 내용을 판단하고 요약하고 그리고 압축해서 Longcontext에서 효율적으로 정보를 찾기 위한 작업이라 할 수 있다. LLMLingu와 같은 방법으로 작은 언어모델을 활용해서, 문서의 중요한 내용 맥락을 판단하고, 중요한 요소를 추린다. Recomp를 통해 정보를 압축하고 , 필요에 따라 제거를 해서 간결한 프롬프팅을 만든다. 위 방법은 사용은 안 해봤지만, 어쨌든 정보의 유실도 고려해야 할 거고, 모델이 들어가서 요약을 한번 거친다는 점에서 Re-rank를 쓰는 게 더 효율적이지 않을까 생각을 하긴 했다. 그건 알아서 판단하면 될듯하다.
Challenge
결국 정확한 정답은 없다. Data , model, prompt , Embedding(try finetune), Chunk, hierarchy , search 등 다양한 요소에 따라 달라질 수 있기 때문에, 그 환경을 계속 계속 시도하고 적어두고, 개선하고 하는 등 끝없는 막일의 반복이다. 그래서 LLMOps 환경을 만들려고 하는 것이다. Auto-Evaluator 와 최근 공개된 Upstage evalverse도 업데이트가 되지 않을까 생각한다.
https://github.com/UpstageAI/evalverse
위 방법들을 써서 분명 Evaluation을 해야 할 것이고 Eval 단계는 더 힘들 것이라고 생각한다. 그에 따라 Data engineer들의 역할도 분명히 중요해 보인다. 그리고 Evaluation에서 Ragas, Treulens, AutoRAG(도입예정) 다양한 라이브러리를 통해 평가도 해보고, 정성평가도 하고 다양하게 평가 검증 QA engineering을 해봐야 좋은 RAG 구조가 나오지 않을까 생각한다. 나도 만만하게 봤던 RAG였는데, 하나의 팀단위로 유기적으로 협업 소통 피드백을 하면서 해야 프로덕트 급에서의 좋은 결과물이 나올 듯하다.
느낀 점은 Product level 에선 MLengineer 가 Framework 없이 구축을 해야 할 것이다. 랭체인을 쓰면 확장성이 떨어지고 종속성에 의해서 자유도가 떨어지기 때문이다. ML엔지니어도 어느 정도 프론트 빽, 또는 앱을 어느정도 할 줄 알아야 유연한 개발이 되겠구나 생각을 했다. 열심히 해야겠다.
+
위 내용은 Project Pluto에서 근무하시는 이호연 테크리더님의 LLM Application 발표회를 토대로 재구성하였습니다.
'NLP' 카테고리의 다른 글
나만의 원피스 루피 챗봇 만들기 with HyperClovaX (33) | 2024.06.14 |
---|---|
Chat Vector 를 통한 한국어 모델 튜닝 (0) | 2024.06.12 |
Multi-Turn 한국어 데이터를 Fine-Tunning 하는 방법 - (1) (0) | 2024.03.28 |
Self Consitency prompt (0) | 2024.03.07 |
Transformer Mechanism 이란? (0) | 2024.03.06 |