LangChain 챗봇7장 · 웹 UI 연동과 배포18 / 22← 이전목차다음 →MetaContents

7장. 웹 UI 연동과 배포

실습 5 — 문서 챗봇 웹 화면

6장에서 만든 문서 검색(약관 PDF → 조각 → BigramEmbeddings 와 FAISS → RetrievalQA)을 채팅 화면에 붙여, 구름다리여행 약관에 답하는 문서 챗봇(out_ch07_docbot.py)을 만듭니다. 색인은 앱이 처음 켜질 때 st.cache_resource 로 한 번만 만들고, 답 아래에 근거 조항을 펼쳐 볼 수 있게 합니다. 약관 travel_terms.pdf 는 연습용으로 지어낸 가상 문서입니다. 6장 실습의 저장 파일에 기대지 않고 앱이 PDF 에서 직접 색인을 만들므로 6장 실습을 먼저 하지 않아도 됩니다. 답을 쓰는 자리에는 가짜 모델을 두어 API 키 없이 돌아갑니다. 가짜 모델의 답은 미리 적어 둔 것이고, 근거 조항은 실제 검색 결과입니다.

구름다리여행 약관 챗봇 화면을 본뜬 그림으로 사이드바의 조각 수와 색인 횟수, 질문과 답의 말풍선, 펼친 근거 조항, 입력창에 번호를 붙여 설명한 그림

화면을 본뜬 그림입니다. 실제 캡처가 아니고, 실제 화면의 색과 배치는 판마다 조금 다릅니다. 1번 사이드바에는 약관 조각 수와 색인을 만든 횟수가, 2번과 3번에는 손님의 질문과 챗봇의 답이, 4번에는 답 아래에 펼친 근거 조항(쪽과 조항 번호)이, 5번에는 입력창이 놓입니다. 이 실습의 4단계까지 마치면 화면이 이 꼴이 됩니다.

1단계. 문서 챗봇 앱 파일 쓰기

Python
from pathlib import Path

