이전 시간까지 하여 LangChain 을 이용하여 LLM 모델을 사용하는 전반적인 방법에 대해 알아보았다. 이제는 그 LLM 모델을 더 효율적으로 사용할 방법에 대해 배울 차례이다.
LLM 모델은 태초부터 챗봇을 위해 나온 모델이 아니라 랜덤한 자연어를 최대한 말이 되게끔 생성하기 위해 나온 모델이었다. 그런 탓에 LLM 모델은 태생적인 한계로 Hallucination 현상 을 가질 수 밖에 없었고 이를 보완하고자 나온 방법이 RAG (Retrieval Augmented Generation) 이다.
RAG 는 LLM 이 답변을 생성할 때 특정 문서에 기반하여 답변을 만들도록 하는 기법이다.
파인튜닝기법 은 LLM 을 특정 도메인에 최적화시키는 작업이다.
LLM 을 원하는 모델로 만들기 위해 파인튜닝기법을 사용할 수도 있지만 이 경우 높은 성능을 기대할 수는 있지만 사용이 도메인 제한적이고 모델을 재학습시키기 위해 많은 데이터와 자원이 필요하다.
반면 RAG 기법은 검색 시스템을 구축하기만 하면 최신 정보를 기반으로 답변을 생성할 수 있으며 모델을 변화시키는 것이 아니기에 도메인 유동적이다.
RAG 를 위해 검색 시스템을 구축한다는 것은 1) 정보 가공, 2) 벡터 데이터베이스에 저장, 3) 모델이 검색 및 답변하는 과정으로 이루어진다. 그 중 정보 가공 과정에 대해 알아보자.
정보는 어떤 형태로든 존재할 수 있다. PDF, CSV, Excel, sqlite3, 웹 등등. 그렇기에 LangChain 에서는 Document 라는 클래스로 이들을 관리하며 여러 DocumentLoader 를 통해 이들을 불러온다.
Document 객체는 고유 id, page_content : 주요 내용 , metadata 속성을 가진다. 간단화된 구조를 사용함으로써 포괄적인 데이터를 담을 수 있다.
from langchain_community.document_loaders import (TextLoader, PypdfLoader,
WebBaseLoader, ArxivLoader, DirectoryLoader)
loader = TextLoader(path) # lazy_load() 를 통해 나중에 불러오기도 가능
text = loader.load()
type(text)
>>> class 'langchain_core.documents.base.Document'
가장 흔히 생각해 볼 수 있는 로컬의 .txt, .pdf 파일은 물론 내부적으로 BeautifulSoup을 사용하여 그 내용을 Document 형식으로 바꿔주는 WebBaseLoader, 논문을 Document 형식으로 가져오는 ArxivLoader 등 LangChain 의 3rd-party 로서 많은 Loader 들이 제공된다.
이렇게 불러온 Document 객체들은 후에 Embedding Model을 통해 각각이 하나의 벡터로 변환될 것이다. 그러기 위해서 하나하나의 객체의 내용은 너무 길지 않게 유지되어야 한다.
LangChain 에서는 하나의 구분자를 사용하는 CharacterTextSplitter 와 여러 구분자를 사용하는 RecursiveCharacterTextSplitter 등의 클래스를 지원해준다.
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=100,
chunk_overlap=10,
separators=["\n\n", "\n", ",", ""]
)
split_docs = splitter.split_documents(documents) # split_text 를 이용해 하나의 문자열을 나눌 수도 있다.
type(split_docs)
>>> class 'langchain_core.documents.base.Document'
RecursiveCharacterTextSplitter는 문서를 재귀적으로 나누되,chunk_size이내가 될 때까지 시도한다.
이 splitter 는 Document 클래스를 유지한 채 separators 의 기준에 따라 문서를 나누려고 시도하며 만약 끝까지 잘리지 않을 경우 chunk_size 에 맞춰 강제로 잘라 최종적으로 문서의 크기를 맞추게 된다.
단, 이렇게 강제로 문서를 자르게 되는 경우 문서의 의미가 훼손될 수 있으므로 이 경우 앞 뒤 맥락을 고려하기 위해 chunk_overlap 만큼 겹치는 구간을 설정해주게 된다.
그럼에도 불구하고 위 방법은 여전히 "문자 수" 를 기준으로 글을 분리하기에 의미론적인 부분에서 아쉬운 점이 존재한다. 이를 보완해낸 것이 "토큰 수" 기준 splitter 이다.
# OpenAI Tokenizer 사용
splitter2 = RecursiveCharacterTextSplitter.from_tiktoken_encoder(
model_name='gpt-4o-mini',
chunk_size=100 # 이 경우 토큰 수 100개 기준, 문자 수 아님
)
# HuggingFace Tokenizer 사용
from transformers import AutoTokenizer
splitter3 = RecursiveCharacterTextSplitter.from_huggingface_tokenizer(
tokenizer=AutoTokenizer.from_pretrained(model_name='beomi/kcbert-base'),
chunk_size=100
)
이 방법을 통해 문서의 의미를 최대한으로 보존함과 동시에 LLM 에 넘겨주는 토큰 수를 명확히 제한해 줄 수 있다.
이제 나눈 문서를 임베딩 벡터로 변환하여 벡터 데이터베이스에 저장할 차례이다.
임베딩 모델은 OpenAI, Ollama, HuggingFace 등 다양한 플랫폼에서 지원해준다.
from langchain_openai import OpenAIEmbeddings
embedding_model = OpenAIEmbeddings(model='text-embedding-3-large')
embeddings = embedding_model.embed_documents(documents) # embed_query 를 이용하여 문자열을 받을 수도 있다.
np.shape(embeddings)
>>> (document 수, embedding 차원)
LLM 의 메모리 기능을 구현할 때 InMemoryChatMessageHistory 를 배우고 SQLChatMessageHistory 를 배웠던 것처럼 이번에도 메모리 상 벡터 DB 와 외부 벡터 DB 를 다뤄보겠다.
from langchain_core.vectorstores import InMemoryVectorStore
# 벡터 DB 생성
vector_store = InMemoryVectorStore(
embedding=embedding_model
)
# 문서 추가
vector_store.add_documents(documents) # add_texts 를 통해 순수 텍스트를 추가할 수도 있다.
# 제공된 문서를 바탕으로 벡터 DB 바로 생성
vector_store2 = InMemoryVectorStore.from_documents( # 마찬가지로 from_texts 도 가능.
documents=documents,
embeddings=embedding_model,
# ids=ids_list
)
Document 객체들로 이루어진 documents 를 이용하여 벡터 DB 를 한 번에 생성할 수도 있으며 별도로 구성 후 내용을 추가할 수도 있다.
내부적으로 embeddings 로 주어진 모델의 .embed_documents 를 호출한 뒤 저장하므로 .from_documents 메소드를 통해 한 번에 너무 많은 양을 넣어줄 경우 max token error 가 발생할 수 있다. 이 경우 먼저 벡터 DB 를 생성한 뒤 batch 로써 문서를 넣어줄 것. 이번 프로젝트에서 한 시간 날림
# 벡터 DB 조회
vector_store.store
>>> {'document_id_1' :
{'id': ~ , 'text': ~ , 'vector': ~ , 'metadata': ~ },
'document_id_2' :
{'id': ~ , 'text': ~ , 'vector': ~ , 'metadata': ~ }, ... }
위처럼 store 속성을 이용해 벡터 DB 를 구경할 수 있다. 다행히? 임베딩 벡터만을 저장하지 않고 원본 데이터도 함께 저장하므로 눈으로 확인할 수 있다는 점.
Document 객체의 id, page_content, metadata 속성이 그대로 들어간다는 점을 알 수 있다. 단, 문서별로 별도의 id 를 지정해주지 않으면 고유 id 생성법인 uuid 를 통해 자동으로 id가 부여된다.
이러한 벡터 DB 의 가장 큰 장점은 내부 문서들이 임베딩 벡터로 저장되어 있으므로 이들과의 유사도 검사가 편리하다는 점이다.
# 벡터 스토어 생성
texts = ["apple", "banana", "sports"]
vector_store = InMemoryVectorStore.from_texts(
texts=texts,
emebedding=embedding_model
)
# 질문
query = "너가 좋아하는 과일이 뭐야?"
# 유사도 검사
vector_store.similarity_search(query, k=2)
>>> [Document(id='fdb1aee1-1abc-45f2-b7ab-7eb126ed2eab', metadata={}, page_content='banana'),
Document(id='061285ff-c782-4c31-bd1b-0ed79f48798c', metadata={}, page_content='apple')]
# 유사도 검사 with 점수
vector_store.similarity_search_with_score(query)
>>> [(Document(id='fdb1aee1-1abc-45f2-b7ab-7eb126ed2eab', metadata={}, page_content='banana'),
0.3248540085773556),
(Document(id='061285ff-c782-4c31-bd1b-0ed79f48798c', metadata={}, page_content='apple'),
0.2868149016178356),
(Document(id='26195b51-a932-4868-ad34-fef050479b69', metadata={}, page_content='sports'),
0.08774698057804514)]
# MMR 기반 유사도 검사
vector_store.max_marginal_relevance_search(
query=query,
lambda_mult=0.5, # 높을수록 다양성보다 유사성을 고려
fetch_k=20, # 첫 검색 문서 수
k=5 # 최종 검색 문서 수
)
VectorStore 는 query:str 를 받아 내부적으로 임베딩 벡터로 변환 후 문서들과의 유사도를 검색해 top_k 개를 반환한다.
유사한 문서의 개수가 충분하지 않을 경우 top_k 개에 질문과 무관한 문서가 검색될 수 있다. 이러한 경우 실제 유사도 점수를 기반으로 filtering 해주기 위해 similarity_search_with_score 메소드를 통해 점수를 튜플 형식으로 받을 수 있다.
반면, 유사한 문서의 개수가 너무 많을 경우 질문과 유사한 문서들이기는 하지만 내용이 매우 흡사한 문서들만이 검색되어 다양성을 떨어트리는 결과를 초래할 수 있다. 이를 위해 나온 것이 MMR 기반 유사도 검사.
MMR(Maximal Marginal Relevance) 알고리즘은 1) 질문과의 유사성 에 더해, 2) 이미 검색된 문서 중 가장 유사한 문서와의 유사성 을 고려한다.
이 비율은lambda_mult값을 통해 지정할 수 있다.
이렇게 구성된 VectorStore 는 메모리 상에만 존재하기에 별도로 저장( dump )과 후에 불러와주는 행동( load )이 필요하다.
다른 DB 들과 마찬가지로 벡터 DB 역시 별도의 저장소들을 필요로 한다. 그 중 오픈 소스인 Chroma에 대해 알아보자. Chroma 는 로컬에 벡터 DB를 저장하는 데이터베이스이다.
from langchain_chroma import Chroma
COLLECTION_NAME="vector_store1" # 벡터 컬렉션의 이름
PERSIST_DIRECTORY="./vector_store/chroma" # 저장할 디렉토리 위치
vector_store = Chroma.from_documents( # 그냥 연결만 할수도 있음
documents=documents, # 임의의 문서들
ids=["document1", "document2" ... ], # 각 문서 id
persist_directory=PERSIST_DIRECTORY, # 디렉토리 위치
collection_name=COLLECTION_NAME # 컬렉션 이름
)
type(vector_store)
>>> langchain_chroma.vectorstores.Chroma
메모리 상에 저장되는 것이 아닌 실제 로컬에 바로 저장되는 방식을 택한 Chroma 이기에 저장할 디렉토리 위치와 각 컬렉션의 이름을 각각 persist_directory, collection_name 을 통해 전달해 주어야 한다. 컬렉션명을 통해 하나의 Chroma VectorStore 에 카테고리별로 저장소를 만들어 관리할 수 있다.
이제 마찬가지로 실제 벡터 DB 를 뜯어보자.
collection = vector_store._collection
collection
>>> Collection(name=vector_store1) # 하나의 컬렉션
collection.count()
>>> 10
collection.get("document1") # 문서 ID 로 검색
>>> {'ids': ['5'],
'embeddings': None,
'documents': ["Wow! That was an amazing movie. I can't wait to see it again."],
'uris': None,
'included': ['metadatas', 'documents'],
'data': None,
'metadatas': [{'source': 'tweet'}]
}
collection.search("영화 추천", search_type="similarity")
>>> [Document( ~ ), Document( ~ ), ...]
이외에도 collection 내의 문서를 삭제, 수정, 추가하는 기능이라든지 search_type 변수를 통해 MMR 알고리즘 검색을 제공한다든지 조금 더 개선된 기능을 보여준다.
로컬에 저장하는 방식을 사용하므로 접근성이 좋고 다루기 쉽다는 장점이 있지만 반대로 백업, 오류, DB 관리 등을 사용자가 모두 해야하며 DB 크기를 크게 유지하기 어렵다는 단점이 존재한다.
이를 해결한 클라우드 벡터 DB의 예시로는 Pinecone이 존재한다.
드디어 모델이 참조할 문서들이 담긴 벡터 DataBase가 준비되었다. 이제 이렇게 유사도를 기반으로 검색된 문서를 바탕으로 모델이 응답을 생성하도록 만들어주면 되겠다.
사용자의 질문과 유사한 문서를 검색하여 가져와주는 친구를 Retriever 라고 한다. 여기서는 Chroma 를 이용한 벡터 DB를 사용하였으므로 여기서 검색해주는 retriever를 사용하겠다.
구성될 RAG의 진행방식을 떠올려보자.
1) 사용자로부터 질문을 받는다.
2) 이 질문을 retriever에게 넘겨 문서들을 받는다.
3) 받은 문서들을 하나의 문자열로 합쳐 본래 질문과 합친 템플릿을 만든다.
4) 이를 LLM에게 넘겨 답변을 받는다.
from langchain_core.prompts import PromptTemplate
from langchain_core.output_parsers import StrOutputParser
from langchain_openai import ChatOpenAI
from langchain_core.runnables import RunnableLambda
from textwrap import dedent
# 프롬프트 구성
prompt_template = PromptTemplate.from_template(
template=dedent("""### Instruction:
당신은 AI 어시스턴트입니다.
Context 로 받은 문서들에 기반하여 답변을 생성하세요.
### Context: {context}
### 질문: {query}
"""))
# 모델 생성
model = ChatOpenAI()
# 리트리버 생성
retriever = vector_store.as_retriever()
# 체인 구성
chain = (RunnableLambda(lambda x: prompt_template.invoke({'query':x,
'context':"\n\n".join(doc.page_content for doc in retriever.invoke(x))}))
| model | StrOutputParser())
response = chain.invoke('10글자 내로 정보를 알려줘')
response
>>> '올림픽이나 하계올림픽 등의 대회가 있습니다.'
RunnablePassthrough 를 통해 배운김에 RunnableLambda 로 구성하려다보니 코드가 조금 맛이 없어지긴 했다.
이렇게 구성한 retriever 는 기본값으로 similarity search 를 진행하며 총 4개의 문서를 가져오게 된다. 이를 변경하고 싶은 경우 search_type 과 search_kwargs 파라미터를 통해 검색 방법이나 세부 사항을 변경할 수 있다.
검색할 벡터 DB - retriever - LLM 모델 간의 관계가 가장 심플한 경우를 살펴보았다. 아래는 이를 발전시킨 retriever에 대한 정리표이다.
| 리트리버 이름 | 주요 특징 | 장점 | 유리한 사용 상황 | LLM 활용 |
|---|---|---|---|---|
| VectorStoreRetriever | 임베딩 기반 유사도 검색 | 빠르고 간단 | 기본 문서 검색 시스템 구축 | ❌ |
| ParentDocumentRetriever | 청크 인덱싱 후 전체 원본 문서 반환 | 문맥 유지 탁월 | 긴 문서, 섹션형 구조 문서 | ❌ |
| MultiVectorRetriever | 문서당 다양한 임베딩 생성 (요약, 질문, 키포인트 등) | 핵심 정보 반영 ↑ | 중요한 부분이 특정된 문서 검색 | ❌ (간접적) |
| SelfQueryRetriever | 자연어 질문을 검색어 + 메타데이터 필터로 자동 변환 | 정밀한 조건 검색 가능 | 작성자/날짜 등 구조화 데이터 기반 검색 | ✅ |
| ContextualCompressionRetriever | 검색 결과를 요약·압축하여 핵심 내용만 전달 | LLM 입력 길이 단축, 관련도 향상 | 장문 처리, 쿼리와 관련 없는 정보 제거 시 | ✅ |
| MultiQueryRetriever | 질문을 다양한 형태로 재작성하여 병렬 검색 | 표현 다양성 대응 | 질문 표현이 다양한 경우, 쿼리 확장 필요 시 | ✅ |
| EnsembleRetriever | 다양한 리트리버 조합 (예: BM25 + 벡터) | 정확도/포괄성 향상 | 다중 접근 방식으로 성능 최적화할 때 | ❌ (혼합 가능) |
ParentDocumentRetriever 와 MultiVectorRetriever 의 경우 기존 청크 단위로 임베딩되어 있는 벡터 DB 를 재구성하여 검색한다. 전자의 경우 너무 긴 문서에 대해 이들을 나누어 검색하기 좋으며, 후자의 경우 문서의 다양한 특징에 대해 검색하기 좋다.
MultiVectorRetriever 의 경우 문서의 여러 포인트들을 통해 하나의 문서에 여러 임베딩 벡터를 할당하는 방식이라면 MultiQueryRetriever 는 그저 사용자의 질문을 여러 형태로 바꾸어가며 검색해 볼 뿐이다.
ContextualCompressionRetriever 의 경우 별도의 Compressor 를 구성하여 Rerank 알고리즘을 구현할 수 있다.
Rerank 알고리즘은 LLM 이 문서의 앞부분을 중요히 여긴다는 점을 반영해 상위 문서를 중요도 기준으로 재정렬하는 알고리즘이다.
외에도 별도의 retriever로 구현되어 있지는 않지만 HyDE 와 MapReduce 방식을 통해 답변의 성능을 올릴 수 있다.
HyDE(Hypothetical Document Embedding) 방식은 아무리 같은 내용일지라도 "질문" 과 "답변" 의 벡터는 다를 수 밖에 없다는 점을 반영해 사용자의 질문을 답변으로 변환한 뒤 RAG 를 이용하자는 알고리즘이다.
반면 MapReduce 방식은 문서의 내용이 너무 길고 사용자의 질문이 특정 부분만을 이용해 알기 힘들 경우를 위해 문서별로 여러 답변을 생성(Map) 한 뒤 이를 합치는(Reduce) 알고리즘이다.