LangChain 챗봇8장 · 최종 프로젝트9 / 12← 이전목차다음 →MetaContents

8장. 최종 프로젝트

프로젝트 2 — 전공 지식 챗봇: 정보통신 학습 도우미

정보통신 기초 수업을 맡은 강사가, 학생들이 수업 노트를 보며 묻는 질문("TCP는 연결할 때 무엇을 주고받나요?", "HTTPS는 몇 번 포트예요?")에 근거 노트와 함께 답하는 학습 도우미를 만들려고 합니다. 학생은 "그건 몇 번 포트를 써요?"처럼 앞의 말을 이어서 묻기도 합니다. 처음 배우는 학생에게 쉽게 설명하고, 노트 번호를 근거로 대고, 노트에 없는 것은 강사에게 넘기는 챗봇을 만들어 보세요. 수업 노트는 연습용으로 지어낸 가상의 강의 노트이고, 내용은 정보통신의 기초 사실을 짧게 적은 것입니다. 진짜 임베딩과 진짜 모델을 부르므로 과목 폴더의 .env 에 API 키가 있어야 돌아갑니다. 이 절의 출력은 API 키를 받기 전에 실행하지 않고 유추한 예상 출력입니다.

항목내용
문서network_notes.pdf. 3쪽(1~3주차), 노트 10개("노트 1. OSI 7계층" ~ "노트 10. 점검 명령"). 쪽마다 맨 위에 머리글 "정보통신 기초 수업 노트 (연습용 가상 문서)"
평가 질문notes_questions.csv. 10행(열: 번호, 질문, 정답노트, 핵심어)
노트 밖 질문코드에 적는 2개: 6G 이동통신, 시험 범위
임베딩OpenAIEmbeddings, 모델은 OPENAI_EMBED_MODEL(없으면 text-embedding-3-small)
답하는 모델ChatOpenAI, 모델은 OPENAI_MODEL(없으면 gpt-4.1-mini), temperature=0
만들 파일out_ch08_notes_faiss/(색인), out_ch08_notes_eval.csv(평가 결과)

요구사항은 다섯 가지입니다.

  1. 노트 PDF 를 머리글을 지우고 노트 단위로 나눠, 진짜 임베딩의 FAISS 색인을 만들어 저장합니다. 저장한 색인이 있으면 불러옵니다.
  2. 처음 배우는 학생의 눈높이로 두세 문장으로 답하고, 근거 노트 번호를 함께 돌려주는 ask(question, history) 를 만듭니다. 노트에 없으면 "수업 노트에 없는 내용입니다. 담당 강사에게 물어 주세요."라고 답하게 합니다.
  3. 이어지는 질문은 대화 기록을 보고 혼자서도 뜻이 통하는 질문으로 바꿔 쓴 뒤 검색합니다. 바꿔 쓰기 전과 뒤에 찾은 노트를 견줍니다.
  4. 평가 질문 10개와 노트 밖 질문 2개로 "근거에 정답 노트", "답에 핵심어", "노트에 없다고 답함"을 확인해 저장합니다.
  5. 어긋난 줄을 눈으로 읽고 까닭을 적습니다.

단계별 힌트입니다.

  1. 노트 단위 나누기는 6장 실습 2의 정규식에서 "제N조 " 를 "노트 N. " 으로 바꾼 re.split(r"(?=노트 \d+\. )", text) 입니다.
  2. 4장의 MessagesPlaceholder("history") 에 {"role": "user", "content": …} 사전 목록을 넣으면 앞의 대화가 함께 갑니다.
  3. 바꿔 쓰기는 모델에게 따로 한 번 묻는 체인입니다. "답하지 말고 바꾼 질문만 쓴다"는 규칙을 넣고, 첫 질문(기록이 빈 때)은 바꾸지 않습니다.
  4. 기록에는 학생이 쓴 원래 질문을 넣고, 검색에는 바꿔 쓴 질문을 씁니다.

