RTX 5090을 사용해서 gcube 문서로 RAG 챗봇 만들기
on this page
들어가며
파인튜닝 시리즈에서는 모델 자체에 지식을 주입했습니다. 이번엔 반대로 갑니다. 모델은 그대로 두고, 외부 문서를 실시간으로 검색해 답변에 활용하는 RAG(Retrieval-Augmented Generation) 입니다.
대상은 gcube 공식 문서(gai-platform-docs)입니다. gcube 문서로 gcube 질문에 답하는 챗봇을 gcube GPU 위에서 만드는, 자기참조적인 실습이죠.
RTX 5090 한 장에서 임베딩 모델과 7B LLM이 함께 돌아가고, 외부 API는 쓰지 않습니다.
왜 RAG인가
| 파인튜닝 | RAG | |
|---|---|---|
| 지식 업데이트 | 재학습 필요 | 문서만 교체하면 즉시 반영 |
| 환각(hallucination) | 모델이 모르면 지어냄 | 근거 문서 기반 → 상대적으로 안전 |
| 비용 | 학습 GPU 시간 필요 | 임베딩 + 검색, 상대적으로 저렴 |
| 적합한 경우 | 말투/포맷/도메인 언어 습득 | 최신 정보, 사내 문서, 자주 바뀌는 지식 |
사내 문서 기반 챗봇처럼 "정확한 근거가 있는 답변" 이 중요한 경우엔 RAG가 명확히 유리합니다.
아키텍처
[MkDocs 문서 (.ko.md)]
│ 청킹
▼
[BGE-M3 임베딩] ──▶ [Chroma 벡터DB]
│ 검색 (top-k)
▼
[사용자 질문] ──▶ [LangChain 검색+프롬프트 조립] ──▶ [vLLM: Qwen2.5-7B] ──▶ [답변]
실습 환경
- gcube GPU 컨테이너 (RTX 5090, 32GB)
- 컨테이너 이미지:
unsloth/unsloth(JupyterLab 내장)
환경 구성
컨테이너 기본 환경에는 파인튜닝용 torch가 이미 깔려 있어, RAG 스택을 그 위에 얹으면 버전이 충돌합니다. 별도 venv를 만들어 그 안에 설치하고, 노트북 커널로 등록해서 씁니다.
노트북 셀에 아래를 붙여넣고 실행하세요 (%%bash로 시작하는 셸 셀입니다):
%%bash
pip install -q virtualenv
python -m virtualenv /workspace/rag-env
/workspace/rag-env/bin/pip install -q vllm langchain langchain-community \
langchain-text-splitters langchain-huggingface langchain-chroma \
langchain-openai chromadb sentence-transformers ipykernel ipywidgets
/workspace/rag-env/bin/python -m ipykernel install --user \
--name=rag-env --display-name "Python (rag-env)"
💡 ipywidgets는 진행률 바를 위해 필요합니다. 빠뜨리면 이후 셀에서 TqdmWarning: IProgress not found가 뜹니다.
설치가 끝나면 노트북의 커널을 rag-env로 바꿔줍니다. 두 가지 방법 중 편한 쪽을 쓰세요.
- 방법 1: 노트북 우측 상단 툴바에 표시된 현재 커널 이름(
Python 3 (ipykernel))을 클릭 - 방법 2: 상단 메뉴바 Kernel → Change Kernel...
둘 다 "Select Kernel" 다이얼로그를 띄웁니다. 가운데 드롭다운에서 Python (rag-env) 를 선택하고 우측 하단 Select Kernel 버튼을 누르면 끝입니다.
📷 (스크린샷: Select Kernel 다이얼로그)
전환되면 우측 상단 표시가 Python (rag-env)로 바뀝니다. 아래 셀로 확인할 수 있습니다.
import sys, vllm
print(sys.executable) # /workspace/rag-env/bin/python
print(vllm.__version__) # 0.25.1
이후 1~8단계의 파이썬 코드는 전부 셀에 그대로 붙여넣어 실행하면 됩니다.
마지막으로 환경변수를 정리합니다.
import os
# 기본값 /data는 이 컨테이너에서 쓰기 권한이 없음
os.environ["HF_HOME"] = "/workspace/hf-cache"
# 이미지에 설정된 구버전 변수 — 두면 매 셀마다 deprecation 경고가 뜸
os.environ.pop("HF_HUB_ENABLE_HF_TRANSFER", None)
💡 HF_HUB_ENABLE_HF_TRANSFER는 unsloth/unsloth 이미지에 기본 설정되어 있는데, 최신 huggingface_hub에서는 더 이상 쓰이지 않아 FutureWarning을 발생시킵니다. 위처럼 지워주면 경고가 사라집니다.
1단계. 문서 수집
gcube 공식 문서 저장소에서 한국어 문서(.ko.md)만 필터링합니다.
%%bash
cd /workspace
git clone https://github.com/data-alliance/gai-platform-docs.git 2>/dev/null || echo "이미 클론되어 있음"
find gai-platform-docs -name "*.ko.md" | wc -l
실행 결과: .ko.md 파일 38개. docs/user-guide/ 하위에 node, gcube-cli, platform-guide 등으로 구조화되어 있어 청킹 시 소스별 메타데이터를 부여하기 좋습니다.
2단계. 전처리 및 청킹
MkDocs 프론트매터(---로 감싸진 메타데이터 블록)를 제거한 뒤, 헤더 기준으로 1차 분할하고 문자 수 기준으로 2차 분할합니다.
from langchain_text_splitters import MarkdownHeaderTextSplitter, RecursiveCharacterTextSplitter
def clean_text(text):
# 프론트매터(--- ... ---) 제거
if text.startswith('---'):
idx = text.find('---', 3)
if idx != -1:
text = text[idx+3:]
return text
headers_to_split_on = [("#", "h1"), ("##", "h2"), ("###", "h3")]
md_splitter = MarkdownHeaderTextSplitter(headers_to_split_on=headers_to_split_on)
char_splitter = RecursiveCharacterTextSplitter(chunk_size=800, chunk_overlap=100)
문서를 읽어 청킹합니다.
import glob
files = glob.glob('/workspace/gai-platform-docs/**/*.ko.md', recursive=True)
print('files found:', len(files))
all_chunks = []
for fp in files:
with open(fp, encoding='utf-8') as f:
raw = f.read()
cleaned = clean_text(raw)
md_docs = md_splitter.split_text(cleaned)
for d in md_docs:
d.metadata['source'] = fp # 근거 문서 추적용
all_chunks.extend(char_splitter.split_documents(md_docs))
print('total chunks:', len(all_chunks))
print('avg chunk len:', sum(len(c.page_content) for c in all_chunks) // len(all_chunks))
⚠️ 임포트 경로 주의: 최신 langchain(0.3+/1.x)은 기능별로 패키지가 쪼개졌습니다. 이 글에서 쓰는 것들은 각각 langchain_text_splitters(splitter), langchain_huggingface(임베딩), langchain_chroma(벡터DB), langchain_openai(LLM 클라이언트)에서 가져옵니다. 예전 글에 나오는 langchain.text_splitter나 langchain_community.* 경로를 쓰면 ModuleNotFoundError가 납니다.
실행 결과: 총 235개 청크, 평균 길이 296자. 첫 번째 청크를 열어보니  같은 이미지 마크다운 링크만 담긴 청크가 섞여 있었습니다 — 텍스트 정보가 거의 없는 "노이즈성 청크"라, 실제 운영에서는 이미지 전용 청크를 필터링하거나 최소 길이 기준으로 걸러내는 후처리가 필요해 보입니다.
3단계. BGE-M3 임베딩
from langchain_huggingface import HuggingFaceEmbeddings
embeddings = HuggingFaceEmbeddings(
model_name="BAAI/bge-m3",
model_kwargs={"device": "cuda"},
encode_kwargs={"normalize_embeddings": True},
)
실행 결과: BGE-M3 모델(약 2.3GB)을 내려받고 235개 청크 임베딩까지 완료됩니다. 첫 실행은 모델 다운로드 때문에 몇 분 걸리고, 이후엔 캐시를 재사용합니다.
💡 BGE-M3는 VRAM을 2~3GB 정도만 쓰기 때문에 뒤에서 띄울 vLLM(7B, --gpu-memory-utilization 0.6)과 같은 GPU에 함께 올려도 32GB 안에서 무리가 없습니다.
📷 (스크린샷: 모델 다운로드 진행률 바)
4단계. Chroma 인덱싱
from langchain_chroma import Chroma
vectordb = Chroma.from_documents(
documents=all_chunks,
embedding=embeddings,
persist_directory="/workspace/rag-lab/chroma_db",
)
print('indexed:', vectordb._collection.count())
실행 결과: 235개 청크 전부 정상 인덱싱 (indexed: 235).
5단계. 검색 테스트
인덱싱만으로 검색 품질을 먼저 확인합니다.
query = "gcube CLI는 어떻게 설치하나요?"
results = vectordb.similarity_search(query, k=3)
for i, doc in enumerate(results):
print(f"--- rank {i+1} ---")
print("SOURCE:", doc.metadata["source"])
print(doc.page_content[:200].replace("\n", " "))
print()
실행 결과: cli-overview.ko.md가 상위권에 잡히지만, top-1은 실행 환경에 따라 갈립니다. 저희 재현 테스트에서는 openwebui-comfyui.ko.md(gcube CLI 설치 명령어가 포함된 문서)가 top-1, cli-overview.ko.md가 top-2로 나왔고 유사도 점수 차이는 0.58 vs 0.60으로 거의 붙어 있었습니다.
--- rank 1 ---
SOURCE: .../platform-guide/openwebui-comfyui.ko.md
gcube 워크로드 터미널에서 아래 명령어를 순서대로 입력합니다. ...
--- rank 2 ---
SOURCE: .../gcube-cli/cli-overview.ko.md
gcube CLI는 gcube AI GPU 클라우드 플랫폼의 공식 커맨드라인 도구입니다.
GPU 워크로드 등록·관리, 리소스 모니터링, 컨테이너 로그 스트리밍을 터미널에서 수행할 수 있습니다.
"설치하나요?"라는 질문이 실제 설치 명령어가 담긴 문서와도 잘 맞기 때문에, 사실 둘 다 타당한 검색 결과입니다. 별도 튜닝 없이도 의미 기반 검색이 관련 문서를 정확히 찾아낸다는 게 확인됩니다 — BGE-M3의 한국어 임베딩 품질이 좋다는 신호입니다.
⚠️ top-1에 의존하지 마세요. 점수가 이렇게 촘촘하면 문서가 조금만 바뀌어도 순위가 뒤집힙니다. k=3 이상으로 근거 후보를 여러 개 확보한 뒤 LLM에 함께 넘기는 게 안전합니다 (6단계에서 그렇게 구성합니다).
6단계. vLLM 서빙 + LangChain 연결
vLLM 서버는 오래 실행되는 프로세스라 Popen으로 백그라운드에 띄웁니다 (subprocess.run()을 쓰면 커널이 해당 셀에 계속 묶입니다).
import subprocess, os, sys, time, urllib.request
env = {**os.environ, "HF_HOME": "/workspace/hf-cache", "VLLM_USE_FLASHINFER_SAMPLER": "0"}
proc = subprocess.Popen(
[sys.executable, "-m", "vllm.entrypoints.openai.api_server",
"--model", "Qwen/Qwen2.5-7B-Instruct",
"--host", "0.0.0.0", "--port", "8001",
"--gpu-memory-utilization", "0.6",
"--max-model-len", "8192"],
stdout=open("/workspace/vllm.log", "w"), stderr=subprocess.STDOUT, env=env,
)
print("서버 기동 중, pid:", proc.pid)
💡 모델 경로: 파인튜닝 시리즈에서 만든 체크포인트가 있다면 --model 값을 그 로컬 경로(예: /workspace/llama-3.1-8b-finetuned)로 바꾸세요. 단, 해당 디렉토리에 config.json이 있어야 합니다. 없는 경로를 지정하면 OSError: Can't load the configuration of ...로 서버가 즉시 죽습니다.
💡 sys.executable 사용 이유: 그냥 "python"으로 쓰면 커널이 아니라 셸의 기본 python이 잡혀서 rag-env가 아닌 환경에서 실행될 수 있습니다.
다음 셀에서 준비될 때까지 폴링합니다. 7B 모델 첫 실행은 15GB 다운로드가 필요해 5~10분 걸릴 수 있습니다.
for i in range(60):
if proc.poll() is not None:
print(f"서버가 종료됨 (exit={proc.returncode}) — 아래 로그 확인")
break
try:
r = urllib.request.urlopen("http://localhost:8001/v1/models", timeout=3)
print("READY:", r.read().decode())
break
except Exception as e:
print(i, "대기 중...", e)
time.sleep(10)
기동에 실패하면 로그에서 원인을 확인합니다.
print(open("/workspace/vllm.log").read()[-3000:])
⚠️ RTX 5090(Blackwell, sm_120)에서는 **VLLM_USE_FLASHINFER_SAMPLER=0**이 필수입니다. 이 플래그 없이 띄우면 모델 로딩까지는 멀쩡히 진행되다가 첫 샘플링 단계에서 RuntimeError: FlashInfer requires GPUs with sm75 or higher로 죽습니다. RTX 5090은 sm75보다 상위 아키텍처인데도 이 에러가 나는 건, flashinfer 0.6.13이 최신 GPU 세대를 제대로 인식하지 못하는 버그로 보입니다.
📷 (스크린샷: READY 응답 확인)
LangChain 연결:
from langchain_openai import ChatOpenAI
llm = ChatOpenAI(
base_url="http://localhost:8001/v1",
api_key="dummy",
model="Qwen/Qwen2.5-7B-Instruct", # 위 --model 값과 반드시 일치해야 함
max_tokens=300,
)
# 가드레일 — 자세한 내용은 7단계 참고
SYSTEM_PROMPT = """당신은 gcube 공식 문서를 기반으로 답변하는 어시스턴트입니다.
아래 제공된 문서 내용에서만 근거를 찾아 답변하세요.
문서에 없는 내용이면 "문서에서 확인할 수 없습니다"라고 답하세요."""
def answer_with_rag(question, k=3):
docs = vectordb.similarity_search(question, k=k)
context = " ".join(d.page_content for d in docs)
messages = [
{"role": "system", "content": SYSTEM_PROMPT},
{"role": "user", "content": f"문서: {context}\n질문: {question}"},
]
resp = llm.invoke(messages)
return resp.content, docs
def answer_without_rag(question):
"""비교용 — 검색 없이 모델에게 바로 물어봅니다 (DEMO 3에서 사용)"""
return llm.invoke([{"role": "user", "content": question}]).content
💡 RetrievalQA 체인 클래스 대신 검색→프롬프트 조립→호출을 직접 함수로 구성했습니다. 최신 LangChain(1.x)에서는 RetrievalQA가 레거시 취급이라, 이렇게 직접 구성하는 편이 디버깅도 쉽고 어차피 로직도 단순합니다.
7단계. 프롬프트 가드레일
6단계에서 정의한 SYSTEM_PROMPT가 환각 방지 장치입니다. 핵심은 "문서에 없는 내용은 모른다고 답하라"는 지시입니다.
당신은 gcube 공식 문서를 기반으로 답변하는 어시스턴트입니다.
아래 제공된 문서 내용에서만 근거를 찾아 답변하세요.
문서에 없는 내용이면 "문서에서 확인할 수 없습니다"라고 답하세요.
이 지시가 실제로 지켜지는지는 다음 단계에서 검증합니다.
8단계. 실제 데모
세 가지 질문으로 RAG의 효과를 확인합니다.
# DEMO 1 — 문서에 있는 질문
ans, docs = answer_with_rag("gcube CLI는 어떤 기능을 제공하나요?")
print(ans)
print("근거 문서:", docs[0].metadata["source"])
# DEMO 2 — 문서에 없는 질문 (가드레일 테스트)
ans, _ = answer_with_rag("gcube는 몇 명이 창업했나요?")
print(ans)
# DEMO 3 — RAG 없이 같은 질문 (환각 비교)
print(answer_without_rag("gcube는 몇 명이 창업했나요?"))
DEMO 1 — 문서에 있는 질문
Q: gcube CLI는 어떤 기능을 제공하나요?
A: gcube CLI는 GPU 워크로드의 등록과 관리, 리소스 모니터링, 컨테이너 로그 스트리밍 기능을 터미널에서 실행할 수 있습니다.
근거 문서:
cli-overview.ko.md✅
검색된 문서 내용에 근거해 정확히 답변했고, 어떤 문서를 근거로 삼았는지도 함께 추적됩니다.
DEMO 2 — 문서에 없는 질문 (가드레일 테스트)
Q: gcube는 몇 명이 창업했나요?
A: 문서에서 gcube가 몇 명이 창업했는지에 대한 정보는 제공하지 않았습니다. 따라서 이 질문에 답변할 수 없습니다.
✅ 가드레일이 의도대로 정확히 작동했습니다. 검색된 컨텍스트에 근거가 없자 답변을 거부했습니다.
DEMO 3 — RAG 없이 같은 질문 (Before/After 비교)
Q: gcube는 몇 명이 창업했나요?
A: GCube는 2008년에 설립된 한국의 클라우드 컴퓨팅 기업으로, 창업자들은 세 명으로 구성되어 있습니다. 구체적으로는 김병선 대표, 최정환 이사, 그리고 김동균 이사가 GCube를 창업했습니다.
🎭 완벽한 환각 사례입니다. 존재하지 않는 설립연도, 존재하지 않는 창업자 이름 세 명까지 구체적으로 지어냈습니다 — RAG가 왜 필요한지 이보다 명확하게 보여주는 예시는 찾기 어려울 것 같습니다.
📷 (스크린샷: DEMO 2 vs DEMO 3 대비)
가드레일은 모델 성능에 의존합니다
같은 파이프라인을 Qwen2.5-0.5B로도 돌려봤는데, DEMO 2에서 가드레일을 무시하고 "GCube는 4명이 창업했습니다"라고 지어냈습니다. 같은 프롬프트, 같은 검색 결과인데도 결과가 갈린 겁니다.
즉 RAG 파이프라인이 근거를 제대로 찾아줘도, 그 근거를 지키는 건 결국 LLM의 instruction-following 능력입니다. 실제 서비스라면 최소 7B급 이상의 instruct 모델을 쓰고, 그보다 작은 모델이 불가피하다면 검색 유사도가 임계값 미만일 때 LLM 호출 자체를 건너뛰는 코드 레벨 차단을 함께 두는 게 안전합니다.
한계와 다음 단계
- 현재 Chroma는 로컬 파일 기반 — 운영 환경에서는 Qdrant 등으로 마이그레이션 권장
- 검색 정확도 개선을 위한 reranker(BGE-reranker 등) 추가 여지
- 멀티턴 대화에서의 컨텍스트 유지는 별도 구현 필요
- 이미지 링크만 담긴 노이즈성 청크 필터링 로직 추가 필요
- 파인튜닝한 자체 모델로 교체 시 답변 품질·말투 개선 여지 (
-model만 로컬 경로로 바꾸면 됨)