Skip to content
Published on

RAG 챗봇 구축 실전 — LangChain + ChromaDB + OpenAI로 나만의 문서 QA 봇 만들기

Authors
  • Name
    Twitter
RAG 챗봇 — LangChain + ChromaDB + OpenAI

개요

LLM은 범용적인 지식을 가지고 있지만, 우리 회사의 내부 문서나 최신 정보에 대해서는 답변할 수 없다. **RAG(Retrieval-Augmented Generation)**는 이 한계를 극복하는 패턴으로, 질문과 관련된 문서를 먼저 검색한 뒤 그 컨텍스트를 LLM에게 전달하여 정확한 답변을 생성한다.

이 글에서는 LangChain + ChromaDB + OpenAI를 사용하여 PDF 문서 기반 QA 챗봇을 처음부터 끝까지 구축한다. 최종적으로 Streamlit으로 웹 UI까지 만들어 실제 사용 가능한 챗봇을 완성한다.

RAG 아키텍처

RAG의 전체 흐름은 두 단계로 나뉜다:

1단계: 인덱싱 (오프라인)

문서 → 청킹 → 임베딩 → 벡터 DB 저장

2단계: 질의 (온라인)

질문 → 임베딩 → 유사 문서 검색 → 프롬프트 구성 → LLM 답변

환경 설정

패키지 설치

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

환경 변수

# .env
OPENAI_API_KEY=sk-proj-your-api-key-here
# config.py
import os
from dotenv import load_dotenv

load_dotenv()

OPENAI_API_KEY = os.getenv("OPENAI_API_KEY")
CHROMA_PERSIST_DIR = "./chroma_db"
COLLECTION_NAME = "my_documents"
CHUNK_SIZE = 1000
CHUNK_OVERLAP = 200
EMBEDDING_MODEL = "text-embedding-3-small"
LLM_MODEL = "gpt-4o-mini"

Step 1: 문서 로딩

# document_loader.py
from langchain_community.document_loaders import (
    PyPDFLoader,
    DirectoryLoader,
    TextLoader,
)

def load_pdf(file_path: str):
    """단일 PDF 파일 로딩"""
    loader = PyPDFLoader(file_path)
    documents = loader.load()
    print(f"Loaded {len(documents)} pages from {file_path}")
    return documents

def load_directory(dir_path: str, glob: str = "**/*.pdf"):
    """디렉토리 내 모든 PDF 로딩"""
    loader = DirectoryLoader(
        dir_path,
        glob=glob,
        loader_cls=PyPDFLoader,
        show_progress=True,
    )
    documents = loader.load()
    print(f"Loaded {len(documents)} pages from {dir_path}")
    return documents

# 사용 예시
documents = load_directory("./docs")

Step 2: 텍스트 청킹 (Chunking)

청킹은 RAG 성능에 큰 영향을 미친다. 너무 작으면 컨텍스트가 부족하고, 너무 크면 검색 정확도가 떨어진다.

# chunker.py
from langchain.text_splitter import RecursiveCharacterTextSplitter

def chunk_documents(documents, chunk_size=1000, chunk_overlap=200):
    """문서를 청크로 분할"""
    text_splitter = RecursiveCharacterTextSplitter(
        chunk_size=chunk_size,
        chunk_overlap=chunk_overlap,
        length_function=len,
        separators=["\n\n", "\n", ".", " ", ""],
    )

    chunks = text_splitter.split_documents(documents)
    print(f"Split {len(documents)} documents into {len(chunks)} chunks")

    # 메타데이터에 청크 인덱스 추가
    for i, chunk in enumerate(chunks):
        chunk.metadata["chunk_index"] = i
        chunk.metadata["chunk_size"] = len(chunk.page_content)

    return chunks

chunks = chunk_documents(documents)

청킹 전략 비교

전략장점단점추천 상황
RecursiveCharacter문맥 보존 우수범용적일반 문서
TokenTextSplitter토큰 수 정확 제어문맥 단절 가능토큰 제한 엄격 시
MarkdownHeader구조 보존Markdown 전용기술 문서
SemanticChunker의미 기반 분할느림, 비용고품질 요구 시

Step 3: 벡터 저장소 (ChromaDB)