APP_CODE = r'''
import re
import zlib
from pathlib import Path

import streamlit as st
from langchain_classic.chains import RetrievalQA
from langchain_community.document_loaders import PyPDFLoader
from langchain_community.vectorstores import FAISS
from langchain_core.embeddings import Embeddings
from langchain_core.language_models.fake_chat_models import FakeListChatModel
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 앱 파일과 같은 폴더의 약관 PDF. 어디서 실행해도 같은 파일을 찾게 앱 파일의 자리를 기준으로 적는다
PDF = Path(__file__).parent / "travel_terms.pdf"
# 쪽마다 맨 위에 붙은 머리글. 조각마다 섞여 검색을 흐리므로 지운다
HEADER = "구름다리여행 여행 약관 (연습용 가상 문서)"


class BigramEmbeddings(Embeddings):
    """글자 두 개 묶음(바이그램)을 세어 숫자 목록을 만드는 연습용 임베딩"""

    def __init__(self, size=512):
        self.size = size

    def _vector(self, text):
        text = text.replace(" ", "").replace("\n", "")
        vec = [0.0] * self.size
        for i in range(len(text) - 1):
            # 글자 두 개를 crc32 로 0~511 사이의 칸 번호로 바꿔 그 칸을 1 올린다
            vec[zlib.crc32(text[i:i + 2].encode("utf-8")) % self.size] += 1.0
        norm = sum(v * v for v in vec) ** 0.5 or 1.0
        return [v / norm for v in vec]

    def embed_documents(self, texts):
        return [self._vector(t) for t in texts]

    def embed_query(self, text):
        return self._vector(text)


@st.cache_resource
def docbot_build_log():
    """색인을 만든 기록. 서버가 켜져 있는 동안 하나만 있다"""
    return []


# show_spinner: 처음 만드는 동안 화면에 보일 기다림 글
@st.cache_resource(show_spinner="약관으로 색인을 만드는 중입니다...")
def load_docbot():
    """6차시의 문서 검색: PDF 읽기 → 머리글 지우기 → 조각 내기 → FAISS 색인 → RetrievalQA"""
    docbot_build_log().append("색인")
    pages = PyPDFLoader(str(PDF)).load()
    for page in pages:
        page.page_content = page.page_content.replace(HEADER, "").strip()
    chunks = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=30).split_documents(pages)
    db = FAISS.from_documents(chunks, BigramEmbeddings())
    # 답을 쓰는 자리의 가짜 모델. 미리 적어 둔 답을 차례로 말한다
    llm = FakeListChatModel(responses=[
        "출발 3일 전부터 6일 전까지는 상품 금액의 30%를 취소 수수료로 뗍니다. 5일 전이면 30%입니다.",
        "반려동물은 '함께 여행' 메뉴의 동반 상품에서만 데려갈 수 있고, 7kg 이하의 개와 고양이만 됩니다.",
    ])
    qa = RetrievalQA.from_chain_type(
        llm=llm,
        retriever=db.as_retriever(search_kwargs={"k": 2}),   # 가까운 조각 2개를 찾는다
        return_source_documents=True,                         # 답과 함께 근거 조각을 돌려준다
    )
    return qa, len(chunks)


def source_label(doc):
    """근거 조각 하나를 '2쪽 · 제5조, 제6조' 꼴로 줄인다"""
    # re.findall: 글에서 '제(숫자)조' 꼴을 모두 찾는다. dict.fromkeys 로 겹친 것을 빼며 순서를 지킨다
    names = list(dict.fromkeys(re.findall(r"제\d+조", doc.page_content)))
    return f"{doc.metadata['page'] + 1}쪽 · {', '.join(names)}"


def show(message):
    """말풍선 하나를 그린다. 답에 근거가 있으면 접어 둔 칸에 넣는다"""
    with st.chat_message(message["role"]):
        st.markdown(message["content"])
        if message.get("sources"):
            # st.expander: 눌러야 펼쳐지는 접힌 칸
            with st.expander("근거 조항 보기"):
                for s in message["sources"]:
                    st.caption(s)


qa, n_chunks = load_docbot()

st.title("구름다리여행 약관 챗봇")
with st.sidebar:
    st.header("문서")
    st.caption(f"약관 조각: {n_chunks}개")
    st.caption(f"색인을 만든 횟수: {len(docbot_build_log())}")

if "doc_messages" not in st.session_state:
    st.session_state.doc_messages = []
for m in st.session_state.doc_messages:
    show(m)

if question := st.chat_input("약관에 대해 물어보세요"):
    user = {"role": "user", "content": question}
    show(user)
    # st.spinner: 기다리는 동안 돌아가는 표시와 글을 보인다
    with st.spinner("약관에서 찾는 중입니다..."):
        result = qa.invoke({"query": question})
    bot = {"role": "assistant", "content": result["result"],
           "sources": [source_label(d) for d in result["source_documents"]]}
    show(bot)
    st.session_state.doc_messages.extend([user, bot])
'''

Path("out_ch07_docbot.py").write_text(APP_CODE.lstrip(), encoding="utf-8")
print("저장: out_ch07_docbot.py,", len(APP_CODE.strip().splitlines()), "줄")
출력
저장: out_ch07_docbot.py, 110 줄

코드가 의미하는 것 6장의 문서 검색 전체(PDF 읽기, 머리글 지우기, 조각 내기, FAISS 색인, RetrievalQA)를 load_docbot() 하나에 담고 @st.cache_resource(show_spinner=…) 로 감쌌습니다. 서버가 켜진 뒤 처음 한 번만 색인을 만들고 모든 손님이 함께 쓴다는 뜻입니다. 답 사전에 "sources" 칸을 더하고 show() 가 st.expander 로 근거를 접어 두므로, 다시 그릴 때도 지난 답의 근거 조항이 함께 나옵니다.

2단계. 첫 화면 열기