확인할 것: 다 풀었으면 항목마다 스스로 확인해 체크해 보세요.

채점 기준배점
노트 단위로 나누고 색인을 저장·불러오기 했다15
학생 눈높이의 답과 근거 노트를 함께 돌려준다25
질문 바꿔 쓰기로 이어지는 질문을 바르게 찾았다25
평가 12개의 확인 표를 만들고 저장했다20
어긋난 줄의 까닭이 출력과 맞다15

코드의 흐름

예시 풀이의 코드가 어떤 차례로 흘러가는지 한 장으로 그렸습니다. 풀기 전에 큰 줄기를 먼저 보고, 풀다가 막히면 지금 어느 상자에 있는지 확인해 보세요.

노트 색인, 근거와 답, 질문 바꿔 쓰기, 평가와 저장의 네 단계를 이은 코드 흐름도

상자 하나가 예시 풀이의 단계 하나이고, 회청 화살표를 따라가면 실행 차례입니다. 1단계는 문서가 바뀔 때만 하는 색인 단계, 2·3단계는 질문마다 하는 단계입니다. 3단계에서는 모델을 두 번 부릅니다. 한 번은 질문을 바꿔 쓰려고, 한 번은 답을 쓰려고입니다.

실제 데이터로 해 보려면

자기 수업의 강의 노트나 실습 안내서로 같은 도우미를 만들 수 있습니다. 직접 쓴 자료라도 교재나 다른 사람의 글을 옮겨 적은 부분이 있으면 저작권을 확인하고, 학생 이름이나 성적이 든 자료는 쓰지 않습니다.

자료구하는 곳쓰는 법
내 강의 노트, 실습 안내서직접 쓴 자료소제목 꼴("노트 N.", "1.1 …")을 하나로 맞춰 두면 그대로 나눌 수 있다
공개된 표준 문서표준을 정하는 기관의 공개 자료이용 조건을 확인한다. 영어 문서는 질문도 영어로 해 보고 견준다
학생이 실제로 물은 질문질의응답 게시판이름을 지우고 말투만 살려 평가 질문으로 옮긴다
결과 보기 (예시 풀이)

1단계. 노트 단위로 나누고 색인 만들기

Python
import os
import re
from pathlib import Path

import pandas as pd
from dotenv import find_dotenv, load_dotenv
from langchain_community.document_loaders import PyPDFLoader
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)

HEADER = "정보통신 기초 수업 노트 (연습용 가상 문서)"
docs = []
for p in PyPDFLoader("network_notes.pdf").load():
    text = re.sub(r"\s+", " ", p.page_content.replace(HEADER, "")).strip()
    # "노트 N. " 바로 앞에서 자른다. 첫 토막은 "1주차 네트워크의 짜임" 같은 주차 제목
    parts = re.split(r"(?=노트 \d+\. )", text)
    week = parts[0].strip()
    for part in parts[1:]:
        note = re.match(r"노트 \d+", part).group()
        docs.append(Document(page_content=part.strip(),
                             metadata={"note": note, "week": week, "page": p.metadata["page"] + 1}))
print("노트 조각:", len(docs), "개 /", [d.metadata["note"] for d in docs[:3]], "…")

INDEX = "out_ch08_notes_faiss"
if Path(INDEX).exists():
    # 내가 만든 색인 폴더라 pickle 을 열어도 된다(6차시)
    db = FAISS.load_local(INDEX, emb, allow_dangerous_deserialization=True)
    how = "불러옴"
else:
    db = FAISS.from_documents(docs, emb)
    db.save_local(INDEX)
    how = "새로 만들어 저장함"
print(f"색인 {how}: 조각 {db.index.ntotal}개 / 모델 {MODEL} / 임베딩 {EMBED_MODEL}")
출력
노트 조각: 10 개 / ['노트 1', '노트 2', '노트 3'] …
색인 새로 만들어 저장함: 조각 10개 / 모델 gpt-4.1-mini / 임베딩 text-embedding-3-small

