본문 바로가기
사이드프로젝트

[사이드 프로젝트] #2. RAG 기반 '업무 지식' AI 챗봇 개발기 (2) - 챗봇 프로토타입 및 RAG 기본개념정리

by Jaejin Sim 2025. 10. 24.
반응형

10월 20일부터 23일까지 4일간, 사이드 프로젝트의 핵심 기능 중 하나인 ChatGPT 채팅 기능을 완성했습니다. 이번 글에서는 챗봇의 기본 기능을 구현한 과정과 다음 단계인 RAG를 위해 학습한 기술적인 내용들을 정리해 봅니다.

1. 챗봇 기본 기능 구현

이번 4일간의 작업으로 챗봇의 뼈대를 완성했습니다. 주요 작업 내용은 다음과 같습니다.

  • 대화 이어서 하기 (세션 관리)
  • 대화 내용 저장 (SQLite DB)
  • 이전 대화 목록 불러오기
  • 스트리밍 응답 처리

Streamlit을 사용하니 챗봇 UI와 기본 로직을 4일 만에 완성할 수 있을 정도로 놀라운 생산성을 보여주었습니다.

다만 Streamlit의 특징상 데이터 수정이나 변경이 발생하면 전체 파일을 다시 로드하는 방식이라, 불필요한 리소스 낭비를 막기 위한 적절한 캐싱 전략이 필수적이었습니다.

2. LangGraph를 선택한 이유와 스트리밍

온라인 강의에서는 단순히 message 배열에 append 하는 방식으로 대화 이력을 관리하는 법을 배웠지만, 저는 LangGraph를 도입했습니다.

LangGraph를 사용하면 config 객체에 thread_id를 설정하여 invoke에 전달하는 것만으로 간단하게 대화를 이어갈 수 있습니다.

config = {
    "configurable": {
        "thread_id": f"{user_id}:{conversation_id}"  # 대화 식별자
    }
}

데이터베이스는 SQLite를 사용했으며, 위 코드의 thread_id를 기준으로 사용자별/대화별 기록을 분류하여 관리하고 있습니다.

LangGraph vs LangChain: 스트리밍의 트레이드오프

제가 LangChain 대신 LangGraph를 채택한 이유는 명확합니다. 이 프로젝트의 최종 목표는 단순한 챗봇이 아니라, 특정 API를 호출하는 등 복잡한 로직 처리가 가능한 AI 에이전트를 구현하는 것입니다. 예를 들어, "주문번호 12345가 정상 승인되었는지 확인해 줘" 같은 요청을 처리하려면 그래프 기반의 로직 처리가 가능한 LangGraph가 필수적입니다.

하지만 이 선택에는 한 가지 트레이드오프가 있었습니다.

  • LangChain: llm.stream()을 사용하면 토큰 기반 스트리밍이 간단하게 구현됩니다. (타이핑하듯 글자가 나옴)
  • LangGraph: 아쉽게도 토큰 기반 스트리밍을 지원하지 않고, 노드(Node) 기반 스트리밍만 가능합니다.

노드 기반 스트리밍은 Gemini가 응답을 생성할 때처럼, 일정 시간 로딩 후 결과가 한 번에 노출됩니다. 이 단점을 보완하기 위해, UI에 "문서를 탐색하고 있습니다..."와 같은 안내 메시지를 먼저 노출하고 이후에 최종 결과를 표시하는 방식으로 사용자 경험(UX)을 개선했습니다.

# LangChain의 간단한 토큰 스트리밍 (사용 안 함)
# response = st.write_stream(
#     llm.stream([HumanMessage(content=user_input)])
# )
	 
# LangGraph의 노드 기반 스트리밍 처리 (실제 사용 코드)
full_response = ""
for chunk in graph.stream(
        {"messages": [HumanMessage(content=user_input)]}, 
        config=current_config,
        stream_mode="values"
    ):
    if "messages" in chunk:
        messages = chunk["messages"]
        if messages:
            last_message = messages[-1]
            if isinstance(last_message, AIMessage):
                full_response = last_message.content
                print(f"✅ 응답 받음: {full_response[:50]}...")
                
    # full_response가 완성되면 루프 종료 (혹은 다른 노드 처리)
    if full_response:
        break

3. RAG 구현을 위한 기술 학습

챗봇의 기본 UI가 완성되었으니, 이제 이 프로젝트의 핵심인 RAG(Retrieval-Augmented Generation) 기능을 구현할 차례입니다.

RAG는 단어가 거창해 보이지만, 개념은 간단합니다. 내가 정리한 '업무 지식' 문서 전체를 LLM에 한 번에 요청하면 토큰 제한에 걸릴 뿐만 아니라, 너무 광범위한 정보 때문에 할루시네이션(거짓 정보)이 발생하기 쉽습니다.

RAG는 이 문제를 해결하기 위해, 사용자의 질문과 가장 관련 있는 문서 조각(Chunk)만 찾아서 LLM에 함께 요청하는 방식입니다.

