Rag로 챗봇 만들기
RAG로 게임 가이드 챗봇 만들기
친구들과 함께 게임을 하게 되면
“이 캐릭터는 어떻게 운영해야 하는지”, “누구를 우선적으로 노려야 하는지”, “상성 관계가 어떻게 되는지” 와 같이
물어보며 나에게 계속 오더를 요청했다.
그냥 AI에게 물어보면 되지 않을까 싶지만 실제로 해보면 이런 문제가 있었다.
- 최근에 추가된 캐릭터 정보가 반영되어 있지 않음
- 스킬을 이전 버전 기준으로 설명함
- 번역 문제로 영웅/스킬 명칭이 공식 한국어 명칭과 다름
이 문제들을 해결하기 위해 선택한 방법이 바로 RAG다.
RAG란?
RAG(Retrieval-Augmented Generation) 는 LLM이 원래 알지 못하거나 최신화되지 않은 정보를
외부 문서에서 찾아서 답변의 근거로 함께 제공하는 방식이다.
쉽게 말하면, “AI가 답하기 전에 먼저 관련 문서를 찾아서 읽고 나서 답하게 만드는 것”이다.
나는 오버워치 2 게임 정보를 정리한 문서를 마크다운 형식으로 작성해
source/overwatch.md에 저장하고 이를 LLM의 참고 자료로 활용했다.
RAG 파이프라인
RAG는 크게 두 단계로 나뉜다.
1단계: 인덱싱 (Indexing) — 문서를 미리 준비하는 과정
1
Document → Chunk → Embed → Store
| 단계 | 설명 |
|---|---|
| Document | PDF, 텍스트, 웹페이지 등 원본 문서를 불러옴 |
| Chunk | 긴 문서를 작은 조각으로 분할 (예: 500자씩) |
| Embed | 각 조각을 숫자 배열(벡터)로 변환 - 의미를 압축하는 과정 |
| Store | 변환된 벡터를 Vector DB에 저장 |
Chunk는 LLM이 읽기 좋은 크기로 문서를 자르는 과정이다.
너무 크게 자르면 → 엉뚱한 내용까지 포함되어 핵심을 찾기 어렵다
너무 작게 자르면 → 문맥이 끊겨 의미가 훼손된다
임베딩은 텍스트를 숫자 배열(벡터)로 바꾸는 과정이다.
컴퓨터는 텍스트를 그대로 이해하지 못하기 때문에 의미를 압축한 벡터 형태로 변환해줘야 한다.
이렇게 변환된 벡터는 일반 DB가 아닌 Vector DB(여기서는 Chroma) 에 저장한다.
2단계: 검색 (Retrieval) — 질문에 맞는 정보를 찾아 답하는 과정
1
Query → Embed Query → Top-K 검색 → Context 조합 → LLM 응답
| 단계 | 설명 |
|---|---|
| Query | 사용자가 질문을 입력 |
| Embed Query | 질문도 벡터로 변환 |
| Top-K 검색 | 저장된 벡터 중 가장 유사한 조각 k개 추출 |
| Context 조합 | 질문 + 검색된 문서 조각을 합쳐서 LLM에 전달 |
| LLM 응답 | 근거 기반으로 최종 답변 생성 |
질문이 들어오면 질문도 벡터로 변환한 뒤
Vector DB에 저장된 chunk들과 비교해 의미가 가장 가까운 것들을 뽑아낸다.
이때 “의미가 비슷하다”를 판단하는 기준이 코사인 유사도다.
RAG 챗봇 만들기
0. 환경 설정
uv는 가상환경 생성, 패키지 설치, 파일 실행까지 한 번에 처리해주는 도구다.
pip보다 설치 속도가 훨씬 빠르고 프로젝트 의존성을 pyproject.toml로 관리한다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 1. 프로젝트 초기화 (Python 3.11 기준)
uv init --python 3.11
# 2. RAG 및 Django 관련 패키지 설치
uv add python-dotenv langchain-chroma langchain-community langchain-core \
langchain-text-splitters langchain-huggingface sentence-transformers \
langchain-google-genai django langgraph
# 3. Django 프로젝트 및 앱 생성
uv run django-admin startproject config .
uv run python manage.py startapp chat
# 4. 서버 실행
uv run python manage.py runserver
LangChain은 RAG에 필요한 구성 요소(문서 로더, 텍스트 분할기, 임베딩, 벡터스토어, LLM 등)를
통일된 인터페이스로 제공하는 프레임워크다.
각 단계를 레고 블록처럼 갈아끼우며 조립할 수 있다는 게 장점이다.
1. Document Loader : 문서 불러오기
인덱싱의 첫 단계는 원본 문서를 코드가 다룰 수 있는 형태로 불러오는 것이다.
TextLoader를 사용해 마크다운 파일을 읽어온다.
1
2
loader = TextLoader(self.md_path, encoding="utf-8")
pages = loader.load()
load()를 실행하면 마크다운 파일 전체가 LangChain의 Document 객체로 변환된다.
이 객체에는 두 가지 정보가 담긴다.
page_content: 실제 텍스트 내용metadata: 출처 파일 경로 등 부가 정보 → 나중에 어떤 chunk를 참고해서 답변했는지 추적할 수 있다
책장에 꽂혀 있던 책(
overwatch.md)을 꺼내서 컴퓨터가 읽을 수 있는 형태로 펼쳐두는 단계라고 생각하면 된다.
2. Text Splitter : 문서 자르기
불러온 문서를 적절한 크기의 chunk로 잘라낸다.
1
2
3
4
5
6
7
8
9
from langchain_text_splitters import RecursiveCharacterTextSplitter
splitter = RecursiveCharacterTextSplitter(
chunk_size=self.chunk_size,
chunk_overlap=self.chunk_overlap,
separators=["\n## ", "\n### ", "\n#### ", "\n", " ", ""],
)
docs = splitter.split_documents(pages)
docs = [d for d in docs if d.page_content.strip()]
separators 목록 순서대로 구분자를 시도하면서 마크다운 제목 단위(##, ###, ####)를 최대한 보존하며 문서를 자른다.
- 큰 제목(
##) 단위로 먼저 나누고 - 그래도 길면 소제목(
###,####) 단위로 - 그래도 길면 줄바꿈, 공백 순으로 점점 더 잘게 나눈다
이렇게 하면 한 chunk 안에 의미적으로 관련 있는 내용이 함께 묶일 가능성이 높아진다.
chunk_size와 k의 관계
chunk를 짧게 자를수록 → k(가져올 chunk 개수)를 늘려야 충분한 문맥을 확보할 수 있다.
chunk를 길게 자를수록 → k가 작아도 하나의 chunk에 충분한 정보가 담긴다.
chunk_overlap이 필요한 이유
chunk끼리 일부 내용을 겹치게 만들면, 앞 chunk의 마지막 문맥이 다음 chunk의 시작 부분에도 이어진다.
1
2
3
chunk 1: ...A B C D E
chunk 2: D E F G H ← D, E가 겹침
chunk 3: G H I J K
덕분에 두 번째 chunk만 따로 꺼내 봐도 의미가 자연스럽게 이어진다.
3. Embedding : 벡터로 변환하기
임베딩은 텍스트를 숫자 배열(벡터)로 변환하는 과정이다.
의미가 비슷한 문장끼리는 벡터 공간에서 서로 가까운 위치에 놓이게 된다.
1
2
3
4
5
6
7
from langchain_huggingface import HuggingFaceEmbeddings
return HuggingFaceEmbeddings(
model_name="BAAI/bge-m3",
model_kwargs={"device": self.embedding_device},
encode_kwargs={"normalize_embeddings": True},
)
무료 모델 중 성능이 좋은 BAAI/bge-m3를 사용했다.
⚠️ 주의: VectorDB를 만들 때와 검색할 때 반드시 동일한 임베딩 모델을 써야 한다.
모델이 다르면 같은 문장도 서로 다른 벡터로 변환되어, 유사도 비교 자체가 의미 없어진다.
4. VectorStore : 벡터 저장하기
VectorStore는 임베딩된 벡터, 원문 텍스트(chunk), metadata를 한 세트로 묶어 저장하는 저장소다.
일반 DB처럼 “정확히 일치하는 값”을 찾는 것이 아니라, “의미적으로 유사한 것”을 빠르게 찾아낸다는 점이 큰 차이다.
1
2
3
4
5
6
vectorstore = Chroma.from_documents(
documents=docs,
embedding=self.get_embeddings(),
collection_name=self.collection_name,
persist_directory=self.db_path,
)
5. Retriever + VectorDB 유사도 검색 : 관련 chunk 꺼내오기
VectorStore에 데이터를 저장하는 것까지가 준비 과정이었다면
Retriever는 실제로 사용자의 질문에 맞는 정보를 꺼내오는 역할을 한다.
VectorStore가 잘 정리된 서가라면, Retriever는 질문을 듣고 서가에서 가장 관련 있는 책을 뽑아오는 사서다.
1
2
3
4
retriever = vectorstore.as_retriever(
search_type="similarity",
search_kwargs={"k": self.search_k},
)
search_type="similarity": 코사인 유사도 기준으로 가장 가까운 chunk를 찾음k=7: 질문 하나당 7개의 chunk를 참고 문서로 LLM에 전달
동작 순서는 다음과 같다.
- 사용자의 질문을 임베딩 모델로 벡터로 변환한다.
- VectorStore에 저장된 chunk 벡터들과 유사도를 비교한다.
- 가장 유사한 chunk 상위 k개를 추출한다.
실제 답변 생성 시에는 이렇게 사용된다.
1
docs = retriever.invoke(cleaned)
retriever.invoke(질문)을 호출하면 유사도 높은 chunk 7개가 반환되고
이 chunk들이 합쳐져 context가 되어 프롬프트의 {context} 자리에 들어간다.
6. LLM : 답변 생성하기
LLM은 실제로 답변을 생성하는 두뇌 역할이다.
구글의 gemini-2.5-flash 모델을 사용했고, langchain_google_genai를 통해 LangChain과 연결한다.
1
2
3
4
5
6
7
from langchain_google_genai import ChatGoogleGenerativeAI
return ChatGoogleGenerativeAI(
model=self.llm_model,
google_api_key=api_key,
temperature=self.temperature,
)
temperature는 답변의 창의성/무작위성을 조절하는 값이다.
0에 가까울수록 → 같은 질문에 더 일관되고 보수적인 답변1에 가까울수록 → 더 다양하고 창의적인 답변
영웅 정보나 전략처럼 정확성이 중요한 답변에는 temperature=0이 적합하다.
7. LCEL 체인 — 파이프라인 연결하기
LCEL(LangChain Expression Language) 은 |(파이프) 기호로
여러 구성 요소를 연결해 데이터를 순서대로 흘려보내는 방식이다.
1
chain = prompt | llm | StrOutputParser()
각 단계의 역할은 다음과 같다.
| 단계 | 역할 |
|---|---|
ChatPromptTemplate (prompt) | 질문과 참고 문서를 받아 LLM에게 전달할 최종 프롬프트를 완성한다 |
LLM (Gemini) | 완성된 프롬프트를 받아 답변을 생성한다 |
StrOutputParser | LLM의 응답 객체에서 순수한 문자열(텍스트)만 추출한다 |
프롬프트(페르소나) 설계하기
이 체인에서 가장 중요한 부분은 첫 번째 단계인 prompt다.
단순히 “질문에 답해줘”라고만 시키는 것이 아니라
앞서 언급했던 문제들(최신 캐릭터 미반영, 번역 이슈 등)을 보완하기 위해 구체적인 역할과 규칙을 부여했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
return ChatPromptTemplate.from_messages([
(
"system",
"당신은 오버워치 2 전문 코치이자 분석가입니다.\n"
"제공된 문서를 바탕으로 질문의 의도를 파악하고, 단편적인 정보들을 논리적으로 조합하여 답변하세요.\n\n"
"【지식 조합 및 추론 규칙】\n"
"- 문장에 직접적인 답이 없더라도, 영웅들의 스킬 메커니즘과 상성을 문서에서 찾아내어 논리적으로 연결하세요.\n"
"- 주어진 문서로 판단이 불가능한 영역은 '[참고 문서 외 의견]'이라고 명확히 구분하여 답변하세요.\n\n"
"【답변 원칙 및 제한 사항】\n"
"- 사용자가 질문한 역할군(탱커/딜러/서포터)에 일치하는 영웅만 추천하세요.\n"
"- 모든 영웅 및 스킬 이름은 반드시 '오버워치 2 한국어 공식 명칭'만 사용하세요.\n\n"
"【질문 유형별 출력 형식】\n"
"1. 카운터/픽 추천 질문인 경우 ...\n"
"2. 운영법/스킬 질문인 경우 ..."
),
("human", "참고 문서:\n{context}\n\n질문: {question}"),
])
프롬프트에는 크게 세 가지 규칙이 담겨 있다.
① 지식 조합 및 추론 규칙
문서에 직접적인 답이 없어도, 영웅 스킬의 메커니즘(투사체 판정, 광선 판정, 방벽 등)을 조합해 상성을 추론하도록 했다.
예를 들어 문서에 “A의 스킬은 투사체를 막는다”와 “B의 공격은 광선이다”가 각각 흩어져 있다면
이를 연결해 “B가 A를 카운터할 수 있다”고 유추하게 만든 것이다.
다만 문서로 판단할 수 없는 부분은 [참고 문서 외 의견]으로 구분해 표시하도록 해
근거 있는 정보와 추론을 명확히 구별할 수 있게 했다.
② 답변 원칙
질문한 역할군(탱커/딜러/서포터)에 맞는 영웅만 추천하도록 제한하고
모든 영웅·스킬 이름은 “오버워치 2 한국어 공식 명칭”만 사용하도록 강제했다.
번역 이슈를 해결하기 위한 규칙이다.
③ 출력 형식 규칙
질문 유형(카운터/픽 추천 vs 운영법/스킬 설명)에 따라 답변 형식을 다르게 지정해 매번 일관된 형태의 답변을 받을 수 있도록 했다.
invoke란?
invoke()는 체인처럼 LangChain의 “실행 가능한(Runnable)” 객체에 입력 값을 넣고 그 결과를 즉시 받아오는 메서드다.
1
2
3
4
5
chain = prompt | llm | StrOutputParser()
answer = chain.invoke({
"context": context,
"question": message,
})
chain.invoke({...})를 호출하면 다음 세 단계가 자동으로 실행된다.
{context, question}딕셔너리 →prompt에 전달되어 최종 프롬프트 완성- 완성된 프롬프트 →
llm으로 전달되어 응답 생성 - 생성된 응답 →
StrOutputParser를 통해 순수 문자열로 변환
8. Django View — 웹과 연결하기
여기까지가 “RAG 챗봇의 두뇌”를 만드는 과정이었다면, 이제는 이 두뇌를 웹에서 사용할 수 있도록 연결하는 부분이다.
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
35
36
_rag = None
_llm = None
_retriever = None
def get_rag_components():
global _rag, _llm, _retriever
if _rag is None:
_rag = ChatBot()
_llm = _rag.get_llm()
_retriever = _rag.build_rag_components()
return _rag, _llm, _retriever
@csrf_exempt
@require_http_methods(["POST"])
def chat_api(request):
try:
data = json.loads(request.body)
message = data.get("message", "").strip()
if not message:
return JsonResponse({"error": "질문을 입력해주세요."}, status=400)
rag, llm, retriever = get_rag_components()
result = rag.answer(retriever=retriever, llm=llm, message=message)
return JsonResponse(result)
except FileNotFoundError as e:
return JsonResponse(
{"error": f"VectorDB가 없습니다. create_vectorstore()를 먼저 실행하세요.\n{str(e)}"},
status=500,
)
except json.JSONDecodeError:
return JsonResponse({"error": "요청 body가 올바른 JSON 형식이 아닙니다."}, status=400)
except Exception as e:
return JsonResponse({"error": str(e)}, status=500)
chat_api의 처리 흐름은 다음과 같다.
- 요청 body에서 JSON을 파싱해 사용자의 질문(
message)을 꺼낸다. get_rag_components()를 호출해ChatBot인스턴스, LLM, Retriever를 가져온다.rag.answer()를 호출해 답변과 참고 문서 목록을 받아온다.- 결과를 JSON 형태로 응답한다.
싱글톤 패턴으로 초기화 비용 절감
get_rag_components()는 전역 변수(_rag, _llm, _retriever)를 활용한 캐싱(싱글톤) 패턴이다.
임베딩 모델 로딩, VectorDB 연결, LLM 초기화는 비용이 큰 작업이기 때문에
요청이 들어올 때마다 새로 만들지 않고 처음 한 번만 생성해서 재사용한다.
또한 에러 유형을 세 가지로 구분해 어떤 문제가 발생했는지 바로 파악할 수 있게 했다.
| 예외 | 원인 |
|---|---|
FileNotFoundError | VectorDB가 아직 생성되지 않은 경우 |
JSONDecodeError | 요청 body가 잘못된 JSON 형식인 경우 |
Exception | 그 외 예외 상황 |
마무리
전체 흐름을 정리하면 다음과 같다.
1
2
3
4
5
① (사전 작업) 오버워치 문서를 chunk로 나누고, 임베딩해서 Chroma DB에 저장
② 사용자가 질문을 입력하면, Retriever가 가장 관련 있는 chunk 7개를 검색
③ 검색된 chunk(참고 문서)와 질문을 프롬프트에 채워 넣음
④ LLM이 역할, 규칙, 참고 문서, 질문을 바탕으로 답변을 생성
⑤ Django API를 통해 답변이 사용자에게 전달
처음 의도했던 “최신 캐릭터 정보 반영”과 “한국어 공식 명칭으로 답변하기” 문제는
RAG로 최신 문서를 참고하게 하고 프롬프트에 명확한 규칙을 명시함으로써 어느 정도 해결할 수 있었다.
다음에는 대화 흐름을 기억하는 멀티턴 대화(LangGraph 활용) 와 문서 자체를 더 풍부하게 채우는 작업을 이어가볼 예정이다.