# vectorstore.py
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma
from config import CHROMA_PERSIST_DIR, COLLECTION_NAME, EMBEDDING_MODEL

def create_vectorstore(chunks):
    """청크를 임베딩하여 ChromaDB에 저장"""
    embeddings = OpenAIEmbeddings(model=EMBEDDING_MODEL)

    vectorstore = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory=CHROMA_PERSIST_DIR,
        collection_name=COLLECTION_NAME,
    )

    print(f"Stored {len(chunks)} chunks in ChromaDB")
    return vectorstore

def load_vectorstore():
    """기존 ChromaDB 로드"""
    embeddings = OpenAIEmbeddings(model=EMBEDDING_MODEL)

    vectorstore = Chroma(
        persist_directory=CHROMA_PERSIST_DIR,
        collection_name=COLLECTION_NAME,
        embedding_function=embeddings,
    )

    count = vectorstore._collection.count()
    print(f"Loaded ChromaDB with {count} documents")
    return vectorstore

임베딩 모델 선택 가이드

모델차원비용성능
text-embedding-3-small1536$0.02/1M tokens좋음
text-embedding-3-large3072$0.13/1M tokens매우 좋음
text-embedding-ada-0021536$0.10/1M tokens보통 (레거시)

비용 대비 성능으로 text-embedding-3-small을 추천한다.

Step 4: 검색기 (Retriever) 설정

# retriever.py

def get_retriever(vectorstore, search_type="mmr", k=4):
    """벡터스토어에서 검색기 생성"""

    if search_type == "mmr":
        # MMR: 관련성 + 다양성 균형
        retriever = vectorstore.as_retriever(
            search_type="mmr",
            search_kwargs={
                "k": k,
                "fetch_k": 20,      # 후보 문서 수
                "lambda_mult": 0.7,  # 1.0=관련성, 0.0=다양성
            },
        )
    elif search_type == "similarity_score":
        # 유사도 임계값 기반
        retriever = vectorstore.as_retriever(
            search_type="similarity_score_threshold",
            search_kwargs={
                "score_threshold": 0.7,
                "k": k,
            },
        )
    else:
        # 기본 유사도 검색
        retriever = vectorstore.as_retriever(
            search_kwargs={"k": k},
        )

    return retriever

MMR (Maximal Marginal Relevance)

MMR은 검색 결과에서 관련성은 높지만 서로 다른 문서를 선택한다. 비슷한 내용의 청크가 중복 반환되는 것을 방지하여 LLM에게 더 풍부한 컨텍스트를 제공한다.

Step 5: RAG 체인 구성

# rag_chain.py
from langchain_openai import ChatOpenAI
from langchain.prompts import ChatPromptTemplate
from langchain.schema.runnable import RunnablePassthrough
from langchain.schema.output_parser import StrOutputParser
from config import LLM_MODEL

SYSTEM_TEMPLATE = """당신은 문서 기반 QA 어시스턴트입니다.
주어진 컨텍스트만을 사용하여 질문에 답변하세요.

규칙:
1. 컨텍스트에 없는 정보는 "해당 정보를 문서에서 찾을 수 없습니다"라고 답변하세요.
2. 답변은 한국어로 작성하세요.
3. 가능하면 구체적인 수치나 인용을 포함하세요.
4. 답변의 근거가 되는 문서를 언급하세요.

컨텍스트:
{context}
"""

def format_docs(docs):
    """검색된 문서를 포맷팅"""
    formatted = []
    for i, doc in enumerate(docs, 1):
        source = doc.metadata.get("source", "unknown")
        page = doc.metadata.get("page", "?")
        formatted.append(
            f"[문서 {i}] (출처: {source}, 페이지: {page})\n{doc.page_content}"
        )
    return "\n\n---\n\n".join(formatted)

def create_rag_chain(retriever):
    """RAG 체인 생성"""
    llm = ChatOpenAI(
        model=LLM_MODEL,
        temperature=0,
        max_tokens=2000,
    )

    prompt = ChatPromptTemplate.from_messages([
        ("system", SYSTEM_TEMPLATE),
        ("human", "{question}"),
    ])

    chain = (
        {
            "context": retriever | format_docs,
            "question": RunnablePassthrough(),
        }
        | prompt
        | llm
        | StrOutputParser()
    )

    return chain

