[AI] SentenceTransformer + FAISS로 구현한 RAG문서 검색

쥬라기·2026년 2월 7일

AI

목록 보기
4/11
post-thumbnail

들어가며

이번주에는 RAG에 대해서 배웠다. 이에 관한 프로젝트도 진행했는데 이번 과제는 지난주에 배운 PyPDF2로 pdf문서를 읽어오기 -> 임베딩 후 벡터값으로 유사도 측정 -> 검색하기 였는데, 처음보는 개념이 많다보니 여전히 어려워서 기초적인 개념들을 정리해보려고 한다.

RAG란 무엇인가

"RAG는 사용자의 질문과 의미적으로 유사한 문서를 벡터 검색으로 찾아 LLM의 입력 컨텍스트로 제공함으로써, 모델이 외부 지식을 기반으로 답변을 생성하도록 하는 구조이다."

이게 뭔말일까.
먼저, 과제 진행을 위해 필요한 벡터,임베딩 이런 것도 중요하겠지만, 가장 기본이 되는 RAG의 개념부터 정립해야한다

RAG(Retrieval Augmented Generation)에 대해서 예시로 풀어보자면,

GPT와 같은 AI는 기본적으로, "자기학습된 지식"만으로 답을 한다.
따라서, GPT에게 어떠한 답을 얻기위해서는 "이 문서를 참고해서 답해~" 라고 사전 정보를 제공해야한다.

이러한 구조가 RAG이다.

즉, Retriever = 검색, 질문과 관련된 문서를 찾는 단계
예를들어, 질문이 "중국 경제 리스크는 뭐야?" 라면, 이러한 질문과 관련된 문단을 "PDF"에서 찾아온다.

Generation = 생성,
그리고 위와 같이 찾아온 문단들을 AI에게 제공하여 답을 만들어줘! 라고 요구하는 것이다.

결론적으로 내가 이해한바로는, RAG는 AI에게 답을 얻기위해 정보를 가공하고 제공하는 것이다.

벡터와 임베딩이란? 그 필요성은?

사전지식을 가공하기 위한 과정에서 "질문(query)"과 관련된 "관련문서의 문단(문장)"을 찾을 때 그 질문과 유사한 문장을 찾아와야하는데, 유사한 문장은 어떻게 찾을까??
이때 사용하는 개념이 벡터이다.

예를들어,
질문(query)이 "중국 부동산 위험"이고,
PDF에는 "중국의 부동산 시장에서 신용 리스크가 증가하고 있다" 라는 문장이 있다고 해보자.

위와 같은 문장들은 1:1로 100% 일치하지 않으므로 문자열 비교만으로는 찾아올 수 없다.

그렇기에 "의미"로 비교하여 찾아오는 방법이 필요한데, 컴퓨터는 사람처럼 그 "의미"를 이해하지 못한다.

* semantic search : 글자가 같은 걸 찾는 게 아닌 의미가 비슷한 걸 찾는 것

따라서 이때 컴퓨터가 이해하게 하기 위해 필요한 과정이 "임베딩(Embedding)" 이다.

임베딩(Embedding)

즉, 임베딩은 하나의 문장을 "숫자들의 묶음"으로 바꾸는 것이다.

예를들어
"한국 경제 위험" 이라는 문장이 있다고 할 때,
이를
[0.12, -0.44, 0.88, ...] 와 같은 숫자 묶음으로 바꾸는 것이다.

이 각각의 숫자에는 문장에 대한 의미 특징이 담겨져 있다.

따라서, 의미가 비슷한 문장은 "비슷한 숫자 패턴"을 갖게 된다.

그리고 이걸 만드는 모델에는 SentenceTransformer가 있다. (이외에도 OpenAI embedding, BGE 등이 있음)

벡터(Vector)

벡터란 위 예시에서처럼 임베딩된 숫자 배열 [0.12, -0.44, 0.88, ...] 을 벡터라고 한다.
즉, 문장 하나 = 벡터 하나

유사도 계산 방법

그럼 임베딩되어 나온 벡터값들로 유사도는 어떻게 계산할까?

방법에는

  1. Cosine SImilarity
  • 1에 가까울수록 비슷
  • 다음과 같이 사용
util.pytorch_cos_sim
  1. L2 Distance
  • 0에 가까울수록 비슷
  • 다음과 같이 사용
IndexFlatL2

등이 있다.

예시코드 (프로젝트코드) 1

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
  • sent_tokenize(text) : text를 문장 단위로 끊어 리스트로 만들어주는 NLTK의 함수이다. NLTK는 텍스트 처리 및 토큰화 라이브러리이다.
  • model.encode(sentences, convert_to_tensor=True) : 문장단위의 리스트를 임베딩하여 벡터값으로 만들어준다.
  • util.pytorch_cos_sim(embeddings[i-1], embeddings[i]).item() : 각 문장의 벡터값을 이용해 두 문장의 유사도를 확인한다. Cosine Similarity 방식을 사용하며, 값이 클수록 유사하다는 것이다. 이 방식을 이용하여, 유사한 문장들끼리 "청크"로 만들어준다.

청크란?

Chunk(청크): 문서를 검색 가능한 작은 단위로 나눈 텍스트 조각

Chunking(청킹) : 긴 문서를 여러 개의 청크로 분할 하는 작업

문서가 3000페이지라고 해보자. 이 긴 문장을 GPT에게 주면 성능 및 비용, 토큰 제한상 분석이 어렵다.
따라서, 필요한 부분만 전달할 수 있도록 이를 쪼개야하는데 이를 청킹, 청크라고 한다.

따라서
위 코드에서 청킹을 한 이유는, 긴 문장에서 유사한 문장들끼리 청크로 분리하여, "질문"과 관련된 청크만 GPT와 같은 LLM에게 제공하기 위함이다.

위 그림은 GPT가 제공해준 청킹 단계를 설명하는 그림이다

예시코드 (프로젝트코드) 2

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
  • embedding_model.encode(all_chunks) : SentenceTransformer 모델로 chunks된 문장들을 임베딩하여 벡터값으로 변환한다.
  • sentence_embeddings.shape: (문장 수, 임베딩 차원)이 반환되며, 임베딩 차원은 모델마다 다르다.
  • faiss.IndexFlatL2(dim) : IndexFlatL2에 dim(임베딩차원)을 주는 이유는, 앞으로 들어올 모든 벡터는 길이가 dim과 일치해야한다는 것이다. 즉 인덱스는 고정된 차원의 벡터만 처리할 수 있기에, 동일한 임베딩 모델로 임베딩된 동일 차원의 벡터값을 사용해야한다.
  • index.add(sentence_embeddings) : IndexFlatL2로 유사도 검색을 할 때, 기준 자료가 될 임베딩 벡터 자료를 인덱스에 추가한다. 이 과정을 FAISS벡터DB구축이라고 하며, 이를 통해 벡터 기반 검색이 가능하다.

나는 위와 같이 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
  • embedding_model.encode([query]): 특정 질문(쿼리)도 임베딩하여 벡터값으로 만들어준다
  • index.search(xq, k): 기준 자료에서 주어진 문장을 찾아오되, 가장 유사한 것 k를 뽑아온다. 이때 결과값은 인덱스값과 거리(유사도, 작을수록 유사함)이다.


결론

LLM, RAG와 같은 용어는 많이들어봤지만 개념에 대해서는 알지 못했었는데, 정리하는 과정에서 이런 기초개념뿐만 아니라, 배웠던 개념도 확실히 알게되었다 !!!
신기하다

프로젝트실습 GITHUB

profile
기록하고 분석하는 개발자

0개의 댓글