코드가 의미하는 것 이 단계는 프로젝트 1의 1·2단계와 짜임이 같고, 바꿔 끼운 것은 두 가지입니다. 나누는 단위가 조항(제N조)에서 노트(노트 N. )로 바뀌었고, 임베딩이 BigramEmbeddings 에서 OpenAIEmbeddings 로 바뀌었습니다. 진짜 임베딩은 부를 때마다 요금이 붙으므로 save_local 로 색인 폴더를 저장해 두고, 다음 실행부터는 load_local 로 불러와 다시 임베딩하지 않게 했습니다.

2단계. 근거와 함께 답하는 함수 만들기

Python
from langchain_core.output_parsers import StrOutputParser
from langchain_core.prompts import ChatPromptTemplate, MessagesPlaceholder

NOT_IN_NOTES = "수업 노트에 없는 내용입니다. 담당 강사에게 물어 주세요."
retriever = db.as_retriever(search_kwargs={"k": 2})

answer_prompt = ChatPromptTemplate.from_messages([
    ("system",
     "너는 정보통신 기초 수업의 학습 도우미다. 처음 배우는 학생에게 아래 [노트]의 내용으로만 두세 문장으로 쉽게 설명한다. "
     "용어와 숫자는 노트의 표기 그대로 쓴다. 노트에 없는 내용이면 '" + NOT_IN_NOTES + "'라고만 답한다.\n\n[노트]\n{context}"),
    MessagesPlaceholder("history"),          # 앞의 대화(4차시)
    ("human", "{question}"),
])
answer_chain = answer_prompt | model | StrOutputParser()


def format_notes(found):
    return "\n".join(f"[{d.metadata['note']} · {d.metadata['page']}쪽] {d.page_content}" for d in found)


def ask(question, history=()):
    """검색할 질문으로 노트를 찾아 답하고, 근거 노트 번호를 함께 돌려준다. 출처는 코드가 metadata 에서 붙인다"""
    found = retriever.invoke(question)
    text = answer_chain.invoke({"context": format_notes(found), "history": list(history), "question": question})
    return {"answer": text, "notes": [d.metadata["note"] for d in found]}


for q in ["TCP는 연결할 때 무엇을 주고받나요?", "/24 네트워크에서 기기에 줄 수 있는 주소는 몇 개인가요?"]:
    r = ask(q)
    print(f"Q. {q}\nA. {r['answer']}\n   근거: {', '.join(r['notes'])}")
출력
Q. TCP는 연결할 때 무엇을 주고받나요?
A. TCP는 보내기 전에 먼저 연결을 맺는데, 이때 SYN, SYN-ACK, ACK 세 번을 주고받습니다. 이것을 3-way handshake라고 합니다. 서로 "보낼게", "좋아, 나도", "알았어"라고 확인하는 인사라고 생각하면 쉽습니다.
   근거: 노트 2, 노트 7
Q. /24 네트워크에서 기기에 줄 수 있는 주소는 몇 개인가요?
A. /24 네트워크에는 주소가 256개 있지만, 네트워크 주소와 브로드캐스트 주소를 빼야 해서 기기에 줄 수 있는 주소는 254개입니다.
   근거: 노트 4, 노트 3

코드가 의미하는 것 ask 는 이 도우미가 한 번 답하는 일입니다. retriever.invoke(question) 로 노트 두 조각(k=2)을 찾아 시스템 프롬프트의 [노트] 자리에 넣고, MessagesPlaceholder("history") 자리에는 앞의 대화를 넣어 4장의 대화 기억을 잇습니다. 근거 노트 번호는 모델의 답에서 뽑지 않고 찾은 조각의 metadata["note"] 에서 코드가 붙이므로, 모델의 답 글이 달라져도 근거 표시는 흔들리지 않습니다.

