LangChain 챗봇6장 · 문서 기반 챗봇16 / 21← 이전목차다음 →MetaContents

6장. 문서 기반 챗봇

실습 3 — 연습용 임베딩과 FAISS 로 검색 적중 재기

실습 2에서 만든 두 가지 조각(크기 200 으로 나눈 것, 조항 단위로 나눈 것)을 연습용 BigramEmbeddings 로 임베딩해 FAISS 색인을 만들고, 평가용 질문 12개(rag_questions.csv)로 정답 조항을 찾는지 잽니다. 질문과 정답 조항은 연습용으로 지어낸 것입니다. 조항 단위 색인은 out_ch06_faiss 폴더로 저장해 실습 4가 씁니다. 실습 2를 먼저 해서 out_ch06_chunks.csv 가 있어야 합니다. 모델은 부르지 않습니다.

1단계. 연습용 임베딩과 두 가지 조각 준비하기

Python
import re
import warnings
import zlib

warnings.filterwarnings("ignore", category=DeprecationWarning)

import pandas as pd
from langchain_community.document_loaders import PyPDFLoader
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
from langchain_core.embeddings import Embeddings
from langchain_text_splitters import RecursiveCharacterTextSplitter


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)


emb = BigramEmbeddings()

# 전처리 전: 실습 2의 1단계와 같이 크기 200, 겹침 30 으로 나눈 조각
pages = PyPDFLoader("travel_terms.pdf").load()
raw_chunks = RecursiveCharacterTextSplitter(chunk_size=200, chunk_overlap=30).split_documents(pages)

# 전처리 후: 실습 2가 저장한 조항 단위 조각을 Document 로 되살린다
saved = pd.read_csv("out_ch06_chunks.csv")
art_chunks = [
    Document(page_content=row.내용, metadata={"article": row.article, "chapter": row.chapter, "page": int(row.page)})
    for row in saved.itertuples()
]
print("전처리 전 조각:", len(raw_chunks), "/ 전처리 후 조각:", len(art_chunks))
print("임베딩 숫자 개수:", len(emb.embed_query("환불은 언제 들어오나요?")))
출력
전처리 전 조각: 12 / 전처리 후 조각: 16
임베딩 숫자 개수: 512

코드가 의미하는 것 같은 PDF 로 두 가지 조각(raw_chunks, art_chunks)을 준비하는 것은 "나누는 법" 하나만 바꾸고 나머지는 모두 같게 두는 공정한 비교를 위해서입니다. pd.read_csv("out_ch06_chunks.csv") 로 읽은 행을 다시 Document 로 되살리는 것은, 색인 단계의 중간 결과를 파일로 넘겨받아 다음 단계로 잇는 방법입니다. 임베딩은 두 쪽 모두 같은 BigramEmbeddings 이므로 결과의 차이는 조각에서만 나옵니다.

2단계. 두 색인으로 같은 질문 검색하기

Python
# from_documents: 조각마다 embed_documents 로 숫자 목록을 만들어 FAISS 색인에 넣는다
raw_db = FAISS.from_documents(raw_chunks, emb)
art_db = FAISS.from_documents(art_chunks, emb)


def articles_of(doc):
    """조각에 든 조항 번호 목록. 조항 단위 조각은 metadata 에서, 크기로 자른 조각은 글 속의 조항 제목에서 찾는다"""
    if "article" in doc.metadata:
        return [doc.metadata["article"]]
    # (?= ): 바로 뒤에 빈칸이 오는 "제N조"만 센다. 본문 속 "제5조의 …" 같은 말은 빼려는 것이다
    return re.findall(r"제\d+조(?= )", doc.page_content)


def label(doc):
    """찍어 볼 이름표. 머리글이 든 조각에는 "머리글+"를 붙인다"""
    head = "머리글+" if "연습용 가상 문서" in doc.page_content else ""
    return head + ("·".join(articles_of(doc)) or "(조항 제목 없음)")


for q in ["출발 5일 전에 취소하면 수수료가 얼마예요?", "강아지 데려가도 돼요?"]:
    print("질문:", q)
    for name, db in [("전처리 전", raw_db), ("전처리 후", art_db)]:
        # similarity_search_with_score: (조각, 거리) 목록. 거리는 작을수록 가깝다
        top = db.similarity_search_with_score(q, k=2)
        print(f"  {name}:", [(label(d), round(float(s), 3)) for d, s in top])
출력
질문: 출발 5일 전에 취소하면 수수료가 얼마예요?
  전처리 전: [('머리글+제5조', 1.393), ('제7조·제8조', 1.577)]
  전처리 후: [('제5조', 1.4), ('제7조', 1.601)]
질문: 강아지 데려가도 돼요?
  전처리 전: [('제6조·제7조', 1.733), ('머리글+제5조', 1.766)]
  전처리 후: [('제6조', 1.691), ('제5조', 1.745)]

