목표: AI 분야에서 주목받고 있는 RAG(Retrieval-Augmented Generation) 기술을 학습하고,  이를 기반으로 챗봇 서비스 만들어보기

 

 

일단 나는 Spring JPA 중심의 백엔드 개발자로,
AI 경험은 간단한 프롬프트 기반 API 연동이 전부이다. 그래서 AI관련 용어도 잘 모른다.


그래서 이 글은 AI 초심자의 눈높이에서, RAG의 개념을 아주 쉽게 풀어가며 공부하려 한다.

 

 

 

 

🧠 RAG란?

RAG (Retrieval-Augmented Generation)는 생성형 AI가 모르는데 아는 척 하는 것(hallucination)을 줄이기 위해 등장한 구조이다.

 

일반적인 GPT 같은 생성형 모델은 자신이 학습한 데이터만 가지고 답을 한다.

 

그래서 GPT에게

“우리 회사 사내 문서 기반으로 질문에 답해줘”
라고 해도, 그 문서를 직접 본 적이 없기 때문에 엉뚱하거나 틀린 내용을 그럴듯하게 지어낸다.

 

RAG는 여기에 외부 지식 검색 기능(Retrieval)을 추가한 구조이다.

문서를 직접 검색해서, 그 내용을 참고한 답변을 생성할 수 있다.

 “검색 + 생성”의 구조를 가지는 것이다.


→ 따라서 회사 내부 문서, 논문, 제품 가이드 등 다양한 도메인 지식에 대해 대응할 수 있다는 강점이있다.

 

 

 

🔧 RAG의 구조

  1. 사용자의 질문이 들어오면
  2. Retriever가 질문과 관련된 문서를 벡터 검색을 통해 찾아낸다.
  3. Generator (GPT 등)가 찾아낸 문서를 기반으로 답변을 생성한다.

 

엑,,, Retriever..? Generator ..? 벡터 검색..? 어려운 용어가 너무 많다.

 

그럼 지금부터 용어 하나하나를 쉽게 풀어서 알아보자.
이걸 이해하고 나면 위 구조 설명도 훨씬 잘 들어올것이다.

 

 

 

 

 

 

 

 

🔎 Retriever

질문에 관련있는 문서를 찾음

 

Rag 서비스에  “퇴직금 정산 기준이 뭐야?” 라고 물었을 때,

수백 개 사내 문서 중에서 “퇴직금 관련 규정.pdf”의 12페이지를 찾아서 꺼내주는 게 Retriever이다.

 

 

작동 방식은 다음과 같다.

  1. 문서를 embedding(=벡터화)해서 벡터DB에 저장해 둠 (예: FAISS, Chroma, Pinecone 등)
  2. 사용자의 질문도 embedding함
  3. 벡터끼리 유사도(cosine similarity) 계산해서 가장 가까운 문서 몇 개를 골라냄

 

 

 

 

 

 

 

🔎 Generator

 

찾아온 문서를 바탕으로 자연어로 설명

 

Retriever가 퇴직금 규정 문서를 찾아왔다면, LLM에 질문과 함께 넘긴다.

그걸 바탕으로  LLM이 “퇴직금은 근속 연수에 따라 계산되며...” 라고 알기 쉽게 말로 풀어주는 것이 generator이다.

 

 

[사용자 질문]
 → Retriever: “관련 문서 여기 있음~”
 → Generator: “그 문서 보니까 이렇대~ 요약해서 말해줄게”
 → [답변 반환]

 

 

 

대충 느낌은 알았다. 근데 또 어려운 용어가 많이나왔다.

 

retrever가 문서를 embedding해서 벡터DB에 저장해 두고, 사용자의 질문도 embedding한 후 벡터끼리 유사도(cosine similarity) 계산해서 가장 가까운 문서 몇 개를 골라낸다는데,,

 

embedding은 뭐고 vectorDB는 뭐고, 코사인 유사도 계산은 어떻게 하는걸까

 

 

 

 

 

 

 

 

 

 

🛏️ 임베딩(Embedding)

 

AI는 사람처럼 말의 의미를 느끼는능력이 없다. 우리가 쓰는 문장도 AI에게는 단순한 문자 나열일 뿐이다.

 

예를 들어,

"퇴직금은 언제 받을 수 있나요?"

라는 문장은 사람에겐 의미가 있지만,
AI에게는 그냥 "ㄸ, ㅊ, ㄱ, ㅇ, ㅈ..." 같은 텍스트일 뿐이다.

 

그래서 AI가 문장의 의미를 이해하게 만들려면,  이 문장이 갖고있는 뜻을 수치로 표현해야 한다.

그래서 자연어 문장을  벡터(방향과 크기를 가진 수학적 값)로 바꾸는 것이고, 이 과정을 임베딩이라고 한다.

 

“퇴직금은 언제 받을 수 있나요?”
→ [0.12, -0.45, 0.77, ...] (768차원 등 숫자 벡터)

 

즉, 문장을 “위치 정보”처럼 벡터 공간에 뿌리는 것.
비슷한 뜻이면 가까운 위치에,
다른 뜻이면 멀리 떨어지게.

