6장. 문서 기반 챗봇
프로젝트 1 — 여행 약관 챗봇 만들고 점검하기
구름다리여행 상담 창구에 놓을 여행 약관 챗봇을 만들려고 합니다. 손님의 질문에 약관에 적힌 대로 두세 문장으로 답하고, 답 아래에 근거 조항과 쪽을 붙이고, 약관에 없는 질문에는 "약관에서 찾지 못했습니다."라고 답해야 합니다. 다 만들면 평가 질문 12개와 약관에 없는 질문 3개로 점검해 보세요. 약관, 질문, 상품은 모두 연습용으로 지어낸 것입니다. 진짜 임베딩과 진짜 모델을 부르므로 과목 폴더의 .env 에 API 키가 있어야 돌아가고, 실습 2를 먼저 해서 out_ch06_chunks.csv 가 있어야 합니다(실습 5의 out_ch06_faiss_openai 가 있으면 그것을 불러 씁니다). 이 절의 출력은 API 키를 받기 전에 실행하지 않고 유추한 예상 출력입니다.
| 항목 | 내용 |
|---|---|
| 조각 | out_ch06_chunks.csv. 조항 단위 조각 16개(열: article, chapter, page, 글자 수, 내용) |
| 평가 질문 | rag_questions.csv. 12행(열: 번호, 질문, 정답조항, 주제, 핵심어). 핵심어는 답에 들어 있어야 할 약관의 표기(예: 30%, 7kg) |
| 약관에 없는 질문 | 코드에 적는 3개: 맛집 추천, 환율, 마일리지 |
| 임베딩 | OpenAIEmbeddings, 모델은 OPENAI_EMBED_MODEL(없으면 text-embedding-3-small) |
| 답하는 모델 | ChatOpenAI, 모델은 OPENAI_MODEL(없으면 gpt-4.1-mini), temperature=0 |
요구사항은 다섯 가지입니다.
- 조항 단위 조각으로 진짜 임베딩의 FAISS 색인을 준비합니다. 저장한 색인이 있으면 불러오고, 없으면 만들어 저장합니다.
- 질문을 받아 답과 근거를 함께 돌려주는
ask(question)함수를 LCEL 로 만듭니다. 근거는 k=3 으로 찾고, 출처("제5조(2쪽)")는 우리 코드가metadata에서 붙입니다. - 프롬프트에 "근거에 없는 내용이면 '약관에서 찾지 못했습니다.'라고만 답한다"는 규칙을 넣습니다.
- 질문 15개(평가 12개 + 약관에 없는 3개)에 답하게 하고, 평가 질문은 "근거에 정답 조항이 있는가"와 "답에 핵심어가 있는가"를, 약관에 없는 질문은 "찾지 못했다고 답했는가"를 확인합니다.
- 질문, 답, 근거, 확인 결과를
out_ch06_bot_answers.csv로 저장하고, 어긋난 질문의 까닭을 두세 문장으로 적습니다.
단계별 힌트입니다.
- 색인 폴더가 있는지는
Path("out_ch06_faiss_openai").exists()로 봅니다. 불러올 때는allow_dangerous_deserialization=True를 주되, 내가 만든 폴더인지 먼저 생각합니다. - 실습 4의 4단계처럼
RunnableParallel(docs=retriever, question=RunnablePassthrough()).assign(answer=...)로 짜면 답과 검색 결과를 함께 받습니다. - 근거 글은 실습 4의
format_docs처럼 "[제5조 · 2쪽] 내용" 줄로 이어 붙이면 모델이 조항 번호를 보고 답할 수 있습니다. - 핵심어 확인은
row.핵심어 in answer로 합니다. 표기가 다르면(3천 원과 3,000원) 놓친 것으로 세어지므로, 어긋난 줄은 눈으로도 확인합니다. - 약관에 없는 질문의 확인은
"찾지 못했습니다" in answer로 합니다.
확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.
| 채점 기준 | 배점 |
|---|---|
| 색인을 준비하고 저장·불러오기를 바르게 했다 | 20 |
ask 가 LCEL 로 답과 근거를 함께 돌려준다 | 25 |
| 프롬프트에 근거 규칙과 "찾지 못함" 규칙을 넣었다 | 15 |
| 질문 15개의 확인 표를 만들고 저장했다 | 25 |
| 어긋난 질문의 까닭이 출력과 맞다 | 15 |
코드의 흐름
예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.
상자 하나가 예시 풀이의 단계 하나이고, 회청 화살표를 따라가면 실행 차례입니다. 상자 아래의 글은 그 단계에서 쓰는 핵심 코드입니다. 1단계는 색인 단계, 2·3단계는 질문 단계이고, 4단계에서 결과를 표로 모아 사람이 읽고 판단합니다.
실제 데이터로 해 보려면
우리 회사나 기관의 실제 문서(이용 약관, 제품 설명서, 사내 규정)로 같은 챗봇을 만들 수 있습니다. 먼저 그 문서를 이런 데 써도 되는지, 그리고 문서를 OpenAI 같은 바깥 서비스에 보내도 되는지 회사의 보안 규정과 서비스 약관을 확인합니다. 손님의 이름이나 연락처 같은 개인정보가 든 문서는 지우거나 가린 뒤에 씁니다.
| 자료 | 구하는 곳 | 쓰는 법 |
|---|---|---|
| 공개된 이용 약관 PDF | 회사 누리집의 약관 쪽에서 내려받기 | PyPDFLoader 로 읽고, 머리글과 조항의 짜임을 실습 1처럼 먼저 확인한다 |
| 공공 기관의 규정·안내서 | 공공 데이터 포털이나 기관 누리집의 자료실 | 이용 조건(출처 표시 등)을 확인하고 내려받는다 |
| 사내 문서 | 사내 문서 관리 시스템 | 보안 규정이 허락하는 문서만 쓴다 |
문서마다 머리글, 쪽 번호, 표의 모양이 다르므로 실습 1과 2의 확인과 전처리를 먼저 하고, 평가 질문을 10개쯤 직접 만들어 실습 3처럼 적중부터 잽니다.
결과 보기 (예시 풀이)
1단계. 색인 준비하기
import os
import warnings
from pathlib import Path
warnings.filterwarnings("ignore", category=DeprecationWarning)
import pandas as pd
from dotenv import find_dotenv, load_dotenv
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
from langchain_openai import ChatOpenAI, OpenAIEmbeddings
# .env 에서 키와 모델 이름을 읽는다
load_dotenv(find_dotenv(usecwd=True))
MODEL = os.getenv("OPENAI_MODEL", "gpt-4.1-mini")
EMBED_MODEL = os.getenv("OPENAI_EMBED_MODEL", "text-embedding-3-small")
model = ChatOpenAI(model=MODEL, temperature=0)
emb = OpenAIEmbeddings(model=EMBED_MODEL)
INDEX = "out_ch06_faiss_openai"
if Path(INDEX).exists():
# 실습 5에서 내가 만든 색인이라 pickle 을 열어도 된다
db = FAISS.load_local(INDEX, emb, allow_dangerous_deserialization=True)
how = "불러옴"
else:
saved = pd.read_csv("out_ch06_chunks.csv")
docs = [Document(page_content=row.내용, metadata={"article": row.article, "page": int(row.page)})
for row in saved.itertuples()]
db = FAISS.from_documents(docs, emb)
db.save_local(INDEX)
how = "새로 만들어 저장함"
print(f"색인 {how}: 조각 {db.index.ntotal}개 / 모델 {MODEL} / 임베딩 {EMBED_MODEL}")색인 불러옴: 조각 16개 / 모델 gpt-4.1-mini / 임베딩 text-embedding-3-small코드가 의미하는 것 if Path(INDEX).exists() 로 색인이 있으면 FAISS.load_local 로 불러오고 없으면 만들어 저장하는 것은, 실제 챗봇이 켜질 때마다 쓰는 짜임입니다. 문서가 바뀌지 않았으면 임베딩 요금과 시간을 다시 쓰지 않습니다. ChatOpenAI(model=MODEL, temperature=0) 의 온도 0 은 같은 질문에 되도록 같은 답을 내게 해, 약관 안내처럼 정확해야 하는 답과 평가를 되풀이하는 데 맞춘 설정입니다.
2단계. 답과 근거를 돌려주는 함수 만들기
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate
from langchain_core.runnables import RunnableParallel, RunnablePassthrough
NOT_FOUND = "약관에서 찾지 못했습니다."
retriever = db.as_retriever(search_kwargs={"k": 3})
def format_docs(docs):
return "\n".join(f"[{d.metadata['article']} · {d.metadata['page']}쪽] {d.page_content}" for d in docs)
prompt = ChatPromptTemplate.from_messages([
("system",
"너는 구름다리여행의 약관 안내 직원이다. 아래 [근거]에 적힌 내용으로만 두세 문장으로 답한다. "
"숫자와 조건은 근거에 적힌 표기 그대로 쓴다.\n"
f"근거에 없는 내용이면 '{NOT_FOUND}'라고만 답한다.\n\n[근거]\n{{context}}"),
("human", "{question}"),
])
answer_chain = prompt | model | StrOutputParser()
rag = RunnableParallel(docs=retriever, question=RunnablePassthrough()).assign(
answer=lambda x: answer_chain.invoke({"context": format_docs(x["docs"]), "question": x["question"]})
)
def ask(question):
"""답과 근거(조항 목록, 출처 글)를 사전으로 돌려준다. 출처는 metadata 에서 우리 코드가 붙인다"""
out = rag.invoke(question)
articles = [d.metadata["article"] for d in out["docs"]]
sources = ", ".join(f"{d.metadata['article']}({d.metadata['page']}쪽)" for d in out["docs"])
return {"answer": out["answer"], "articles": articles, "sources": sources}
for q in ["강아지 데려가도 돼요?", "출발 5일 전에 취소하면 수수료가 얼마예요?"]:
r = ask(q)
print(f"Q. {q}\nA. {r['answer']}\n 근거: {r['sources']}")Q. 강아지 데려가도 돼요?
A. 네, '함께 여행' 메뉴의 반려동물 동반 상품에서만 데려가실 수 있습니다. 몸무게 7kg 이하의 개와 고양이만 되며, 이동장에 넣어야 합니다.
근거: 제12조(3쪽), 제10조(3쪽), 제11조(3쪽)
Q. 출발 5일 전에 취소하면 수수료가 얼마예요?
A. 출발 3일 전부터 6일 전까지 취소하면 상품 금액의 30%를 취소 수수료로 뗍니다.
근거: 제5조(2쪽), 제6조(2쪽), 제15조(4쪽)코드가 의미하는 것 이 단계가 챗봇의 심장입니다. 프롬프트에는 "근거로만, 표기 그대로, 없으면 NOT_FOUND"라는 세 규칙을 적고, RunnableParallel(...).assign(answer=…) 로 답과 검색 결과를 함께 받습니다. ask 는 그 결과에서 조항 목록과 출처 글을 metadata 로 만들어 돌려주므로, 화면에 무엇을 보일지와 채점에 무엇을 쓸지를 한 함수가 정합니다. 예상 출력의 답 글은 모델마다 달라지고, 볼 것은 강아지 질문의 근거 첫 자리에 제12조가 오는 짜임입니다.
3단계. 질문 15개에 답하게 하기
questions = pd.read_csv("rag_questions.csv")
OUTSIDE = ["제주도 맛집 추천해 주세요.", "지금 엔화 환율이 얼마예요?", "항공 마일리지는 어떻게 쌓나요?"]
rows = []
for row in questions.itertuples():
r = ask(row.질문)
rows.append({"번호": row.번호, "질문": row.질문, "답": r["answer"], "근거": r["sources"],
"근거에 정답": row.정답조항 in r["articles"],
"핵심어 있음": row.핵심어 in r["answer"],
"찾지 못함이라 답함": NOT_FOUND in r["answer"]})
for k, q in enumerate(OUTSIDE, start=len(questions) + 1):
r = ask(q)
rows.append({"번호": k, "질문": q, "답": r["answer"], "근거": r["sources"],
"근거에 정답": None, "핵심어 있음": None,
"찾지 못함이라 답함": NOT_FOUND in r["answer"]})
report = pd.DataFrame(rows)
print(report[["번호", "근거에 정답", "핵심어 있음", "찾지 못함이라 답함"]].to_string(index=False)) 번호 근거에 정답 핵심어 있음 찾지 못함이라 답함
1 True True False
2 True True False
3 True True False
4 True True False
5 True True False
6 True True False
7 True True False
8 True True False
9 True True False
10 True False False
11 True True False
12 True True False
13 None None True
14 None None True
15 None None True코드가 의미하는 것 평가 질문 12개에는 "근거에 정답 조항이 있는가"(row.정답조항 in r["articles"])와 "답에 핵심어가 있는가"(row.핵심어 in r["answer"])를 따로 묻습니다. 앞은 검색을, 뒤는 모델의 답을 채점하므로 틀렸을 때 어느 부품 탓인지 가를 수 있습니다. 약관에 없는 질문 OUTSIDE 셋은 "모르면 모른다"는 규칙이 지켜지는지만 보며, 이 칸에는 정답 조항이 없으므로 앞의 두 칸을 None 으로 둡니다.
4단계. 어긋난 곳 읽고 저장하기
inside, outside = report.iloc[:len(questions)], report.iloc[len(questions):]
print("근거에 정답 조항:", int(inside["근거에 정답"].sum()), "/", len(inside))
print("답에 핵심어:", int(inside["핵심어 있음"].sum()), "/", len(inside))
print("약관에 없는 질문에 찾지 못함:", int(outside["찾지 못함이라 답함"].sum()), "/", len(outside))
# 핵심어가 빠진 답은 눈으로 읽어 본다
for row in inside[~inside["핵심어 있음"].astype(bool)].itertuples():
print(f"[{row.번호}] {row.질문}\n → {row.답}")
print("[13]", outside.iloc[0]["답"])
report.to_csv("out_ch06_bot_answers.csv", index=False, encoding="utf-8-sig")
print("저장: out_ch06_bot_answers.csv")근거에 정답 조항: 12 / 12
답에 핵심어: 11 / 12
약관에 없는 질문에 찾지 못함: 3 / 3
[10] 국내 여행도 보험이 들어 있나요?
→ 국내 상품에는 여행자 보험이 들어 있지 않습니다. 예약할 때 1인 3,000원을 더 내시면 국내 여행자 보험을 넣을 수 있습니다.
[13] 약관에서 찾지 못했습니다.
저장: out_ch06_bot_answers.csv예상 출력에서 평가 질문 12개 모두 근거 세 조각 안에 정답 조항이 들어 있다고 보았습니다. 실습 5의 예상처럼 진짜 임베딩이 1위를 하나 놓쳐도 k=3 이면 근거에 들어가기 때문입니다. 핵심어는 12개 가운데 11개에서 나왔습니다. 빠진 10번은 답이 틀린 것이 아니라 약관의 "3천 원"을 모델이 "3,000원"으로 바꿔 써서 글자 비교(in)가 놓친 것입니다. 프롬프트에 "표기 그대로 쓴다"고 적었어도 모델이 바꿔 쓸 수 있으므로, 자동 채점에서 어긋난 줄은 꼭 눈으로 읽습니다. 약관에 없는 질문 셋에는 모두 "약관에서 찾지 못했습니다."라고 답했다고 보았습니다. 검색기는 맛집 질문에도 무언가 조각 셋을 가져오지만, 프롬프트의 규칙이 그 조각으로 지어내지 못하게 막습니다. 모델이 쓰는 답의 글은 실행할 때마다, 모델마다 조금씩 달라지므로 여러분의 답과 확인 결과는 다를 수 있습니다.
이 챗봇을 상담 창구에 놓는다면, 근거 적중과 "찾지 못함" 규칙은 믿을 만하고 핵심어 채점은 사람이 함께 봐야 한다는 결론입니다. 실제로 쓰려면 평가 질문을 손님이 실제로 묻는 말투로 더 늘리고, 약관이 바뀔 때마다 색인을 다시 만들고 같은 평가를 다시 돌리면 됩니다.
코드가 의미하는 것 세 줄의 sum() 은 확인 표를 검색 적중, 핵심어, "찾지 못함" 세 점수로 줄이는 일이고, inside[~inside["핵심어 있음"].astype(bool)] 은 자동 채점이 놓친 줄만 골라 사람이 읽게 하는 장치입니다. 점수만 보지 않고 어긋난 답을 원문으로 읽어야 "틀린 답"과 "표기만 다른 맞는 답"을 가를 수 있습니다. out_ch06_bot_answers.csv 는 약관이나 프롬프트를 바꾼 뒤 같은 평가를 다시 돌려 견줄 기준 기록입니다.
이 프로젝트가 뜻하는 것
이 프로젝트는 새로 뽑은 상담원에게 약관집을 쥐여 주고, 모의 상담 15건으로 실력을 확인하는 일과 같습니다. 약관집을 펴 두고(1단계), "약관에 적힌 대로, 출처를 대며, 모르면 모른다"는 응대 규칙을 가르치고(2단계), 모의 상담을 하고(3단계), 상담 기록을 채점표와 함께 읽습니다(4단계). 여기서 약관집이 FAISS 색인, 응대 규칙이 프롬프트, 상담 기록이 out_ch06_bot_answers.csv 입니다. 비유와 다른 점은, 사람 상담원은 "3,000원"과 "3천 원"이 같은 말인 줄 알지만 우리의 채점 코드는 모른다는 것입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 색인 | 조각 16개 불러옴 | 실습 5에서 만든 진짜 임베딩 색인을 다시 임베딩하지 않고 썼습니다 |
| 2. ask | 답 + 제12조(3쪽) 등 | 연습용이 놓친 강아지 질문도 제12조로 답했고, 출처는 코드가 붙였습니다 |
| 3. 15개 | 정답 근거 12, 찾지 못함 3 | 약관에 있는 것은 근거와 함께, 없는 것은 모른다고 답했습니다(예상) |
| 4. 어긋난 곳 | 10번 "3,000원" | 답은 맞지만 표기가 달라 자동 채점이 놓쳤습니다(예상) |
한 줄로 줄이면, 문서 기반 챗봇은 "찾기(임베딩과 나누기) + 근거로만 답하기(프롬프트) + 출처 붙이기(metadata) + 점검(평가 질문)"의 네 가지가 함께 있어야 믿고 쓸 수 있다는 것을 본 프로젝트입니다. 이 장에서 만든 색인과 BigramEmbeddings 는 7장에서 웹 화면을 입혀 브라우저에서 묻고 답하는 챗봇으로 이어집니다.