LangChain 독학 가이드 10 - 실전 프로젝트

learning by Seven Fingers Studio 20분
LangChainRAGStreamlit챗봇Python프로젝트

드디어 마지막 글이에요! 지금까지 배운 프롬프트 템플릿, LCEL, 모델 연결, 출력 파서, 메모리, RAG, Agent를 모두 합쳐서 실제로 쓸 수 있는 PDF 문서 기반 QA 챗봇을 만들어볼 거예요.

회사에서 “이 문서에서 이거 찾아줘”라는 요청 많이 받으시죠? 이 프로젝트를 완성하면 PDF를 업로드하고 질문하면 답변해주는 나만의 AI 비서를 가질 수 있어요!

프로젝트 구조

우리가 만들 챗봇의 구조예요:

  1. PDF 업로드 → 텍스트 추출
  2. 텍스트 분할 → 적당한 크기로 나누기
  3. 벡터 저장소 → 임베딩하여 저장
  4. 질문 입력 → 관련 문서 검색
  5. 답변 생성 → LLM이 답변
  6. 대화 기록 → 이전 대화 기억

필요한 패키지 설치

pip install langchain langchain-openai langchain-community
pip install chromadb pypdf streamlit python-dotenv

1단계: PDF 로드 및 분할

먼저 PDF를 읽고 적당한 크기로 나누는 코드예요:

from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter

def load_and_split_pdf(pdf_path: str):
    """PDF를 로드하고 청크로 분할합니다."""

    # PDF 로드
    loader = PyPDFLoader(pdf_path)
    documents = loader.load()

    # 텍스트 분할
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=1000,      # 청크 크기
        chunk_overlap=200,    # 청크 간 겹침
        separators=["\n\n", "\n", ".", " "]
    )

    chunks = text_splitter.split_documents(documents)
    print(f"총 {len(chunks)}개의 청크로 분할됨")

    return chunks

# 테스트
chunks = load_and_split_pdf("sample.pdf")
print(chunks[0].page_content[:200])

실행 결과:

총 45개의 청크로 분할됨
제1장 서론
본 보고서는 2024년 상반기 실적을 분석하고...

2단계: 벡터 저장소 구축

분할된 텍스트를 벡터로 변환하고 저장해요:

from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

def create_vector_store(chunks):
    """청크를 벡터화하여 저장소에 저장합니다."""

    # 임베딩 모델
    embeddings = OpenAIEmbeddings()

    # 벡터 저장소 생성
    vector_store = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory="./chroma_db"  # 로컬에 저장
    )

    print("벡터 저장소 생성 완료!")
    return vector_store

# 실행
vector_store = create_vector_store(chunks)

실행 결과:

벡터 저장소 생성 완료!

3단계: RAG 체인 구성

이제 핵심인 RAG 체인을 만들어요:

from langchain_openai import ChatOpenAI
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough

def create_rag_chain(vector_store):
    """RAG 체인을 생성합니다."""

    # 검색기 설정
    retriever = vector_store.as_retriever(
        search_type="similarity",
        search_kwargs={"k": 3}  # 상위 3개 문서 검색
    )

    # 프롬프트 템플릿
    prompt = ChatPromptTemplate.from_messages([
        ("system", """당신은 문서 기반 질의응답 AI입니다.
주어진 컨텍스트를 바탕으로 질문에 답변하세요.
컨텍스트에 없는 내용은 "문서에서 해당 정보를 찾을 수 없습니다"라고 답변하세요.

컨텍스트:
{context}"""),
        MessagesPlaceholder(variable_name="chat_history"),
        ("human", "{question}")
    ])

    # 모델
    model = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)

    # 문서 포맷팅 함수
    def format_docs(docs):
        return "\n\n".join(doc.page_content for doc in docs)

    # RAG 체인 구성
    rag_chain = (
        {
            "context": retriever | format_docs,
            "question": RunnablePassthrough(),
            "chat_history": lambda x: x.get("chat_history", [])
        }
        | prompt
        | model
        | StrOutputParser()
    )

    return rag_chain

# 체인 생성
rag_chain = create_rag_chain(vector_store)

4단계: 대화 기록 관리

이전 대화를 기억하는 기능을 추가해요:

from langchain_core.messages import HumanMessage, AIMessage

class ChatBot:
    def __init__(self, vector_store):
        self.chain = create_rag_chain(vector_store)
        self.chat_history = []

    def ask(self, question: str) -> str:
        """질문하고 답변을 받습니다."""

        # 체인 실행
        response = self.chain.invoke({
            "question": question,
            "chat_history": self.chat_history
        })

        # 대화 기록에 추가
        self.chat_history.append(HumanMessage(content=question))
        self.chat_history.append(AIMessage(content=response))

        # 기록이 너무 길어지면 오래된 것 삭제
        if len(self.chat_history) > 10:
            self.chat_history = self.chat_history[-10:]

        return response

    def clear_history(self):
        """대화 기록을 초기화합니다."""
        self.chat_history = []

