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 비서를 만들 수 있었습니다.