코드가 의미하는 것 FAISS.from_documents 를 두 번 불러 색인 두 개를 만들고 같은 질문을 던지는 것은, 챗봇의 검색 부품을 바꿨을 때 무엇이 달라지는지 나란히 보는 실험대입니다. articles_of 와 label 은 검색 결과를 사람이 읽을 이름표로 바꾸는 함수로, 조각에 metadata 가 있으면 그것을 믿고 없으면 글 속에서 조항 제목을 찾습니다. similarity_search_with_score 의 거리를 함께 찍는 것은 "1위가 얼마나 확실한 1위인가"까지 보려는 것입니다.

3단계. 평가 질문 12개로 적중 재기

Python
questions = pd.read_csv("rag_questions.csv")


def hit(db, question, answer, k):
    """상위 k 개 조각 가운데 정답 조항(의 제목)이 든 조각이 있으면 True"""
    return any(answer in articles_of(d) for d in db.similarity_search(question, k=k))


rows = []
for row in questions.itertuples():
    top3 = [label(d) for d in art_db.similarity_search(row.질문, k=3)]
    rows.append({
        "번호": row.번호, "정답": row.정답조항,
        "전처리 전 1위": hit(raw_db, row.질문, row.정답조항, 1),
        "전처리 후 1위": hit(art_db, row.질문, row.정답조항, 1),
        "전처리 후 3위 안": hit(art_db, row.질문, row.정답조항, 3),
        "전처리 후 상위 3": " ".join(top3),
    })
hits = pd.DataFrame(rows)
print(hits.to_string(index=False))
print()
for col in ["전처리 전 1위", "전처리 후 1위", "전처리 후 3위 안"]:
    print(f"{col}: {int(hits[col].sum())} / {len(hits)}")

hits.to_csv("out_ch06_hits.csv", index=False, encoding="utf-8-sig")
print("저장: out_ch06_hits.csv")
출력
 번호   정답  전처리 전 1위  전처리 후 1위  전처리 후 3위 안     전처리 후 상위 3
  1  제5조      True      True        True    제5조 제7조 제3조
  2 제12조     False     False       False    제6조 제5조 제9조
  3 제14조      True      True        True 제14조 제16조 제11조
  4 제10조     False      True        True 제10조 제12조 제13조
  5 제11조     False     False       False  제15조 제2조 제16조
  6  제3조      True      True        True    제3조 제9조 제6조
  7  제7조     False     False       False  제11조 제8조 제10조
  8  제8조     False      True        True   제8조 제4조 제12조
  9 제15조      True      True        True 제15조 제12조 제10조
 10 제13조      True      True        True   제13조 제1조 제4조
 11  제9조     False     False       False   제1조 제16조 제6조
 12  제4조      True      True        True   제4조 제12조 제8조

전처리 전 1위: 6 / 12
전처리 후 1위: 8 / 12
전처리 후 3위 안: 8 / 12
저장: out_ch06_hits.csv

코드가 의미하는 것 rag_questions.csv 의 질문과 정답 조항은 검색기의 모의시험지이고, hit(db, question, answer, k) 는 그 시험을 채점하는 함수입니다. 모델의 답을 보기 전에 "근거를 제대로 찾았는가"만 따로 채점하는 것은, RAG 챗봇이 틀렸을 때 검색 탓인지 모델 탓인지 가르려는 것입니다. k=1 과 k=3 을 함께 재면 k 를 늘리는 것으로 고쳐지는 실패인지도 알 수 있습니다.

4단계. 색인 저장하고 다시 불러오기

Python
from pathlib import Path

# save_local: 색인을 폴더에 저장한다. index.faiss(숫자 목록)와 index.pkl(조각의 글과 metadata)이 생긴다
art_db.save_local("out_ch06_faiss")
print("저장: out_ch06_faiss →", sorted(f.name for f in Path("out_ch06_faiss").iterdir()))

# load_local: 저장한 색인을 불러온다. 만들 때와 같은 임베딩을 넘겨야 질문을 같은 지도에 놓는다
# allow_dangerous_deserialization=True: index.pkl(pickle)을 열어도 된다고 허락한다. 내가 만든 색인에만 쓴다
loaded = FAISS.load_local("out_ch06_faiss", emb, allow_dangerous_deserialization=True)
print("불러온 조각 수:", loaded.index.ntotal)
doc, score = loaded.similarity_search_with_score("여권 기간이 얼마나 남아야 해요?", k=1)[0]
print("검색:", doc.metadata["article"], round(float(score), 3), "|", doc.page_content[:40], "…")
출력
저장: out_ch06_faiss → ['index.faiss', 'index.pkl']
불러온 조각 수: 16
검색: 제14조 1.623 | 제14조 여권 해외 상품은 출발일 기준으로 여권의 남은 기간이 6개월 이 …

