RAG 기초: LLM에게 우리 문서를 근거로 답하게 하는 법
LLM이 모르는 최신 정보나 사내 문서를 답의 근거로 넣어주는 RAG의 원리를 정리한 노트입니다. 문서 적재, 청크 분할, 임베딩, 벡터 검색, 생성으로 이어지는 파이프라인을 단계별로 설명하고 파이썬으로 작은 RAG를 직접 만듭니다.
LLM은 학습 시점에 본 것만 알고, 우리 회사 문서나 어제 나온 뉴스는 모릅니다. RAG(Retrieval-Augmented Generation)는 질문과 관련된 문서를 먼저 찾아 프롬프트에 넣어준 뒤 답하게 하는 방법입니다. 이 글은 왜 RAG가 필요한지에서 출발해 파이프라인 각 단계를 정리하고, 파이썬으로 작동하는 작은 RAG를 만들어봅니다. 앞선 프롬프트 엔지니어링 글을 먼저 보고 오면 이해가 수월합니다.
LLM이 모르는 것
LLM은 학습에 쓰인 데이터까지만 알고 있습니다. 그래서 세 가지 상황에서 막힙니다.
- 최신 정보 학습 시점 이후에 일어난 일은 모릅니다.
- 비공개 정보 사내 위키, 계약서, 제품 매뉴얼처럼 인터넷에 없는 문서는 학습하지 않았습니다.
- 환각(hallucination) 모르는 것을 모른다고 하지 않고 그럴듯하게 지어내는 경우가 있습니다.
이 셋을 한 번에 풀 수 있는 손쉬운 방법은, 답에 필요한 문서를 질문과 함께 프롬프트에 넣어주는 것입니다. 모델은 자신의 기억이 아니라 우리가 건넨 문서를 근거로 답하게 됩니다. RAG는 이 발상을 자동화한 구조입니다.
그냥 다 넣으면 안 되나
문서가 몇 장뿐이라면 통째로 프롬프트에 넣어도 됩니다. 문제는 양입니다. 모델이 한 번에 읽을 수 있는 텍스트 길이(컨텍스트 윈도우)에는 한계가 있고, 문서가 수천 건이면 애초에 다 넣을 수 없습니다. 넣을 수 있다 해도 질문과 무관한 내용까지 함께 넣으면 비용이 늘고 답의 정확도가 떨어집니다.
그래서 RAG는 “다 넣기” 대신 “질문과 관련된 부분만 찾아서 넣기”를 합니다. 이 “찾기”의 핵심이 의미 기반 검색입니다.
파이프라인 한눈에 보기
RAG는 두 단계로 나뉩니다. 문서를 미리 검색 가능한 형태로 준비하는 색인(indexing) 단계와, 질문이 들어올 때마다 도는 질의(query) 단계입니다.
1
2
3
4
5
[색인 단계, 미리 한 번]
문서 → 청크 분할 → 임베딩 → 벡터 저장소에 저장
[질의 단계, 질문마다]
질문 → 임베딩 → 유사한 청크 검색 → 프롬프트에 넣어 LLM 생성 → 답
각 단계를 차례로 보겠습니다.
1단계: 청크로 나눈다
긴 문서를 통째로 다루면 검색 단위가 너무 큽니다. 그래서 문서를 문단이나 몇백 글자 단위의 조각으로 나눕니다. 이 조각을 청크(chunk)라고 부릅니다.
청크 크기는 너무 크면 관련 없는 내용이 섞이고, 너무 작으면 문맥이 끊깁니다. 보통 수백 자 단위로 나누되, 조각 사이에 약간 겹치는 부분(overlap)을 둬서 문장이 경계에서 잘리는 것을 완화합니다.
1
2
3
4
5
6
7
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=500, # 청크 하나의 최대 글자 수
chunk_overlap=50, # 인접 청크끼리 겹치는 글자 수
)
chunks = splitter.split_text(long_document)
2단계: 임베딩으로 의미를 좌표로 바꾼다
검색을 하려면 “질문과 청크가 얼마나 비슷한가”를 숫자로 잴 수 있어야 합니다. 여기서 임베딩(embedding)이 등장합니다. 임베딩은 텍스트를 수백 차원의 숫자 벡터로 바꾸는 기술입니다. 의미가 비슷한 문장은 벡터 공간에서 가까운 곳에, 다른 문장은 먼 곳에 놓입니다.
예를 들어 “환불 규정”과 “돈을 돌려받는 방법”은 글자가 겹치지 않지만 의미가 가까워, 임베딩 벡터도 서로 가깝게 나옵니다. 단어가 일치하는지를 보는 키워드 검색과 달리, 임베딩 검색은 뜻이 통하는지를 봅니다.
두 벡터가 얼마나 가까운지는 보통 코사인 유사도로 잽니다. 두 벡터가 이루는 각도가 작을수록(가리키는 방향이 비슷할수록) 유사도가 높습니다.
3단계: 벡터 저장소에 넣는다
청크를 임베딩해 벡터로 만들었으면, 이 벡터들을 저장하고 빠르게 검색할 수 있는 곳에 넣어야 합니다. 이 저장소를 벡터 스토어(vector store)라고 합니다. FAISS, Chroma, Pinecone 등이 널리 쓰입니다.
벡터 스토어의 역할은 하나입니다. 질문 벡터가 들어오면, 저장된 수많은 벡터 중 가장 가까운 몇 개를 빠르게 찾아 돌려주는 것입니다.
4단계: 찾아서 답하게 한다
이제 조각을 맞춰보겠습니다. 아래는 문서 하나를 색인하고 질문에 답하는 최소한의 RAG입니다. 임베딩은 무료로 쓸 수 있는 오픈소스 모델(sentence-transformers)을, 생성은 Claude를 씁니다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
from langchain_community.vectorstores import FAISS
from langchain_huggingface import HuggingFaceEmbeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter
from langchain_anthropic import ChatAnthropic
from langchain_core.prompts import ChatPromptTemplate
# --- 색인 단계 (미리 한 번) ---
document = open("사내_환불정책.txt", encoding="utf-8").read()
splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = splitter.split_text(document)
embeddings = HuggingFaceEmbeddings(
model_name="jhgan/ko-sroberta-multitask" # 한국어 임베딩 모델
)
store = FAISS.from_texts(chunks, embeddings)
retriever = store.as_retriever(search_kwargs={"k": 3}) # 가까운 청크 3개
# --- 질의 단계 (질문마다) ---
question = "구매 후 며칠까지 환불할 수 있나요?"
found = retriever.invoke(question)
context = "\n\n".join(doc.page_content for doc in found)
prompt = ChatPromptTemplate.from_messages([
("system",
"너는 고객 지원 담당자야. 아래 <문서>만 근거로 답해. "
"문서에 없는 내용은 '자료에 없습니다'라고 말해.\n\n<문서>\n{context}"),
("user", "{question}"),
])
llm = ChatAnthropic(model="claude-sonnet-5")
chain = prompt | llm
answer = chain.invoke({"context": context, "question": question})
print(answer.content)
흐름을 다시 보겠습니다. 질문을 임베딩해 벡터 스토어에서 가까운 청크 세 개를 찾고(retriever.invoke), 그 세 조각을 context로 묶어 프롬프트에 넣습니다. 모델은 자기 기억이 아니라 이 context를 근거로 답합니다.
근거로만 답하게 만드는 한 줄
위 프롬프트에서 가장 중요한 부분은 system 안의 이 지시입니다.
“아래 문서만 근거로 답해. 문서에 없는 내용은 ‘자료에 없습니다’라고 말해.”
이 한 줄이 환각을 크게 줄입니다. 이 제약이 없으면 모델은 검색된 문서와 자기 기억을 뒤섞어 답하고, 문서에 없는 내용까지 그럴듯하게 지어낼 수 있습니다. RAG의 신뢰도는 검색 품질뿐 아니라 이렇게 근거를 못 박는 프롬프트에서도 나옵니다.
RAG가 잘 안 될 때
RAG를 붙였는데 답이 부실하다면, 대개 원인은 생성이 아니라 검색에 있습니다.
- 엉뚱한 청크가 검색된다 청크가 너무 크거나 작지 않은지, 임베딩 모델이 해당 언어와 도메인에 맞는지 봅니다.
- 필요한 청크가 검색되지 않는다 찾아오는 개수(
k)를 늘리거나, 질문을 다듬어 다시 검색하는 방법을 씁니다. - 문서에 답이 아예 없다 이때는 모델이 지어내지 않고 “자료에 없습니다”라고 말하는 것이 옳은 동작입니다.
문제를 나눠 보려면 검색된 청크를 눈으로 먼저 확인하세요. 사람이 봐도 답이 없는 청크가 왔다면 검색 단계를, 좋은 청크가 왔는데 답이 틀렸다면 프롬프트를 손봅니다.
정리
| 단계 | 하는 일 |
|---|---|
| 청크 분할 | 긴 문서를 검색 단위인 수백 자 조각으로 나눈다 |
| 임베딩 | 텍스트를 의미가 담긴 벡터로 바꿔 유사도를 잴 수 있게 한다 |
| 벡터 저장 | 벡터를 저장소에 넣어 가까운 것을 빠르게 찾게 한다 |
| 검색 | 질문 벡터와 가까운 청크 몇 개를 골라온다 |
| 생성 | 검색된 청크를 근거로 못 박아 LLM이 답하게 한다 |
RAG의 핵심 발상은 단순합니다. 모델이 모르는 것을 답하게 하려면, 그 답의 근거를 질문과 함께 건네주면 됩니다. 어려움은 “질문과 관련된 근거를 어떻게 잘 찾아오느냐”에 있고, 그래서 청크 분할과 임베딩, 검색 품질이 RAG의 성패를 좌우합니다. 위 코드에서는 LangChain으로 각 단계를 이어 붙였는데, 이 프레임워크 자체는 다음 글에서 다룹니다.