목차
- LLM이 모르는 정보를 물어보면 어떻게 될까요?
- RAG가 뭔지 쉽게 이해해보기
- 검색 증강 생성(RAG)이란?
- 임베딩(Embedding)이 RAG의 핵심입니다
- 환경 준비 — 시작 전에 챙겨야 할 것들
- 필요한 패키지 설치
- 환경변수 설정
- LangChain RAG 실전 구현 — 단계별 가이드
- 1단계: 문서 로드(Document Loading)
- 2단계: 문서 청킹(Text Splitting)
- 3단계: 임베딩 생성 및 벡터 저장소 구축
- 4단계: 검색기(Retriever) 설정
- 5단계: RAG 체인(Chain) 완성
- ⚠️ 삽질 기록 — 실제로 겪었던 문제들
- 문제 1: 한국어 문서 청킹이 이상하게 됨
- 문제 2: 검색 결과가 관련 없는 내용을 가져옴
- 문제 3: ChromaDB 재시작 후 데이터 날아감
- 결과 검증 — 실제로 잘 동작하는지 확인하기
- 더 나아가기 — RAG 품질을 높이는 방법들
- 자주 묻는 질문 (FAQ)
- Q. OpenAI API 없이 로컬에서만 RAG를 구축할 수 있나요?
- Q. 문서가 수만 개인데 ChromaDB로 충분한가요?
- Q. 임베딩 모델을 바꾸면 기존 벡터 데이터도 다시 만들어야 하나요?
- 마무리 — 오늘 배운 것 정리
LLM이 모르는 정보를 물어보면 어떻게 될까요?
GPT나 Claude 같은 LLM(Large Language Model, 대형 언어 모델)을 써보신 분들은 한 번쯤 이런 경험 있으실 거예요. 회사 내부 문서나 최신 정보를 물어봤더니 "저는 그 정보를 가지고 있지 않습니다"라고 하거나, 아예 그럴싸한 거짓말을 늘어놓는 경우 말이죠. 이게 바로 LLM의 고질적인 한계인 지식 컷오프(Knowledge Cutoff)와 할루시네이션(Hallucination, 환각) 문제입니다.
저도 처음에 사내 기술 문서 기반 Q&A 시스템을 만들어보려고 했을 때 이 벽에 딱 부딪혔거든요. 그때 찾은 해답이 바로 RAG(Retrieval-Augmented Generation, 검색 증강 생성)였습니다. LangChain RAG 조합을 실제로 구축해보면서 생각보다 강력하다는 걸 느꼈고, 오늘은 그 경험을 처음부터 차근차근 공유해드리려고 합니다.
이 글은 RAG 시스템을 처음 접하시는 분부터, 개념은 알지만 실제 구현에서 막히는 분들까지 모두 도움이 될 수 있도록 개념 설명부터 실제 코드까지 담았습니다.
▲ RAG 시스템의 전체 파이프라인 — 문서 수집부터 임베딩, 벡터 저장소, 검색, 그리고 LLM 응답 생성까지의 흐름
RAG가 뭔지 쉽게 이해해보기
검색 증강 생성(RAG)이란?
쉽게 말해, LLM한테 "오픈북 시험"을 보게 해주는 방식이에요. 기존 LLM은 학습된 데이터만으로 답을 내야 하는 "클로즈드 북" 방식이었다면, RAG는 질문이 들어왔을 때 관련 문서를 먼저 찾아서 LLM에게 "이 자료 참고해서 답해봐"라고 넘겨주는 거거든요.
RAG의 핵심 흐름은 크게 두 단계로 나뉩니다.
- 인덱싱(Indexing) 단계: 문서를 잘게 쪼개고(Chunking), 임베딩(Embedding, 텍스트를 숫자 벡터로 변환)해서 벡터 데이터베이스에 저장
- 검색 및 생성(Retrieval & Generation) 단계: 사용자 질문을 임베딩하고, 유사한 문서 조각을 찾아서 LLM에게 컨텍스트로 전달
임베딩(Embedding)이 RAG의 핵심입니다
임베딩은 RAG에서 가장 중요한 개념이에요. 텍스트를 수백~수천 차원의 숫자 벡터로 변환하는 건데, 의미가 비슷한 문장은 벡터 공간에서 가까이 위치하게 됩니다. 예를 들어 "강아지"와 "개"는 벡터 공간에서 서로 가깝고, "강아지"와 "자동차"는 멀리 떨어져 있는 식이죠.
이 거리를 기반으로 질문과 가장 관련 있는 문서 조각을 찾아내는 게 RAG의 검색 로직입니다. 결국 임베딩 품질이 곧 RAG 시스템의 성능을 좌우한다고 봐도 됩니다.
| 방식 | 장점 | 단점 | 적합한 상황 |
|---|---|---|---|
| 순수 LLM | 구현 간단, 빠름 | 지식 컷오프, 할루시네이션 | 일반적인 지식 질의 |
| Fine-tuning | 모델 자체가 지식 보유 | 비용 높음, 업데이트 어려움 | 특정 도메인 전문화 |
| RAG | 최신 정보 반영, 유연함 | 검색 품질에 의존 | 사내 문서, 최신 정보 Q&A |
환경 준비 — 시작 전에 챙겨야 할 것들
저는 Python 3.11 환경을 기준으로 구성했습니다. 로컬이든 클라우드든 동일하게 적용되는 내용이에요.
필요한 패키지 설치
# 가상환경 생성 및 활성화
python -m venv rag-env
source rag-env/bin/activate # Windows: rag-env\Scripts\activate
# 핵심 패키지 설치
pip install langchain langchain-community langchain-openai
pip install chromadb # 벡터 데이터베이스
pip install tiktoken # 토큰 카운팅
pip install pypdf # PDF 파일 처리
pip install python-dotenv # 환경변수 관리
💡 팁: OpenAI API 대신 로컬 LLM을 쓰고 싶다면 ollama와 langchain-ollama를 추가로 설치하면 됩니다. 이 부분은 다음 글에서 자세히 다룰 예정이에요.
환경변수 설정
# .env 파일 생성
cat > .env << 'EOF'
OPENAI_API_KEY=sk-your-api-key-here
EOF
LangChain RAG 실전 구현 — 단계별 가이드
이제 진짜 구현 단계입니다. 저는 기술 문서 몇 개를 PDF로 준비해서 테스트했는데요, 예제에서는 텍스트 파일을 기준으로 설명드릴게요. 흐름만 이해하면 PDF든 웹페이지든 동일하게 적용됩니다.
1단계: 문서 로드(Document Loading)
from langchain_community.document_loaders import TextLoader, DirectoryLoader
from langchain_community.document_loaders import PyPDFLoader
# 단일 텍스트 파일 로드
loader = TextLoader("./docs/my_document.txt", encoding="utf-8")
documents = loader.load()
# 폴더 내 모든 PDF 파일 로드
# loader = DirectoryLoader("./docs", glob="**/*.pdf", loader_cls=PyPDFLoader)
# documents = loader.load()
print(f"로드된 문서 수: {len(documents)}")
print(f"첫 번째 문서 미리보기: {documents[0].page_content[:200]}")
2단계: 문서 청킹(Text Splitting)
문서를 그대로 넣으면 너무 길어서 LLM이 처리하기 어렵거든요. 적당한 크기로 잘라줘야 합니다. chunk_size와 chunk_overlap 설정이 은근히 중요한데, 저도 처음엔 이 값을 어떻게 잡아야 하나 고민 많이 했습니다.
from langchain.text_splitter import RecursiveCharacterTextSplitter
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=1000, # 각 청크의 최대 문자 수
chunk_overlap=200, # 청크 간 겹치는 문자 수 (문맥 연속성 유지)
length_function=len,
separators=["\n\n", "\n", " ", ""] # 분할 우선순위
)
chunks = text_splitter.split_documents(documents)
print(f"생성된 청크 수: {len(chunks)}")
print(f"첫 번째 청크: {chunks[0].page_content}")
⚠️ 주의: chunk_overlap을 너무 크게 잡으면 중복 내용이 많아져서 검색 결과가 편향될 수 있어요. 보통 chunk_size의 10~20% 정도가 적당합니다.
3단계: 임베딩 생성 및 벡터 저장소 구축
이제 진짜 핵심인 임베딩 단계입니다. 텍스트를 벡터로 변환해서 ChromaDB(로컬 벡터 데이터베이스)에 저장합니다. ChromaDB는 가볍고 설정이 간단해서 개인 프로젝트나 중소 규모 시스템에 딱 맞아요.
import os
from dotenv import load_dotenv
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
load_dotenv()
# 임베딩 모델 초기화
embeddings = OpenAIEmbeddings(
model="text-embedding-3-small" # 비용 효율적인 임베딩 모델
)
# 벡터 저장소 생성 (청크를 임베딩해서 저장)
vectorstore = Chroma.from_documents(
documents=chunks,
embedding=embeddings,
persist_directory="./chroma_db" # 로컬에 영구 저장
)
print("✅ 벡터 저장소 생성 완료!")
print(f"저장된 벡터 수: {vectorstore._collection.count()}")
▲ 텍스트 청크가 임베딩 벡터로 변환되어 벡터 데이터베이스에 저장되는 과정 — 유사도 기반 검색의 핵심 메커니즘
4단계: 검색기(Retriever) 설정
# 기존에 저장된 벡터 저장소 불러오기 (재시작 시)
vectorstore = Chroma(
persist_directory="./chroma_db",
embedding_function=embeddings
)
# 검색기 생성 — 질문과 유사한 상위 4개 청크 반환
retriever = vectorstore.as_retriever(
search_type="similarity", # 유사도 기반 검색
search_kwargs={"k": 4} # 상위 4개 문서 반환
)
# 검색 테스트
test_query = "RAG 시스템의 장점은 무엇인가요?"
results = retriever.invoke(test_query)
for i, doc in enumerate(results):
print(f"\n--- 검색 결과 {i+1} ---")
print(doc.page_content[:200])
5단계: RAG 체인(Chain) 완성
드디어 마지막 단계입니다! 검색기와 LLM을 연결해서 실제로 질문에 답하는 RAG 체인을 만들어볼게요. LangChain의 LCEL(LangChain Expression Language) 문법을 사용하면 복잡한 파이프라인도 간단하게 구성할 수 있습니다.
from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnablePassthrough
from langchain_core.output_parsers import StrOutputParser
# LLM 초기화
llm = ChatOpenAI(
model="gpt-4o-mini",
temperature=0 # 일관된 답변을 위해 temperature를 낮게
)
# 프롬프트 템플릿 — 이 부분이 RAG 품질을 좌우합니다
prompt_template = """
당신은 주어진 컨텍스트를 바탕으로 질문에 답하는 전문 어시스턴트입니다.
컨텍스트:
{context}
질문: {question}
답변 지침:
- 반드시 제공된 컨텍스트에 기반하여 답변하세요.
- 컨텍스트에 없는 내용은 "제공된 문서에서 해당 정보를 찾을 수 없습니다"라고 명시하세요.
- 명확하고 구체적으로 답변하세요.
답변:
"""
prompt = ChatPromptTemplate.from_template(prompt_template)
# 문서 포맷팅 함수
def format_docs(docs):
return "\n\n".join(doc.page_content for doc in docs)
# RAG 체인 구성 (LCEL 방식)
rag_chain = (
{
"context": retriever | format_docs,
"question": RunnablePassthrough()
}
| prompt
| llm
| StrOutputParser()
)
# 실제 질문 테스트
question = "RAG 시스템에서 임베딩의 역할은 무엇인가요?"
response = rag_chain.invoke(question)
print("\n🎉 RAG 응답:")
print(response)
⚠️ 삽질 기록 — 실제로 겪었던 문제들
솔직히 말씀드리면, 처음 구현할 때 꽤 헤맸습니다. 비슷한 상황에서 도움이 될 것 같아서 공유드릴게요.
문제 1: 한국어 문서 청킹이 이상하게 됨
영어 문서는 잘 되는데 한국어 문서를 넣었더니 청킹이 이상하게 잘리더라고요. RecursiveCharacterTextSplitter의 기본 separators가 영어 기준이라서 그런 거였어요. 한국어에는 문장 끝 기준을 추가해주면 훨씬 나아집니다.
# 한국어에 최적화된 청킹 설정
text_splitter = RecursiveCharacterTextSplitter(
chunk_size=800,
chunk_overlap=150,
separators=["\n\n", "\n", "。", ".", "! ", "? ", " ", ""] # 한국어 구분자 추가
)
문제 2: 검색 결과가 관련 없는 내용을 가져옴
질문과 전혀 관련 없는 청크가 상위에 올라오는 경우가 있었는데요. 이때는 MMR(Maximal Marginal Relevance) 검색 방식을 써보세요. 다양성과 관련성을 동시에 고려해줘서 훨씬 나은 결과가 나오더라고요.
# MMR 방식으로 검색기 설정
retriever = vectorstore.as_retriever(
search_type="mmr",
search_kwargs={
"k": 4, # 최종 반환 문서 수
"fetch_k": 20, # 후보로 가져올 문서 수
"lambda_mult": 0.5 # 0: 다양성 최대, 1: 관련성 최대
}
)
문제 3: ChromaDB 재시작 후 데이터 날아감
처음에 persist_directory를 안 지정했다가 서버 재시작하고 나서 임베딩 데이터가 다 사라졌었어요. 정말 황당했죠. 반드시 영구 저장 경로를 지정하세요. 위 코드에는 이미 반영되어 있으니 그대로 따라하시면 됩니다.
결과 검증 — 실제로 잘 동작하는지 확인하기
구현이 끝났으면 제대로 동작하는지 확인해봐야죠. 간단한 평가 코드를 만들어서 테스트해봤습니다.
# 다양한 질문으로 RAG 시스템 테스트
test_questions = [
"문서의 주요 내용을 요약해주세요.",
"구체적인 설정 방법을 알려주세요.",
"문서에 없는 내용을 물어보면?" # 할루시네이션 방지 테스트
]
print("=" * 60)
print("RAG 시스템 검증 테스트")
print("=" * 60)
for q in test_questions:
print(f"\n❓ 질문: {q}")
print("-" * 40)
# 어떤 청크가 검색됐는지도 확인
retrieved_docs = retriever.invoke(q)
print(f"📚 검색된 청크 수: {len(retrieved_docs)}")
response = rag_chain.invoke(q)
print(f"💬 답변: {response}")
print("=" * 60)
▲ RAG 시스템 테스트 결과 화면 — 각 질문에 대해 검색된 청크와 생성된 응답을 확인할 수 있습니다
세 번째 테스트 질문처럼 문서에 없는 내용을 물어봤을 때, 잘 구성된 RAG 시스템은 "해당 정보를 문서에서 찾을 수 없습니다"라고 답해야 합니다. 이게 안 되면 프롬프트 템플릿을 좀 더 강하게 제약해줘야 해요.
더 나아가기 — RAG 품질을 높이는 방법들
기본 RAG는 이제 동작하는데, 실제 프로덕션 환경에서 쓰려면 몇 가지 더 고려해야 할 것들이 있습니다.
- 청크 크기 최적화: 문서 종류에 따라 최적 chunk_size가 달라요. 코드 문서는 크게, FAQ 형태는 작게
- Re-ranking(재순위화): 검색된 문서를 다시 한번 관련성 순으로 정렬하는 기법. 검색 품질이 크게 향상됩니다
- 하이브리드 검색: 벡터 유사도 검색과 키워드 기반 BM25 검색을 함께 사용하면 더 정확해져요
- 메타데이터 필터링: 문서 출처, 날짜, 카테고리 등 메타데이터를 활용해 검색 범위를 좁힐 수 있습니다
- 대화 기록 관리: 멀티턴 대화를 위해
ConversationalRetrievalChain활용 고려
▲ 기본 RAG와 고도화된 RAG 파이프라인 비교 — Re-ranking, 하이브리드 검색 등 품질 향상 기법들
자주 묻는 질문 (FAQ)
Q. OpenAI API 없이 로컬에서만 RAG를 구축할 수 있나요?
네, 가능합니다. 임베딩은 sentence-transformers 라이브러리의 오픈소스 모델을, LLM은 Ollama로 로컬 모델을 사용하면 돼요. 비용 없이 완전히 로컬에서 돌릴 수 있어요. 이 부분은 다음 글에서 자세히 다룰 예정입니다.
Q. 문서가 수만 개인데 ChromaDB로 충분한가요?
소규모~중규모는 ChromaDB로 충분합니다. 수십만 개 이상의 대규모 문서라면 Pinecone, Weaviate, pgvector 같은 전용 벡터 데이터베이스를 고려해보세요.
Q. 임베딩 모델을 바꾸면 기존 벡터 데이터도 다시 만들어야 하나요?
네, 맞습니다. 임베딩 모델이 달라지면 벡터 공간이 달라지기 때문에 기존 데이터를 새 모델로 전부 다시 임베딩해야 합니다. 처음 모델 선택을 신중하게 하는 게 좋아요.
마무리 — 오늘 배운 것 정리
오늘 LangChain RAG 시스템을 처음부터 구축해봤는데요, 정리하면 이렇습니다.
- 문서를 로드하고 적절한 크기로 청킹
- 임베딩 모델로 텍스트를 벡터로 변환해서 벡터 저장소에 저장
- 사용자 질문을 임베딩해서 유사 문서 검색
- 검색된 문서를 컨텍스트로 LLM에게 전달해서 응답 생성
처음엔 개념이 복잡해 보여도, 막상 코드로 짜보면 생각보다 직관적이에요. LangChain이 복잡한 파이프라인을 상당히 추상화해줘서 핵심 로직에 집중할 수 있거든요.
다음 글에서는 OpenAI 없이 Ollama로 완전 로컬 RAG 시스템을 구축하는 방법을 다룰 예정입니다. API 비용 걱정 없이 사내 문서를 분석하고 싶으신 분들께 도움이 될 거예요. 궁금하신 점은 댓글로 남겨주세요! 🎉
'IT > AI' 카테고리의 다른 글
| [AI] LLM 파인튜닝 실전 가이드: LoRA/QLoRA로 도메인 특화 모델 만들기 (1) | 2026.05.07 |
|---|---|
| [AI] AI 코딩 도우미 비교: GitHub Copilot vs Cursor AI, 개발 생산성 극대화 전략 (2) | 2026.04.30 |
| [AI] Gemini API 실전 활용 가이드: 멀티모달 기능으로 AI 서비스 구축하기 (1) | 2026.04.30 |
| [AI] RAG 실전 구현 가이드: LLM 환각 현상 줄이고 최신 정보 활용하기 (0) | 2026.04.29 |
| [AI] 고급 프롬프트 엔지니어링: ChatGPT와 Gemini 활용 극대화 전략 (0) | 2026.04.23 |
| [AI] Gemini API 실전 활용 가이드: 모델 선택부터 멀티모달 요청, 비용 절감 전략까지 (0) | 2026.04.23 |