이걸 해주는 게 바로 OpenAIEmbeddings, HuggingFaceEmbeddings 같은 임베딩 모델들이다.

 

 

이 블로그에서 자세하게 설명하고 있는것 같다.

https://medium.com/@minji.sql/%EC%9E%84%EB%B2%A0%EB%94%A9-%EA%B9%8A%EC%9D%B4-%ED%8C%8C%ED%97%A4%EC%B9%98%EA%B8%B0-rag%EC%9D%98-%ED%95%B5%EC%8B%AC-%EA%B8%B0%EC%88%A0-6f077b8344e7

 

 

이떄, 긴 문서를 한 번에 LLM에 넘기면 부담이 크다.

이들이 한번에 처리할 수 있는 컨텍스트에는 한계(token 제한)가 있기 때문이다.

 

ex)

GPT-3.5 turbo → 약 16,345 tokens

GPT-4 → 약 128,000 tokens

 

 

 

그래서 보통 문서를 LangChain의 TextSplitter등을 사용하여 적절한 크기로 나눠서(Chunk) 임베딩하고 저장한다.

 

 

근데,, 문장 길이와 상관없이 항상 고정 차원 (384, 768, 1536...) 을 쓰는걸까?

어떤건 한문장에 두단언데 384개 다쓰고, 어떤건 막 엄청긴데 384개 쓰고 이러면 정확성에 차이가 생기는거 아닌가 하는 궁금증이 생겼다.

 

-> 맞다.문장 길이에 상관없이 항상 384개 (또는 768, 1536...) 짜리 벡터가 나온다.

고정 길이여야 코사인 유사도, 거리 계산, 인덱싱이 효율적으로 가능하기 때문이다.

그리고 그 때문에 긴 문장은 정보가 압축되기 때문에, 짧고 간결한 문장이 더 정확한 임베딩을 만들 수 있다.

이도 chunk size를 나눠주는 이유 중 하나다.

 

 

문장을 임베딩해서 나온 벡터 값을 직접 출력해서
"벡터가 실제로 어떤 숫자들로 구성되어 있는지" 확인해보자.

from sentence_transformers import SentenceTransformer
import numpy as np

# 1. 모델 로드
model = SentenceTransformer('all-MiniLM-L6-v2')

# 2. 문장
sentence = "퇴직금은 언제 받을 수 있나요?"

# 3. 임베딩 (벡터화)
vector = model.encode(sentence)

# 4. 벡터 출력
print(f"문장: {sentence}")
print(f"벡터 차원 수: {len(vector)}")
print("앞쪽 10개 요소만 출력:")
print(vector[:10])  # 전체 벡터는 너무 기니까 앞에 몇 개만

# 5. 벡터 전체 출력 원한다면 아래도 가능
# print("전체 벡터:")
# print(vector)

 

 

결과

문장: 퇴직금은 언제 받을 수 있나요?
벡터 차원 수: 384
앞쪽 10개 요소만 출력:
[ 0.01882134  0.00819211 -0.01318623  0.04036659  0.05520485
  0.0169004  -0.00709183 -0.01310762  0.03287694 -0.032233  ]

 

 

구체적인 임베딩 과정은 다음과 같다.

  1. 문장을 → 여러 토큰(token) 으로 나눔
    (예: “퇴직금은 언제 받나요?” → [퇴직, 금, 은, 언제, 받, 나, 요])
  2. 각 토큰을 → 각각 벡터로 변환
    → [토큰 수 × 차원 수]짜리 행렬이 됨
  3. 이걸 하나의 벡터로 줄이기 위해
    👉 압축(=pooling) 을 함 → 최종 벡터 [384]

 

압축 방식에는 주로 3가지 방식이 쓰인다.

(각 토큰 벡터를 평균내는 mean Pooling, 문장의 대표 토큰만 사용하는 CLS, 각 차원별로 가장 큰 값을 사용하는 max pooling 등)

mean pooling이 가장 많이 쓰지만,  임베딩 방식에 대한 다양한 논문과 견해들이 있으니 찾아보면 공부가 될것같다.

 

 

 

 

 

 

↗️ Vector search

사용자가 입력한 문장을 임베딩해서,

벡터 공간에서 가장 가까운 문서들을 찾는 것!

 

벡터가 의미를 수치로 표현한거라고 했으니.

벡터값을 기반으로 방향이 가까운 문서들을 찾으면 의미가 비슷한 문장을 찾을 수 있을 것이다.

 

실제 AI는 문장 하나를 384, 768,1536등 다차원 벡터로 표현한다.

우리가 머릿속으로는 상상 못하지만, 수학적으로는 고차원 공간이 가능하기 때문이다.

 

768차원 공간: [0.5, -0.2, ...(768개 반복).., 0.003] →  (문장의 위치)

 

앗.. 그럼 784차원이나 되는데 ,, 가까운걸 어떻게 찾지? 하고 궁금할 수 있다..

이는 코사인 유사도를 기반으로 검색한다.

 

 

 

 

 

 

 

 

 

 

 

 

 

📐  Cosine Similarity

 

