AIRAGLangChainChromaGradioChatbot

LangChain + Chroma로 PDF 기반 금융 용어 RAG 챗봇 만들기

July 28, 202615
조회수 불러오는 중

들어가며

최근 개인 프로젝트로 RAG(Retrieval-Augmented Generation) 관련 실습을 진행하면서, PDF 문서를 지식 베이스로 삼는 챗봇을 만들어봤다. 이번 글에서는 한국은행이 배포한 『2020 경제금융용어 700선』 PDF를 벡터DB에 저장하고, 이를 기반으로 질문에 답하는 챗봇을 LangChain과 Gradio로 구현한 과정을 정리한다.

핵심 스택은 다음과 같다.

  • LangChain: 문서 로딩, 텍스트 분할, RAG 체인 구성
  • Chroma: 임베딩 벡터 저장소
  • OpenAI Embeddings / GPT-4.1: 임베딩 생성 및 답변 생성
  • Gradio: 챗봇 UI

1. 전체 아키텍처

RAG 파이프라인은 크게 두 단계로 나뉜다.

  1. 인덱싱(Indexing): PDF → 텍스트 분할 → 임베딩 → 벡터DB 저장
  2. 질의응답(Query): 질문 임베딩 → 유사 문서 검색 → LLM에 컨텍스트로 전달 → 답변 생성
plain text
[PDF] → [PyPDFLoader] → [텍스트 분할] → [임베딩] → [Chroma 벡터DB]

[사용자 질문] → [검색기(Retriever)] → [관련 문서 k개] → [프롬프트 + LLM] → [답변]

2. 문서 로드 및 준비

먼저 한국은행에서 배포한 PDF를 다운로드하고, PyPDFLoader로 페이지 단위 문서 객체로 변환한다.

python
import os
from dotenv import load_dotenv
import urllib.request
from langchain_community.document_loaders import PyPDFLoader

load_dotenv("agent_env/.env")

urllib.request.urlretrieve(
    "https://raw.githubusercontent.com/ai-agent-kr/agent-tutorial/main/Ch02/2020_경제금융용어 700선_게시.pdf",
    "2020_경제금융용어 700선_게시.pdf"
)

loader = PyPDFLoader("2020_경제금융용어 700선_게시.pdf")
texts = loader.load_and_split()
print('문서의 수:', len(texts))

PyPDFLoader.load_and_split()은 페이지 단위로 문서를 분할해준다. 다만 실제 PDF에는 표지, 목차, 발간사처럼 본문과 무관한 페이지가 섞여 있기 때문에, 이런 부분은 별도로 잘라내는 전처리가 필요했다.

python
# 앞부분(표지, 목차 등) 제거
texts = texts[13:]
print('줄어든 문서의 개수:', len(texts))

# 마지막 페이지(판권 정보 등) 제거
texts = texts[:-1]
print('마지막 데이터 제거 후 문서의 개수:', len(texts))
포인트: RAG 품질은 결국 "얼마나 깨끗한 텍스트를 넣느냐"에 달려 있다. 인덱싱 전 불필요한 페이지를 잘라내는 작업만으로도 검색 정확도가 눈에 띄게 달라진다.

3. 임베딩 생성 및 벡터DB 저장

전처리된 문서를 OpenAI Embeddings로 벡터화하고 Chroma에 저장한다.

python
from langchain_openai import OpenAIEmbeddings
from langchain_community.vectorstores import Chroma

embedding = OpenAIEmbeddings()

vectordb = Chroma.from_documents(documents=texts, embedding=embedding)
print(vectordb._collection.count())  # 저장된 청크(벡터) 개수

저장된 데이터의 메타데이터를 확인하면 각 청크가 어느 페이지에서 왔는지도 추적할 수 있다.

python
metadatas = vectordb._collection.get()['metadatas']
print('metadatas의 개수 :', len(metadatas))
print('첫 번째 문서의 출처 :', metadatas[0])

4. 검색기(Retriever) 구성 및 테스트

Chroma를 검색기로 변환하고, 유사도 기반 검색이 잘 동작하는지 확인한다.

python
retriever = vectordb.as_retriever(search_kwargs={"k": 2})

docs = retriever.invoke("비트코인이 궁금해")
print('유사 문서 개수 :', len(docs))
print('첫 번째 유사 문서 :', docs[0])
print('두 번째 유사 문서 :', docs[1])