3단계. 이어지는 질문을 바꿔 써서 찾기

Python
rewrite_prompt = ChatPromptTemplate.from_messages([
    ("system", "대화 기록을 보고 마지막 질문을 앞의 대화 없이도 뜻이 통하는 질문 한 문장으로 바꿔 쓴다. "
               "답하지 말고 바꾼 질문만 쓴다."),
    MessagesPlaceholder("history"),
    ("human", "{question}"),
])
rewrite_chain = rewrite_prompt | model | StrOutputParser()

history = []
for q in ["DNS는 무슨 일을 하나요?", "그건 몇 번 포트를 써요?", "그럼 DHCP는요?"]:
    # 첫 질문은 기록이 비어 있어 바꾸지 않는다
    standalone = rewrite_chain.invoke({"history": history, "question": q}) if history else q
    plain = [d.metadata["note"] for d in retriever.invoke(q)]          # 바꿔 쓰지 않고 찾았다면
    r = ask(standalone, history)
    # 기록에는 학생이 쓴 원래 질문을 넣는다
    history += [{"role": "user", "content": q}, {"role": "assistant", "content": r["answer"]}]
    print(f"Q. {q}\n   바꿔 쓴 질문: {standalone}\n   바꾸지 않고 찾은 노트: {plain} → 바꿔 쓰고 찾은 노트: {r['notes']}")
    print(f"A. {r['answer']}")
출력
Q. DNS는 무슨 일을 하나요?
   바꿔 쓴 질문: DNS는 무슨 일을 하나요?
   바꾸지 않고 찾은 노트: ['노트 6', '노트 7'] → 바꿔 쓰고 찾은 노트: ['노트 6', '노트 7']
A. DNS는 www.example.com 같은 도메인 이름을 IP 주소로 바꿔 주는 전화번호부 같은 것입니다. 우리가 이름만 적어도 컴퓨터는 DNS 덕분에 찾아갈 주소를 알게 됩니다.
Q. 그건 몇 번 포트를 써요?
   바꿔 쓴 질문: DNS는 몇 번 포트를 사용하나요?
   바꾸지 않고 찾은 노트: ['노트 8', '노트 7'] → 바꿔 쓰고 찾은 노트: ['노트 6', '노트 8']
A. DNS의 기본 포트는 53번입니다.
Q. 그럼 DHCP는요?
   바꿔 쓴 질문: DHCP는 몇 번 포트를 사용하나요?
   바꾸지 않고 찾은 노트: ['노트 7', '노트 6'] → 바꿔 쓰고 찾은 노트: ['노트 7', '노트 8']
A. DHCP는 서버가 67번, 클라이언트가 68번 포트를 씁니다. 기기가 네트워크에 붙으면 IP 주소를 자동으로 나눠 주는 일을 합니다.

코드가 의미하는 것 rewrite_chain 은 답하지 않고 질문만 다시 쓰는 작은 체인입니다. 검색기는 "그건"이 무엇을 가리키는지 모르므로, 대화 기록을 본 모델에게 혼자서도 뜻이 통하는 질문으로 고쳐 쓰게 한 뒤 그 질문으로 찾습니다. 같은 차례에 plain(바꿔 쓰지 않고 찾은 노트)을 함께 찍는 것은 바꿔 쓰기가 검색 결과를 실제로 바꾸었는지 견주려는 것이고, 예상 출력의 노트 목록은 실제로 돌리면 달라질 수 있습니다.

4단계. 평가 질문 12개로 확인하고 저장하기

Python
questions = pd.read_csv("notes_questions.csv")
OUTSIDE = ["6G 이동통신은 언제부터 쓸 수 있나요?", "이번 학기 시험 범위가 어디까지예요?"]

