6장. 문서 기반 챗봇
검색기와 k
도서관 사서에게 책을 부탁할 때 "딱 한 권만"이라고 하면 그 한 권이 빗나가면 끝입니다. "열 권"이라고 하면 맞는 책은 들어 있겠지만 다 읽기 힘듭니다. 보통은 "세 권쯤"이라고 합니다. 검색기의 k 가 이 "몇 권"입니다.
세 상자는 같은 질문에 k 만 바꾼 모습입니다. 파랑 상자(k=1)는 프롬프트가 짧지만 하나가 빗나가면 근거가 없고, 보라 상자(k=3)는 둘이 빗나가도 하나는 맞을 수 있고, 하늘 상자(k=10)는 거의 다 들어 있지만 엉뚱한 조각이 많이 섞이고 프롬프트가 길어집니다. 여기서 사서가 검색기(retriever), "몇 권"이 k, 받은 책 더미가 프롬프트에 들어갈 조각들입니다. 비유와 다른 점은, 사람은 열 권 가운데 맞는 책을 골라 읽지만 모델은 엉뚱한 조각에 끌려 답이 흐려질 수 있다는 것입니다.
벡터DB 와 검색기
| 견줄 것 | 벡터DB(db) | 검색기(db.as_retriever(...)) |
|---|---|---|
| 부르는 법 | db.similarity_search(질문, k=3) | retriever.invoke(질문) |
| k 를 정하는 곳 | 부를 때마다 | 만들 때 search_kwargs={"k": 3} |
| 체인에 잇기 | 함수로 감싸야 한다 | | 로 바로 잇는다(Runnable) |
| 돌려주는 것 | Document 목록 | Document 목록 |
as_retriever 의 search_kwargs 는 search keyword arguments(검색에 넘길 이름 붙은 인자)의 줄임말입니다. 검색기는 3장에서 본 Runnable 이라서 invoke 로 부르고 | 로 프롬프트에 잇습니다.
짧은 예시
조항 네 개짜리 벡터DB 에서 k 를 1 과 3 으로 바꾼 검색기를 만들어 봅니다. 절이 바뀌었으므로 첫 블록에서 앞 절의 벡터DB 를 다시 만듭니다.
# 준비: "FAISS 벡터DB 에 저장하고 검색하기" 절과 같은 연습용 임베딩과 조항 네 개짜리 벡터DB
import zlib
from langchain_community.vectorstores import FAISS
from langchain_core.documents import Document
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)
emb = BigramEmbeddings()
docs = [
Document("제5조 출발 10일 전까지 취소하면 전액 돌려준다.", metadata={"article": "제5조"}),
Document("제10조 국내선 상품은 15kg 짐 한 개가 포함된다.", metadata={"article": "제10조"}),
Document("제12조 반려동물은 7kg 이하의 개와 고양이만 된다.", metadata={"article": "제12조"}),
Document("제14조 여권의 남은 기간이 6개월 이상이어야 한다.", metadata={"article": "제14조"}),
]
db = FAISS.from_documents(docs, emb)for k in [1, 3]:
# as_retriever: 벡터DB 를 검색기(Runnable)로 바꾼다. k 는 돌려줄 조각 수
retriever = db.as_retriever(search_kwargs={"k": k})
found = retriever.invoke("강아지 데려가도 돼요?")
print(f"k={k}:", [d.metadata["article"] for d in found], type(retriever).__name__)k=1: ['제5조'] VectorStoreRetriever
k=3: ['제5조', '제10조', '제12조'] VectorStoreRetrieverk=1 이면 제5조 하나, k=3 이면 제5조, 제10조, 제12조 세 개가 나왔습니다. "강아지"와 글자가 겹치는 조항이 없는데도 제5조와 제10조가 앞에 온 것은, 512 칸에 수많은 묶음을 나눠 넣다 보니 서로 다른 묶음이 같은 칸에 떨어지는 일(충돌)이 생겨 점수가 조금 낮아졌기 때문입니다. 실제로 이 질문의 점수는 제5조 1.855, 제10조 1.858 이고, 제12조와 제14조는 겹치는 것이 없어 2.0 으로 같습니다. 제12조가 셋째로 들어온 것은 맞혀서가 아니라 조각이 넷뿐이기 때문입니다. k 를 늘려 정답이 "딸려 들어오는" 것과 검색이 정답을 "찾는" 것은 다릅니다. 검색기의 자료형은 VectorStoreRetriever(벡터DB 검색기)입니다.
코드가 의미하는 것
이 코드는 같은 사서에게 "한 권만"과 "세 권쯤"을 차례로 부탁해 보는 일입니다. 첫 블록은 앞 절의 서가(조항 네 개짜리 벡터DB)를 다시 세우는 준비이고, 둘째 블록이 k 만 바꾼 검색기 두 개를 만들어 같은 질문을 던집니다.
| 코드 | 하는 일 | 챗봇에서 뜻하는 것 |
|---|---|---|
첫 블록 전체(BigramEmbeddings, docs, FAISS.from_documents) | 앞 절과 같은 임베딩과 벡터DB 를 다시 만든다 | 절이 바뀌면 처음부터 다시 실행되므로 서가를 다시 세웁니다. 실제 챗봇에서는 저장한 색인을 불러오는 자리입니다 |
db.as_retriever(search_kwargs={"k": k}) | 벡터DB 를 k 개를 돌려주는 검색기로 바꾼다 | k 를 만들 때 한 번 정해 두므로, 챗봇의 다른 부분은 "몇 개"를 신경 쓰지 않고 질문만 넘기면 됩니다 |
retriever.invoke("강아지 데려가도 돼요?") | 질문을 넣어 Document 목록을 받는다 | 3장의 체인 부품과 같은 invoke 로 부릅니다. 그래서 | 로 프롬프트 앞에 바로 이을 수 있습니다 |
type(retriever).__name__ | 검색기의 자료형 이름을 찍는다 | 겉모습은 벡터DB 와 같은 검색이지만 Runnable 로 감싼 다른 물건이라는 것을 확인합니다 |
결과에서 읽을 것은 두 가지입니다.
- k=1 의 답은 빗나갔습니다. "강아지" 질문에 제5조(취소 규정) 하나만 왔습니다. 이 조각만 프롬프트에 넣으면 모델은 반려동물 규정을 볼 수 없습니다.
- k=3 에서 제12조가 들어온 것은 운입니다. 조각이 넷뿐이라 셋째로 딸려 들어왔을 뿐, 순위로는 맨 앞이 아닙니다. k 를 키우는 것은 검색의 실력을 높이는 일이 아니라 그물을 넓히는 일입니다.
주의할 점
- k 를 늘리면 정답 조각이 들어 있을 확률은 오르지만, 프롬프트의 토큰이 늘어 요금과 시간이 늘고 엉뚱한 근거도 늘어납니다. 이 장은 k=3 에서 시작합니다.
- 맞는 조각이 아예 높은 순위에 오지 않는다면 k 를 늘려도 소용없습니다. 그때는 나누는 법(실습 2)이나 임베딩(실습 5)을 바꿉니다.