Step 6: 대화 히스토리 지원

# conversation.py
from langchain.memory import ConversationBufferWindowMemory
from langchain.chains import ConversationalRetrievalChain
from langchain_openai import ChatOpenAI
from config import LLM_MODEL

def create_conversational_chain(retriever):
    """대화 히스토리를 지원하는 RAG 체인"""
    llm = ChatOpenAI(model=LLM_MODEL, temperature=0)

    memory = ConversationBufferWindowMemory(
        memory_key="chat_history",
        return_messages=True,
        output_key="answer",
        k=5,  # 최근 5턴만 유지
    )

    chain = ConversationalRetrievalChain.from_llm(
        llm=llm,
        retriever=retriever,
        memory=memory,
        return_source_documents=True,
        verbose=False,
    )

    return chain

# 사용 예시
chain = create_conversational_chain(retriever)

result = chain.invoke({"question": "이 문서의 핵심 내용은 무엇인가요?"})
print(result["answer"])
print(f"\n참조 문서 {len(result['source_documents'])}건")

Step 7: Streamlit UI

# app.py
import streamlit as st
from document_loader import load_pdf
from chunker import chunk_documents
from vectorstore import create_vectorstore, load_vectorstore
from retriever import get_retriever
from rag_chain import create_rag_chain

st.set_page_config(page_title="📚 문서 QA 챗봇", layout="wide")
st.title("📚 RAG 문서 QA 챗봇")

# 사이드바: 문서 업로드
with st.sidebar:
    st.header("📁 문서 업로드")
    uploaded_files = st.file_uploader(
        "PDF 파일을 업로드하세요",
        type=["pdf"],
        accept_multiple_files=True,
    )

    if uploaded_files and st.button("🔄 문서 처리"):
        with st.spinner("문서 처리 중..."):
            all_chunks = []
            for file in uploaded_files:
                # 임시 파일 저장
                temp_path = f"/tmp/{file.name}"
                with open(temp_path, "wb") as f:
                    f.write(file.getbuffer())

                docs = load_pdf(temp_path)
                chunks = chunk_documents(docs)
                all_chunks.extend(chunks)

            vectorstore = create_vectorstore(all_chunks)
            st.session_state["vectorstore"] = vectorstore
            st.success(f"✅ {len(all_chunks)}개 청크 처리 완료!")

# 메인: 채팅 인터페이스
if "messages" not in st.session_state:
    st.session_state.messages = []

