반응형

 

들어가며

Retrieval-Augmented Generation(RAG)은 LLM의 한계를 극복하는 가장 효과적인 패턴 중 하나입니다. LLM이 모르는 최신 정보, 사내 문서, 도메인 특화 지식을 동적으로 제공하여 환각(hallucination)을 줄이고 정확도를 높입니다.

이 글은 LLM-RAG-Study 레포지토리를 바탕으로, RAG의 기초 개념을 정리합니다.

목차

  1. RAG란 무엇인가?
  2. 환경 설정과 빠른 시작
  3. Basic RAG 파이프라인

RAG란 무엇인가?

핵심 개념

RAG는 다음 두 단계로 구성됩니다:

  1. Retrieval (검색): 질의와 관련된 문서를 벡터 DB에서 찾기
  2. Generation (생성): 검색된 문서를 컨텍스트로 LLM에 제공하여 답변 생성

왜 RAG가 필요한가?

LLM 고유의 한계:

문제 LLM만 사용 시 RAG 적용 시
시간적 한계 학습 데이터 시점 이후 정보 모름 (예: 2024년 이후 이벤트) 최신 문서 검색으로 해결
환각 (Hallucination) 모르는 내용을 그럴듯하게 지어냄 실제 문서 기반 응답으로 정확도 향상
도메인 지식 부족 사내 문서, 전문 분야 지식 제한적 자체 지식 베이스 활용
출처 추적 불가 답변의 근거 확인 어려움 검색된 문서 출처 제공 가능

RAG vs Fine-tuning

기준 RAG Fine-tuning
지식 업데이트 실시간 (문서 추가 시) 재학습 필요 (시간·비용 소모)
초기 구축 비용 낮음 높음 (GPU, 데이터 준비)
추론 지연 중간 (검색 오버헤드) 낮음
도메인 적응성 매우 높음 높음 (특정 태스크)
투명성 높음 (출처 확인 가능) 낮음 (블랙박스)

권장 전략: 대부분 RAG로 시작 → 특수 태스크만 Fine-tuning 고려

환경 설정과 빠른 시작

사전 준비

# 레포지토리 클론
git clone https://github.com/Chenjae-kr/LLM-RAG-Study.git
cd LLM-RAG-Study/01-basic-rag

# 가상환경 생성 (Python 3.8+)
python -m venv .venv

# 활성화 (macOS/Linux)
source .venv/bin/activate

# 활성화 (Windows)
.venv\Scripts\activate

# 의존성 설치
pip install -r requirements.txt

최소 요구사항

  • Python: 3.8 이상
  • 메모리: 최소 4GB (임베딩 모델 로딩)
  • 디스크: 최소 2GB (모델 캐시)
  • GPU: 선택사항 (CPU도 가능하지만 느림)

30초 데모

# 1. 샘플 문서 임베딩 (벡터 저장소 생성)
python ingest.py

# 출력 예시:
# Processing documents...
# Embedding 50 chunks...
# Vector store saved to ./vector_store

# 2. 질의 실행
python query_rag.py "인공지능이란 무엇인가요?"

# 출력:
# [Retrieved Context]
# - Document 1: "인공지능(AI)은 컴퓨터 시스템이 인간의 지능을 모방..."
#
# [Generated Answer]
# 인공지능은 기계가 인간처럼 학습하고 추론하는 기술입니다...

Basic RAG 파이프라인

1. 문서 수집 및 전처리 (Ingestion)

ingest.py 핵심 로직:

from pathlib import Path
from sentence_transformers import SentenceTransformer
import numpy as np
from utils import embed_texts, save_vector_store

# 1. 문서 로드
def load_documents(data_dir: str) -> list[str]:
    """텍스트 파일 읽기"""
    documents = []
    for file_path in Path(data_dir).glob("*.txt"):
        with open(file_path, 'r', encoding='utf-8') as f:
            documents.append(f.read())
    return documents

# 2. 청킹 (Chunking)
def chunk_text(text: str, chunk_size: int = 512, overlap: int = 50) -> list[str]:
    """텍스트를 겹치는 청크로 분할"""
    words = text.split()
    chunks = []
    for i in range(0, len(words), chunk_size - overlap):
        chunk = ' '.join(words[i:i + chunk_size])
        chunks.append(chunk)
    return chunks

