6장. 문서 기반 챗봇
RAG 파이프라인의 두 단계
도서관을 생각해 봅시다. 새 책이 들어오면 사서는 책을 읽고, 분류 번호를 매기고, 서가의 알맞은 자리에 꽂습니다. 이 일은 책이 들어올 때 한 번만 합니다. 이용자가 "강아지 키우는 법 책 있어요?"라고 물으면, 사서는 분류 번호로 서가를 찾아 책 몇 권을 뽑아 오고, 이용자는 그 책을 읽고 답을 얻습니다. RAG 파이프라인(pipeline, 일이 차례로 흘러가는 관)도 미리 하는 단계와 물을 때마다 하는 단계로 나뉩니다.
위 줄(파랑 화살표)이 색인 단계입니다. PDF 를 읽고, 조각으로 나누고, 조각마다 임베딩을 만들어 벡터DB 에 저장합니다. 아래 줄(하늘 화살표)이 질문 단계입니다. 질문도 임베딩으로 바꾸고, 벡터DB 에서 가까운 조각을 찾고, 그 조각을 프롬프트에 넣어 모델이 답을 씁니다. 두 줄을 잇는 점선은 질문 단계가 색인 단계에서 만든 벡터DB 를 꺼내 쓴다는 뜻입니다. 여기서 분류 번호가 임베딩, 서가가 벡터DB, 책을 뽑아 오는 사서가 검색기, 책을 읽고 답하는 이용자가 모델입니다. 비유와 다른 점은, 사서는 책 한 권을 통째로 뽑지만 RAG 는 책을 미리 몇 쪽짜리 조각으로 잘라 두고 조각 단위로 뽑는다는 것입니다.
단계마다 쓰는 부품
| 단계 | 하는 일 | 이 장에서 쓰는 것 | 언제 |
|---|---|---|---|
| 읽기 | PDF 를 쪽마다 Document 로 | PyPDFLoader | 문서가 바뀔 때 한 번 |
| 나누기 | 쪽을 조각으로 | RecursiveCharacterTextSplitter, 조항 단위 나누기 | 문서가 바뀔 때 한 번 |
| 임베딩 | 조각을 숫자 목록으로 | BigramEmbeddings(연습용), OpenAIEmbeddings | 문서가 바뀔 때 한 번 |
| 저장 | 숫자 목록을 색인으로 | FAISS.from_documents, save_local | 문서가 바뀔 때 한 번 |
| 검색 | 질문과 가까운 조각 k 개 | as_retriever(search_kwargs={"k": 3}) | 질문마다 |
| 답 쓰기 | 조각을 넣은 프롬프트로 답 | RetrievalQA, LCEL 체인 | 질문마다 |
짧은 예시
세 문장짜리 작은 "문서"로 두 단계를 끝까지 한 번 돌려 봅니다. 임베딩은 다음다음 절에서 볼 연습용 BigramEmbeddings 를 미리 씁니다. 키가 필요 없습니다.
import zlib
from langchain_community.vectorstores import FAISS
from langchain_core.embeddings import Embeddings
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)
# 색인 단계: 글 → 임베딩 → 벡터DB (여기서는 나누기를 건너뛰고 문장 하나를 조각 하나로 본다)
texts = ["출발 10일 전까지 취소하면 전액 환불한다.", "국내선은 15kg 짐 한 개가 포함된다.", "여권은 6개월 이상 남아야 한다."]
db = FAISS.from_texts(texts, BigramEmbeddings())
# 질문 단계: 질문 → 임베딩 → 가까운 조각 → 프롬프트에 넣을 글
question = "짐은 몇 kg 까지 되나요?"
found = db.similarity_search(question, k=1)
print("찾은 조각:", found[0].page_content)
print("프롬프트에 넣을 글:", f"[약관] {found[0].page_content}\n[질문] {question}")찾은 조각: 국내선은 15kg 짐 한 개가 포함된다.
프롬프트에 넣을 글: [약관] 국내선은 15kg 짐 한 개가 포함된다.
[질문] 짐은 몇 kg 까지 되나요?세 문장 가운데 "짐"과 "kg"이 겹치는 둘째 문장을 찾았습니다. 이 글을 프롬프트에 넣어 모델에 보내면 질문 단계가 끝납니다. 이 장의 실습은 이 짧은 코드의 칸마다 진짜 PDF, 나누기, 저장과 불러오기, 모델을 하나씩 채워 넣습니다.
코드가 의미하는 것
이 코드는 도서관 사서의 하루를 아주 작게 줄인 것입니다. 책 세 권에 분류 번호를 매겨 서가에 꽂고(색인 단계), 이용자의 질문에 맞는 책 한 권을 뽑아 건넵니다(질문 단계).
| 코드 | 하는 일 | 챗봇에서 뜻하는 것 |
|---|---|---|
class BigramEmbeddings(Embeddings) | embed_documents 와 embed_query 두 메서드를 가진 임베딩을 만든다 | 분류 번호를 매기는 규칙입니다. 이 두 메서드만 있으면 LangChain 의 벡터DB 에 끼울 수 있습니다 |
FAISS.from_texts(texts, BigramEmbeddings()) | 글마다 임베딩을 만들어 FAISS 색인에 넣는다 | 색인 단계 전체가 이 한 줄입니다. 책이 들어올 때 한 번만 하는 일입니다 |
db.similarity_search(question, k=1) | 질문을 같은 임베딩으로 바꿔 가장 가까운 글 1개를 찾는다 | 질문 단계의 검색입니다. k 는 사서가 뽑아 올 책의 권수입니다 |
f"[약관] …\n[질문] {question}" | 찾은 글과 질문을 한 덩어리 글로 잇는다 | 모델에 보낼 프롬프트입니다. 이 예시는 모델을 부르지 않고 여기서 멈춥니다 |
결과에서 읽을 것은 두 가지입니다.
- 글자가 겹친 문장이 뽑혔습니다. 찾은 조각은 "국내선은 15kg 짐 한 개가 포함된다."입니다. 질문과 "짐", "kg"이 겹치기 때문이고, 연습용 임베딩은 글자 겹침으로 가깝고 먼 것을 정합니다.
- 프롬프트에는 찾은 조각만 들어갑니다. 나머지 두 문장(환불, 여권)은 모델에게 가지 않습니다. 모델이 볼 수 있는 근거는 검색이 고른 것뿐입니다.
주의할 점
- 색인 단계는 문서가 바뀔 때만 다시 합니다. 질문마다 PDF 를 다시 읽고 임베딩을 다시 만들면 시간과 (진짜 임베딩이면) 요금이 질문 수만큼 듭니다. 그래서 만든 색인을 파일로 저장해 둡니다.
- 색인을 만들 때와 질문을 임베딩할 때는 같은 임베딩을 써야 합니다. 서로 다른 임베딩의 숫자는 같은 지도 위의 점이 아니라서 거리를 재는 뜻이 없습니다.