RAG란 무엇인가?
RAG(Retrieval-Augmented Generation, 검색 증강 생성)는 LLM이 외부 지식을 검색하여 응답을 생성하는 기법입니다. 쉽게 말해 오픈북 시험과 같습니다. LLM이 모든 것을 암기하는 대신, 필요한 정보를 참고 자료에서 찾아 답변하는 방식입니다.
LLM은 학습 데이터의 컷오프 시점 이후 정보를 알지 못하고, 할루시네이션(Hallucination)으로 사실과 다른 답변을 생성할 수 있습니다. RAG는 이 두 가지 문제를 외부 문서 검색으로 해결합니다.
| 방식 | 장점 | 단점 |
|---|---|---|
| 순수 LLM | 빠른 응답, 별도 인프라 불필요 | 할루시네이션, 최신 정보 부재 |
| 파인튜닝 | 도메인 특화 성능 | 높은 비용, 데이터 업데이트 어려움 |
| RAG | 최신 정보 반영, 출처 명시 가능 | 검색 품질에 의존, 지연 시간 증가 |
RAG 아키텍처 3단계
RAG 파이프라인은 인덱싱(Indexing), 검색(Retrieval), 생성(Generation) 세 단계로 구성됩니다.
1단계 — 인덱싱: 문서를 작은 청크(Chunk)로 나누고, 임베딩 모델로 벡터로 변환한 뒤 벡터 데이터베이스에 저장합니다.
2단계 — 검색: 사용자 질문을 동일한 임베딩 모델로 벡터화하고, 벡터 DB에서 유사도가 높은 청크를 검색합니다.
3단계 — 생성: 검색된 청크를 컨텍스트로 LLM 프롬프트에 포함하여 최종 답변을 생성합니다.
핵심 구성 요소
임베딩 모델
임베딩(Embedding)은 텍스트를 고차원 벡터로 변환하는 과정입니다. 의미가 유사한 텍스트는 벡터 공간에서 가까운 위치에 놓입니다.
| 모델 | 차원 | 특징 |
|---|---|---|
| OpenAI text-embedding-3-small | 1536 | 높은 성능, API 호출 필요 |
| sentence-transformers/all-MiniLM-L6-v2 | 384 | 오픈소스, 로컬 실행 가능 |
| Cohere embed-v3 | 1024 | 다국어 지원 우수 |
벡터 데이터베이스
벡터 DB는 임베딩 벡터를 저장하고 유사도 검색을 수행하는 특수 데이터베이스입니다. 대표적으로 ChromaDB(로컬/경량), Pinecone(클라우드/관리형), FAISS(Meta 오픈소스, 대규모 검색 최적화) 등이 있습니다.
청킹 전략
문서를 나누는 방식은 RAG 성능에 큰 영향을 미칩니다.
| 전략 | 설명 | 적합한 경우 |
|---|---|---|
| 고정 크기 | 일정 토큰/문자 수로 분할 | 균일한 문서 |
| 재귀적 분할 | 단락 → 문장 → 단어 순으로 분할 | 일반 텍스트 |
| 시맨틱 분할 | 의미 단위로 분할 | 구조화된 문서 |
일반적으로 5001,000 토큰 크기에 1020% 오버랩을 권장합니다.
Python으로 기본 RAG 구현
LangChain과 ChromaDB를 사용한 기본 RAG 파이프라인을 구현해봅시다. 먼저 필요한 패키지를 설치합니다.
# 필요한 패키지 설치
pip install langchain langchain-openai langchain-community chromadb
1단계: 문서 로드와 청킹
텍스트 문서를 로드하고 적절한 크기로 분할합니다.
from langchain_community.document_loaders import TextLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
# 문서 로드
loader = TextLoader("docs/guide.txt", encoding="utf-8")
documents = loader.load()
# 재귀적 텍스트 분할기 설정
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 청크당 최대 500자
chunk_overlap=50, # 청크 간 50자 오버랩
separators=["\n\n", "\n", " ", ""] # 분할 우선순위
)
# 문서를 청크로 분할
chunks = text_splitter.split_documents(documents)
print(f"총 {len(chunks)}개 청크 생성") # 총 12개 청크 생성
RecursiveCharacterTextSplitter는 단락(\n\n) 단위로 먼저 나누고, 그래도 크면 줄바꿈(\n), 공백( ) 순서로 분할합니다. 이렇게 하면 문맥이 보존되는 자연스러운 청크를 만들 수 있습니다.
2단계: 임베딩 생성과 벡터 저장
분할된 청크를 벡터로 변환하여 ChromaDB에 저장합니다.
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
# 임베딩 모델 초기화 (환경변수 OPENAI_API_KEY 필요)
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
# ChromaDB에 벡터 저장
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory="./chroma_db" # 로컬 디스크에 영구 저장
)
# 검색기 생성 (상위 3개 결과 반환)
retriever = vectorstore.as_retriever(search_kwargs={"k": 3})
print("벡터 인덱싱 완료") # 벡터 인덱싱 완료
persist_directory를 지정하면 벡터 데이터가 디스크에 저장되어, 프로그램을 재시작해도 인덱싱을 다시 할 필요가 없습니다.
3단계: 검색 + 생성 (RAG 체인)
검색된 문서를 컨텍스트로 활용하여 LLM이 답변을 생성합니다.
from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
from langchain.schema.runnable import RunnablePassthrough
from langchain.schema.output_parser import StrOutputParser
# LLM 초기화
llm = ChatOpenAI(model="gpt-4o", temperature=0)
# RAG 프롬프트 템플릿
prompt = ChatPromptTemplate.from_template("""
다음 컨텍스트를 기반으로 질문에 답변하세요.
컨텍스트에 없는 내용은 "해당 정보를 찾을 수 없습니다"라고 답하세요.
컨텍스트: {context}
질문: {question}
""")
# RAG 체인 구성
rag_chain = (
{"context": retriever, "question": RunnablePassthrough()}
| prompt
| llm
| StrOutputParser()
)
# 질문하기
answer = rag_chain.invoke("RAG의 주요 장점은 무엇인가요?")
print(answer)
RunnablePassthrough()는 사용자 입력을 그대로 전달하고, retriever는 같은 입력으로 관련 문서를 검색합니다. 두 결과가 프롬프트 템플릿에 합쳐져 LLM에 전달됩니다.
RAG 성능 최적화 팁
청크 크기 조절: 청크가 너무 작으면 문맥이 부족하고, 너무 크면 노이즈가 증가합니다. 도메인에 맞게 실험하여 최적 크기를 찾으세요.
하이브리드 검색: 벡터 유사도 검색만으로 부족할 때, BM25 같은 키워드 검색을 함께 사용하면 검색 정확도가 향상됩니다.
리랭킹(Reranking): 초기 검색 결과를 Cross-Encoder 모델로 재정렬하면 관련성 높은 문서가 상위에 위치합니다.
메타데이터 필터링: 문서에 날짜, 카테고리 등 메타데이터를 부여하고, 검색 시 필터링하면 불필요한 결과를 줄일 수 있습니다.
정리
RAG는 LLM의 한계를 외부 지식 검색으로 보완하는 실용적인 기법입니다. 핵심은 좋은 청킹 전략, 적절한 임베딩 모델, 효율적인 벡터 검색 세 가지입니다. LangChain과 ChromaDB를 활용하면 수십 줄의 코드로 기본 RAG 파이프라인을 구축할 수 있으니, 직접 문서를 넣어 실험해보시길 권장합니다.