RAG란 무엇인가?

**RAG(Retrieval-Augmented Generation)**는 AI가 답변을 생성하기 전에, 관련 문서를 먼저 검색하여 참고 자료로 활용하는 기술입니다. 일반적인 LLM은 학습 데이터에만 의존하지만, RAG를 적용하면 최신 정보나 개인 문서를 기반으로 정확한 답변을 생성할 수 있습니다.

RAG의 동작 원리

사용자 질문 → 문서 검색(Retrieval) → 관련 문서 추출 → LLM에 전달 → 답변 생성(Generation)

왜 로컬 RAG인가?

항목클라우드 LLM로컬 LLM (Ollama)
비용API 호출당 과금무료
프라이버시데이터 외부 전송로컬에서만 처리
속도네트워크 의존GPU 성능에 의존
모델 선택제공사 모델만오픈소스 모델 자유 선택

환경 설정

1. Ollama 설치

# macOS / Linux
curl -fsSL https://ollama.com/install.sh | sh

# 모델 다운로드
ollama pull llama3
ollama pull nomic-embed-text

2. Python 패키지 설치

pip install langchain langchain-community chromadb
pip install langchain-ollama
pip install pypdf docx2txt

기본 구조 설계

RAG 시스템의 핵심 구성 요소는 세 가지입니다.

# rag_system.py
from langchain_ollama import OllamaLLM, OllamaEmbeddings
from langchain_community.vectorstores import Chroma
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.chains import RetrievalQA

Step 1: 문서 로딩

다양한 형식의 문서를 읽어 텍스트로 변환합니다.

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

def load_documents(directory_path):
    """디렉토리 내 모든 문서를 로드합니다."""
    documents = []

    for filename in os.listdir(directory_path):
        filepath = os.path.join(directory_path, filename)

        if filename.endswith('.pdf'):
            loader = PyPDFLoader(filepath)
        elif filename.endswith('.txt'):
            loader = TextLoader(filepath, encoding='utf-8')
        elif filename.endswith('.docx'):
            loader = Docx2txtLoader(filepath)
        else:
            continue

        documents.extend(loader.load())
        print(f"로드 완료: {filename}")

    print(f"총 {len(documents)}개 문서 로드됨")
    return documents

Step 2: 텍스트 분할 (Chunking)

문서를 적절한 크기의 조각으로 나눕니다. 청크 크기는 RAG 성능에 큰 영향을 미칩니다.

def split_documents(documents):
    """문서를 검색에 적합한 크기로 분할합니다."""
    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)}개 청크 생성됨")
    return chunks

청크 크기 선택 가이드

청크 크기장점단점적합한 용도
200-500정밀한 검색문맥 부족FAQ, 사전
500-1000균형 잡힌 성능-일반 문서
1000-2000풍부한 문맥검색 정밀도 저하논문, 보고서

Step 3: 벡터 데이터베이스 생성

텍스트를 벡터(숫자 배열)로 변환하여 유사도 검색이 가능한 데이터베이스에 저장합니다.

def create_vector_store(chunks, persist_directory="./chroma_db"):
    """청크를 벡터로 변환하여 ChromaDB에 저장합니다."""
    embeddings = OllamaEmbeddings(model="nomic-embed-text")

    vector_store = Chroma.from_documents(
        documents=chunks,
        embedding=embeddings,
        persist_directory=persist_directory,
    )

    print(f"벡터 DB 생성 완료: {persist_directory}")
    return vector_store

def load_vector_store(persist_directory="./chroma_db"):
    """기존 벡터 DB를 로드합니다."""
    embeddings = OllamaEmbeddings(model="nomic-embed-text")

    vector_store = Chroma(
        persist_directory=persist_directory,
        embedding_function=embeddings,
    )

    return vector_store

Step 4: RAG 체인 구성

검색과 생성을 연결하는 체인을 만듭니다.

from langchain.prompts import PromptTemplate

def create_rag_chain(vector_store):
    """RAG 체인을 생성합니다."""
    llm = OllamaLLM(model="llama3", temperature=0.3)

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

    # 프롬프트 템플릿
    prompt_template = PromptTemplate(
        template="""다음 참고 자료를 바탕으로 질문에 답변하세요.
참고 자료에 답이 없으면 "관련 정보를 찾을 수 없습니다"라고 답하세요.

참고 자료:
{context}

질문: {question}

답변:""",
        input_variables=["context", "question"],
    )

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

    return qa_chain

Step 5: 전체 통합

def main():
    docs_path = "./my_documents"

    # 1. 문서 로딩 및 처리
    documents = load_documents(docs_path)
    chunks = split_documents(documents)

    # 2. 벡터 DB 생성
    vector_store = create_vector_store(chunks)

    # 3. RAG 체인 생성
    qa_chain = create_rag_chain(vector_store)

    # 4. 대화 루프
    print("\nRAG 시스템이 준비되었습니다. 질문을 입력하세요.")
    print("종료하려면 'quit'을 입력하세요.\n")

    while True:
        question = input("질문: ")
        if question.lower() == 'quit':
            break

        result = qa_chain.invoke({"query": question})

        print(f"\n답변: {result['result']}")

        # 참고 문서 출력
        print("\n참고 문서:")
        for doc in result['source_documents']:
            source = doc.metadata.get('source', '알 수 없음')
            print(f"  - {source}")
        print()

if __name__ == "__main__":
    main()

성능 최적화 팁

1. 임베딩 모델 선택

# 한국어 문서가 많은 경우
embeddings = OllamaEmbeddings(model="bge-m3")

# 영어 중심 문서
embeddings = OllamaEmbeddings(model="nomic-embed-text")

2. 검색 방식 비교

# 유사도 검색 (기본)
retriever = vector_store.as_retriever(search_type="similarity")

# MMR (Maximal Marginal Relevance) - 다양성 확보
retriever = vector_store.as_retriever(
    search_type="mmr",
    search_kwargs={"k": 4, "fetch_k": 10},
)

3. 대화 기록 유지

from langchain.memory import ConversationBufferMemory

memory = ConversationBufferMemory(
    memory_key="chat_history",
    return_messages=True,
)

결론

로컬 RAG 시스템을 구축하면 프라이버시를 지키면서도 개인 문서를 AI와 대화할 수 있습니다. LangChain은 각 컴포넌트를 모듈화하여 유연한 구성이 가능하고, Ollama는 다양한 오픈소스 모델을 손쉽게 교체할 수 있게 해줍니다.

실제로 이 기술 스택을 활용하여 Odin 프로젝트를 개발했으며, 내 컴퓨터의 모든 문서를 이해하는 AI 비서를 만들 수 있었습니다.