# 3. 임베딩 생성
def main():
    documents = load_documents("./data")

    # 모든 문서를 청크로 분할
    all_chunks = []
    for doc in documents:
        all_chunks.extend(chunk_text(doc))

    print(f"Total chunks: {len(all_chunks)}")

    # 임베딩 모델 로드 (384차원)
    embeddings = embed_texts(all_chunks, model_name="all-MiniLM-L6-v2")

    # 벡터 저장소에 저장
    save_vector_store(embeddings, all_chunks, "./vector_store")
    print("✓ Vector store created successfully")

if __name__ == "__main__":
    main()

청킹 전략 비교:

방법 장점 단점 권장 사용처
고정 크기 구현 간단, 빠름 문맥 경계 무시 구조화된 문서
문장 단위 문맥 보존 크기 불균일 자연어 텍스트
의미 기반 의미적 일관성 높음 느림, 복잡 고품질 요구 시

권장 설정:

chunk_size = 512      # 토큰 수 (약 300-400단어)
overlap = 50          # 10% 중첩으로 문맥 연결

2. 벡터 검색 (Retrieval)

utils.py의 검색 함수:

import numpy as np
from sentence_transformers import SentenceTransformer

def search(query: str, vector_store_path: str, top_k: int = 3):
    """코사인 유사도 기반 검색"""
    # 1. 벡터 저장소 로드
    embeddings, chunks = load_vector_store(vector_store_path)

    # 2. 질의 임베딩
    model = SentenceTransformer("all-MiniLM-L6-v2")
    query_embedding = model.encode([query])[0]

    # 3. 코사인 유사도 계산
    similarities = np.dot(embeddings, query_embedding) / (
        np.linalg.norm(embeddings, axis=1) * np.linalg.norm(query_embedding)
    )

    # 4. 상위 K개 반환
    top_indices = np.argsort(similarities)[-top_k:][::-1]
    results = [(chunks[i], similarities[i]) for i in top_indices]

    return results

검색 품질 개선 팁:

  1. top_k 조정: 3-5개 권장 (너무 많으면 노이즈 증가)
  2. 유사도 임계값: 0.5 이하 결과는 필터링 고려
  3. 재랭킹: Cross-Encoder로 2차 정렬 (후술)

3. 프롬프트 구성 및 생성 (Generation)

query_rag.py:

from transformers import AutoTokenizer, AutoModelForSeq2SeqLM
from utils import search

def generate_answer(query: str, context: list[str], model_name: str = "google/flan-t5-small"):
    """컨텍스트 기반 답변 생성"""
    # 1. 검색 결과를 프롬프트로 구성
    context_text = "\n\n".join([f"[{i+1}] {text}" for i, text in enumerate(context)])

    prompt = f"""다음 문서를 참고하여 질문에 답하세요.

문서:
{context_text}

질문: {query}

답변:"""

    # 2. 모델 로드 및 생성
    tokenizer = AutoTokenizer.from_pretrained(model_name)
    model = AutoModelForSeq2SeqLM.from_pretrained(model_name)

    inputs = tokenizer(prompt, return_tensors="pt", max_length=1024, truncation=True)
    outputs = model.generate(**inputs, max_new_tokens=256)

    answer = tokenizer.decode(outputs[0], skip_special_tokens=True)
    return answer

# 메인 실행
if __name__ == "__main__":
    query = "인공지능의 주요 응용 분야는?"

    # 검색
    results = search(query, "./vector_store", top_k=3)
    contexts = [text for text, score in results]

    # 출력: 검색된 문서
    print("=== Retrieved Documents ===")
    for i, (text, score) in enumerate(results):
        print(f"\n[{i+1}] (Score: {score:.3f})")
        print(text[:200] + "...")

    # 생성
    answer = generate_answer(query, contexts)
    print("\n=== Generated Answer ===")
    print(answer)

프롬프트 엔지니어링 팁:

# ❌ 나쁜 프롬프트
prompt = f"{context}\n\n{query}"

# ✅ 좋은 프롬프트
prompt = f"""당신은 정확한 답변을 제공하는 전문가입니다.
주어진 문서에만 기반하여 답변하세요. 확실하지 않으면 "문서에 정보가 없습니다"라고 답하세요.

문서:
{context}

질문: {query}

답변 (문서 기반):"""
반응형

+ Recent posts