ASAC 6기/기업 Match 프로젝트

LangChain을 활용한 지역화폐 정책 RAG QA 챗봇 구축하기(1)

helena1129 2025. 5. 17. 14:30

Introduction

ASAC 6기 기업 MATCH 프로젝트가 끝나고, 랭콘에서의 발표를 위해 지역화폐 프로젝트를 RAG 챗봇까지 확장했다.

우리가 구축한 지역화폐 데이터에 대한 Text-to-SQL 챗봇과 일반 정책 및 혜택에 대한 자연어 QA 챗봇을 함께 구축하고, 웹서비스에서 하나의 챗봇 UI를 통해 유저 인풋에 대해 자동 분기처리해서 답변하도록 구성했다.

 

전체적인 아키텍처는 다음과 같았다.

내 역할은 데이터 수집 + 파이프라인 구축 + 지도 대시보드 구축 + 프론트엔드(Streamlit) + 백엔드(연결, 배포) + RAG QA 챗봇 구축이었다. 그 중 프론트엔드, 백엔드, RAG 구축은 대부분 단독으로 수행했다. 물론 멘토님의 도움도 받고~

 

 

RAG 챗봇 구축을 위한 프레임워크는 LangChain을 활용했다.

테디노트님의 랭체인 노트가 많은 도움이 되었다.

 

<랭체인LangChain 노트> - LangChain 한국어 튜토리얼🇰🇷

**추천**은 공유할 수 있는 무료 전자책을 집필하는데 정말 큰 힘이 됩니다. **"추천"** 한 번씩만 부탁 드리겠습니다🙏🙏 ✅ **랭체인 한국어 튜토리얼 강의** …

wikidocs.net

 

지금 돌이켜보면 이 프로젝트에는 LangGraph를 사용하는게 더 효율적이었을 것 같다.

왜 이렇게 했지 싶은 구석도 많고 더 개선할 부분도 많아 보이지만 일단은 RAG를 활용한 첫 번째 프로젝트였다는 점에 의의를...!

 

RAG 아키텍처는 다음과 같다.

전체적인 프로세스나 프레임워크가 사용하기 어렵진 않고, 기획이나 성능 향상 측면에서 고민해야 할 부분이 많았다.

 

벡터스토어를 구축하자

데이터

RAG 챗봇의 첫 번째 스텝은 데이터를 수집하는 것이다.

직접적으로 답변에 필요한 만큼 품질 좋은 데이터가 필요하지만...?

나는 마땅치 않아서 일단 각 지자체 홈페이지를 돌며 지역화폐 정책을 PDF로 추출해왔다.

 

참고로 PDF는 텍스트 추출 가능한 형태인지 아닌지(텍스트 드래그가 가능한지)가 처리 난이도에 큰 영향을 주기 때문에 웬만하면 텍스트 추출 가능한 형태로 가져와야 한다.

 

최종적으로 사용한 데이터는 다음과 같다.

1. PDF: 전국 지역화폐 홈페이지 설명란 발췌

2: CSV: 정부24 보조금24 지역화폐 관련 정보 크롤링

 

여기서 신경쓴 부분은 추후 메타데이터 적용을 위해서 PDF 파일명을 구조화했다는 점이다.

모두 '시도_시군구_지역화폐명_파일정보'와 같은 형식으로 통일했다.(ex. 경북_청도_청도사랑상품권_소개)

 

import os