Python
import streamlit as st
from streamlit.testing.v1 import AppTest

st.cache_resource.clear()

# 처음 켤 때 PDF 를 읽고 색인을 만들므로 기다리는 시간을 30초로 넉넉히 준다
at = AppTest.from_file("out_ch07_docbot.py", default_timeout=30).run()
print("제목:", at.title[0].value)
for c in at.sidebar.caption:
    print(c.value)
print("오류:", len(at.exception), "개")
출력
제목: 구름다리여행 약관 챗봇
약관 조각: 11개
색인을 만든 횟수: 1
오류: 0 개

코드가 의미하는 것 default_timeout=30 은 첫 실행에서 PDF 를 읽고 색인을 만드는 시간을 기다리려는 것입니다. 첫 화면을 그리는 동안 load_docbot() 이 처음 불려 색인이 만들어지고, 사이드바에 조각 수와 만든 횟수가 적힙니다. len(at.exception) 이 0 이면 문서 검색 부품을 모두 제대로 불러왔다는 뜻입니다.

3단계. 취소 수수료 묻기

Python
at.chat_input[0].set_value("출발 5일 전에 취소하면 수수료가 얼마예요?").run()

bot = at.chat_message[-1]
print("답:", bot.markdown[0].value)
# 답 말풍선 안의 접힌 칸에서 근거를 꺼낸다
print("접힌 칸:", bot.expander[0].label)
for c in bot.expander[0].caption:
    print("  근거:", c.value)
출력
답: 출발 3일 전부터 6일 전까지는 상품 금액의 30%를 취소 수수료로 뗍니다. 5일 전이면 30%입니다.
접힌 칸: 근거 조항 보기
  근거: 2쪽 · 제5조, 제6조
  근거: 2쪽 · 제6조, 제7조

코드가 의미하는 것 질문이 오면 qa.invoke({"query": question}) 이 약관에서 가까운 조각 2개(k=2)를 찾고 가짜 모델이 답을 씁니다. bot.expander[0].caption 으로 답 말풍선 안의 접힌 칸에서 근거를 꺼내, source_label() 이 만든 "쪽 · 조항" 꼴을 확인합니다. 손님이 답을 약관과 직접 대조할 수 있게 하는 장치입니다.

4단계. 반려동물 묻고 기록과 색인 횟수 보기

Python
at.chat_input[0].set_value("반려동물 데려가도 돼요?").run()

bot = at.chat_message[-1]
print("답:", bot.markdown[0].value)
print("근거:", [c.value for c in bot.expander[0].caption])
print("말풍선:", len(at.chat_message), "개 / 기록:", len(at.session_state["doc_messages"]), "개")
# 스크립트는 세 번 돌았지만 색인은 몇 번 만들었을까
print([c.value for c in at.sidebar.caption][-1])
출력
답: 반려동물은 '함께 여행' 메뉴의 동반 상품에서만 데려갈 수 있고, 7kg 이하의 개와 고양이만 됩니다.
근거: ['3쪽 · 제12조', '2쪽 · 제6조, 제7조']
말풍선: 4 개 / 기록: 4 개
색인을 만든 횟수: 1

코드가 의미하는 것 둘째 질문 뒤에 말풍선 수, 기록 수, 색인을 만든 횟수를 함께 봅니다. 스크립트는 여러 번 다시 돌았어도 캐시 덕분에 색인 만들기는 처음 한 번뿐이고, 이것이 무거운 색인을 웹 앱에 붙일 때 캐시를 쓰는 까닭입니다. 기록 사전에 근거까지 넣었으므로 화면을 다시 그려도 지난 답의 근거가 사라지지 않습니다.

이 실습이 뜻하는 것