코드가 의미하는 것 save_local 과 load_local 은 색인 단계와 질문 단계를 시간상으로 떼어 놓는 장치입니다. 색인은 한 번 만들어 폴더에 두고, 챗봇은 켤 때마다 불러와 질문만 처리합니다. load_local 에 만들 때와 같은 emb 를 넘기고 allow_dangerous_deserialization=True 를 내가 만든 색인에만 켜는 것이 이 짝을 안전하게 쓰는 두 규칙입니다. 7장의 웹 화면은 이 폴더를 불러오지 않고, 앱이 처음 켜질 때 약관 PDF 에서 색인을 직접 만듭니다.

이 실습이 뜻하는 것

이 실습은 쪽지를 붙인 두 권의 책(대충 붙인 것과 조항마다 붙인 것)으로 모의시험 열두 문제를 풀어 보고, 맞는 쪽을 몇 번 폈는지 세는 일과 같습니다. 두 책을 준비하고(1단계), 같은 문제로 펴 보고(2단계), 열두 문제를 채점하고(3단계), 잘 붙인 책을 보관합니다(4단계). 여기서 모의시험이 rag_questions.csv, 채점이 적중률, 보관한 책이 out_ch06_faiss 입니다. 비유와 다른 점은, 이 학생(연습용 임베딩)은 글자가 겹치는 쪽만 찾을 줄 알아서, 뜻은 같아도 다른 말로 물으면 엉뚱한 쪽을 편다는 것입니다.

단계출력뜻
1. 준비조각 12개 / 16개, 숫자 512개같은 PDF 를 두 가지로 나눈 조각입니다. 임베딩은 질문 하나를 숫자 512개로 바꿉니다
2. 같은 질문취소 질문은 둘 다 제5조 1위전처리 전에는 제5조에 머리글이 붙어 있고, 둘째 조각에 제7조·제8조가 섞여 있습니다
2. 강아지둘 다 제12조 없음"강아지"와 겹치는 글자가 약관에 없어서 엉뚱한 조항(제6조)이 1위입니다
3. 적중1위 6 → 8 / 12, 3위 안 8 / 12전처리로 짐(제10조)과 환불(제8조) 질문을 새로 맞혔습니다. k 를 3 으로 늘려도 더 맞히지 못했습니다
4. 저장index.faiss, index.pkl, 조각 16개저장하고 다시 불러와도 같은 검색 결과(제14조, 1.623)가 나옵니다

한 줄로 줄이면, 같은 임베딩이라도 조각을 잘 나누면 검색이 나아지지만, 낱말이 겹치지 않는 질문은 나누는 법으로 고칠 수 없다는 것을 확인한 실습입니다. 전처리 뒤에도 놓친 넷은 강아지(정답 제12조 "반려동물"), 아이 요금(제11조 "아동"), 태풍으로 비행기가 안 뜨면(제7조 "항공편"), 공항에 언제까지(제9조 "집합 시간")입니다. 모두 손님이 쓴 낱말과 약관이 쓴 낱말이 다릅니다. 3위 안 적중이 1위 적중과 같은 8 이라는 것은, 놓친 질문의 정답이 4위 아래로 밀려 있다는 뜻입니다. 이 넷을 찾으려면 뜻을 아는 임베딩이 필요합니다. 실습 5에서 바꿔 끼웁니다.

코드 설명

  • class BigramEmbeddings(Embeddings) 는 "연습용 임베딩과 진짜 임베딩" 절의 클래스 그대로입니다. 7장의 웹 화면도 이 코드를 그대로 씁니다.
  • Document(page_content=row.내용, metadata={...}) 는 csv 의 한 행을 다시 Document 로 만듭니다. itertuples() 는 행을 row.열이름 으로 꺼내 쓰는 묶음으로 돌려줍니다.
  • articles_of(doc) 는 조각이 어느 조항인지 정합니다. 크기로 자른 조각은 글 속의 조항 제목(제\d+조 뒤에 빈칸)을 모두 찾습니다. label(doc) 은 찍어 볼 이름표로, 머리글이 들었으면 "머리글+"를 앞에 붙입니다. 조항 제목만 끝에 걸친 조각도 그 조항으로 세므로 전처리 전 쪽에 조금 너그러운 채점입니다.
  • hit(db, question, answer, k) 는 상위 k 개에 정답 조항이 있는지 봅니다. 이렇게 "정답이 상위 k 안에 있는 질문의 비율"을 재는 것을 적중률(hit rate)이라고 합니다.
  • save_local("out_ch06_faiss") 와 FAISS.load_local("out_ch06_faiss", emb, allow_dangerous_deserialization=True) 는 짝입니다. 불러올 때도 만들 때와 같은 BigramEmbeddings 를 넘깁니다.
← 이전 절6장 · 실습 2 — 청킹 견주기와 조항 단위로 나누기다음 절 →6장 · 실습 4 — RetrievalQA 와 LCEL 로 근거 붙인 답의 짜임 보기