1. RAG 개요 — 처음 만나는 RAG¶
이 챕터를 읽고 나면...
- RAG가 무엇인지, 왜 생겨났는지 이해할 수 있다
- LLM, 임베딩, 벡터, 할루시네이션 같은 핵심 용어를 설명할 수 있다
- RAG의 3단계 흐름(인덱싱 → 검색 → 생성)을 그림으로 그릴 수 있다
- 간단한 RAG 파이프라인 코드를 읽고 이해할 수 있다
0. 시작하기 전에 — 핵심 용어 사전¶
RAG를 이해하려면 몇 가지 용어를 먼저 알아야 한다. 처음 보는 단어가 나오면 이 섹션으로 돌아오자.
LLM (대형 언어 모델)¶
한줄 요약: 엄청나게 많은 텍스트를 학습해서 "다음에 올 단어"를 예측하는 AI 모델
LLM(Large Language Model, 대형 언어 모델)이란 인터넷의 수천억 개 문장을 학습한 AI다. GPT-4, Claude, Gemini 같은 챗봇들이 모두 LLM을 기반으로 한다.
LLM은 마치 수백만 권의 책을 다 읽은 사람처럼, 질문을 받으면 자신이 학습한 지식을 바탕으로 답변을 만들어낸다.
실생활 비유
LLM은 백과사전을 통째로 암기한 학생과 같다.
시험장에 아무 자료도 없이 들어가서, 머릿속에 있는 지식만으로 답을 써야 한다. - 잘 아는 내용: 정확하게 답변 가능 - 모르거나 잊은 내용: 그럴듯하게 지어낼 수 있음 (= 할루시네이션) - 학습 이후의 새로운 정보: 전혀 모름 (= 지식 컷오프)
할루시네이션 (Hallucination)¶
한줄 요약: AI가 사실이 아닌 내용을 마치 사실인 것처럼 자신있게 말하는 현상
할루시네이션(Hallucination)은 LLM이 모르는 내용을 그럴듯하게 "지어내는" 현상이다. AI가 환각을 보는 것처럼 없는 정보를 만들어낸다고 해서 붙은 이름이다.
예를 들어, "2025년 3월에 있었던 A 회사 컨퍼런스 발표 내용은?" 이라고 물으면, LLM은 실제로 그 발표가 있었는지 없었는지 모르지만 그럴듯한 내용을 꾸며낼 수 있다.
왜 이게 중요한가?
할루시네이션은 단순한 실수가 아니다. 확신에 찬 거짓말처럼 보이기 때문에 사용자가 틀린 정보를 사실로 믿을 수 있다. 의료, 법률, 금융 분야에서는 이런 오류가 심각한 결과를 낳을 수 있다.
지식 컷오프 (Knowledge Cutoff)¶
한줄 요약: LLM이 학습을 멈춘 날짜 이후의 정보는 전혀 모른다는 한계
LLM은 특정 날짜까지의 데이터만 학습한다. 예를 들어 "지식 컷오프가 2024년 4월"인 모델은 그 이후에 발생한 뉴스, 논문, 제품 출시 등을 전혀 모른다.
학습 데이터 범위
┌─────────────────────────────────┐ ┆
│ 인터넷 텍스트, 책, 논문 등... │ ┆ 이 구간은 모름!
│ (학습 데이터) │ ┆
└─────────────────────────────────┘ ┆
▲ ▲
학습 시작 컷오프 날짜 현재
임베딩 (Embedding)¶
한줄 요약: 텍스트를 컴퓨터가 "의미"를 이해할 수 있는 숫자 배열로 바꾸는 기술
임베딩(Embedding)은 문장이나 단어를 숫자들의 목록(벡터)으로 변환하는 기술이다.
실생활 비유
임베딩은 지도 위의 좌표와 같다.
- "서울"이라는 도시를 지도 위 좌표 (37.5, 126.9)로 표현하듯이
- "강아지"라는 단어를 [0.2, 0.8, -0.3, 0.9, ...] 같은 숫자 배열로 표현한다
지도에서 서울과 부산은 가깝고, 서울과 뉴욕은 멀다. 임베딩 공간에서도 "강아지"와 "고양이"는 가깝고, "강아지"와 "비행기"는 멀다.
# 임베딩이 어떻게 생겼는지 보자
# 실제 OpenAI 임베딩은 1536개의 숫자로 구성된다
"강아지" → [0.234, -0.891, 0.127, 0.445, -0.302, ...] # 1536개 숫자
"고양이" → [0.241, -0.876, 0.134, 0.438, -0.289, ...] # 강아지와 비슷!
"비행기" → [-0.102, 0.334, -0.567, 0.023, 0.891, ...] # 강아지와 다름!
벡터 (Vector)¶
한줄 요약: 여러 숫자를 순서대로 나열한 것 — 방향과 크기를 가진 수학적 개념
벡터(Vector)는 수학에서 숫자들의 순서 있는 목록이다. [1, 2, 3]도 벡터고, [0.2, -0.5, 0.8, ...] 1536개의 숫자도 벡터다.
임베딩이 만들어내는 숫자 배열이 바로 벡터다. 임베딩 벡터라고 부르기도 한다.
벡터를 너무 어렵게 생각하지 말자
이 강의에서 벡터는 그냥 "텍스트를 표현하는 숫자 배열" 정도로 이해하면 충분하다.
벡터 DB (Vector Database)¶
한줄 요약: 벡터(숫자 배열)를 저장하고, 비슷한 벡터를 빠르게 찾아주는 특수한 데이터베이스
일반 데이터베이스는 WHERE name = '철수' 같은 정확한 검색을 한다. 벡터 DB는 "이 벡터와 비슷한 벡터를 찾아줘" 라는 유사도 검색을 한다.
일반 DB 검색:
"철수"를 찾아라 → 정확히 "철수"인 행만 반환
벡터 DB 검색:
[0.2, 0.8, -0.3, ...] 과 비슷한 벡터를 찾아라
→ 의미적으로 유사한 문서들을 반환
청크 (Chunk)¶
한줄 요약: 긴 문서를 LLM이 처리하기 좋은 크기로 잘라낸 조각
청크(Chunk)는 긴 문서를 작은 단위로 나눈 것이다. 책 한 권을 통째로 LLM에게 줄 수 없으니, 적당한 크기로 잘라서 관련 있는 부분만 골라 사용한다.
1. RAG란 무엇인가?¶
RAG(Retrieval-Augmented Generation, 검색 증강 생성)는 LLM이 답변을 만들기 전에, 외부 문서에서 관련 정보를 먼저 검색(Retrieve)해서 그것을 참고 자료로 사용하는 기술이다.
영어를 풀어보면: - Retrieval (검색): 관련 정보를 찾아온다 - Augmented (증강): LLM의 능력을 보강한다 - Generation (생성): 최종 답변을 생성한다
실생활 비유 — 오픈북 시험
RAG는 오픈북 시험을 보는 학생과 같다.
- 기존 LLM (클로즈드북 시험): 외운 것만으로 답을 써야 한다. 모르면 지어낸다.
- RAG (오픈북 시험): 시험 중에 교재를 찾아볼 수 있다. 교재에서 관련 내용을 찾아 읽고 답을 쓴다.
오픈북 시험의 학생이 더 정확하고 신뢰할 수 있는 답을 쓸 수 있듯이, RAG를 사용한 LLM이 더 정확한 답변을 한다.
┌─────────────────────────────────────────────────────────────┐
│ 기존 LLM (클로즈드북) │
│ │
│ 사용자 질문 ──────────────────▶ LLM ──▶ 답변 │
│ "우리 회사 ↑ │
│ 반차 신청 (학습된 지식만) │
│ 방법은?" │
│ ❌ 사내 규정을 모름 │
└─────────────────────────────────────────────────────────────┘
┌─────────────────────────────────────────────────────────────┐
│ RAG (오픈북) │
│ │
│ 사용자 질문 ──▶ 검색 ──▶ 관련 문서 ──▶ LLM ──▶ 답변 │
│ "우리 회사 ↑ ↑ ↑ │
│ 반차 신청 벡터 DB 사내 규정 검색된 규정을 │
│ 방법은?" 에서 검색 찾아냄 참고해서 답변 │
│ │
│ ✅ 정확한 사내 규정 기반 답변 │
└─────────────────────────────────────────────────────────────┘
2. 왜 RAG가 필요한가?¶
LLM은 강력하지만 혼자서는 해결할 수 없는 문제들이 있다. RAG는 그 문제들을 해결한다.
2-1. 문제 1: 할루시네이션¶
LLM은 모르는 내용을 지어낼 수 있다. 특히 특정 회사나 조직의 내부 정보, 최신 뉴스 등은 학습 데이터에 없기 때문에 틀린 내용을 자신있게 말할 수 있다.
RAG의 해결책: 실제 문서에서 검색된 내용만을 근거로 답변하게 한다.
2-2. 문제 2: 지식 컷오프¶
LLM은 학습 이후의 정보를 모른다. 2024년에 출시된 신제품, 최근 법 개정 내용 등은 LLM이 알 수 없다.
RAG의 해결책: 최신 문서를 벡터 DB에 넣어두면, LLM이 훈련받지 않아도 최신 정보를 활용할 수 있다.
2-3. 문제 3: 도메인 특화 지식의 부재¶
병원의 진료 기록, 특정 기업의 사내 문서, 특수 분야의 기술 매뉴얼 같은 정보는 인터넷에 없기 때문에 LLM이 학습할 수 없었다.
RAG의 해결책: 회사 내부 문서를 벡터 DB에 저장하면, LLM이 그 지식을 활용할 수 있다.
2-4. 문제 4: 파인튜닝의 높은 비용¶
LLM에 새로운 지식을 "심어주는" 방법으로 파인튜닝(Fine-tuning)이 있다. 모델 자체를 다시 학습시키는 것인데, 비용이 수백만 원 이상 들고 데이터가 바뀔 때마다 다시 해야 한다.
RAG의 해결책: 벡터 DB의 문서만 업데이트하면 된다. 모델을 다시 학습시킬 필요가 없다.
왜 이게 중요한가?
RAG는 단순한 기술 트릭이 아니다. LLM을 실제 업무에 쓸 수 있게 만드는 핵심 기술이다.
- 사내 문서 기반 질의응답 시스템
- 최신 뉴스 기반 요약 서비스
- 의료 가이드라인 기반 진료 보조 시스템
- 법령 기반 법률 상담 서비스
이 모든 것이 RAG로 구현된다.
비교 표: RAG vs 기존 LLM vs 파인튜닝¶
| 특성 | 기존 LLM | 파인튜닝 | RAG |
|---|---|---|---|
| 최신 정보 반영 | ❌ 불가 | ❌ 재학습 필요 | ✅ 문서 추가만으로 가능 |
| 사내 문서 활용 | ❌ 불가 | ⚠️ 데이터 노출 위험 | ✅ 안전하게 활용 가능 |
| 할루시네이션 방지 | ❌ 많음 | ⚠️ 다소 줄어듦 | ✅ 크게 줄어듦 |
| 비용 | 낮음 | 매우 높음 | 중간 |
| 도입 난이도 | 쉬움 | 어려움 | 중간 |
| 답변 출처 제공 | ❌ 불가 | ❌ 불가 | ✅ 가능 |
3. RAG의 3단계 아키텍처¶
RAG는 크게 인덱싱 → 검색 → 생성 3단계로 동작한다. 각 단계를 자세히 살펴보자.
╔══════════════════════════════════════════════════════════════════════════╗
║ RAG 전체 흐름도 ║
╠══════════════════════════════════════════════════════════════════════════╣
║ ║
║ [사전 준비 단계 - 한 번만 실행] ║
║ ║
║ 📄 원본 문서 ║
║ (PDF, Word, 웹페이지 등) ║
║ │ ║
║ ▼ ║
║ ✂️ 청킹 (문서를 조각으로 분할) ║
║ │ ║
║ ▼ ║
║ 🔢 임베딩 (각 조각을 숫자 벡터로 변환) ║
║ │ ║
║ ▼ ║
║ 🗄️ 벡터 DB에 저장 ◀── 1단계: 인덱싱 ║
║ │ ║
╠══════════════════════════════════════════════════════════════════════════╣
║ ║
║ [질문이 올 때마다 실행] ║
║ ║
║ 💬 사용자 질문 ║
║ │ ║
║ ▼ ║
║ 🔢 질문도 임베딩으로 변환 ║
║ │ ║
║ ▼ ║
║ 🔍 벡터 DB에서 유사한 조각들 검색 ◀── 2단계: 검색 (Retrieval) ║
║ │ ║
║ ▼ ║
║ 📝 [질문 + 검색된 조각들]을 LLM에 전달 ║
║ │ ║
║ ▼ ║
║ 🤖 LLM이 근거를 바탕으로 답변 생성 ◀── 3단계: 생성 (Generation) ║
║ │ ║
║ ▼ ║
║ ✅ 최종 답변 (출처 포함) ║
║ ║
╚══════════════════════════════════════════════════════════════════════════╝
1단계: 인덱싱 (Indexing) — 문서를 검색 가능하게 준비하기¶
인덱싱은 원본 문서를 벡터 DB에 저장하는 사전 준비 단계다. 이 단계는 보통 서비스를 시작할 때 한 번 실행하거나, 문서가 업데이트될 때 다시 실행한다.
인덱싱의 세부 단계¶
1. 문서 로드
PDF, Word, 텍스트 파일, 웹페이지 등을 파이썬으로 읽어온다
↓
2. 청킹 (Chunking)
긴 문서를 적당한 크기의 조각(청크)으로 자른다
예: 500글자씩 자르되, 앞뒤 50글자는 겹치게 (overlap)
↓
3. 임베딩 (Embedding)
각 청크를 임베딩 모델에 넣어 벡터로 변환한다
"반차 신청은 HR 시스템에서..." → [0.23, -0.45, 0.87, ...]
↓
4. 벡터 DB 저장
(청크 텍스트, 벡터, 메타데이터)를 벡터 DB에 저장한다
청킹(Chunking)이 왜 필요한가?¶
왜 문서를 그냥 통째로 넣지 않나요?
두 가지 이유가 있다.
-
LLM의 컨텍스트 윈도우 한계: LLM은 한 번에 처리할 수 있는 텍스트 양에 제한이 있다. 책 한 권(수백만 글자)을 통째로 넣을 수 없다.
-
검색 품질: 질문과 관련 있는 작은 조각을 찾는 것이, 긴 문서 전체를 비교하는 것보다 훨씬 정확하다. 마치 책에서 원하는 내용을 찾을 때 목차와 인덱스를 쓰는 것과 같다.
청크 겹침(Overlap)이 왜 필요한가?¶
원본 텍스트:
"...A부서 직원은 연 20일의 휴가를 받는다. 단, 입사 후 1년 미만인 경우
10일만 부여된다. 반차는 오전/오후로 나뉜다. 신청은 HR 시스템..."
청크 1 (0~500자):
"...A부서 직원은 연 20일의 휴가를 받는다. 단, 입사 후 1년 미만인 경우
10일만 부여된다. 반차는 오전/오후로" ← 문장이 잘림!
청크 2 (450~950자): ← 50자 겹침(overlap)
"오전/오후로 나뉜다. 신청은 HR 시스템..." ← 앞 맥락이 포함됨
※ overlap이 없으면 문장이 잘려서 의미를 잃을 수 있음
※ overlap이 있으면 앞뒤 맥락이 보존됨
코드 예제: 인덱싱 구현¶
# 필요한 라이브러리 설치 (터미널에서 실행)
# pip install langchain langchain-openai langchain-community chromadb
import os
from langchain_community.document_loaders import TextLoader # 텍스트 파일을 읽는 로더
from langchain_text_splitters import RecursiveCharacterTextSplitter # 청킹 도구
from langchain_openai import OpenAIEmbeddings # OpenAI 임베딩 모델
from langchain_community.vectorstores import Chroma # 벡터 DB (Chroma)
# ── 1단계: 문서 로드 ──────────────────────────────────────────────
# TextLoader: 텍스트 파일을 읽어서 Document 객체로 만들어준다
# Document 객체는 page_content(텍스트 내용)와 metadata(파일명 등)를 가진다
loader = TextLoader("company_policy.txt", encoding="utf-8")
documents = loader.load()
print(f"로드된 문서 수: {len(documents)}")
print(f"첫 번째 문서 앞 100글자: {documents[0].page_content[:100]}")
# ── 2단계: 청킹 ──────────────────────────────────────────────────
# RecursiveCharacterTextSplitter:
# - chunk_size: 각 청크의 최대 글자 수 (500자)
# - chunk_overlap: 청크 간에 겹치는 글자 수 (50자)
# - 문단 → 문장 → 단어 순서로 자를 위치를 찾는다 (의미 파괴 최소화)
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 각 조각을 최대 500자로 제한
chunk_overlap=50, # 앞뒤 청크와 50자씩 겹치게 함
length_function=len # 길이를 글자 수로 측정
)
# split_documents: Document 목록을 받아서 청크 목록을 반환한다
chunks = text_splitter.split_documents(documents)
print(f"\n청킹 결과:")
print(f" 원본 문서 수: {len(documents)}")
print(f" 생성된 청크 수: {len(chunks)}")
print(f" 첫 번째 청크:\n{chunks[0].page_content}")
# ── 3단계: 임베딩 모델 준비 ────────────────────────────────────────
# OpenAIEmbeddings: OpenAI의 text-embedding-3-small 모델을 사용
# 각 텍스트를 1536차원(1536개 숫자)의 벡터로 변환한다
# OPENAI_API_KEY 환경변수가 설정되어 있어야 한다
# os.environ["OPENAI_API_KEY"] = "..." # 환경변수로 미리 설정하세요 (코드에 직접 입력 금지)
embeddings = OpenAIEmbeddings(
model="text-embedding-3-small" # 빠르고 저렴한 임베딩 모델
)
# 임베딩이 어떻게 생겼는지 직접 확인해보자
sample_vector = embeddings.embed_query("반차 신청 방법")
print(f"\n임베딩 벡터 크기: {len(sample_vector)}차원")
print(f"앞 5개 값: {sample_vector[:5]}")
# ── 4단계: 벡터 DB에 저장 ──────────────────────────────────────────
# Chroma.from_documents:
# - chunks: 저장할 청크 목록
# - embeddings: 임베딩 모델 (각 청크를 벡터로 변환할 때 사용)
# - persist_directory: 벡터 DB를 디스크에 저장할 경로
vectorstore = Chroma.from_documents(
documents=chunks, # 청크 목록
embedding=embeddings, # 임베딩 모델
persist_directory="./db" # 로컬 디스크에 저장 (없으면 메모리에만 존재)
)
print(f"\n벡터 DB 저장 완료!")
print(f"저장된 벡터 수: {len(chunks)}") # _collection.count()는 내부 API이므로 사용 지양
코드 설명 요약
TextLoader로 파일을 읽는다 → Document 객체 생성RecursiveCharacterTextSplitter로 500자씩 자른다 → 청크 목록 생성OpenAIEmbeddings를 준비한다 → 텍스트를 벡터로 바꿀 도구Chroma.from_documents로 청크를 벡터로 변환하여 DB에 저장한다
2단계: 검색 (Retrieval) — 질문과 관련된 청크 찾기¶
검색 단계는 사용자의 질문을 받아서, 벡터 DB에서 가장 관련 있는 청크들을 찾아오는 단계다.
유사도 검색의 원리¶
1. 사용자 질문을 임베딩으로 변환
"반차 신청 방법을 알려줘" → [0.12, 0.89, -0.34, ...]
2. 벡터 DB에 있는 모든 청크의 벡터와 거리를 계산
청크1 벡터: [0.15, 0.85, -0.30, ...] ← 거리: 0.05 (매우 가까움!)
청크2 벡터: [0.89, -0.12, 0.67, ...] ← 거리: 1.23 (멀다)
청크3 벡터: [0.11, 0.91, -0.36, ...] ← 거리: 0.08 (가까움)
3. 거리가 가장 가까운 상위 k개 청크를 반환
→ 청크1, 청크3이 반환됨 (k=2인 경우)
실생활 비유 — 도서관 사서
검색 단계는 도서관 사서에게 질문하는 것과 같다.
당신이 "반차 신청 방법이 뭐야?"라고 물으면, 사서는: 1. 질문의 핵심 키워드를 파악한다 ("반차", "신청") 2. 도서관의 모든 책 목록에서 관련 있을 법한 책들을 찾는다 3. 가장 관련성 높은 책 3권을 꺼내온다 4. 당신에게 그 책들을 건네준다
단, 벡터 DB의 사서는 단순 키워드 매칭이 아닌 "의미"를 기반으로 검색한다. "반차 신청"을 물었을 때 "연차 사용법" 문서도 관련 있다고 판단할 수 있다.
코드 예제: 검색 구현¶
# 이전 코드에서 vectorstore가 이미 만들어진 상태라고 가정
# ── Retriever 만들기 ─────────────────────────────────────────────
# as_retriever(): vectorstore를 검색 도구(Retriever)로 변환한다
# search_kwargs={"k": 3}: 유사한 청크를 상위 3개 반환
retriever = vectorstore.as_retriever(
search_type="similarity", # 코사인 유사도 기반 검색
search_kwargs={"k": 3} # 상위 3개 청크를 가져옴
)
# ── 검색 실행 ────────────────────────────────────────────────────
query = "반차 신청은 어떻게 하나요?"
# invoke(): 질문을 입력하면 관련 Document 목록을 반환한다
relevant_docs = retriever.invoke(query)
# 검색 결과 확인
print(f"질문: {query}")
print(f"검색된 문서 수: {len(relevant_docs)}")
print()
for i, doc in enumerate(relevant_docs, 1):
print(f"── 검색 결과 {i} ──")
print(f"내용: {doc.page_content}")
print(f"출처: {doc.metadata}") # 어느 파일의 몇 번째 청크인지
print()
# ── 유사도 점수와 함께 검색 ──────────────────────────────────────
# similarity_search_with_score(): 각 결과의 유사도 점수도 반환한다
# 점수가 낮을수록 더 유사함 (거리이므로)
docs_with_scores = vectorstore.similarity_search_with_score(query, k=3)
print("유사도 점수와 함께 검색:")
for doc, score in docs_with_scores:
print(f" 점수(거리): {score:.4f} | 내용: {doc.page_content[:50]}...")
코드 설명
as_retriever(): 벡터스토어를 랭체인의 Retriever 인터페이스로 감싼다search_type="similarity": 코사인 유사도로 검색 (기본값)k=3: 가장 유사한 청크 3개를 반환similarity_search_with_score(): 각 결과가 얼마나 관련 있는지 점수도 같이 보여줌
3단계: 생성 (Generation) — LLM이 답변 만들기¶
생성 단계에서는 검색된 청크들을 LLM에게 "참고 자료"로 제공하고, LLM이 그것을 바탕으로 답변을 만들어낸다.
프롬프트 구성의 원리¶
사용자에게 보이는 것:
"반차 신청은 어떻게 하나요?"
실제로 LLM에게 전달되는 것:
┌─────────────────────────────────────────────────┐
│ 당신은 회사 인사팀 도우미입니다. │
│ 아래 제공된 참고 문서를 바탕으로만 답변하세요. │
│ │
│ [참고 문서 1] │
│ 반차는 오전(09:00~13:00)과 오후(14:00~18:00)로 │
│ 나뉘며, HR 시스템에서 3일 전까지 신청해야 합니다. │
│ │
│ [참고 문서 2] │
│ 반차 신청 후 팀장 승인이 필요합니다. 승인 기한은... │
│ │
│ 질문: 반차 신청은 어떻게 하나요? │
│ │
│ 답변: │
└─────────────────────────────────────────────────┘
LLM이 생성한 답변:
"반차는 오전과 오후 두 종류가 있습니다. HR 시스템에서
신청하며, 이용 3일 전까지 신청 후 팀장 승인을 받아야 합니다."
코드 예제: 생성 구현 (전체 RAG 체인)¶
from langchain_openai import ChatOpenAI
from langchain_core.prompts import PromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
# ── LLM 설정 ─────────────────────────────────────────────────────
# ChatOpenAI: OpenAI의 채팅 모델을 사용하는 클래스
# model: 사용할 모델 이름 (gpt-4o는 가장 최신 고성능 모델)
# temperature: 0에 가까울수록 일관되고 보수적인 답변, 1에 가까울수록 창의적
llm = ChatOpenAI(
model="gpt-4o",
temperature=0 # RAG에서는 사실 기반이므로 0 (창의성 최소화)
)
# ── 프롬프트 템플릿 설정 ─────────────────────────────────────────
# PromptTemplate: LLM에게 어떤 형식으로 질문할지 정의한다
# {context}: 검색된 문서들이 여기에 삽입됨
# {question}: 사용자 질문이 여기에 삽입됨
prompt = PromptTemplate(
input_variables=["context", "question"],
template="""당신은 회사 내부 정책 도우미입니다.
아래 참고 문서를 바탕으로 질문에 답변하세요.
참고 문서에 없는 내용은 "관련 문서에서 찾을 수 없습니다"라고 답변하세요.
참고 문서:
{context}
질문: {question}
답변:"""
)
# ── RAG 체인 구성 (LCEL 방식) ─────────────────────────────────────
# format_docs: 검색된 Document 목록을 하나의 문자열로 합치는 함수
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
# LCEL(LangChain Expression Language) 파이프라인
# retriever → format_docs → prompt → llm → StrOutputParser 순으로 실행
chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
# ── 질문하기 ─────────────────────────────────────────────────────
query = "반차 신청은 어떻게 하나요?"
result = chain.invoke(query)
# 결과 출력
print("=" * 50)
print(f"질문: {query}")
print("=" * 50)
print(f"답변:\n{result}")
코드 설명
ChatOpenAI(temperature=0): 답변의 무작위성을 최소화. 사실 기반 답변에 적합PromptTemplate: LLM에게 "참고 문서만 보고 답해라"고 명확히 지시- LCEL(
|연산자): LangChain의 최신 체인 구성 방식.RetrievalQA대신 사용 RunnablePassthrough(): 입력(질문)을 그대로 다음 단계에 전달StrOutputParser(): LLM 응답 객체에서 텍스트 문자열만 추출
전체 코드: 처음부터 끝까지¶
"""
완전한 RAG 예제: 처음부터 끝까지
이 코드를 실행하면 간단한 RAG 시스템을 체험할 수 있다.
"""
import os
from langchain_community.document_loaders import TextLoader
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import Chroma
from langchain_core.prompts import PromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
# ── 0. 준비: 샘플 문서 생성 ───────────────────────────────────────
# 테스트용 회사 정책 문서 생성
sample_policy = """
회사 휴가 정책
1. 연차 휴가
직원들은 입사 첫 해에 연 15일의 유급 휴가를 받습니다.
근속 3년 이후에는 1년마다 1일씩 추가되어 최대 25일까지 늘어납니다.
2. 반차 규정
반차는 하루 휴가를 오전과 오후로 나눈 것입니다.
- 오전 반차: 오전 9시부터 오후 1시까지
- 오후 반차: 오후 2시부터 오후 6시까지
반차 신청은 최소 3일 전에 HR 시스템(hr.company.com)에서 해야 합니다.
팀장의 사전 승인이 필요합니다.
3. 병가
질병으로 인한 결근 시 3일 이상은 의사 진단서를 제출해야 합니다.
연간 최대 10일의 유급 병가가 제공됩니다.
"""
# 샘플 파일 저장
with open("company_policy.txt", "w", encoding="utf-8") as f:
f.write(sample_policy)
# ── 1. 문서 로드 ──────────────────────────────────────────────────
loader = TextLoader("company_policy.txt", encoding="utf-8")
documents = loader.load()
print(f"[1단계] 문서 로드 완료: {len(documents)}개")
# ── 2. 청킹 ──────────────────────────────────────────────────────
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=300, # 작은 문서이므로 300자로 설정
chunk_overlap=30
)
chunks = text_splitter.split_documents(documents)
print(f"[2단계] 청킹 완료: {len(chunks)}개 청크 생성")
# ── 3. 임베딩 & 벡터 DB 저장 ─────────────────────────────────────
# os.environ["OPENAI_API_KEY"] = "..." # 환경변수로 미리 설정하세요 (코드에 직접 입력 금지)
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
vectorstore = Chroma.from_documents(chunks, embeddings)
print(f"[3단계] 벡터 DB 저장 완료: {len(chunks)}개 벡터") # _collection.count()는 내부 API이므로 사용 지양
# ── 4. RAG 체인 구성 (LCEL 방식) ─────────────────────────────────
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
llm = ChatOpenAI(model="gpt-4o", temperature=0)
prompt = PromptTemplate(
input_variables=["context", "question"],
template="""당신은 회사 내부 정책 도우미입니다.
아래 참고 문서를 바탕으로 질문에 답변하세요.
참고 문서에 없는 내용은 "관련 문서에서 찾을 수 없습니다"라고 답변하세요.
참고 문서:
{context}
질문: {question}
답변:"""
)
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
chain = (
{"context": retriever | format_docs, "question": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
print(f"[4단계] RAG 체인 구성 완료")
# ── 5. 질문하기 ───────────────────────────────────────────────────
questions = [
"반차 신청은 어떻게 하나요?",
"병가를 낼 때 진단서가 필요한가요?",
"연차는 몇 일이나 받을 수 있나요?"
]
for question in questions:
print(f"\n{'='*50}")
print(f"Q: {question}")
result = chain.invoke(question)
print(f"A: {result}")
4. Naive RAG vs Advanced RAG¶
RAG는 기본형(Naive RAG)과 고급형(Advanced RAG)으로 발전해 왔다.
한줄 요약: Naive RAG는 "단순하게 검색해서 그냥 쓰기", Advanced RAG는 "더 잘 검색하고 더 잘 정제해서 쓰기"
Naive RAG (기본형):
질문 → 단순 유사도 검색 → 검색 결과 그대로 LLM에 전달 → 답변
Advanced RAG (고급형):
질문 → [쿼리 변환/분해]
→ [하이브리드 검색 (키워드 + 의미)]
→ [Reranking (재순위 매기기)]
→ [답변 검증]
→ 답변 + 출처
| 구분 | Naive RAG | Advanced RAG |
|---|---|---|
| 쿼리 처리 | 원본 질문 그대로 사용 | 질문을 다르게 표현하거나 여러 개로 분해 |
| 검색 방식 | 벡터 유사도만 사용 | 벡터 + 키워드 하이브리드 검색 |
| 결과 정렬 | 유사도 순서 그대로 | Reranker 모델로 재순위 매기기 |
| 청킹 방식 | 고정 크기 (500자 등) | 문단, 문장 등 의미 기반으로 분할 |
| 후처리 | 없음 | 할루시네이션 검증, 출처 표시 |
| 사용 시나리오 | 프로토타입, 소규모 문서 | 실제 프로덕션 서비스 |
왜 이게 중요한가?
Naive RAG는 시작하기 쉽지만, 실제 서비스에서는 검색 품질이 부족할 수 있다.
예를 들어 사용자가 "애플 주가"를 물을 때 Naive RAG는 단어 "애플"이 포함된 모든 문서를 찾는다. 과일 관련 문서도 섞일 수 있다.
Advanced RAG는 문맥을 이해해서 더 정확한 결과를 가져온다. 이후 챕터에서 각 기법을 상세히 배운다.
2024-2025 트렌드: Modular RAG & Multi-modal RAG
최근에는 Modular RAG가 주목받고 있다. 검색, 재순위, 생성 등 각 단계를 독립 모듈로 분리해 자유롭게 조합하는 방식으로, 파이프라인 유연성이 크게 높아진다. 또한 텍스트뿐 아니라 이미지, 표, 차트를 함께 처리하는 Multi-modal RAG도 빠르게 확산되고 있다. 이 강의의 후반부 챕터에서 두 트렌드를 모두 다룬다.
5. RAG의 핵심 구성 요소 요약¶
┌─────────────────────────────────────────────────────────────┐
│ RAG의 핵심 구성 요소 │
├─────────────────────────────────────────────────────────────┤
│ │
│ 📄 Document Loader ── 다양한 형식의 문서를 읽어오는 도구 │
│ (PDF, Word, 웹페이지, DB 등) │
│ │
│ ✂️ Text Splitter ── 문서를 청크로 나누는 도구 │
│ (크기, 겹침, 분할 방식 설정) │
│ │
│ 🔢 Embedding Model ── 텍스트를 벡터로 변환하는 모델 │
│ (OpenAI, HuggingFace 등) │
│ │
│ 🗄️ Vector Store ── 벡터를 저장하고 검색하는 DB │
│ (Chroma, FAISS, Pinecone, Weaviate 등) │
│ │
│ 🔍 Retriever ── 질문에 맞는 청크를 찾는 검색기 │
│ (유사도, 하이브리드, MMR 등) │
│ │
│ 🤖 LLM ── 최종 답변을 생성하는 언어 모델 │
│ (GPT-4, Claude, Llama 등) │
│ │
└─────────────────────────────────────────────────────────────┘
6. 자주 묻는 질문 (FAQ)¶
Q1. RAG와 ChatGPT에 파일을 업로드하는 것의 차이가 뭔가요?
ChatGPT에 파일을 업로드하면, ChatGPT가 내부적으로 RAG와 유사한 방식으로 처리한다. 다만, 우리가 직접 RAG를 구현하면 어떤 문서를 검색할지, 어떻게 분할할지, 어떤 LLM을 쓸지 완전히 제어할 수 있다. 기업 환경에서는 문서를 외부 서비스에 올리지 않고 자체 서버에서 처리해야 하므로 직접 구현이 필요하다.
Q2. 임베딩 모델을 꼭 OpenAI 것을 써야 하나요?
아니다. HuggingFace의 무료 모델도 있고(예:
sentence-transformers/all-MiniLM-L6-v2), 한국어에 특화된 모델도 있다. OpenAI 모델이 가장 널리 쓰이고 성능이 좋지만, 비용이 발생한다. 이후 챕터에서 다양한 임베딩 모델을 비교한다.
Q3. 벡터 DB는 꼭 Chroma를 써야 하나요?
아니다. Chroma는 로컬에서 쉽게 쓸 수 있어 학습용으로 좋다. 실제 프로덕션에서는 Pinecone(클라우드), Weaviate, Qdrant 등 다양한 선택지가 있다. FAISS는 메모리 기반으로 매우 빠르다. 이후 챕터에서 각 벡터 DB를 비교한다.
Q4. k=3으로 설정하면 항상 3개의 문서가 반환되나요?
DB에 3개 이상의 문서가 있다면 항상 3개가 반환된다. 하지만 3개가 항상 최적은 아니다. k가 작으면 관련 문서를 놓칠 수 있고, k가 크면 관련 없는 문서가 섞여 LLM이 혼란스러울 수 있다. 일반적으로 3~7 사이를 실험해보는 것이 좋다.
Q5. RAG를 쓰면 할루시네이션이 완전히 없어지나요?
아니다. 크게 줄어들지만 완전히 없어지지는 않는다. 검색된 문서에 없는 내용이 물어봐지거나, LLM이 검색 결과를 잘못 해석하면 여전히 할루시네이션이 발생할 수 있다. "참고 문서에 없으면 모른다고 답하라"는 프롬프트 지시가 중요한 이유다.
Q6. 한국어 문서에도 RAG가 잘 작동하나요?
잘 작동한다. 단, 임베딩 모델과 LLM이 한국어를 잘 지원해야 한다. OpenAI의 임베딩 모델과 GPT-4는 한국어를 잘 처리한다. 한국어 특화 임베딩 모델(예:
jhgan/ko-sroberta-multitask)을 쓰면 더 좋은 결과를 얻을 수 있다.
7. 핵심 요약¶
핵심 요약
RAG = 검색(Retrieve) + 생성(Generate)
LLM에게 답변하기 전에 관련 문서를 찾아서 참고 자료로 주는 것. 오픈북 시험처럼, 외운 것만이 아닌 찾아서 쓸 수 있게 한다.
RAG가 해결하는 4가지 문제: 1. 할루시네이션 → 실제 문서 기반으로 답변 2. 지식 컷오프 → 최신 문서를 실시간으로 추가 가능 3. 도메인 특화 → 사내 문서, 전문 매뉴얼 활용 가능 4. 파인튜닝 비용 → 벡터 DB 문서 업데이트만으로 대응 가능
RAG의 3단계: 1. 인덱싱: 문서 → 청킹 → 임베딩 → 벡터 DB 저장 2. 검색: 질문 → 임베딩 → 유사 청크 검색 3. 생성: [질문 + 청크] → LLM → 최종 답변
8. 다음 챕터 예고¶
다음 챕터에서는 RAG의 첫 번째 핵심 단계인 문서 로딩과 청킹을 깊이 있게 다룬다.
- PDF, Word, 웹페이지, 데이터베이스에서 문서를 로드하는 방법
- 청크 크기와 겹침이 검색 품질에 미치는 영향
- 의미 기반 청킹 vs 고정 크기 청킹
- 실제 프로젝트에서 청킹 전략을 어떻게 선택하는가
실습 과제
이 챕터의 완전한 코드 예제를 직접 실행해보자.
- OpenAI API 키를 발급받는다 (platform.openai.com)
pip install langchain langchain-openai langchain-community chromadb를 설치한다- 5번 섹션의 "전체 코드"를 실행해본다
- 자신만의 텍스트 파일을 만들어서 질문해본다