k=2로 설정해 질문당 상위 2개의 관련 청크만 가져오도록 했다. k값은 답변의 풍부함과 프롬프트 길이(비용) 사이의 트레이드오프이므로, 실제 서비스에서는 문서 특성에 맞게 튜닝이 필요하다.

테스트 결과


5. 프롬프트 설계 및 LLM 연결

검색된 문서를 컨텍스트로 활용해 답변을 생성하도록 프롬프트를 설계했다. 이 단계에서 챗봇의 "페르소나"와 "답변 정책"을 정의한다.

python
from langchain_core.prompts import PromptTemplate
from langchain_openai import ChatOpenAI

template = """당신은 한국은행에서 만든 금융 용어를 설명해주는 금융쟁이입니다.
주어진 검색 결과를 바탕으로 답변하세요.
검색 결과에 없는 내용이라면 답변할 수 없다고 하세요. 반말로 친근하게 답변하세요.
{context}

Question: {question}
Answer:
"""

prompt = PromptTemplate.from_template(template)

llm = ChatOpenAI(model_name="gpt-4.1", temperature=0)

여기서 핵심은 "검색 결과에 없는 내용이라면 답변할 수 없다고 하세요" 라는 지시문이다. 이 한 줄이 없으면 LLM이 검색되지 않은 정보까지 그럴듯하게 지어내는(hallucination) 문제가 발생하기 쉽다. RAG 챗봇을 만들 때 반드시 넣어야 하는 안전장치라고 생각한다.


6. RAG 체인 함수화

검색 → 프롬프트 조립 → LLM 호출까지의 흐름을 하나의 함수로 묶었다.

python
def query_rag(question):
    docs = retriever.invoke(question)
    context = "\n\n".join([doc.page_content for doc in docs])
    formatted_prompt = prompt.format(question=question, context=context)
    response = llm.invoke(formatted_prompt)
    return {
        "query": question,
        "result": response.content,
        "source_documents": docs
    }

chatbot_response = query_rag("디커플링이란 무엇인가?")
print(chatbot_response)

chatbot_response 결과

