LLM 엔지니어링7장 · 자체 임베딩과 벡터DB, 검색 품질16 / 22← 이전목차다음 →MetaContents

7장. 자체 임베딩과 벡터DB, 검색 품질

실습 3 — 벡터DB 에 넣고 찾기

실습 2의 조각과 임베딩을 FAISS 와 Chroma 에 넣고, 파일로 저장했다가 다시 불러 찾아봅니다. faiss-cpu 와 chromadb 가 설치되어 있어야 합니다. 저장한 파일과 폴더는 out_helpdesk.faiss, out_chroma/ 입니다.

1단계. 실습 2와 같은 준비

Python
import numpy as np
from sentence_transformers import SentenceTransformer

text = open("it_helpdesk.md", encoding="utf-8").read()
chunks = ["## " + s.strip() for s in text.split("\n## ")[1:]]
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
E = emb.encode(["passage: " + c for c in chunks], normalize_embeddings=True)
def title(c):
    return c.split("\n")[0].removeprefix("## ")
question = "핸드폰을 새로 샀는데 인증은 어떻게 옮기나요?"
q = emb.encode(["query: " + question], normalize_embeddings=True)     # 모양 (1, 384): FAISS 는 2차원 배열을 받는다
print(E.dtype, E.shape, q.shape)
출력
float32 (30, 384) (1, 384)

2단계. FAISS 에 넣고 저장하기

Python
import faiss

index = faiss.IndexFlatIP(E.shape[1])   # 384차원, 내적(IP)으로 모두 견주는 인덱스
index.add(E)                            # 조각 30개를 0~29 번으로 넣는다
faiss.write_index(index, "out_helpdesk.faiss")

loaded = faiss.read_index("out_helpdesk.faiss")    # 파일에서 다시 불러온다
D, I = loaded.search(q, 3)              # D: 점수, I: 조각 번호. 둘 다 (질문 수, k) 모양
print("넣은 벡터 수:", loaded.ntotal)
for s, i in zip(D[0], I[0]):
    print(round(float(s), 3), i, title(chunks[i]))    # 번호에 맞는 원문은 우리가 든 chunks 에서 찾는다
출력
넣은 벡터 수: 30
0.887 4 2단계 인증
0.843 17 개인 기기
0.837 0 VPN 접속

3단계. Chroma 에 넣고 저장하기

Python
import chromadb

client = chromadb.PersistentClient(path="out_chroma")    # 이 폴더에 저장한다
# 같은 이름의 컬렉션이 있으면 지우고 새로 만든다(다시 실행해도 겹치지 않게)
if "helpdesk" in [c.name for c in client.list_collections()]:
    client.delete_collection("helpdesk")
col = client.create_collection("helpdesk", metadata={"hnsw:space": "cosine"})   # 거리: 코사인
col.add(ids=[f"s{i}" for i in range(len(chunks))],
        documents=chunks,                                  # 원문도 함께 넣는다
        embeddings=E.tolist(),                             # 우리가 만든 임베딩을 직접 넣는다
        metadatas=[{"제목": title(c)} for c in chunks])
print("컬렉션의 조각 수:", col.count())

r = col.query(query_embeddings=q.tolist(), n_results=3)
for i, d, m in zip(r["ids"][0], r["distances"][0], r["metadatas"][0]):
    print(i, round(d, 3), m["제목"])                       # distance = 1 - 코사인 유사도
출력
컬렉션의 조각 수: 30
s4 0.113 2단계 인증
s17 0.157 개인 기기
s0 0.163 VPN 접속

4단계. 메타데이터로 거르기

Python
# 제목이 '와이파이' 인 조각 안에서만 찾는다
r = col.query(query_embeddings=q.tolist(), n_results=1, where={"제목": "와이파이"})
print(r["ids"][0], r["documents"][0][0][:40])

# 다시 열어도 남아 있는지: 새 클라이언트로 같은 폴더를 연다
again = chromadb.PersistentClient(path="out_chroma").get_collection("helpdesk")
print("다시 연 컬렉션의 조각 수:", again.count())
출력
['s16'] ## 와이파이
직원은 'SN-Staff', 손님은 'SN-Guest' 와
다시 연 컬렉션의 조각 수: 30

이 실습이 뜻하는 것

