들어가며
이번주에는 RAG에 대해서 배웠다. 이에 관한 프로젝트도 진행했는데 이번 과제는 지난주에 배운 PyPDF2로 pdf문서를 읽어오기 -> 임베딩 후 벡터값으로 유사도 측정 -> 검색하기 였는데, 처음보는 개념이 많다보니 여전히 어려워서 기초적인 개념들을 정리해보려고 한다.
"RAG는 사용자의 질문과 의미적으로 유사한 문서를 벡터 검색으로 찾아 LLM의 입력 컨텍스트로 제공함으로써, 모델이 외부 지식을 기반으로 답변을 생성하도록 하는 구조이다."
이게 뭔말일까.
먼저, 과제 진행을 위해 필요한 벡터,임베딩 이런 것도 중요하겠지만, 가장 기본이 되는 RAG의 개념부터 정립해야한다
RAG(Retrieval Augmented Generation)에 대해서 예시로 풀어보자면,
GPT와 같은 AI는 기본적으로, "자기학습된 지식"만으로 답을 한다.
따라서, GPT에게 어떠한 답을 얻기위해서는 "이 문서를 참고해서 답해~" 라고 사전 정보를 제공해야한다.
이러한 구조가 RAG이다.
즉, Retriever = 검색, 질문과 관련된 문서를 찾는 단계
예를들어, 질문이 "중국 경제 리스크는 뭐야?" 라면, 이러한 질문과 관련된 문단을 "PDF"에서 찾아온다.
Generation = 생성,
그리고 위와 같이 찾아온 문단들을 AI에게 제공하여 답을 만들어줘! 라고 요구하는 것이다.
결론적으로 내가 이해한바로는, RAG는 AI에게 답을 얻기위해 정보를 가공하고 제공하는 것이다.
사전지식을 가공하기 위한 과정에서 "질문(query)"과 관련된 "관련문서의 문단(문장)"을 찾을 때 그 질문과 유사한 문장을 찾아와야하는데, 유사한 문장은 어떻게 찾을까??
이때 사용하는 개념이 벡터이다.
예를들어,
질문(query)이 "중국 부동산 위험"이고,
PDF에는 "중국의 부동산 시장에서 신용 리스크가 증가하고 있다" 라는 문장이 있다고 해보자.
위와 같은 문장들은 1:1로 100% 일치하지 않으므로 문자열 비교만으로는 찾아올 수 없다.
그렇기에 "의미"로 비교하여 찾아오는 방법이 필요한데, 컴퓨터는 사람처럼 그 "의미"를 이해하지 못한다.
따라서 이때 컴퓨터가 이해하게 하기 위해 필요한 과정이 "임베딩(Embedding)" 이다.
즉, 임베딩은 하나의 문장을 "숫자들의 묶음"으로 바꾸는 것이다.
예를들어
"한국 경제 위험" 이라는 문장이 있다고 할 때,
이를
[0.12, -0.44, 0.88, ...] 와 같은 숫자 묶음으로 바꾸는 것이다.
이 각각의 숫자에는 문장에 대한 의미 특징이 담겨져 있다.
따라서, 의미가 비슷한 문장은 "비슷한 숫자 패턴"을 갖게 된다.
그리고 이걸 만드는 모델에는 SentenceTransformer가 있다. (이외에도 OpenAI embedding, BGE 등이 있음)
벡터란 위 예시에서처럼 임베딩된 숫자 배열 [0.12, -0.44, 0.88, ...] 을 벡터라고 한다.
즉, 문장 하나 = 벡터 하나
그럼 임베딩되어 나온 벡터값들로 유사도는 어떻게 계산할까?
방법에는
util.pytorch_cos_sim
IndexFlatL2
등이 있다.
def semantic_chunker(text: str, model: SentenceTransformer, similarity_threshold: float = 0.85) -> List[str]:
"""
의미 기반으로 텍스트를 청크로 분할
- 각 문장 embedding들끼리 유사도를 계산하여 유사도가 높은 문장들을 하나의 청크로 묶음
"""
sentences = sent_tokenize(text) # 텍스트를 문장 단위 리스트로 잘라주는 함수
if len(sentences) == 0:
return []
if len(sentences) == 1:
return sentences
embeddings = model.encode(sentences, convert_to_tensor=True)
chunks = []
current_chunk = [sentences[0]]
for i in range(1, len(sentences)):
similarity = util.pytorch_cos_sim(embeddings[i - 1], embeddings[i]).item()
if similarity > similarity_threshold:
current_chunk.append(sentences[i]) # 이전 문장과 현 문장이 기준 유사도 넘는 경우 같은 청크로 묶음
else:
# 유사도가 기준을 넘지 못한 경우, 이전 문장은 다른 청크로 분리되고, 현 문장은 하나의 청크로 생성
chunks.append(' '.join(current_chunk))
current_chunk = [sentences[i]]
# for문을 다 돌고나서 남은 chunk 처리
if current_chunk:
chunks.append(' '.join(current_chunk))
return chunks
문서가 3000페이지라고 해보자. 이 긴 문장을 GPT에게 주면 성능 및 비용, 토큰 제한상 분석이 어렵다.
따라서, 필요한 부분만 전달할 수 있도록 이를 쪼개야하는데 이를 청킹, 청크라고 한다.
따라서
위 코드에서 청킹을 한 이유는, 긴 문장에서 유사한 문장들끼리 청크로 분리하여, "질문"과 관련된 청크만 GPT와 같은 LLM에게 제공하기 위함이다.

위 그림은 GPT가 제공해준 청킹 단계를 설명하는 그림이다
sentence_embeddings = embedding_model.encode(all_chunks)
dim = sentence_embeddings.shape[1]
index = faiss.IndexFlatL2(dim)
index.is_trained
index.add(sentence_embeddings)
print(f"FAISS 인덱스에 추가된 벡터 수: {index.ntotal}")
sentence_embeddings.shape
나는 위와 같이 faiss의 IndexFlatL2를 사용하여 유사한 문장을 찾아왔다.
def search_similar_chunks(query: str, k: int = 3):
"""쿼리와 유사한 청크 검색"""
xq = embedding_model.encode([query])
distances, indices = index.search(xq, k)
########################################
results = []
for i, idx in enumerate(indices[0]):
results.append({
'rank': i + 1,
'chunk': all_chunks[idx],
'source': chunk_sources[idx],
'distance': distances[0][i]
})
return results
LLM, RAG와 같은 용어는 많이들어봤지만 개념에 대해서는 알지 못했었는데, 정리하는 과정에서 이런 기초개념뿐만 아니라, 배웠던 개념도 확실히 알게되었다 !!!
신기하다