python
{'query': '디커플링이란 무엇인가?', 'result': '디커플링(decoupling)은 탈동조화라고 하는데, 어떤 나라나 지역의 경제가 인접한 다른 국가나 전반적인 세계 경제의 흐름과는 다르게 움직이는 현상을 말해. 예를 들어, 미국 경제가 침체인데 신흥국 경제는 잘 나간다거나, 미국 주가는 오르는데 한국 주가는 내리는 식으로 서로 다른 방향으로 움직이는 거지. 또 한 나라 안에서도 주가는 떨어지는데 그 나라 통화는 강세를 보이는 경우도 디커플링이라고 할 수 있어. 반대로 경제 흐름이 비슷하게 움직이면 그건 커플링(동조화)라고 해.', 'source_documents': [Document(metadata={'moddate--text': 'January 31, 2018 16:21', 'moddate': 'January 31, 2018 16:21', 'author': 'INSUNG DATA INC.', 'creator': 'Smart PDF Imposition 1.0', 'total_pages': 371, 'title': '°æÁ¦±ÝÀ¶¿ë¾î 700¼±-ÃÖÁ¾.PDF', 'creationdate': 'January 31, 2018 16:21', 'subject': 'CoreImposition PDF 1.0 Output', 'producer': 'Smart PDF Imposition 1.0', 'source': '2020_경제금융용어 700선_게시.pdf', 'creationdate--text': 'January 31, 2018 16:21', 'page': 114, 'page_label': '115'}, page_content='98\n경제금융용어 700선\n가계와 기업이 소비와 투자를 미룸으로써 수요의 위축을 초래하여 디플레이션 압력을 \n보다 크게 할 수도 있다. 이 같은 상황에서는 경제주체들의 물가상승률 예상치가 더 \n낮아지지 않도록 사전에 통화 ・재정정책 면에서 적극적으로 대응하는 것이 중요하다 .\n 연관검색어 : 디플레이션,  통화정책\n디커플링/커플링\n디커플링(decoupling)이란 탈동조화라고 번역할 수 있는데, 어떤 나라나 지역의 경제\n가 인접한 다른 국가나 전반적인 세계 경제의 흐름과는 다른 모습을 보이는 현상을 \n말한다. 최근의 디커플링의 예로는 금융위기 이후 신흥국가나 유로지역 국가 등이 특히 \n미국 경제와 다른 모습을 보이는 것을 들 수 있다. 이외에도 주가나 금리, 환율 등 \n일부 경제 변수의 흐름이 국가 간 또는 특정국가내에서 서로 다른 흐름을 보이는 현상도 \n디커플링이라고 할 수 있다. 국가 간의 경우 미국의 주가와 한국의 주가가 다른 방향으로 \n움직이거나, 한 국가 내에서의 경우 주가가 하락함에도 해당국 통화가 강세 현상을 \n보이는 경우 등을 말한다. 반대로 한 나라 또는 지역의 경제가 인접한 다른 국가나 \n세계경제 흐름과 유사한 흐름을 보이는 것을 커플링(동조화 ・coupling)이라 한다.\n디플레이션\n물가가 지속적으로 하락하는 현상을 말한다. 디플레이션(deflation) 하에서는 물가상\n승률이 마이너스로 하락하는 인플레이션이 나타난다. 디플레이션이 발생하는 원인은 \n생산물의 과잉공급, 자산거품의 붕괴, 과도한 통화 긴축정책, 생산성 향상 등 다양하다. \n그러나 궁극적으로는 유통되는 통화의 양이 재화 및 서비스의 양보다 적기 때문에 \n화폐가치는 상승하고 반대로 물가는 하락하는 디플레이션이 발생한다는 점에 이견이 \n없다. 디플레이션이 발생하면 통화의 가치는 상승하고 실물자산의 가치는 하락함에 \n따라 인플레이션과 반대 방향으로 소득 및 부의 비자발적 재분배가 발생한다. 이외에도 \n실질금리 상승에 따른 총수요 감소, 실질임금 상승에 따른 고용 및 생산 감소, 소비지출 \n연기에 따른 경제활동 위축, 부채디플레이션에 따른 총수요 감소, 통화정책 및 재정정책 \n등 정책적 대응 제약, 디플레이션 악순환 가능성 등의 문제를 일으킬 수 있다 . \n 연관검색어 : 인플레이션, 피셔효과'), Document(metadata={'total_pages': 371, 'moddate': 'January 31, 2018 16:21', 'creationdate': 'January 31, 2018 16:21', 'creator': 'Smart PDF Imposition 1.0', 'producer': 'Smart PDF Imposition 1.0', 'source': '2020_경제금융용어 700선_게시.pdf', 'title': '°æÁ¦±ÝÀ¶¿ë¾î 700¼±-ÃÖÁ¾.PDF', 'subject': 'CoreImposition PDF 1.0 Output', 'author': 'INSUNG DATA INC.', 'page': 113, 'creationdate--text': 'January 31, 2018 16:21', 'moddate--text': 'January 31, 2018 16:21', 'page_label': '114'}, page_content='97\n\n제가 시행됨에 따라 은행들이 금융기관을 대상으로 CD를 발행하는 경우 대부분 CD를 \n실물발행하지 않고 한국예탁결제원을 통해 등록발행하고 있다 . \n디레버리징\n디레버리징(deleveraging)은 부채를 축소하는 것을 말한다. 미시경제 측면에서 보면, \n가계나 기업 등 개별 경제주체의 대차대조표에서 부채의 비중을 낮추는 것을 의미한다. \n경기가 호황일 때는 상대적으로 낮은 금리로 자금을 차입하여 수익성이 높은 곳에 투자해 \n빚을 상환하고도 수익을 많이 낼 수 있는 레버리지(leverage)가 효과적인 투자기법이 된다. \n그러나 경기가 불황일 때는 자산가치가 급격히 하락하여 수익성이 낮아지고 금리가 상대적\n으로 높아지게 되므로 부채를 상환, 정리, 감축하는 디레버리징이 효과적인 투자기법이 \n된다. 디레버리징을 위해서는 경제주체는 자산을 매각하거나 증자를 하거나 해서 자금을 \n조달해야 한다. 디레버리징은 자금난을 겪고 있는 민간부문에게는 고통스러운 과정인데, \n이는 자산을 할인가격에 매각하는 것 자체가 심각한 손실로 귀결되기 때문이다. 거시경제 \n측면에서 보면, 경제 전체의 디레버리징은 민간과 정부 등 여러 부문에서의 동시다발적인 \n부채 수준의 감축을 뜻한다. 통상 국민계정에서 GDP 대비 총부채 비율의 하락으로 측정된\n다. 2008년 글로벌 금융위기 이후 세계 각국에서 나타난 경제 전체의 디레버리징은 거시경\n제 전반에 심대한 영향을 미쳤으며, 종종 경기불황으로 이어졌다. 민간부문의 디레버리징을 \n상쇄하는 과정에서 정부부채가 증가하는 현상이 많은 나라에서 관찰되었다.\n 연관검색어 : 레버리지 효과\n디스인플레이션\n물가수준이 지속적으로 높아지고 있으나 물가상승률은 둔화되는 현상을 말한다. 예를 \n들어 소비자물가상승률이 2015년에는 5.0%이었으나 2016년에는 3.5%, 2017 년에는 \n2.0% 등으로 낮아지는 경우 매년 물가상승률이 0보다 크기 때문에 상품과 서비스의 \n가격 수준은 지속적으로 상승하고 있지만, 상승률은 5.0%→3.5%→2.0%로 낮아지고 \n있는데 바로 이러한 현상을 디스인플레이션(disinflation)이라 한다. 디스인플레이션은 \n단기간에 그치면 크게 걱정할 일은 아니다. 예를 들어 에너지가격 하락이나 생산성 \n증대 등 공급요인으로 인한 일시적인 물가상승률의 둔화는 경제에 이로울 수 있다 . \n그러나 디스인플레이션이 계속돼 물가상승률의 추가적인 하락이 전망되는 경우에는 \n디스인플레이션 ∙')]}