rows = []
for row in questions.itertuples():
    r = ask(row.질문)
    rows.append({"번호": row.번호, "질문": row.질문, "답": r["answer"], "근거": ", ".join(r["notes"]),
                 "근거에 정답": row.정답노트 in r["notes"], "핵심어 있음": row.핵심어 in r["answer"],
                 "노트에 없음": NOT_IN_NOTES in r["answer"]})
for k, q in enumerate(OUTSIDE, start=len(questions) + 1):
    r = ask(q)
    rows.append({"번호": k, "질문": q, "답": r["answer"], "근거": ", ".join(r["notes"]),
                 "근거에 정답": None, "핵심어 있음": None, "노트에 없음": NOT_IN_NOTES in r["answer"]})

report = pd.DataFrame(rows)
print(report[["번호", "근거", "근거에 정답", "핵심어 있음", "노트에 없음"]].to_string(index=False))
inside = report.iloc[:len(questions)]
print("근거에 정답 노트:", int(inside["근거에 정답"].sum()), "/", len(inside),
      "/ 답에 핵심어:", int(inside["핵심어 있음"].sum()), "/", len(inside),
      "/ 노트 밖에 노트에 없음:", int(report.iloc[len(questions):]["노트에 없음"].sum()), "/", len(OUTSIDE))
# 핵심어가 빠진 답은 눈으로 읽어 본다
for row in inside[~inside["핵심어 있음"].astype(bool)].itertuples():
    print(f"[{row.번호}] {row.질문}\n    → {row.답}")

report.to_csv("out_ch08_notes_eval.csv", index=False, encoding="utf-8-sig")
print("저장: out_ch08_notes_eval.csv,", len(report), "행")
출력
 번호            근거 근거에 정답 핵심어 있음 노트에 없음
  1   노트 1, 노트 3   True   True  False
  2   노트 2, 노트 7   True   True  False
  3   노트 2, 노트 9   True   True  False
  4   노트 3, 노트 4   True   True  False
  5   노트 4, 노트 3   True   True  False
  6   노트 5, 노트 3   True   True  False
  7   노트 6, 노트 7   True   True  False
  8   노트 8, 노트 9   True   True  False
  9   노트 9, 노트 8   True  False  False
 10  노트 10, 노트 6   True   True  False
 11  노트 2, 노트 1   None   None   True
 12  노트 8, 노트 9   None   None   True
근거에 정답 노트: 10 / 10 / 답에 핵심어: 9 / 10 / 노트 밖에 노트에 없음: 2 / 2
[9] 404 오류는 무슨 뜻인가요?
    → 404는 찾는 페이지가 서버에 없다는 뜻입니다. 4로 시작하는 코드는 요청한 쪽의 잘못을 가리키므로, 주소를 잘못 적었는지 먼저 확인해 보세요.
저장: out_ch08_notes_eval.csv, 12 행

예상 출력에서 평가 질문 10개 모두 근거 두 조각 안에 정답 노트가 들어 있다고 보았습니다. 노트가 10개뿐이고 주제가 서로 뚜렷하게 달라서, 낱말의 뜻을 아는 진짜 임베딩이면 찾기 쉬운 문서입니다. 핵심어는 10개 가운데 9개에서 나왔다고 보았습니다. 빠진 9번은 답이 틀린 것이 아니라 노트의 "찾는 페이지가 없음"을 모델이 "찾는 페이지가 서버에 없다는 뜻"으로 풀어 써서 글자 비교가 놓친 것입니다. 6장 프로젝트의 "3천 원"과 "3,000원"처럼, 자동 채점에서 어긋난 줄은 눈으로 읽어야 합니다. 노트 밖 질문 둘에는 정해 둔 말로 답했다고 보았습니다. 이어지는 질문에서는 "그건 몇 번 포트를 써요?"를 바꿔 쓰지 않고 찾으면 포트 번호 노트(노트 8)가 먼저 나와 DNS 의 53번을 놓칠 수 있지만, "DNS는 몇 번 포트를 사용하나요?"로 바꿔 쓰자 노트 6이 먼저 나온다고 보았습니다. 모델이 쓰는 답의 글과 바꿔 쓴 질문은 실행할 때마다, 모델마다 조금씩 달라지므로 여러분의 결과는 다를 수 있습니다.