current_dir = os.path.dirname(os.path.abspath(__file__))
base_dir = os.path.dirname(current_d

pdf_directory = os.path.join(base_dir, "이용가이드")
csv_file_path = os.path.join(base_dir, "보조금24_혜택", "정부24_보조금24_지역화폐_크롤링_전처리.csv")
vector_store_path = os.path.join(base_dir, "vector_store", "text_chatbot_chromadb")

 

 

문서 로더

저장한 데이터를 문서로 사용하기 위해 처리해주는 과정을 거쳤다.

파일 형태에 따라 적절한 로더를 선택해야 한다.

이 과정에서 메타데이터 추출을 위한 처리도 진행했다.

 

1. PDF: PyMuPDFLoader

- 구조화된 파일명 인덱싱하여 메타데이터 구축

2. CSV: Pandas DataFrame → Document

- CSV 내부에 구조화된 메타데이터 입력하고 슬라이싱하여 구축

 

import pandas as pd
from langchain.document_loaders import PyMuPDFLoader
from langchain.schema import Document

# PDF 로드
def load_pdf_documents(pdf_directory):
    documents = []

    if not os.path.exists(pdf_directory):
        print(f"🚨 PDF 디렉토리가 존재하지 않습니다: {pdf_directory}")
        return documents

    for filename in os.listdir(pdf_directory):
        if filename.endswith(".pdf"):
            file_path = os.path.join(pdf_directory, filename)
            loader = PyMuPDFLoader(file_path)
            doc_list = loader.load()

            # 파일명에서 지역명, 지역화폐명 추출
            parts = filename.replace(".pdf", "").split("_")
            sido = parts[0] if len(parts) > 0 else "정보 없음"
            sigungu = parts[1] if len(parts) > 1 else "정보 없음"
            currency_name = parts[2] if len(parts) > 2 else "정보 없음"

            # PDF 파일별 자동 메타데이터 추가
            for doc in doc_list:
                doc.metadata.update({
                    "시도": sido,
                    "시군구": sigungu,
                    "지역화폐명": currency_name,
                    "파일명": filename,
                    "문서유형": "database"
                })
            documents.extend(doc_list)

    print(f"PDF 문서 로드 완료: {len(documents)} 개")
    return documents
    
   # CSV 로드
   def load_csv_documents(csv_file_path):
    if not os.path.exists(csv_file_path):
        print(f"🚨 CSV 파일이 존재하지 않습니다: {csv_file_path}")
        return []

    df = pd.read_csv(csv_file_path, encoding="cp949")

    # 첫 3개 컬럼은 메타데이터, 나머지는 본문
    meta_columns = df.iloc[:, :3].columns.tolist()
    content_columns = df.iloc[:, 3:].columns.tolist()

    csv_documents = []
    for _, row in df.iterrows():
        metadata = {
            "시도": row[meta_columns[0]],
            "시군구": row[meta_columns[1]],
            "지역화폐명": row[meta_columns[2]],
            "문서유형": "database"
        }

        # 본문 내용 결합
        text_content = "\n".join([f"{col}: {row[col]}" for col in content_columns])

        # CSV 데이터를 `Document` 객체로 변환
        csv_documents.append(Document(page_content=text_content, metadata=metadata))

    print(f"CSV 문서 로드 완료: {len(csv_documents)} 개")
    return csv_documents

 

 

문서 분할

문서 분할, 즉 청킹 방법도 문서의 특징, 전략에 따라 적절하게 선택해주면 된다.

나는 일반적인 텍스트에 활용할 수 있는 Recursive 방법을 선택했다.

RecursiveCharacterTextSplitter(chunk_size=700, chunk_overlap=150)

 

추가로, 분할 후 불필요한 메타데이터 필드를 제거하고 특정 필드로만 제한했다.

- 시도, 시군구, 지역화폐명, 파일명(pdf만), 문서유형 + 정보없음

 

from langchain.text_splitter import RecursiveCharacterTextSplitter

def split_documents(documents):
    text_splitter = RecursiveCharacterTextSplitter(chunk_size=700, chunk_overlap=150)
    split_docs = text_splitter.split_documents(documents)

    print(f"문서 조각 생성 완료: {len(split_docs)} 개")

    # 메타데이터 필드 제한
    allowed_metadata_keys = {"시도", "시군구", "지역화폐명", "파일명", "문서유형"}

    cleaned_docs = []
    for doc in split_docs:
        new_metadata = {key: doc.metadata.get(key, "정보 없음") for key in allowed_metadata_keys}
        cleaned_docs.append(Document(page_content=doc.page_content, metadata=new_metadata))

    print(f"메타데이터 정리 완료: {len(cleaned_docs)} 개")
    return cleaned_docs

 

 

임베딩

임베딩은 OpenAI의 OpenAIEmbeddings를 사용했다.

 

from langchain_openai import OpenAIEmbeddings

embeddings = OpenAIEmbeddings()

 

 

벡터스토어

작은 프로젝트에서는 보통 FAISS와 ChromaDB 중 하나를 선택하는 것 같은데, 나는 메타데이터를 활용할 생각이었으므로 문서와 메타데이터를 함께 저장할 수 있는 ChromaDB를 선택했다.

 

ChromaDB의 파라미터인 collection_name으로는 벡터스토어를 분리할 수 있지만, 벡터스토어를 불러올 때 콜렉션 네임이 정확해야 불러와진다.

 

from langchain.vectorstores import Chroma

def save_to_chroma(split_docs, persist_directory):
    os.makedirs(persist_directory, exist_ok=True)

    vectorstore = Chroma.from_documents(
        documents=split_docs,
        embedding=embeddings,
        persist_directory=persist_directory,
        collection_name='text_db_0'
    )

    vectorstore.persist()
    print(f"ChromaDB 저장 완료: {persist_directory}")

 

 

벡터DB스토어 구축을 위한 모든 파이프라인은 다음과 같다.

 

pdf_documents = load_pdf_documents(pdf_directory)
csv_documents = load_csv_documents(csv_file_path)

# 문서 통합
all_documents = pdf_documents + csv_documents

if len(all_documents) == 0:
    print("🚨 로드된 문서가 없습니다. 프로세스를 종료합니다.")
else:
    # 문서 분할
    split_docs = split_documents(all_documents)

    # ChromaDB 저장
    save_to_chroma(split_docs, persist_directory=vector_store_path)

 

 

모든 코드는 깃허브에서 볼 수 있다.

 

 

GitHub - da-analysis/asac_6_dataanalysis: 전국 지역 화폐 데이터 현황판 구축 (with 챗봇) - ASAC 데이터 프로

전국 지역 화폐 데이터 현황판 구축 (with 챗봇) - ASAC 데이터 프로젝트. Contribute to da-analysis/asac_6_dataanalysis development by creating an account on GitHub.

github.com

 

 

다음 포스트에서는 본격적으로 RAG 모델을 구축하는 프로세스를 정리하고자 한다.