# 이전 메시지 표시
for message in st.session_state.messages:
    with st.chat_message(message["role"]):
        st.markdown(message["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"):
        if "vectorstore" not in st.session_state:
            try:
                st.session_state["vectorstore"] = load_vectorstore()
            except Exception:
                st.error("먼저 문서를 업로드해주세요.")
                st.stop()

        vectorstore = st.session_state["vectorstore"]
        retriever = get_retriever(vectorstore)
        chain = create_rag_chain(retriever)

        with st.spinner("답변 생성 중..."):
            response = chain.invoke(prompt)

        st.markdown(response)
        st.session_state.messages.append(
            {"role": "assistant", "content": response}
        )
# 실행
streamlit run app.py --server.port 8501

성능 최적화 팁

1. 하이브리드 검색 (키워드 + 벡터)

from langchain.retrievers import EnsembleRetriever
from langchain_community.retrievers import BM25Retriever

def create_hybrid_retriever(chunks, vectorstore, k=4):
    """BM25 + 벡터 검색 앙상블"""
    bm25_retriever = BM25Retriever.from_documents(chunks)
    bm25_retriever.k = k

    vector_retriever = vectorstore.as_retriever(search_kwargs={"k": k})

    ensemble = EnsembleRetriever(
        retrievers=[bm25_retriever, vector_retriever],
        weights=[0.4, 0.6],  # 벡터 검색에 가중치
    )

    return ensemble

2. Reranker로 검색 정확도 향상

from langchain.retrievers import ContextualCompressionRetriever
from langchain_community.document_compressors import CohereRerank

def create_reranked_retriever(vectorstore, k=4, top_n=3):
    """Cohere Reranker로 검색 결과 재정렬"""
    base_retriever = vectorstore.as_retriever(search_kwargs={"k": k * 3})

    compressor = CohereRerank(
        model="rerank-v3.5",
        top_n=top_n,
    )

    return ContextualCompressionRetriever(
        base_compressor=compressor,
        base_retriever=base_retriever,
    )

3. 청크 메타데이터 강화

# 청크에 요약 메타데이터 추가
from langchain_openai import ChatOpenAI

llm = ChatOpenAI(model="gpt-4o-mini", temperature=0)

for chunk in chunks:
    summary = llm.invoke(
        f"다음 텍스트를 한 문장으로 요약하세요:\n{chunk.page_content}"
    ).content
    chunk.metadata["summary"] = summary

마무리

RAG 챗봇의 핵심은 검색 품질이다. LLM이 아무리 뛰어나도 관련 없는 문서가 전달되면 좋은 답변을 생성할 수 없다. 성능 개선 우선순위를 정리하면:

  1. 청킹 전략 최적화 — 문서 특성에 맞는 청크 크기와 분할 방법
  2. 임베딩 모델 선택 — 도메인에 적합한 임베딩 모델
  3. 하이브리드 검색 — BM25 + 벡터 검색 앙상블
  4. Reranker 적용 — 검색 결과 재정렬로 정밀도 향상
  5. 프롬프트 엔지니어링 — 출력 형식과 규칙 명시

이 모든 것을 LangChain이 추상화해주기 때문에, 각 컴포넌트를 쉽게 교체하며 실험할 수 있다는 것이 큰 장점이다.

퀴즈

Q1: RAG에서 Retrieval과 Generation의 역할은? Retrieval은 질문과 관련된 문서를 벡터 유사도 검색으로 찾아오는 단계이고, Generation은 검색된 문서를 컨텍스트로 LLM에 전달하여 답변을 생성하는 단계이다.

Q2: 청킹에서 chunk_overlap을 설정하는 이유는? 청크 경계에서 문맥이 단절되는 것을 방지하기 위해서다. 인접 청크 간에 겹치는 부분을 두면 문장이 중간에 잘려도 다음 청크에서 완전한 문맥을 유지할 수 있다.

Q3: ChromaDB의 persist_directory 설정의 의미는? 벡터 데이터를 디스크에 영구 저장하는 경로를 지정한다. 이를 설정하면 프로세스 재시작 시에도 임베딩을 다시 계산할 필요 없이 기존 벡터 DB를 로드할 수 있다.

Q4: MMR(Maximal Marginal Relevance) 검색의 장점은? 관련성이 높으면서도 서로 다양한 문서를 선택한다. 유사한 내용의 청크가 중복 반환되는 것을 방지하여 LLM에게 더 넓은 범위의 컨텍스트를 제공할 수 있다.

Q5: text-embedding-3-small vs text-embedding-3-large의 선택 기준은? small은 비용이 6.5배 저렴하면서 대부분의 용도에 충분한 성능을 제공한다. 의료, 법률 등 도메인 특화 고정밀이 필요한 경우에만 large를 고려한다.

Q6: 하이브리드 검색(BM25 + 벡터)이 순수 벡터 검색보다 나은 이유는? 벡터 검색은 의미적 유사성에 강하지만 정확한 키워드 매칭에 약하다. BM25는 키워드 매칭에 강하므로, 두 방식을 앙상블하면 의미적 유사성과 키워드 정확도를 모두 확보할 수 있다.

Q7: ConversationBufferWindowMemory에서 k=5의 의미는? 최근 5턴의 대화만 메모리에 유지한다는 뜻이다. 전체 대화를 유지하면 토큰이 초과될 수 있으므로, 최근 대화만 유지하여 컨텍스트 윈도우를 효율적으로 사용한다.

Q8: Reranker를 사용할 때 base retriever의 k를 크게 설정하는 이유는? Reranker가 더 넓은 후보군에서 가장 관련성 높은 문서를 재정렬하도록 하기 위해서다. 예를 들어 k=12로 후보를 가져온 뒤 top_n=3으로 최종 선택하면, 초기 검색에서 놓친 관련 문서를 재정렬 과정에서 살릴 수 있다.