이 실습은 6장에 차려 둔 자료실(약관 색인)을 홀의 키오스크에 이어, 손님이 묻는 대로 사서가 근거 쪽을 찾아 답과 함께 내미는 창구를 연 일과 같습니다. 창구를 짓고(1단계), 문을 열 때 자료실 색인을 한 번 만들고(2단계), 손님의 질문 두 개를 받고(3·4단계), 색인을 몇 번 만들었는지 봅니다(4단계). 여기서 자료실 색인이 load_docbot() 이 만든 FAISS 와 RetrievalQA, 근거 쪽이 접힌 칸의 근거 조항입니다. 비유와 다른 점은, 이 창구의 사서(가짜 모델)는 찾아 온 쪽을 읽지 않고 미리 적어 둔 답을 말한다는 것입니다. 근거 조항만은 실제 검색 결과입니다.

단계출력뜻
1. 앱 파일110 줄6장의 문서 검색 전체와 채팅 화면이 파일 하나에 들어갔습니다
2. 첫 화면조각 11개, 만든 횟수 1, 오류 0개머리글을 지운 약관 4쪽이 조각 11개가 되어 색인에 들어갔습니다
3. 취소 수수료근거 2쪽 · 제5조, 제6조 / 2쪽 · 제6조, 제7조첫 근거에 수수료를 정한 제5조가 들어 있습니다
4. 반려동물근거 3쪽 · 제12조 / 2쪽 · 제6조, 제7조첫 근거가 반려동물 조항입니다. 둘째 근거는 질문과 관계가 적습니다
4. 횟수말풍선 4개, 기록 4개, 만든 횟수 1스크립트는 세 번 돌았지만 색인은 처음 한 번만 만들었습니다

한 줄로 줄이면, 6장의 문서 검색을 st.cache_resource 로 감싸 채팅 화면에 붙이면 색인은 한 번만 만들고 질문마다 근거 조항을 함께 보여 주는 문서 챗봇이 된다는 것을 본 실습입니다. 두 질문 모두 첫 근거가 맞는 조항(제5조, 제12조)이었고, 둘째 근거는 두 번 다 제6조와 제7조가 든 조각이었습니다. k=2 로 늘 두 조각을 가져오므로 관계가 적은 조각도 하나씩 따라옵니다. 근거를 접힌 칸에 함께 보여 주면 손님이 답을 직접 대조할 수 있습니다. 답의 글은 가짜 모델에 미리 적어 둔 것이므로, 진짜 모델로 바꾸면 근거 조각을 읽고 쓴 답이 나옵니다.

코드 설명

  • load_docbot() 에 붙인 @st.cache_resource(show_spinner=…) 는 PDF 읽기부터 RetrievalQA 만들기까지를 서버에서 한 번만 하고, 처음 만드는 동안 기다림 글을 보입니다. 돌려주는 (qa, len(chunks)) 를 모든 손님이 함께 씁니다.
  • PDF = Path(__file__).parent / "travel_terms.pdf" 는 앱 파일이 있는 폴더를 기준으로 PDF 를 찾습니다. __file__ 은 지금 실행 중인 파이썬 파일의 경로입니다. 배포할 때 이 줄이 중요합니다(실습 6).
  • source_label() 은 근거 조각에서 제\d+조(제, 숫자 하나 이상, 조) 꼴을 찾아 조항 번호만 모읍니다. metadata['page'] 는 0 부터 세므로 1 을 더해 사람이 읽는 쪽 번호로 바꿉니다.
  • 기록 사전에 "sources" 칸을 더해 두면, 다시 그릴 때도 지난 답의 근거가 함께 나옵니다. st.expander("근거 조항 보기") 는 눌러야 펼쳐지는 칸이라 화면이 덜 붐빕니다.
  • 진짜 모델로 바꾸려면 FakeListChatModel(...) 자리에 실습 4의 ChatOpenAI(model=…, api_key=get_api_key(), temperature=0) 를 넣습니다. 실습 6에서 키가 있을 때만 진짜 모델을 쓰게 바꿉니다.
← 이전 절7장 · 실습 4 — 진짜 모델을 붙인 채팅 화면다음 절 →7장 · 실습 6 — 배포할 폴더 만들기