실용적인 RAG, 파트 1: 실제로 작동하는 가장 단순한 RAG
요약
RAG(검색 증강 생성)의 기본 원리를 이해하기 위해 가장 단순한 형태의 파이프라인을 직접 구축하는 튜토리얼입니다. 로드, 청킹, 임베딩, 검색, 생성의 5단계를 Python 코드로 구현하며 RAG의 핵심 메커니즘을 설명합니다.
핵심 포인트
- RAG의 5단계: 로드, 청킹, 임베딩, 검색, 생성 과정 이해
- 프레임워크 없이 순수 Python과 라이브러리로 구현하는 실습
- 미세 조정 없이 외부 데이터를 활용하는 RAG의 장점 파악
- 단순한(Naive) RAG 파이프라인의 구조와 한계점 학습
_Suman 작성 — **실용적인 RAG** 시리즈의 파트 1입니다. 모든 코드는 실행 가능한 노트북에 있습니다: https://www.kaggle.com/code/sumannath88/ep01-simple-rag
모두가 RAG에 대해 이야기합니다. 하지만 그보다 훨씬 적은 수의 사람들이 가장 **단순한** 버전을 처음부터 끝까지 직접 구축해 보고, 정확히 어느 지점에서 실패하는지 살펴보았습니다.
이 시리즈가 바로 그 일을 합니다. 우리는 실제로 작동하는 가장 순진한 (naive) RAG 파이프라인 (pipeline)으로 시작하여, 이를 완전히 이해한 다음 — 한 번에 하나의 구체적인 문제를 해결하며 — 개선해 나갈 것입니다. 움직이는 부품들을 숨기는 프레임워크 (framework)는 없습니다. 그저 읽을 수 있는 Python뿐입니다.
이 포스트가 끝날 때쯤 여러분은 질문에 올바르게 답하는 약 40줄 정도의 작동하는 파이프라인을 갖게 될 것이며, 왜 그 성공이 오해의 소지가 있는지 정확히 이해하게 될 것입니다. 그러한 숨겨진 약점들이 이 시리즈의 나머지 부분을 위한 로드맵이 됩니다.
RAG란 실제로 무엇인가
RAG — 검색 증강 생성 (Retrieval-Augmented Generation) — 은 하나의 아이디어입니다: 모델에게 질문을 하기 전에, 관련 텍스트를 찾아 프롬프트 (prompt)에 붙여넣는 것. 그것이 전부입니다. "검색 (retrieval)"은 텍스트를 찾는 것이고, "생성 (generation)"은 LLM이 그 텍스트를 앞에 두고 답변하는 것입니다.
왜 굳이 이렇게 할까요? 왜냐하면 미세 조정 (fine-tuning) 없이도 모델이 학습 데이터에 없었던 **여러분의** 데이터 — 즉 문서 — 에 대한 질문에 답할 수 있게 해주며, 모델의 기억 대신 실제 출처에 답변의 근거를 두기 때문입니다.
순진한 파이프라인은 다섯 단계로 구성됩니다:
- 로드 (Load): 문서를 불러옵니다.
- 청킹 (Chunk): 문서들을 조각으로 나눕니다.
- 임베딩 (Embed): 각 조각을 벡터 (vector)로 변환합니다.
- 검색 (Retrieve): 질문과 가장 유사한 조각들을 찾습니다.
- 생성 (Generate): 해당 조각들을 컨텍스트 (context)로 사용하여 답변을 생성합니다.
각 단계를 구축해 봅시다.
설정
우리는 검색 비용이 무료이고 API 키가 필요 없는 로컬 임베딩 (sentence-transformers 사용)을 사용할 것이며, 생성에는 여러 모델에 대해 OpenAI 호환 API를 제공하는 OpenRouter를 사용할 것입니다.
pip install sentence-transformers openai numpy
import os
import numpy as np
...
이 노트북은 Kaggle, Colab 또는 로컬 환경에서 실행됩니다. 임베딩 (Embeddings)은 로컬에서 계산되므로, 생성 (generation) 단계에서만 네트워크를 사용합니다.
1 & 2. 로드 및 청킹 (Load and chunk)
모든 것을 독립적으로 유지하기 위해, 우리의 "코퍼스 (corpus)"는 행성에 관한 몇 가지 짧은 구절들로 구성됩니다. 그리고 우리의 청킹 (chunking) 전략은 상상할 수 있는 가장 단순한 방식인 문서당 하나의 청크 (one chunk per document) 입니다.
DOCUMENTS = [
"Mercury is the smallest planet ... no moons ...",
"Venus is the hottest planet ... 465 degrees Celsius.",
...
구절들이 이미 짧기 때문에 이 방식은 괜찮습니다. 하지만 이 주의 사항을 꼭 기억하세요. 실제 데이터에서는 이 부분이 가장 먼저 무너지는 지점입니다.
3. 임베딩 (Embed)
임베딩 (embedding)은 텍스트를 숫자 벡터 (vector)로 변환하여, 유사한 의미를 가진 것들이 공간상에서 서로 가까이 위치하도록 합니다. 우리는 사전에 청크당 하나의 벡터를 한 번씩 계산합니다.
from sentence_transformers import SentenceTransformer
embedder = SentenceTransformer(EMBED_MODEL)
...
우리는 코사인 유사도 (cosine similarity) — "두 의미가 얼마나 가까운가"를 측정하는 표준 척도 — 가 단순한 내적 (dot product)으로 수렴하도록 벡터를 정규화 (normalize) 합니다.
4. 검색 (Retrieve)
질문에 답하기 위해, 질문을 동일한 방식으로 임베딩하고, 모든 청크와 비교하여 점수를 매긴 뒤, 상위 _k_개를 유지합니다.
def retrieve(question, k=TOP_K):
q_emb = embedder.encode([question], normalize_embeddings=True)[0]
scores = chunk_embeddings @ q_emb # 코사인 유사도 (cosine similarity)
...
"어느 행성이 가장 많은 위성을 가지고 있나요?"라고 물으면 목성 (Jupiter) 청크가 가장 상위에 나타납니다. 아직 LLM은 관여하지 않았습니다. 이것은 순수한 벡터 검색 (vector search)입니다.
5. 생성 (Generate)
이제 검색된 청크들을 프롬프트 (prompt)에 결합하여 모델에게 요청합니다. 이때 모델에게 오직 제공된 컨텍스트 (context) 내에서만 답변하도록 지시합니다. 이 지시 사항이 RAG 원칙의 핵심입니다. 모델이 추측하는 대신 근거에 기반하여 답변하도록 (grounded) 유지해 주는 역할을 합니다.
from openai import OpenAI
client = OpenAI(base_url="https://openrouter.ai/api/v1",
...
answer("Which planet has the most moons?")[0]
# -> "Jupiter, with at least 95 known moons."
이것이 완전한 RAG 시스템입니다. 로드 (Load) → 청킹 (chunk) → 임베딩 (embed) → 검색 (retrieve) → 생성 (generate).
작동은 합니다 — 그리고 그것이 함정입니다
반전이 있습니다. 이 파이프라인은 까다로워 보이는 질문들도 아주 잘 처리합니다.
코퍼스 (corpus) 외의 질문:
answer("How far is Pluto from the Sun?")[0]
# -> "모르겠습니다."
명왕성(Pluto)은 우리의 문서에 없으며, 모델은 답변을 지어내지 않고 올바르게 거절합니다. 그라운딩 (Grounding)이 제 역할을 하고 있는 것입니다.
두 개의 청크 (chunk)에 걸친 비교 질문:
answer("Which is hotter, Venus or Mercury, and why?")[0]
# -> "금성이 더 뜨겁습니다 (~465°C). 금성의 두꺼운 CO2 대기가 열을 가두는 반면,
# 수성은 대기가 거의 없기 때문입니다."
답변이 두 개의 청크에 걸쳐 존재하며, top-k 검색 (retrieval)이 두 청크를 모두 가져옵니다. 정확할 뿐만 아니라 추론도 잘 되어 있습니다.
따라서 단순한 (naive) RAG는 작동합니다. 완벽하게 작동하죠. 그리고 바로 그것이 문제입니다. 왜냐하면 이 시스템은 깨끗하고 짧으며 엄선된 6개의 단락 위에서 작동하고 있기 때문입니다. 작고 깔끔한 코퍼스는 이 기술이 가진 모든 약점을 숨겨버립니다.
데모 뒤에 숨겨진 약점들 — 그리고 로드맵
장난감 데이터 (toy data)에서의 깔끔한 답변은 거의 아무것도 증명하지 못합니다. 단순한 RAG를 실제 문서에 적용하는 순간 다음의 문제들이 발생하며, 이 각각의 문제들은 이 시리즈의 후반부에서 해결할 과제들입니다.
- 단순한 청킹 (Chunking): 문서당 하나의 청크를 사용하는 방식은 문서가 길어지면 무너집니다. 적절한 구절이 노이즈 속에 파묻히거나 조각나 버립니다.
- 순수하게 의미론적인 (semantic) 검색: 정확한 키워드 — 이름, ID, 에러 코드 등 — 는 벡터 유사도 (vector similarity) 검색을 통과하지 못할 수 있습니다. 하이브리드 (Hybrid, 키워드 + 벡터) 검색이 도움이 됩니다.
- 리랭킹 (Reranking) 부재: 수백 개의 청크가 있을 때, 코사인 유사도 (cosine similarity) 기준의 top _k_가 반드시 가장 유용한 k라고 신뢰할 수는 없습니다.
- 평가 (Evaluation) 부재: 우리는 지금 두 개의 답변을 눈으로 확인하고 있습니다. 수치 없이는 어떤 "개선"이 실제로 무엇인가를 개선했는지 알 수 없습니다.
**파트 2 (Part 2)**에서는 청킹과 검색 품질을 다루며, 이후의 모든 변경 사항을 측정할 수 있도록 작은 평가 프레임워크 (evaluation harness)를 추가합니다.
이 파트를 위한 전체 실행 가능한 노트북은 여기 있습니다: https://www.kaggle.com/code/sumannath88/ep01-simple-rag
이 내용이 유용했다면 계속해서 함께해 주세요. 단순한 (naive) 버전이 한계에 부딪히기 시작하면서 시리즈는 더욱 흥미로워질 것입니다.
다음: 파트 2 — 더 나은 청크 (chunks), 하이브리드 검색 (hybrid retrieval), 그리고 실제로 RAG를 측정하는 방법.
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기