그렇다면 "내 질문에 맞는 문서 조각"은 어떻게 찾을까요? 이 과정을 벡터 DB임베딩(Embedding)이라는 기술로 처리합니다.

  1. 임베딩: 내 문서를 조각조각(Chunk) 내어 컴퓨터가 이해할 수 있는 숫자 배열(벡터)로 변환합니다.
  2. 벡터 DB 저장: 이 벡터 값들을 DB에 저장합니다.
  3. 검색: 사용자 질문 또한 벡터로 변환한 뒤, DB에서 가장 '유사한' 의미를 가진 문서 조각(벡터)을 찾아냅니다.

1) 임베딩 모델과 CPU 설정

임베딩을 할 때는 유료 모델(e.g., OpenAI)과 무료 모델(e.g., HuggingFace)을 사용할 수 있습니다.

  • 유료 모델: 빠르고 성능이 좋지만 비용이 듭니다.
  • 무료 모델: 내 컴퓨터 자원을 사용하므로 느릴 수 있지만 비용이 무료입니다.

저는 무료 모델인 HuggingFaceEmbeddings를 사용하기로 했습니다.

from langchain_huggingface import HuggingFaceEmbeddings

embeddings = HuggingFaceEmbeddings(
    model_name="jhgan/ko-sroberta-multitask",  # 한국어 모델
    model_kwargs={'device': 'cpu'},            # GPU 대신 CPU 사용
    encode_kwargs={'normalize_embeddings': True}
)

여기서 중요한 점은 model_kwargs={'device': 'cpu'} 설정입니다. 제 컴퓨터는 사양이 높지 않아 GPU 대신 CPU 기반 임베딩을 선택했습니다.

이때 한 가지 이슈가 있었습니다. HuggingFaceEmbeddings를 사용하려면 sentence-transformers 패키지를 설치해야 하는데, 이 패키지는 기본적으로 GPU 관련 의존성(torch, torchvision 등)을 포함하고 있어 용량이 매우 큽니다.

CPU만 사용하려는 제게는 불필요한 설치였습니다. 그래서 다음과 같이 GPU 패키지를 제외하고 CPU 버전만 수동으로 설치했습니다. (uv 기준)

# 1. CPU용 PyTorch 먼저 설치
uv pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu

# 2. sentence-transformers 설치 (GPU 의존성 제외)
uv pip install sentence-transformers

남은 과제: uv sync를 실행하면 수동으로 설치한 패키지가 삭제되는 문제가 있습니다. 추후 pyproject.toml에 이 설정을 고정하는 방법을 찾아야 합니다.

2) 문서를 '잘' 쪼개는 기술: Chunking

RAG의 성능은 문서를 '얼마나 잘 쪼개는지(Chunking)'에 달려있습니다.

단순히 300줄씩 자른다면, 문장의 맥락이 중간에 끊겨 검색 품질이 크게 저하될 것입니다.

A. 기본: RecursiveCharacterTextSplitter

가장 많이 사용되는 RecursiveCharacterTextSplitter는 단순 길이로 자르지 않고, 아래와 같이 우선순위를 두고 문서를 분할합니다.

  1. 문단 구분 (\n\n)
  2. 줄바꿈 (\n)
  3. 문장 끝 (., !, ?)
  4. 공백 ( )
  5. 최후의 수단 (글자 단위)

 

from langchain.text_splitter import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    chunk_size=200,    # 청크 크기
    chunk_overlap=50,  # <-- 핵심
    separators=["\n\n", "\n", ".", "!", "?", " ", ""]
)

여기서 핵심은 chunk_overlap=50 옵션입니다. 이는 청크와 청크 사이에 50글자를 겹치게 만드는 옵션입니다.

예시: 원본: "A 문장. B 문장. C 문장. D 문장. E 문장."

Chunk 1: "A 문장. B 문장. C 문장." [overlap: "C 문장."] Chunk 2: "C 문장. D 문장. E 문장."

이렇게 하면 청크의 경계에서 맥락이 끊기더라도, 겹치는 부분(C 문장)을 통해 다음 청크에서 의미가 이어져 검색 품질이 향상됩니다.

B. 심화: SemanticChunker (의미 기반 분할)

RecursiveCharacterTextSplitter보다 한 단계 더 나아간 방식도 있습니다. 바로 **'의미'**를 분석하여 주제가 바뀌는 지점에서 문서를 분할하는 SemanticChunker입니다.

from langchain_experimental.text_splitter import SemanticChunker

# 의미가 바뀌는 지점을 감지하여 자동 분할
semantic_splitter = SemanticChunker(
    embeddings=embeddings,  # 위에서 만든 임베딩 모델 사용
    breakpoint_threshold_type="percentile", # 상위 X% 의미 차이가 나면 분할
    breakpoint_threshold_amount=95
)

# docs_text는 실제 문서 내용
chunks = semantic_splitter.split_text(docs_text)

이 방식은 문장의 의미(시맨틱)를 분석하여 주제가 전환되는 지점을 스스로 찾아내 분할하므로, 논리적으로 더 잘 분리된 문서 조각을 얻을 수 있습니다.

4. 다음 단계

지금까지 챗봇 UI를 완성하고, RAG의 핵심인 임베딩과 청킹(Chunking) 기술에 대해 학습하고 테스트해 보았습니다.

다음 블로그 글에서는 실제 '업무 지식' 문서를 다듬고, 오늘 배운 청킹 및 임베딩 전략을 적용하여 벡터 DB를 구축하는 과정을 다루겠습니다.

반응형