# 사용 예시
bot = ChatBot(vector_store)
print(bot.ask("이 문서의 주요 내용이 뭐야?"))
print(bot.ask("좀 더 자세히 설명해줘"))  # 이전 대화 컨텍스트 유지

실행 결과:

[첫 번째 질문]
이 문서는 2024년 상반기 실적 보고서입니다. 주요 내용은...
[두 번째 질문 - 맥락 유지됨]
네, 더 자세히 설명드리겠습니다. 실적 보고서에 따르면...

5단계: Streamlit 웹 UI

이제 웹 인터페이스를 만들어요. app.py 파일을 만드세요:

import streamlit as st
from dotenv import load_dotenv
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import Chroma
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.output_parsers import StrOutputParser
from langchain_core.runnables import RunnablePassthrough
from langchain_core.messages import HumanMessage, AIMessage
import tempfile
import os

load_dotenv()

st.set_page_config(page_title="PDF 챗봇", page_icon="📚")
st.title("📚 PDF 문서 QA 챗봇")

# 세션 상태 초기화
if "chat_history" not in st.session_state:
    st.session_state.chat_history = []
if "vector_store" not in st.session_state:
    st.session_state.vector_store = None

# 사이드바 - PDF 업로드
with st.sidebar:
    st.header("📁 PDF 업로드")
    uploaded_file = st.file_uploader("PDF 파일을 선택하세요", type="pdf")

    if uploaded_file and st.button("문서 처리 시작"):
        with st.spinner("PDF 처리 중..."):
            # 임시 파일로 저장
            with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as tmp:
                tmp.write(uploaded_file.getvalue())
                tmp_path = tmp.name

            # PDF 로드 및 분할
            loader = PyPDFLoader(tmp_path)
            documents = loader.load()

            text_splitter = RecursiveCharacterTextSplitter(
                chunk_size=1000,
                chunk_overlap=200
            )
            chunks = text_splitter.split_documents(documents)

            # 벡터 저장소 생성
            embeddings = OpenAIEmbeddings()
            st.session_state.vector_store = Chroma.from_documents(
                documents=chunks,
                embedding=embeddings
            )

            # 임시 파일 삭제
            os.unlink(tmp_path)

            st.success(f"✅ {len(chunks)}개 청크 처리 완료!")

    if st.button("대화 기록 초기화"):
        st.session_state.chat_history = []
        st.rerun()

# 메인 화면 - 채팅
if st.session_state.vector_store is None:
    st.info("👈 왼쪽에서 PDF 파일을 업로드해주세요.")
else:
    # 이전 대화 표시
    for msg in st.session_state.chat_history:
        if isinstance(msg, HumanMessage):
            st.chat_message("user").write(msg.content)
        else:
            st.chat_message("assistant").write(msg.content)

    # 질문 입력
    if question := st.chat_input("질문을 입력하세요..."):
        st.chat_message("user").write(question)

        # RAG 체인 실행
        retriever = st.session_state.vector_store.as_retriever(search_kwargs={"k": 3})

        prompt = ChatPromptTemplate.from_messages([
            ("system", """주어진 컨텍스트를 바탕으로 질문에 답변하세요.
컨텍스트에 없는 내용은 "문서에서 해당 정보를 찾을 수 없습니다"라고 답변하세요.

컨텍스트:
{context}"""),
            MessagesPlaceholder(variable_name="chat_history"),
            ("human", "{question}")
        ])

        model = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)

        def format_docs(docs):
            return "\n\n".join(doc.page_content for doc in docs)

        with st.spinner("답변 생성 중..."):
            # 관련 문서 검색
            docs = retriever.invoke(question)
            context = format_docs(docs)

            # 답변 생성
            chain = prompt | model | StrOutputParser()
            response = chain.invoke({
                "context": context,
                "question": question,
                "chat_history": st.session_state.chat_history
            })

        st.chat_message("assistant").write(response)

        # 대화 기록 저장
        st.session_state.chat_history.append(HumanMessage(content=question))
        st.session_state.chat_history.append(AIMessage(content=response))

실행하기

터미널에서 다음 명령어로 실행하세요:

streamlit run app.py

실행 결과:

You can now view your Streamlit app in your browser.
Local URL: http://localhost:8501

브라우저에서 http://localhost:8501로 접속하면 챗봇이 실행돼요!

전체 코드 (단일 파일)

전체 코드를 한 파일에 정리했어요. pdf_chatbot.py로 저장하세요:

"""
PDF 문서 기반 QA 챗봇 - LangChain 실전 프로젝트
실행: streamlit run pdf_chatbot.py
"""