코드가 의미하는 것 이 단계는 평가를 세 칸으로 나눠 잽니다. row.정답노트 in r["notes"] 는 근거 표시(검색이 맞는 노트를 가져왔나), row.핵심어 in r["answer"] 는 답의 내용, NOT_IN_NOTES in r["answer"] 는 "모르면 모른다"를 봅니다. 셋을 따로 두었기 때문에 핵심어가 빠진 줄이 검색 탓인지 모델이 말을 바꾼 탓인지 가를 수 있고, ~inside["핵심어 있음"].astype(bool) 로 그런 줄만 골라 사람이 읽게 합니다.

결론은, 주제가 뚜렷한 전공 노트는 진짜 임베딩만으로도 근거를 잘 찾고, 학생이 이어서 묻는 질문은 바꿔 쓰기를 한 번 거쳐야 맞는 노트를 찾는다는 것입니다. 바꿔 쓰기는 모델을 한 번 더 부르므로 질문마다 요금과 시간이 조금 더 듭니다. 첫 질문(기록이 빈 때)은 바꾸지 않아 그만큼 아꼈습니다.

받은 피드백(지어낸 것)고친 것다시 잰 결과(예상)
"그건 몇 번 포트예요?"처럼 이어 물으면 엉뚱한 노트로 답한다질문 바꿔 쓰기 체인을 검색 앞에 둠이어 묻기 둘 모두 맞는 노트가 첫째
답에 어려운 말이 그대로 나온다시스템 프롬프트에 "처음 배우는 학생에게 쉽게"TCP 답에 인사 비유가 붙음
404 가 틀렸다고 채점된다채점은 그대로 두고 어긋난 줄을 눈으로 읽음답은 맞고 표기만 달랐음

이 프로젝트가 뜻하는 것

이 프로젝트는 조교에게 수업 노트를 주고 질의응답 시간을 맡기는 일과 같습니다. 노트를 주제마다 나눠 꽂아 두고(1단계), "노트를 펴 보이며 쉽게 설명하라"고 일러 주고(2단계), 학생이 "그건요?" 하고 이어 물으면 무엇을 묻는지 먼저 또렷하게 고쳐 듣게 하고(3단계), 모의 질문 12개로 확인합니다(4단계). 여기서 꽂아 둔 노트가 FAISS 색인, 일러 준 말이 시스템 프롬프트, 고쳐 듣기가 질문 바꿔 쓰기입니다. 비유와 다른 점은, 조교는 앞의 대화를 머리에 담고 있지만 챗봇은 기록을 매번 함께 넘겨야 안다는 것입니다(4장).

단계출력(예상)뜻
1. 색인노트 조각 10개노트 하나가 조각 하나가 되었습니다
2. ask답 + 노트 2, 노트 4학생 눈높이의 답과 근거 노트가 함께 나옵니다
3. 바꿔 쓰기노트 8 → 노트 6"그건"이 DNS 라는 것을 알아야 맞는 노트를 찾습니다
4. 평가근거 10/10, 핵심어 9/10, 노트 밖 2/2근거는 믿을 만하고, 핵심어 채점은 사람이 함께 봅니다

한 줄로 줄이면, 전공 지식 챗봇은 "주제 단위로 나눈 노트 + 쉬운 설명 규칙 + 이어 묻기를 위한 바꿔 쓰기"가 함께 있어야 학생이 쓸 만하다는 것을 본 프로젝트입니다.

← 이전 절8장 · 프로젝트 1 — 실무 문서 챗봇: 사내 규정 도우미다음 절 →8장 · 프로젝트 3 — 구름다리여행 종합 상담 챗봇