RAG 챗봇 만들기 10 - 실전 프로젝트 완성

learning by Seven Fingers Studio 15분
RAG프로젝트완성배포LangChain챗봇

블로그 대표 이미지

처음 RAG 개념을 들었을 때 “복잡해 보인다”고 생각했다. 막상 하나씩 만들어보니 각 단계가 그렇게 어렵지 않았다. 문서를 로드하고, 쪼개고, 임베딩하고, 저장하고, 검색해서 답변을 만드는 과정. 한 번 흐름을 익히면 다른 프로젝트에도 쉽게 적용할 수 있다.

오늘은 마지막으로 전체 코드를 정리하고, 실제로 동작하는 RAG 챗봇을 완성한다.

프로젝트 구조

최종 프로젝트 폴더 구조는 다음과 같다:

rag-chatbot/
├── app.py                 # Streamlit 앱
├── rag/
│   ├── __init__.py
│   ├── loader.py          # 문서 로딩
│   ├── chunker.py         # 텍스트 청킹
│   ├── vectorstore.py     # 벡터 저장소
│   └── chain.py           # RAG 체인
├── docs/                   # 문서 폴더
│   └── (PDF 파일들)
├── chroma_db/             # 벡터 DB 저장소
├── requirements.txt
├── .env
└── README.md

핵심 모듈 코드

rag/loader.py

from langchain_community.document_loaders import (
    PyPDFLoader,
    TextLoader,
    DirectoryLoader
)
from typing import List
from langchain.schema import Document

def load_pdf(file_path: str) -> List[Document]:
    """PDF 파일 로드"""
    loader = PyPDFLoader(file_path)
    return loader.load()

def load_directory(dir_path: str, glob: str = "**/*.pdf") -> List[Document]:
    """폴더 내 모든 PDF 로드"""
    loader = DirectoryLoader(
        dir_path,
        glob=glob,
        loader_cls=PyPDFLoader
    )
    return loader.load()

rag/chunker.py

from langchain.text_splitter import RecursiveCharacterTextSplitter
from typing import List
from langchain.schema import Document

def chunk_documents(
    documents: List[Document],
    chunk_size: int = 1000,
    chunk_overlap: int = 200
) -> List[Document]:
    """문서를 청크로 분할"""
    splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        separators=["\n\n", "\n", ". ", " ", ""]
    )
    return splitter.split_documents(documents)

블로그 대표 이미지

rag/vectorstore.py

from langchain_community.vectorstores import Chroma
from langchain_openai import OpenAIEmbeddings
from typing import List
from langchain.schema import Document

class VectorStore:
    def __init__(self, persist_directory: str = "./chroma_db"):
        self.embeddings = OpenAIEmbeddings()
        self.persist_directory = persist_directory
        self.vectorstore = None

    def create_from_documents(self, documents: List[Document]):
        """문서로 벡터 저장소 생성"""
        self.vectorstore = Chroma.from_documents(
            documents=documents,
            embedding=self.embeddings,
            persist_directory=self.persist_directory
        )
        return self

    def load(self):
        """기존 벡터 저장소 로드"""
        self.vectorstore = Chroma(
            persist_directory=self.persist_directory,
            embedding_function=self.embeddings
        )
        return self

    def as_retriever(self, k: int = 3):
        """Retriever 반환"""
        return self.vectorstore.as_retriever(search_kwargs={"k": k})

rag/chain.py

from langchain_openai import ChatOpenAI
from langchain.chains import ConversationalRetrievalChain
from langchain.memory import ConversationBufferMemory
from langchain.prompts import PromptTemplate

PROMPT_TEMPLATE = """당신은 문서 기반 질의응답 도우미입니다.

아래 문서를 참고하여 질문에 답변해주세요.
- 문서에 있는 내용만 답변하세요
- 문서에 없는 내용은 "해당 정보를 찾을 수 없습니다"라고 말해주세요
- 친절하고 명확하게 답변해주세요

참고 문서:
{context}

질문: {question}

답변:"""

class RAGChain:
    def __init__(self, retriever, model: str = "gpt-3.5-turbo"):
        self.llm = ChatOpenAI(model=model, temperature=0)
        self.retriever = retriever
        self.memory = ConversationBufferMemory(
            memory_key="chat_history",
            return_messages=True,
            output_key="answer"
        )
        self.chain = self._create_chain()

    def _create_chain(self):
        return ConversationalRetrievalChain.from_llm(
            llm=self.llm,
            retriever=self.retriever,
            memory=self.memory,
            return_source_documents=True
        )

    def ask(self, question: str) -> dict:
        """질문하고 답변 받기"""
        result = self.chain.invoke({"question": question})
        return {
            "answer": result["answer"],
            "sources": result["source_documents"]
        }

    def clear_memory(self):
        """대화 기록 초기화"""
        self.memory.clear()

메인 앱 (app.py)

import streamlit as st
from dotenv import load_dotenv
import os

from rag.loader import load_directory
from rag.chunker import chunk_documents
from rag.vectorstore import VectorStore
from rag.chain import RAGChain

# 환경 변수 로드
load_dotenv()

# 페이지 설정
st.set_page_config(
    page_title="RAG 챗봇",
    page_icon="🤖",
    layout="wide"
)

