DataScience
반응형

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단계:

  1. 질문 임베딩 생성
  2. 벡터 DB에서 유사 문서 검색
  3. 검색 결과를 컨텍스트로 변환
  4. 시스템 프롬프트 + 컨텍스트 + 질문 조합
  5. 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

스크린캐스트 2026-01-09 16-08-29.mp4
11.78MB


성능 지표

항목 수치
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 챗봇을 구축했습니다. 핵심은:

  1. PDF 처리: 텍스트/이미지 추출 → 청킹 → 메타데이터 보존
  2. 임베딩: KURE 한국어 특화 모델로 85%+ 정확도
  3. 검색: ChromaDB 코사인 유사도 Top-5 검색
  4. 생성: 컨텍스트 기반 LLM 답변 생성
  5. 대화: 스마트 히스토리 관리로 자연스러운 대화

이 시스템은 OO제품 매뉴얼뿐만 아니라 모든 PDF 기반 문서에 적용 가능하며, 완전히 로컬에서 실행되어 데이터 프라이버시가 보장됩니다.


기술 스택: Python, KURE, ChromaDB, Ollama, PyMuPDF, FastAPI, RAG, LLM


참고자료

로컬 RAG를 구축하고 싶으신가요?

KURE(Korea University Retrieval Embedding model) 고려대 한국어 임베딩 모델

RAG 도입기 챗봇을 만들다 (medium)

'AI' 카테고리의 다른 글

Ollama를 이용한 로컬 코딩 어시스턴트 모델 (qwen2.5)  (1) 2026.01.07
profile

DataScience

@Ninestar

포스팅이 좋았다면 "좋아요❤️" 또는 "구독👍🏻" 해주세요!