import streamlit as st
from dotenv import load_dotenv
from langchain_community.document_loaders import PyPDFLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_community.vectorstores import Chroma
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder
from langchain_core.output_parsers import StrOutputParser
from langchain_core.messages import HumanMessage, AIMessage
import tempfile
import os

# 환경 변수 로드
load_dotenv()

# 페이지 설정
st.set_page_config(
    page_title="PDF 문서 챗봇",
    page_icon="📚",
    layout="wide"
)

# 제목
st.title("📚 PDF 문서 QA 챗봇")
st.caption("PDF를 업로드하고 문서에 대해 질문하세요!")

# 세션 상태 초기화
if "messages" not in st.session_state:
    st.session_state.messages = []
if "vector_store" not in st.session_state:
    st.session_state.vector_store = None

# 사이드바
with st.sidebar:
    st.header("설정")

    # PDF 업로드
    uploaded_file = st.file_uploader(
        "PDF 파일 업로드",
        type="pdf",
        help="분석할 PDF 문서를 업로드하세요"
    )

    if uploaded_file:
        if st.button("📄 문서 처리", use_container_width=True):
            with st.spinner("문서를 처리하는 중..."):
                # 임시 파일 저장
                with tempfile.NamedTemporaryFile(delete=False, suffix=".pdf") as f:
                    f.write(uploaded_file.getvalue())
                    temp_path = f.name

                # PDF 로드
                loader = PyPDFLoader(temp_path)
                docs = loader.load()

                # 텍스트 분할
                splitter = RecursiveCharacterTextSplitter(
                    chunk_size=1000,
                    chunk_overlap=200
                )
                chunks = splitter.split_documents(docs)

                # 벡터 저장소 생성
                embeddings = OpenAIEmbeddings()
                st.session_state.vector_store = Chroma.from_documents(
                    chunks, embeddings
                )

                # 임시 파일 삭제
                os.unlink(temp_path)

                st.success(f"✅ {len(chunks)}개 청크 처리 완료!")

    st.divider()

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

# 메인 영역
if st.session_state.vector_store is None:
    st.info("👈 왼쪽 사이드바에서 PDF를 업로드하세요.")
else:
    # 이전 메시지 표시
    for msg in st.session_state.messages:
        with st.chat_message(msg["role"]):
            st.write(msg["content"])

    # 사용자 입력
    if prompt := st.chat_input("문서에 대해 질문하세요..."):
        # 사용자 메시지 표시
        st.session_state.messages.append({"role": "user", "content": prompt})
        with st.chat_message("user"):
            st.write(prompt)

        # 답변 생성
        with st.chat_message("assistant"):
            with st.spinner("생각 중..."):
                # 검색
                retriever = st.session_state.vector_store.as_retriever(
                    search_kwargs={"k": 3}
                )
                docs = retriever.invoke(prompt)
                context = "\n\n".join(d.page_content for d in docs)

                # 대화 기록 변환
                history = []
                for m in st.session_state.messages[:-1]:
                    if m["role"] == "user":
                        history.append(HumanMessage(content=m["content"]))
                    else:
                        history.append(AIMessage(content=m["content"]))

                # 프롬프트
                chat_prompt = ChatPromptTemplate.from_messages([
                    ("system", f"""문서 내용을 바탕으로 답변하세요.
문서에 없는 내용은 "문서에서 찾을 수 없습니다"라고 하세요.

문서 내용:
{context}"""),
                    MessagesPlaceholder(variable_name="history"),
                    ("human", "{question}")
                ])

                # 체인 실행
                llm = ChatOpenAI(model="gpt-3.5-turbo", temperature=0)
                chain = chat_prompt | llm | StrOutputParser()

                response = chain.invoke({
                    "history": history,
                    "question": prompt
                })

                st.write(response)

        # 어시스턴트 메시지 저장
        st.session_state.messages.append({"role": "assistant", "content": response})

다음 단계

이 프로젝트를 확장하고 싶다면:

  1. LangGraph: 더 복잡한 워크플로우 구현
  2. LangSmith: 디버깅과 모니터링
  3. 여러 문서 지원: 여러 PDF를 동시에 분석
  4. 배포: Streamlit Cloud나 AWS로 배포

시리즈를 마치며

10편에 걸쳐 LangChain의 핵심을 모두 다뤘어요:

  1. LangChain 소개
  2. 개발 환경 설정
  3. 프롬프트 템플릿
  4. LCEL 체인
  5. 다양한 LLM 연결
  6. 출력 파서
  7. 대화 메모리
  8. RAG 구현
  9. Agent와 Tools
  10. 실전 프로젝트 (지금!)

여기까지 따라오셨다면 이제 LangChain으로 웬만한 AI 애플리케이션은 만들 수 있어요. 직접 프로젝트를 만들어보면서 실력을 키워보세요!


다음 글 보기

← 이전 글
LangChain 독학 가이드 9 - Agent와 Tools
← 블로그 목록으로