# 사이드바
with st.sidebar:
    st.title("⚙️ 설정")

    # 문서 새로 인덱싱
    if st.button("📄 문서 다시 로드", use_container_width=True):
        with st.spinner("문서 로딩 중..."):
            docs = load_directory("./docs")
            chunks = chunk_documents(docs)
            vs = VectorStore()
            vs.create_from_documents(chunks)
            st.success(f"{len(chunks)}개 청크 저장 완료!")

    st.divider()

    model = st.selectbox("모델", ["gpt-3.5-turbo", "gpt-4"])
    k = st.slider("검색 문서 수", 1, 10, 3)

    st.divider()

    if st.button("🗑️ 대화 초기화", use_container_width=True):
        st.session_state.messages = []
        if "chain" in st.session_state:
            st.session_state.chain.clear_memory()
        st.rerun()

# 초기화
@st.cache_resource
def init_chain(model: str, k: int):
    vs = VectorStore().load()
    retriever = vs.as_retriever(k=k)
    return RAGChain(retriever, model)

if "messages" not in st.session_state:
    st.session_state.messages = []

# 메인 UI
st.title("🤖 RAG 문서 챗봇")
st.caption("PDF 문서를 기반으로 질문에 답변합니다")

# 체인 초기화
chain = init_chain(model, k)

# 대화 표시
for msg in st.session_state.messages:
    with st.chat_message(msg["role"]):
        st.markdown(msg["content"])

# 입력 처리
if prompt := st.chat_input("질문을 입력하세요"):
    st.session_state.messages.append({"role": "user", "content": prompt})
    with st.chat_message("user"):
        st.markdown(prompt)

    with st.chat_message("assistant"):
        with st.spinner("답변 생성 중..."):
            result = chain.ask(prompt)

        st.markdown(result["answer"])

        with st.expander("📚 참고 문서"):
            for i, doc in enumerate(result["sources"]):
                st.markdown(f"**{i+1}.** {doc.page_content[:200]}...")

    st.session_state.messages.append({
        "role": "assistant",
        "content": result["answer"]
    })

requirements.txt

langchain==0.1.0
langchain-openai==0.0.2
langchain-community==0.0.10
openai==1.6.1
chromadb==0.4.22
pypdf==3.17.4
python-dotenv==1.0.0
tiktoken==0.5.2
streamlit==1.29.0

실행 방법

1. 환경 설정

# 가상환경 생성
python -m venv venv
source venv/bin/activate  # Windows: venv\Scripts\activate

# 패키지 설치
pip install -r requirements.txt

2. API 키 설정

.env 파일:

OPENAI_API_KEY=sk-your-api-key

3. 문서 추가

docs/ 폴더에 PDF 파일 넣기

4. 실행

streamlit run app.py

시리즈 요약

10편에 걸쳐 배운 내용은 다음과 같다:

주제핵심
1편RAG 개념검색 + 생성
2편개발 환경Python, 가상환경
3편문서 로딩Document Loader
4편청킹Text Splitter
5편임베딩텍스트→벡터
6편벡터 저장소ChromaDB, FAISS
7편pgvectorPostgreSQL 연동
8편RAG 체인검색+응답 생성
9편웹 UIStreamlit
10편완성전체 통합

다음 단계

더 발전시키고 싶다면 다음을 고려해볼 수 있다:

  • 멀티모달: 이미지도 처리
  • 하이브리드 검색: 벡터 + 키워드 검색 결합
  • 평가 시스템: RAG 품질 측정
  • 대화 요약: 긴 대화 컨텍스트 관리
  • 스트리밍: 답변 실시간 출력

운영자 실전 노트

실제 프로젝트 진행하며 겪은 문제

  • 모듈 간 의존성 관리: 벡터 저장소 변경 시 여러 파일 수정 필요 → 인터페이스 추상화로 해결
  • 청크 크기 최적화 실패: chunk_size=500으로 설정하니 문맥 부족 → 도메인별 실험으로 1000이 최적 발견
  • 메모리 누수: 대화가 길어지면 메모리 사용량 급증 → ConversationSummaryMemory로 교체
  • 배포 후 비용 폭증: API 호출 과다로 비용 초과 → 캐싱과 rate limiting 적용

이 경험을 통해 알게 된 점

  • 완벽한 설정은 없다: chunk_size, k값, 프롬프트 등은 도메인마다 다르므로 실험 필수
  • 모니터링 구축 필요: 응답 시간, 토큰 사용량, 검색 품질을 측정해야 개선 가능
  • 점진적 개선이 답: 한 번에 완벽하게 만들려 하지 말고 작동하는 버전 먼저 만들기

마무리

RAG 시리즈가 끝났다. 처음엔 복잡해 보여도 하나씩 만들다 보면 생각보다 어렵지 않다는 것을 알 수 있다. 이제 PDF 외에 다른 문서를 넣어보고, 프롬프트를 변경하며 실험해보자.

처음 RAG를 만들 때 chunk_size를 잘못 설정해서 검색 품질이 나오지 않아 한참 헤맸던 경험이 있다. 그런 시행착오가 모두 공부가 된다. 일단 만들어보고, 문제가 생기면 하나씩 고쳐가며 배우는 것이 가장 빠른 방법이다.


RAG 챗봇 만들기 시리즈:

시리즈를 완주하셨어요! 수고하셨습니다. 🎉

← 블로그 목록으로