Retrieval-Augmented Generation(RAG)은 LLM의 환각(hallucination) 문제를 해결하는 가장 실용적인 방법이다. 사내 문서, 기술 위키, 고객 FAQ 등 자체 데이터를 기반으로 정확한 답변을 생성하는 RAG 시스템을 LangChain과 벡터 데이터베이스를 활용하여 구축하는 방법을 단계별로 설명한다.

RAG 아키텍처 개요

RAG 시스템은 크게 3단계로 구성된다.

  1. 문서 수집 및 청킹(Chunking): 원본 문서를 적절한 크기로 분할
  2. 임베딩 및 인덱싱: 텍스트를 벡터로 변환하여 벡터DB에 저장
  3. 검색 및 생성: 쿼리와 유사한 문서를 검색하고 LLM에 컨텍스트로 전달

환경 설정

# 필요한 패키지 설치
pip install langchain langchain-community langchain-anthropic 
pip install chromadb sentence-transformers
pip install unstructured pypdf docx2txt

# 환경 변수 설정
export ANTHROPIC_API_KEY="your-api-key"

1단계: 문서 로딩 및 청킹

다양한 형식의 문서를 로드하고 적절한 크기로 분할하는 것이 RAG 성능의 핵심이다.

from langchain_community.document_loaders import (
    PyPDFLoader,
    TextLoader,
    Docx2txtLoader,
    DirectoryLoader
)
from langchain.text_splitter import RecursiveCharacterTextSplitter
import os

# 다양한 문서 형식 로딩
def load_documents(docs_dir: str):
    """디렉토리에서 PDF, TXT, DOCX 문서를 모두 로드한다."""
    loaders = []
    
    # PDF 파일
    pdf_loader = DirectoryLoader(
        docs_dir, glob="**/*.pdf", loader_cls=PyPDFLoader
    )
    loaders.append(pdf_loader)
    
    # 텍스트 파일
    txt_loader = DirectoryLoader(
        docs_dir, glob="**/*.txt", loader_cls=TextLoader
    )
    loaders.append(txt_loader)
    
    # Word 문서
    docx_loader = DirectoryLoader(
        docs_dir, glob="**/*.docx", loader_cls=Docx2txtLoader
    )
    loaders.append(docx_loader)
    
    all_docs = []
    for loader in loaders:
        try:
            docs = loader.load()
            all_docs.extend(docs)
        except Exception as e:
            print(f"로딩 오류: {e}")
    
    return all_docs

# 청킹 설정 - 성능에 가장 큰 영향을 미치는 부분
def chunk_documents(documents, chunk_size=1000, chunk_overlap=200):
    """문서를 재귀적으로 분할한다.
    
    chunk_size: 각 청크의 최대 문자 수
    chunk_overlap: 청크 간 겹치는 문자 수 (문맥 유지)
    """
    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"원본 문서 {len(documents)}개 -> {len(chunks)}개 청크로 분할")
    return chunks

# 실행
docs = load_documents("./company_docs")
chunks = chunk_documents(docs)

2단계: 벡터 임베딩 및 저장

ChromaDB를 사용하여 로컬에서 벡터 검색을 수행한다. 프로덕션에서는 Pinecone, Weaviate, Qdrant 등의 관리형 벡터DB를 고려할 수 있다.

from langchain_community.embeddings import HuggingFaceEmbeddings
from langchain_community.vectorstores import Chroma

# 임베딩 모델 설정
# 한국어 문서에는 multilingual 모델 사용
embeddings = HuggingFaceEmbeddings(
    model_name="intfloat/multilingual-e5-large",
    model_kwargs={"device": "cpu"},
    encode_kwargs={"normalize_embeddings": True}
)

# ChromaDB에 저장
PERSIST_DIR = "./chroma_db"

def create_vectorstore(chunks, persist_directory=PERSIST_DIR):
    """청크를 벡터로 변환하여 ChromaDB에 저장한다."""
    vectorstore = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory=persist_directory,
        collection_name="company_docs"
    )
    print(f"{len(chunks)}개 청크를 벡터DB에 저장 완료")
    return vectorstore

# 기존 DB 로드
def load_vectorstore(persist_directory=PERSIST_DIR):
    """저장된 벡터DB를 로드한다."""
    return Chroma(
        persist_directory=persist_directory,
        embedding_function=embeddings,
        collection_name="company_docs"
    )

vectorstore = create_vectorstore(chunks)

3단계: RAG 체인 구성

from langchain_anthropic import ChatAnthropic
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate

# LLM 설정
llm = ChatAnthropic(
    model="claude-sonnet-4-20250514",
    temperature=0,
    max_tokens=4096
)

# 커스텀 프롬프트
qa_prompt = PromptTemplate(
    input_variables=["context", "question"],
    template="""다음 문서 내용을 기반으로 질문에 답변하세요.
문서에 없는 내용은 "해당 정보를 찾을 수 없습니다"라고 답변하세요.

참고 문서:
{context}

질문: {question}

답변:"""
)

# 검색 설정
retriever = vectorstore.as_retriever(
    search_type="mmr",          # Maximum Marginal Relevance
    search_kwargs={
        "k": 5,                  # 반환할 문서 수
        "fetch_k": 20,           # MMR 후보 문서 수
        "lambda_mult": 0.7       # 다양성 vs 관련성 (0~1)
    }
)

# RAG 체인 생성
qa_chain = RetrievalQA.from_chain_type(
    llm=llm,
    chain_type="stuff",
    retriever=retriever,
    chain_type_kwargs={"prompt": qa_prompt},
    return_source_documents=True
)

# 질의
result = qa_chain.invoke({"query": "우리 회사의 휴가 정책은?"})
print("답변:", result["result"])
print("\n참조 문서:")
for doc in result["source_documents"]:
    print(f"  - {doc.metadata.get("source", "unknown")}")

4단계: 성능 최적화

하이브리드 검색

벡터 검색과 키워드 검색을 결합하면 정확도가 크게 향상된다.

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

# BM25 키워드 검색기
bm25_retriever = BM25Retriever.from_documents(chunks)
bm25_retriever.k = 5

# 벡터 검색기
vector_retriever = vectorstore.as_retriever(search_kwargs={"k": 5})

# 하이브리드 (앙상블) 검색기
ensemble_retriever = EnsembleRetriever(
    retrievers=[bm25_retriever, vector_retriever],
    weights=[0.4, 0.6]  # 키워드 40% + 벡터 60%
)

Reranking

# Cohere Reranker로 검색 결과 재정렬
from langchain.retrievers import ContextualCompressionRetriever
from langchain_community.document_compressors import CohereRerank

compressor = CohereRerank(model="rerank-v3.5", top_n=3)
compression_retriever = ContextualCompressionRetriever(
    base_compressor=compressor,
    base_retriever=ensemble_retriever
)

프로덕션 고려사항

  • 청크 크기 실험: 문서 유형에 따라 500~2000자 사이에서 최적값을 찾을 것
  • 메타데이터 활용: 문서 출처, 날짜, 카테고리 등을 메타데이터로 저장하여 필터링에 활용
  • 주기적 재인덱싱: 문서 변경 시 자동으로 벡터DB를 업데이트하는 파이프라인 구축
  • 평가 체계: RAGAS 프레임워크로 faithfulness, relevancy, precision 등을 정량 평가

RAG는 LLM을 실무에 적용하는 가장 현실적인 방법이다. 위 코드를 기반으로 자사 문서에 맞게 청킹 전략과 검색 파라미터를 조정하면 높은 정확도의 사내 AI 검색 시스템을 구축할 수 있다.