6장. 문서 기반 챗봇
FAISS 벡터DB 에 저장하고 검색하기
큰 서점의 안내 기계에 책 제목을 치면 "3층 B 서가"처럼 자리를 알려 줍니다. 수만 권의 책을 하나씩 훑지 않고도 금방 찾는 것은, 책을 들여놓을 때 자리를 정리해 두었기 때문입니다. FAISS 는 임베딩을 위한 안내 기계입니다. 조각의 숫자 목록을 정리해 넣어 두면, 질문의 숫자 목록과 가까운 것을 금방 골라 줍니다.
앞의 두 상자는 색인 단계에서 한 번, 뒤의 두 상자는 질문마다 합니다. 넷째 상자에서 FAISS 는 거리가 가까운 순서로 조각 k 개와 거리 점수를 돌려줍니다. 여기서 서가의 자리 정리가 색인 만들기, 안내 기계가 FAISS, 알려 준 자리가 가까운 조각입니다. 비유와 다른 점은, 안내 기계는 제목이 정확히 맞는 책을 찾지만 FAISS 는 "가장 가까운 것"을 늘 k 개 돌려준다는 것입니다. 맞는 조각이 하나도 없어도 무언가를 돌려줍니다.
FAISS 를 다루는 메서드
| 메서드 | 하는 일 | 돌려주는 것 |
|---|---|---|
FAISS.from_documents(조각, 임베딩) | 조각마다 임베딩을 만들어 색인을 만든다 | 벡터DB |
FAISS.from_texts(글, 임베딩) | 글자열 목록으로 만든다 | 벡터DB |
db.similarity_search(질문, k=3) | 가까운 조각 k 개 | Document 목록 |
db.similarity_search_with_score(질문, k=3) | 가까운 조각과 거리 점수 | (Document, 점수) 목록. 점수는 작을수록 가깝다 |
db.save_local("폴더") | 색인을 폴더에 저장 | 폴더 안에 index.faiss, index.pkl |
FAISS.load_local("폴더", 임베딩, allow_dangerous_deserialization=True) | 저장한 색인을 불러온다 | 벡터DB |
짧은 예시
약관의 조항 네 개로 벡터DB 를 만들고, 거리 점수를 봅니다. 그리고 점수가 앞 절의 "거리의 제곱 = 2 - 2 × 방향 닮음"과 맞는지 확인합니다. BigramEmbeddings 는 앞 절의 클래스를 그대로 씁니다.
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)
print("색인에 든 조각:", db.index.ntotal)
question = "취소하면 돈을 돌려주나요?"
q_vec = emb.embed_query(question)
for doc, score in db.similarity_search_with_score(question, k=2):
d_vec = emb.embed_query(doc.page_content)
cos = sum(a * b for a, b in zip(q_vec, d_vec))
# FAISS 의 점수(거리의 제곱)와 2 - 2 × 방향 닮음을 나란히 찍는다
print(doc.metadata["article"], "점수", round(float(score), 3), "/ 2 - 2×닮음", round(2 - 2 * cos, 3))색인에 든 조각: 4
제5조 점수 1.342 / 2 - 2×닮음 1.342
제12조 점수 1.759 / 2 - 2×닮음 1.759색인에 조각 4개가 들어갔고, "취소하면 돈을 돌려주나요?"에 제5조가 1.342 로 가장 가까웠습니다. 둘째인 제12조는 1.759 로 한참 멉니다. 오른쪽에 손으로 계산한 "2 - 2 × 방향 닮음"이 FAISS 의 점수와 소수 셋째 자리까지 같습니다. 두 임베딩 모두 화살표 길이가 1 이라서, FAISS 의 거리 점수와 방향 닮음은 같은 것을 거꾸로 재고 있는 셈입니다. 점수가 0 이면 같은 글, 2 면 겹치는 것이 하나도 없는 글입니다.
코드가 의미하는 것
이 코드는 서점의 안내 기계에 책 네 권을 정리해 넣고, 질문 하나로 자리를 물어보는 일입니다. 조항마다 꼬리표를 달아 색인을 만들고, 점수와 함께 가까운 둘을 받은 뒤, 그 점수를 손으로 다시 계산해 맞춰 봅니다.
| 코드 | 하는 일 | 챗봇에서 뜻하는 것 |
|---|---|---|
Document(..., metadata={"article": "제5조"}) | 조항 글에 조항 번호를 꼬리표로 붙인다 | 검색해서 꺼낸 조각이 몇 조인지 코드가 알 수 있게 합니다. 답 아래에 출처를 붙이는 바탕입니다 |
FAISS.from_documents(docs, emb) | 조각마다 임베딩을 만들어 색인에 넣는다 | 색인 단계입니다. db.index.ntotal 로 색인에 든 조각 수를 셉니다 |
db.similarity_search_with_score(question, k=2) | 가까운 조각 2개를 거리 점수와 함께 돌려준다 | 검색 결과가 얼마나 확실한지 숫자로 봅니다. 뒤의 "모르면 모른다고 답하기" 절이 이 점수로 문턱을 둡니다 |
round(2 - 2 * cos, 3) | 방향 닮음으로 거리의 제곱을 손으로 계산한다 | FAISS 가 안에서 무엇을 재는지 검산합니다. 블랙박스처럼 보이는 점수를 앞 절의 계산과 이어 줍니다 |
결과에서 읽을 것은 두 가지입니다.
- 점수는 작을수록 가깝습니다. 제5조 1.342 가 제12조 1.759 보다 앞에 왔습니다. 방향 닮음과 달리 FAISS 의 점수는 거꾸로 읽습니다.
- 손 계산과 FAISS 의 점수가 같습니다. 두 열이 1.342, 1.759 로 똑같습니다. 길이가 1 인 임베딩에서는 거리 점수만 보고도 방향 닮음을 아는 셈입니다.
저장한 색인을 불러올 때의 옵션
save_local 이 만드는 index.pkl 은 파이썬의 pickle(피클, 객체를 바이트로 절여 두는 저장 방식) 파일입니다. pickle 은 불러올 때 파일 안의 코드를 실행할 수 있어서, 남이 만든 pickle 을 열면 그 사람이 넣어 둔 코드가 내 컴퓨터에서 돌 수 있습니다. 그래서 FAISS.load_local 은 기본으로 pickle 을 열지 않고, allow_dangerous_deserialization=True(위험할 수 있는 역직렬화를 허락한다)를 주어야 엽니다. 이 옵션은 내가 직접 만든 색인에만 켭니다. 인터넷에서 받거나 남이 보내 준 색인 폴더는 이 옵션으로 열지 않고, 원래 문서를 받아 내 컴퓨터에서 다시 색인합니다.
주의할 점
- FAISS 의 기본 점수는 거리(정확히는 거리의 제곱)라서 작을수록 가깝습니다. 방향 닮음처럼 클수록 좋은 점수로 착각하지 않습니다.
- 점수는
np.float32꼴로 오므로 찍을 때round(float(score), 3)처럼 파이썬 수로 바꿉니다. - FAISS 는 컴퓨터 메모리 안에서 도는 가벼운 벡터DB 입니다. 조각이 수백만 개이거나 여럿이 함께 쓰는 서비스라면 서버로 도는 벡터DB 를 따로 씁니다. 이 장의 약관처럼 작은 문서에는 FAISS 로 충분합니다.