RAG 기반 OO제품 매뉴얼 챗봇 구축
한국어 특화 RAG 챗봇 구축 (KURE-v1 + Ollama + ChromaDB)
"RAG 파이프라인 완벽 구현: PDF → 임베딩 → 검색 → 생성"
프로젝트 개요
로컬 LLM(Ollama)과 한국어 특화 임베딩을 사용하여 OO제품 매뉴얼 PDF를 학습하고 질문에 답변하는 RAG 챗봇을 구축했습니다.
핵심 기술 스택
- 임베딩: KURE-v1 (고려대 한국어 특화)
- 벡터 DB: ChromaDB
- LLM: Qwen2.5-Coder 32B (Ollama)
- PDF 처리: PyMuPDF
시스템 아키텍처
전체 흐름도
PDF 매뉴얼
↓ (텍스트/이미지 추출)
청킹 (1000자, 200 오버랩)
↓ (KURE 임베딩)
ChromaDB 벡터 저장
↓
사용자 질문
↓ (질문 임베딩)
벡터 검색 (Top 5)
↓ (컨텍스트 구성)
LLM 답변 생성
↓
답변 + 참고자료 + 관련 이미지
핵심 모듈 구조
manual-chatbot/
├── src/
│ ├── pdf_processor.py # PDF → 텍스트/이미지 추출 → 청킹
│ ├── embedder_kure.py # KURE 한국어 임베딩
│ ├── vector_store.py # ChromaDB 벡터 저장/검색
│ ├── rag_pipeline.py # RAG 로직 (검색 → 생성)
│ └── chatbot.py # 대화형 인터페이스
└── main.py # CLI 진입점
핵심 구현 코드
PDF 처리 및 청킹
PDF에서 텍스트와 이미지를 추출하고, 의미있는 단위로 분할합니다.
class PDFProcessor:
"""PDF에서 텍스트와 이미지를 추출"""
def extract_content(self, pdf_path: str) -> List[Dict]:
"""페이지별 텍스트와 이미지 추출"""
doc = fitz.open(pdf_path)
pages_content = []
for page_num in range(len(doc)):
page = doc.load_page(page_num)
text = page.get_text()
# 의미있는 이미지만 필터링
page_images = []
for img in page.get_images(full=True):
if self._is_valid_image(image_bytes, width, height):
page_images.append(image_path)
pages_content.append({
"page": page_num + 1,
"text": text.strip(),
"images": page_images,
"pdf_name": pdf_name
})
return pages_content
def chunk_content(self, pages_content, chunk_size=1000, overlap=200):
"""텍스트를 오버랩이 있는 청크로 분할"""
chunks = []
for page_data in pages_content:
text = page_data["text"]
start = 0
while start < len(text):
end = start + chunk_size
chunk_text = text[start:end]
chunks.append({
"text": chunk_text,
"metadata": {
"page": page_data["page"],
"pdf_name": page_data["pdf_name"],
"images": page_data["images"]
}
})
start = end - overlap # 오버랩 적용
return chunks
핵심 포인트:
- 이미지 필터링: 너무 작거나 중복된 이미지 제거 (아이콘, 로고 등)
- 청킹 오버랩: 문맥 유지를 위해 200자 중첩
- 메타데이터 보존: 원본 페이지 번호, 이미지 경로 등 추적
KURE 한국어 임베딩
한국어에 특화된 KURE 모델로 텍스트를 1024차원 벡터로 변환합니다.
class KUREEmbedder:
"""KURE 모델을 사용한 한국어 특화 임베딩"""
def __init__(self, model_name="nlpai-lab/KURE-v1"):
from sentence_transformers import SentenceTransformer
self.model = SentenceTransformer(model_name)
def embed_text(self, text: str) -> List[float]:
"""단일 텍스트 임베딩"""
embedding = self.model.encode(text, convert_to_numpy=True)
return embedding.tolist()
def embed_batch(self, texts: List[str], batch_size=32) -> List[List[float]]:
"""배치 임베딩 (성능 최적화)"""
embeddings = self.model.encode(
texts,
batch_size=batch_size,
show_progress_bar=True,
normalize_embeddings=True # 코사인 유사도 최적화
)
return embeddings.tolist()
KURE 선택 이유:
- 한국어 처리 성능 우수
- 1024차원 고밀도 벡터
- 코사인 유사도 85%+ 달성
ChromaDB 벡터 저장소
벡터 임베딩을 저장하고 빠르게 검색합니다.
class VectorStore:
"""ChromaDB 벡터 저장소"""
def __init__(self, persist_directory="data/vectordb"):
import chromadb
self.client = chromadb.PersistentClient(path=persist_directory)
self.collection = self.client.get_or_create_collection(
name="manual",
metadata={"hnsw:space": "cosine"}
)
def add_documents(self, texts, embeddings, metadatas):
"""문서와 임베딩 추가"""
ids = [f"doc_{i}" for i in range(len(texts))]
self.collection.add(
embeddings=embeddings,
documents=texts,
metadatas=metadatas,
ids=ids
)
def search(self, query_embedding, top_k=5):
"""유사도 기반 검색"""
results = self.collection.query(
query_embeddings=[query_embedding],
n_results=top_k,
include=['documents', 'metadatas', 'distances']
)
return {
'documents': results['documents'][0],
'metadatas': results['metadatas'][0],
'distances': results['distances'][0]
}
핵심 기능:
- 코사인 유사도 기반 검색
- 영속성 저장 (디스크 저장)
- Top-K 검색 (기본 5개)
RAG 파이프라인
검색과 생성을 통합한 RAG 로직의 핵심입니다.
class RAGPipeline:
"""RAG 파이프라인: 검색 → 컨텍스트 생성 → LLM 응답"""
def __init__(self, vector_store, embedder, llm, top_k=5):
self.vector_store = vector_store
self.embedder = embedder
self.llm = llm
self.top_k = top_k
self.system_prompt = """당신은 OO제품 매뉴얼 전문가입니다.
매뉴얼 내용을 바탕으로 정확하고 친절하게 답변하세요.
확실하지 않으면 "매뉴얼에서 해당 정보를 찾을 수 없습니다"라고 말하세요."""
def query(self, question: str) -> Dict:
"""질문에 대한 답변 생성"""
# 1. 질문 임베딩
query_embedding = self.embedder.embed_text(question)
# 2. 유사 문서 검색
search_results = self.vector_store.search(
query_embedding=query_embedding,
top_k=self.top_k
)
# 3. 컨텍스트 생성
context = self._build_context(search_results)
# 4. 프롬프트 생성
prompt = f"""다음은 OO제품 매뉴얼에서 검색된 관련 내용입니다:
{context}
위 매뉴얼 내용을 바탕으로 다음 질문에 답변해주세요:
질문: {question}
답변:"""
# 5. LLM 답변 생성
answer = self.llm.generate(
prompt=prompt,
system=self.system_prompt
)
# 6. 관련 이미지 수집
images = self._collect_images(search_results)
return {
"answer": answer,
"images": images,
"sources": self._format_sources(search_results)
}
def _build_context(self, search_results):
"""검색 결과를 컨텍스트로 변환"""
context_parts = []
for doc, metadata in zip(search_results['documents'],
search_results['metadatas']):
pdf_name = metadata.get('pdf_name', 'Unknown')
page = metadata.get('page', 'Unknown')
context_item = f"[출처: {pdf_name} - 페이지 {page}]\n{doc}"
context_parts.append(context_item)
return "\n\n---\n\n".join(context_parts)
RAG 5단계:
- 질문 임베딩 생성
- 벡터 DB에서 유사 문서 검색
- 검색 결과를 컨텍스트로 변환
- 시스템 프롬프트 + 컨텍스트 + 질문 조합
- LLM으로 답변 생성
대화 히스토리 관리
이전 대화를 기억하는 ConversationRAG로 확장합니다.
class ConversationRAG(RAGPipeline):
"""대화 히스토리를 관리하는 RAG 파이프라인"""
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.conversation_history = []
def _is_follow_up_question(self, question: str) -> bool:
"""후속 질문인지 LLM으로 판단"""
if not self.conversation_history:
return False
last_qa = self.conversation_history[-1]
prompt = f"""다음 두 질문이 관련이 있는지 판단해주세요.
이전 질문: {last_qa['question']}
현재 질문: {question}
후속 질문이면 "예", 새로운 주제면 "아니오"라고만 답변하세요."""
response = self.llm.generate(prompt=prompt)
return "예" in response.strip()
def query(self, question: str, use_history=True):
"""대화 히스토리를 고려한 답변 생성"""
is_follow_up = False
if use_history:
is_follow_up = self._is_follow_up_question(question)
# 벡터 검색 (항상 원본 질문 사용)
query_embedding = self.embedder.embed_text(question)
search_results = self.vector_store.search(query_embedding, self.top_k)
context = self._build_context(search_results)
# 후속 질문인 경우 히스토리 포함
if is_follow_up and self.conversation_history:
recent_history = self.conversation_history[-2:]
history_text = "\n".join([
f"Q: {h['question']}\nA: {h['answer']}"
for h in recent_history
])
prompt = f"""이전 대화:
{history_text}
---
매뉴얼 내용:
{context}
질문: {question}
답변:"""
else:
prompt = self._build_prompt(question, context)
answer = self.llm.generate(prompt=prompt, system=self.system_prompt)
# 히스토리에 추가
self.conversation_history.append({
"question": question,
"answer": answer
})
return {"answer": answer, ...}
스마트 히스토리 관리:
- LLM으로 후속 질문 자동 감지
- 관련 있을 때만 히스토리 컨텍스트 추가
- 최근 2개 대화만 사용 (토큰 절약)
성능 최적화 기법
배치 임베딩
# 나쁜 예: 루프로 하나씩 처리
embeddings = [embedder.embed_text(text) for text in texts]
# 좋은 예: 배치로 한번에 처리
embeddings = embedder.embed_batch(texts, batch_size=32)
컨텍스트 길이 제한
def _build_context(self, search_results):
context_parts = []
total_length = 0
max_context_length = 4000 # 토큰 제한
for doc, metadata in zip(...):
context_item = f"[출처: {pdf_name}]\n{doc}"
if total_length + len(context_item) > max_context_length:
break # 길이 초과 시 중단
context_parts.append(context_item)
total_length += len(context_item)
return "\n\n---\n\n".join(context_parts)
이미지 중복 제거
def _is_valid_image(self, image_bytes, width, height):
# 해시 기반 중복 체크
image_hash = hashlib.md5(image_bytes).hexdigest()
if image_hash in self.image_hashes:
return False, "duplicate"
self.image_hashes.add(image_hash)
return True, "valid"
실행 예시
데이터 수집 (최초 1회)
python main.py ingest --manual-dir ~/manual
# 출력:
# 📄 Processing PDF: OO제품_사용자매뉴얼 (127 pages)
# ✓ Extracted 127 pages with content
# 📊 Image Statistics:
# Total images found: 234
# Valid images saved: 89
# Filtered images: 145
# ✓ Created 147 chunks from content
# 🔄 KURE로 147개 텍스트 임베딩 생성 중...
# ✓ 완료: 147개 임베딩 생성
대화형 챗봇 실행
python main.py chat
# 출력:
# 💬 OO제품 Manual Chatbot
# Type your question or '/help' for commands
#
# You: OO제품 비밀번호를 재설정하는 방법은?
#
# 🔍 Searching relevant documents...
# 💭 Generating answer...
#
# Assistant: OO제품 비밀번호를 재설정하는 방법은 다음과 같습니다:
#
# 1. 설정 메뉴 진입
# 2. 시스템 > 사용자 관리 선택
# 3. 해당 사용자 선택 후 '비밀번호 변경' 클릭
# 4. 기존 비밀번호와 새 비밀번호 입력
#
# 📚 관련 자료:
# 1. OO제품_사용자매뉴얼 (Page 45) [유사도: 89%]
# 2. OO제품_간편매뉴얼 (Page 12) [유사도: 76%]
#
# 📷 관련 이미지 (2개):
# - data/raw/images/OO제품_사용자매뉴얼_page045_img1.png
성능 지표
| 항목 | 수치 |
|---|---|
| PDF 문서 | 4개 (총 200페이지) |
| 추출 이미지 | 89개 (필터링 후) |
| 청크 개수 | 147개 |
| 임베딩 차원 | 1024-dim |
| 데이터 수집 시간 | ~35초 |
| 질의 응답 시간 | ~3-6초 |
| 평균 유사도 | 85%+ |
| 메모리 사용량 | ~650MB |
핵심 설계 원칙
모듈화
각 레이어가 독립적으로 작동하여 유지보수가 쉽습니다.
PDF Processor → Embedder → Vector Store → RAG Pipeline → Chatbot
한국어 최적화
- KURE-v1: 한국어 특화 임베딩
- Qwen2.5-Coder: 한국어 생성 우수
- 85%+ 유사도 정확도 달성
컨텍스트 보존
- 청킹 오버랩 200자
- 출처 정보 메타데이터 보존
- 이미지 경로 추적
사용자 경험
- 실시간 진행 상황 표시
- 상세한 출처 정보 제공
- 관련 이미지 자동 연결
결론
로컬 LLM과 한국어 특화 임베딩을 활용하여 실용적인 RAG 챗봇을 구축했습니다. 핵심은:
- PDF 처리: 텍스트/이미지 추출 → 청킹 → 메타데이터 보존
- 임베딩: KURE 한국어 특화 모델로 85%+ 정확도
- 검색: ChromaDB 코사인 유사도 Top-5 검색
- 생성: 컨텍스트 기반 LLM 답변 생성
- 대화: 스마트 히스토리 관리로 자연스러운 대화
이 시스템은 OO제품 매뉴얼뿐만 아니라 모든 PDF 기반 문서에 적용 가능하며, 완전히 로컬에서 실행되어 데이터 프라이버시가 보장됩니다.
기술 스택: Python, KURE, ChromaDB, Ollama, PyMuPDF, FastAPI, RAG, LLM
참고자료
KURE(Korea University Retrieval Embedding model) 고려대 한국어 임베딩 모델
- https://yjoonjang.medium.com/koe5-%EC%B5%9C%EC%B4%88%EC%9D%98-%ED%95%9C%EA%B5%AD%EC%96%B4-%EC%9E%84%EB%B2%A0%EB%94%A9-%EB%AA%A8%EB%8D%B8-multilingual-e5-finetune-22fa7e56d220
- https://github.com/nlpai-lab/KURE
RAG 도입기 챗봇을 만들다 (medium)
'AI' 카테고리의 다른 글
| Ollama를 이용한 로컬 코딩 어시스턴트 모델 (qwen2.5) (1) | 2026.01.07 |
|---|