이 실습은 지도에 꽂아 둔 위치를 두 종류의 서류함에 넣어 보관하는 것과 같습니다. 실습 2처럼 위치를 다시 만들고(1단계), 번호표만 붙여 보관하는 서류함에 넣어 파일로 저장했다가 다시 꺼내 찾고(2단계), 원문과 이름표까지 함께 보관하는 서류함에 넣어 찾고(3단계), 이름표로 범위를 좁혀 찾은 뒤 서류함을 다시 열어 그대로 있는지 봅니다(4단계). 여기서 번호표만 보관하는 서류함이 FAISS(Facebook AI Similarity Search), 원문까지 보관하는 서류함이 Chroma, 이름표가 메타데이터(제목처럼 조각에 덧붙이는 정보)입니다.

단계출력뜻
1. 준비float32 (30, 384) (1, 384)조각 30개와 질문 1개의 임베딩입니다. 질문도 (1, 384)처럼 2차원으로 만들어야 FAISS 가 받습니다
2. FAISS 저장넣은 벡터 수: 30파일로 저장했다 다시 불러도 30개가 모두 들어 있습니다
2. FAISS 찾기0.887 4 2단계 인증 외 2개휴대폰 인증 질문에 4번 조각 "2단계 인증"이 1등입니다. FAISS 는 번호와 점수만 주므로 제목은 우리 목록에서 찾아 붙였습니다
3. Chroma 저장컬렉션의 조각 수: 30Chroma 의 서랍(컬렉션)에 30조각을 원문, 제목과 함께 넣었습니다
3. Chroma 찾기s4 0.113 2단계 인증 외 2개FAISS 와 같은 세 조각을 같은 순서로 찾았습니다. 0.113 은 거리(1 - 유사도)라 1 - 0.887 과 같은 값입니다
4. 거르기['s16'] ## 와이파이 …제목이 와이파이인 조각만 보게 했더니, 질문과 상관없는 와이파이 조각이 나왔습니다. 거르는 조건이 질문보다 앞섭니다
4. 다시 열기다시 연 컬렉션의 조각 수: 30프로그램에서 새로 열어도 저장한 30조각이 남아 있습니다

한 줄로 줄이면, 두 벡터DB 모두 같은 조각을 찾고 저장해 두었다 다시 쓸 수 있으며, 다른 점은 원문과 메타데이터를 함께 보관하느냐라는 것을 보인 실습입니다. 조각이 30개뿐이라 두 도구의 속도 차이는 이 실습에서 보이지 않습니다. 실습 4부터는 검색이 얼마나 잘 되는지를 질문 묶음으로 잽니다.

코드 설명

  • FAISS 는 30개를 넣고 파일로 저장한 뒤 다시 불러 찾았습니다. "핸드폰을 새로 샀는데 인증은 어떻게 옮기나요?"의 1등은 4번 "2단계 인증"(0.887)입니다. FAISS 는 번호(4, 17, 0)만 돌려주므로, 제목은 우리가 든 chunks 에서 찾아 붙였습니다. IndexFlatIP 는 실습 2의 E @ q 와 같은 계산이라 점수도 같습니다.
  • Chroma 도 같은 세 조각(s4, s17, s0)을 같은 순서로 찾았습니다. Chroma 는 유사도 대신 거리(1 - 코사인 유사도)를 돌려주므로 0.113 은 0.887 과 같은 값입니다. 원문과 메타데이터(제목)가 함께 돌아와 따로 찾아 붙일 필요가 없습니다.
  • where={"제목": "와이파이"} 는 메타데이터가 맞는 조각 안에서만 찾습니다. 질문과 상관없이 거른 범위 안의 가장 가까운 것을 돌려주므로, 거르는 조건을 잘못 주면 엉뚱한 조각이 1등이 됩니다.
  • 거르기 결과는 질문과 상관없는 와이파이 조각(s16)이고, 새 클라이언트로 같은 폴더를 다시 열어도 조각 30개가 남아 있습니다. 한 번 넣어 두면 프로그램을 다시 켜도 임베딩을 새로 만들지 않아도 됩니다.
← 이전 절7장 · 실습 2 — 사내 문서 의미 검색다음 절 →7장 · 실습 4 — 검색 품질 재기