간단한 래퍼 함수도 추가해, UI 단에서는 답변 텍스트만 깔끔하게 받아 쓸 수 있게 했다.

python
def get_chatbot_response(input_text):
    chatbot_response = query_rag(input_text)
    return chatbot_response['result'].strip()

7. Gradio로 챗봇 UI 붙이기

참고사이트https://gradio.app/guides/creating-a-chatbot-fast

마지막으로 Gradio의 Chatbot 컴포넌트를 사용해 웹 UI를 구성했다.

python
import gradio as gr

def respond(message, chat_history):
    if chat_history is None:
        chat_history = []

    result = query_rag(message)
    bot_message = result.get("result", str(result)) if isinstance(result, dict) else str(result)

    chat_history.append({"role": "user", "content": message})
    chat_history.append({"role": "assistant", "content": bot_message})

    return "", chat_history


with gr.Blocks() as demo:
    chatbot = gr.Chatbot(label="경제금융용어 챗봇", height=500)
    msg = gr.Textbox(label="질문해주세요!")
    clear = gr.Button("대화 초기화")

    msg.submit(respond, [msg, chatbot], [msg, chatbot])
    clear.click(lambda: [], outputs=chatbot, queue=False)

demo.launch(debug=True)

chat_history{"role": ..., "content": ...} 형태의 딕셔너리 리스트로 관리하는 방식은 최신 Gradio의 메시지 포맷을 따른 것으로, OpenAI Chat Completion 형식과 호환되어 나중에 대화 이력을 그대로 LLM 컨텍스트에 넣기도 편하다.

Chatbot UI 테스트

8. 회고 및 개선 아이디어

실습을 진행하며 느낀 점과, 다음에 시도해보고 싶은 개선 방향을 정리했다.

  • 청크 분할 전략: 이번에는 페이지 단위 분할(load_and_split)만 사용했는데, 용어집처럼 항목 단위로 정보가 나뉘는 문서는 RecursiveCharacterTextSplitterSemanticChunker로 의미 단위 청킹을 적용하면 검색 정확도가 더 올라갈 것 같다.
  • 출처 표시(citation): source_documents를 이미 받아오고 있으니, UI에서 "몇 페이지 근거"인지 답변과 함께 보여주면 신뢰도가 올라갈 것이다.
  • 벡터DB 영속화: 현재는 인메모리 Chroma라 매번 재실행 시 재인덱싱이 필요하다. persist_directory를 지정하거나 pgvector 같은 영속 저장소로 교체하면 실서비스에 가까워진다.
  • 평가(Evaluation): 답변 품질을 정성적으로만 확인했는데, RAGAS 같은 프레임워크로 정량 평가를 붙여보고 싶다.

마무리

이번 실습으로 PDF 한 장으로 시작해 "인덱싱 → 검색 → 생성 → UI"로 이어지는 RAG 파이프라인의 전체 흐름을 한 번에 훑을 수 있었다.

관련 글

댓글 기능은 준비 중입니다.