벡터 공간에서 두 벡터가 이루는 각도를 기반으로 유사도를 계산. 각도가 작을수록 비슷하다

 

 

Cosine Similarity=∥A∥×∥B∥ /  A⋅B

A⋅B: 두 벡터의 내적 (각 요소의 곱)
∥A∥, ∥B∥ : 벡터 A, B의 크기 (각 요소 제곱의 합의 제곱근)

 

 

 

  • 두 벡터가 같은 방향이면 → 코사인 유사도 ≈ 1
  • 완전히 반대 방향이면 → 코사인 유사도 ≈ -1
  • 서로 직각이면 → 코사인 유사도 ≈ 0 → 관련 없음

 

벡터 임베딩 + 코사인 유사도 기반 유사 문서 검색과정을 예시로 보자

 

❔질문 : “퇴직금은 언제 받나요?”
→ 벡터 A = [0.11, -0.42, 0.78]

📚 벡터 DB
문서1: 퇴직금 계산 기준	[0.10, -0.40, 0.80]
문서2: 휴가 규정	[0.90, 0.22, -0.30]
문서3: 퇴직금 지급 시점	[0.12, -0.45, 0.77]


📐코사인 유사도 검색
CosineSim(A,문서1)≈0.999
CosineSim(A,문서2)≈0.25
CosineSim(A,문서3)≈0.9989


❕결과
문서3이 가장 유사도 점수가 높음! 의미가 가장 비슷
문서 2가 가장 유사도 점수가 낮음! 의미가 가장 다름

 -> 문서 3을 꺼내옴

 

 

 

실제 LangChain에서 사용하는 예시를 보자

 

LangChain : LLM(AI 모델)을 이용한 앱을 쉽게 만들 수 있게 도와주는 파이썬 프레임워크

문서 검색, 외부 API 연결, 메모리 관리, 체이닝(연속 흐름) 같은걸 한줄 코드로 연결해준다.

LangChain 의 주요 기능들
LLM 연결 : OpenAI, HuggingFace 등 LLM 쉽게 붙이기
Embedding :  처리문장을 벡터로 변환하는 작업 자동 처리
Retriever : 구성벡터 DB에 저장하고, 유사 문서 찾는 기능 제공
PromptTemplate : 프롬프트 포맷 관리
Memory 관리 : 대화 이력 기억하는 챗봇 구현도 가능

 

 

코드 예시

embeddings = OpenAIEmbeddings()
vector_db = Chroma.from_documents(docs, embeddings)
retriever = vector_db.as_retriever()  # 내부적으로 cosine similarity로 가장 가까운 문서 추출

 

 

 

 

 

 

 

 

 

 

 

 

 

+α)

 

 

이제 임베딩 성능 최적화랑 chunk 쪼개는 기준을 실무 관점에서 알아보자!

 

임베딩 성능 최적화

어떻게 해야 문서를 임베딩했을 때 검색 정확도가 높아질까?

 

좋은 문장 단위로 자르기

한 문장이 너무 길면 압축이 일어나서 정보가 손실, 

반대로 너무 짧으면 의미 단편화돼서 유사도가 떨어진다.

 

따라서 chunk size를 적절하게 설정해주어야 한다.

일반적으로 500~800 tokens 사이가 적당하다.

 

또 이전 청크와 다음 청크 사이에 끝과 시작 글자들을 overlap 시켜주는 것이 좋다.

중요한 문장이 중간에서 끊기더라도 다음 chunk에서 다시 등장하니까 검색할 때 의미가 끊기지 않고 더 부드럽게 이어진다.

 

 

from langchain.text_splitter import RecursiveCharacterTextSplitter

splitter = RecursiveCharacterTextSplitter(
    chunk_size=500,
    chunk_overlap=50,  # 문맥 유지용
)
chunks = splitter.split_documents(documents)

 

 

 

 

② 데이터 전처리

  • 중요한 문장은 강조, 불필요한 문장은 제거
  • 목차, 페이지 번호, “이하 동일함” 같은 텍스트는 제거
  • 표제어, 문단 제목은 chunk 앞에 덧붙여서 넣어주면 정확도 ↑

[Title: 퇴직금 지급 기준]
퇴직금은 근속연수에 따라 지급되며, 평균임금을 기준으로...

 

LLM이  문맥이 뭔지 모른 채 문장만 해석하는 것을 방지한다.

 

 

 

 

임베딩 모델 성능

  • 임베딩 모델 자체의 성능도 중요하다.
  • 모델 성능이 높을수록 속도나 리소스 소비도 증가하기 때문에,사용 환경에 맞게 적절한 모델을 선택해야 한다.

 

 

이 외 검색할때도 reranking, top-k, 프롬포트 설정, metadata 지정 등 다양한 방법을 사용하여 검색의 정확도를 높일 수 있으니, 

이를 적절히 활용하여 정확한 RAG를 만드는것이 기술의 핵심 아닐까 싶다.

 

 

 

 

활용 사례들

Reranking 이용한 성능 향상

PDF 데이터 전처리

Self query를 이용한 메타데이터 활용

 

 

 

 

 

 

 

 

복사했습니다!