11장. 폐쇄망 문서 파이프라인과 폐쇄형 RAG
실습 4 — 벡터DB 세 가지 견주기
실습 3의 조각을 임베딩해 FAISS, Chroma, Qdrant(로컬 모드)에 넣고, 같은 질문으로 찾고, 등급으로 거르고, 문서 하나를 지워 봅니다. 셋 다 서버 없이 돕니다. qdrant-client 가 설치되어 있어야 하고, 실습 1과 3을 먼저 실행해야 합니다. 저장되는 것은 out_intranet.faiss, out_chroma11/, out_qdrant11/ 입니다.
1단계. 바깥을 막고 조각을 임베딩하기
import json, os, shutil, time
from pathlib import Path
import numpy as np
import sys
sys.path.insert(0, ".") # 작업 폴더(data)에 만든 out_offline.py 를 찾게 한다
import out_offline
out_offline.guard() # 라이브러리를 부르기 전에 막는다(실습 1의 파일)
import faiss
import chromadb
from chromadb.config import Settings
from qdrant_client import QdrantClient, models
from sentence_transformers import SentenceTransformer
chunks = [json.loads(line) for line in open("out_chunks.jsonl", encoding="utf-8")]
emb = SentenceTransformer("intfloat/multilingual-e5-small", device="cpu")
E = emb.encode(["passage: " + c["글"] for c in chunks], normalize_embeddings=True)
question = "법인카드로 한 번에 얼마까지 쓸 수 있나요?"
q = emb.encode("query: " + question, normalize_embeddings=True)
print("조각", E.shape, "· 질문", q.shape)조각 (51, 384) · 질문 (384,)2단계. 셋에 넣고 같은 질문으로 찾기
ids = np.arange(len(chunks), dtype="int64") # FAISS 와 Qdrant 는 정수 id 를 쓴다
timing = {}
# FAISS: IndexIDMap 으로 감싸야 정해 준 id 로 넣고 지울 수 있다
t = time.time()
fx = faiss.IndexIDMap(faiss.IndexFlatIP(E.shape[1]))
fx.add_with_ids(E, ids)
faiss.write_index(fx, "out_intranet.faiss")
timing["FAISS"] = time.time() - t
# Chroma: 폴더에 저장한다. 다시 돌릴 때를 위해 같은 이름의 컬렉션이 있으면 지운다
t = time.time()
cc = chromadb.PersistentClient(path="out_chroma11", settings=Settings(anonymized_telemetry=False))
if "intranet" in [c.name for c in cc.list_collections()]:
cc.delete_collection("intranet")
col = cc.create_collection("intranet", metadata={"hnsw:space": "cosine"}) # 코사인 거리로 찾는다
col.add(ids=[c["id"] for c in chunks], embeddings=E.tolist(), documents=[c["글"] for c in chunks],
metadatas=[{k: c[k] for k in ("경로", "제목", "등급", "버전")} for c in chunks])
timing["Chroma"] = time.time() - t
# Qdrant: path 를 주면 서버 없이 폴더에 저장한다
t = time.time()
qc = QdrantClient(path="out_qdrant11")
if qc.collection_exists("intranet"):
qc.delete_collection("intranet")
qc.create_collection("intranet", vectors_config=models.VectorParams(size=E.shape[1], distance=models.Distance.COSINE))
qc.upsert("intranet", points=[models.PointStruct(id=int(i), vector=E[i].tolist(), payload=chunks[i]) for i in ids])
timing["Qdrant"] = time.time() - t
# 같은 질문의 상위 3개를 셋에서 찾아 견준다
_, I = fx.search(q[None], 3)
r_c = col.query(query_embeddings=[q.tolist()], n_results=3)
r_q = qc.query_points("intranet", query=q.tolist(), limit=3).points
print("FAISS :", [chunks[i]["id"] for i in I[0]])
print("Chroma:", r_c["ids"][0])
print("Qdrant:", [p.payload["id"] for p in r_q])
print("넣는 데 걸린 시간:", {k: f"{v:.2f}초" for k, v in timing.items()})FAISS : ['finance/법인카드_규정.html#0', 'finance/법인카드_규정.html#2', 'finance/법인카드_규정.html#1']
Chroma: ['finance/법인카드_규정.html#0', 'finance/법인카드_규정.html#2', 'finance/법인카드_규정.html#1']
Qdrant: ['finance/법인카드_규정.html#0', 'finance/법인카드_규정.html#2', 'finance/법인카드_규정.html#1']
넣는 데 걸린 시간: {'FAISS': '0.00초', 'Chroma': '0.26초', 'Qdrant': '0.39초'}3단계. 협력사 직원(등급 1)으로 거르기
level = 1 # 협력사 직원은 공개(1) 문서만 본다
allowed = ids[[c["등급"] <= level for c in chunks]] # FAISS 는 볼 수 있는 id 목록을 우리가 만든다
_, I = fx.search(q[None], 3, params=faiss.SearchParameters(sel=faiss.IDSelectorBatch(allowed)))
r_c = col.query(query_embeddings=[q.tolist()], n_results=3, where={"등급": {"$lte": level}})
r_q = qc.query_points("intranet", query=q.tolist(), limit=3,
query_filter=models.Filter(must=[models.FieldCondition(key="등급", range=models.Range(lte=level))])).points
print("FAISS :", [chunks[i]["제목"] for i in I[0]])
print("Chroma:", [m["제목"] for m in r_c["metadatas"][0]])
print("Qdrant:", [p.payload["제목"] for p in r_q])FAISS : ['경비 정산 기준', '휴가 규정', '휴가 규정']
Chroma: ['경비 정산 기준', '휴가 규정', '휴가 규정']
Qdrant: ['경비 정산 기준', '휴가 규정', '휴가 규정']4단계. 문서 하나 지우고 저장 크기 보기
target = "it/VPN_접속안내.txt"
del_ids = ids[[c["경로"] == target for c in chunks]]
print("지울 조각:", len(del_ids), "개")
# 셋에서 같은 문서를 지운다. FAISS 는 id 로, Chroma 와 Qdrant 는 메타데이터 조건으로 지운다
fx.remove_ids(del_ids)
col.delete(where={"경로": target})
qc.delete("intranet", points_selector=models.FilterSelector(filter=models.Filter(
must=[models.FieldCondition(key="경로", match=models.MatchValue(value=target))])))
print("지운 뒤 개수:", fx.ntotal, col.count(), qc.count("intranet").count)
# 원래대로 다시 넣는다(실습 6이 이 Chroma 를 쓴다)
back = [int(i) for i in del_ids]
fx.add_with_ids(E[back], del_ids)
col.add(ids=[chunks[i]["id"] for i in back], embeddings=E[back].tolist(), documents=[chunks[i]["글"] for i in back],
metadatas=[{k: chunks[i][k] for k in ("경로", "제목", "등급", "버전")} for i in back])
qc.upsert("intranet", points=[models.PointStruct(id=i, vector=E[i].tolist(), payload=chunks[i]) for i in back])
print("다시 넣은 뒤:", fx.ntotal, col.count(), qc.count("intranet").count)
faiss.write_index(fx, "out_intranet.faiss")
qc.close() # Qdrant 로컬 모드는 폴더를 잠가 두므로 닫는다
def size_kb(path):
p = Path(path)
files = [p] if p.is_file() else [f for f in p.rglob("*") if f.is_file()]
return round(sum(f.stat().st_size for f in files) / 1024)
print("저장 크기(KB):", {"FAISS": size_kb("out_intranet.faiss"), "Chroma": size_kb("out_chroma11"),
"Qdrant": size_kb("out_qdrant11")})
print("막은 주소:", out_offline.BLOCKED)지울 조각: 4 개
지운 뒤 개수: 47 47 47
다시 넣은 뒤: 51 51 51
저장 크기(KB): {'FAISS': 77, 'Chroma': 1049, 'Qdrant': 229}
막은 주소: ['huggingface.co']이 실습이 뜻하는 것
이 실습은 같은 색인 카드를 세 가지 서랍장에 넣어 보고 쓰임새를 견주는 일과 같습니다. 출입을 막은 채 카드마다 위치 좌표를 매기고(1단계), 세 서랍장에 넣어 같은 질문으로 가장 가까운 카드 셋을 찾고(2단계), 협력사 직원이 볼 수 있는 공개 카드에서만 찾게 하고(3단계), 서류 하나의 카드를 뺐다 다시 넣으며 서랍장이 차지하는 자리를 잽니다(4단계). 여기서 위치 좌표가 임베딩, 세 서랍장이 FAISS(Facebook AI Similarity Search, 페이스북이 만든 유사도 검색 라이브러리), Chroma, Qdrant 이고, 카드의 꼬리표(등급, 경로)가 메타데이터입니다.
| 단계 | 출력 | 뜻 |
|---|---|---|
| 1. 임베딩 | 조각 (51, 384) · 질문 (384,) | 조각 51개와 질문 하나가 저마다 숫자 384개짜리 좌표가 되었습니다 |
| 2. 찾기 | 셋 모두 법인카드 규정 #0, #2, #1 | 같은 좌표를 같은 거리 재는 법(코사인)으로 견주니, 셋이 같은 조각을 같은 순서로 찾았습니다 |
| 2. 넣는 시간 | FAISS 0.00초, Chroma 0.26초, Qdrant 0.39초 | FAISS 는 좌표만 넣어 시간이 거의 들지 않고, 나머지 둘은 원문과 꼬리표까지 저장하느라 조금 더 걸렸습니다 |
| 3. 등급 1로 거르기 | 셋 모두 ['경비 정산 기준', '휴가 규정', '휴가 규정'] | 법인카드 규정(등급 2)이 빠지고 공개 문서만 나왔습니다. 남은 조각은 질문과 상관이 없습니다 |
| 4. 지우기 | 지울 조각: 4 개, 지운 뒤 개수: 47 47 47 | VPN 안내의 4조각이 세 곳에서 똑같이 빠졌습니다 |
| 4. 다시 넣기 | 다시 넣은 뒤: 51 51 51 | 원래대로 돌려놓았습니다. 실습 6이 이 Chroma 색인을 씁니다 |
| 4. 저장 크기 | FAISS 77KB, Chroma 1049KB, Qdrant 229KB | FAISS 가 가장 작습니다. 좌표만 저장하기 때문입니다 |
| 4. 막은 주소 | ['huggingface.co'] | 실습 1에서 시험한 주소 하나뿐이라, 세 DB 모두 바깥을 찾지 않았습니다 |
한 줄로 줄이면, 조각이 51개뿐인 이 규모에서는 세 벡터DB 가 같은 답을 찾고, 차이는 거르는 방법(FAISS 는 볼 수 있는 id 목록을 직접 만든다)과 저장하는 양에서 난다는 것을 보인 실습입니다. 시간과 크기는 이 PC 에서 한 번 잰 값이고, Chroma 의 크기는 실행을 되풀이하며 커진 것이라(코드 설명) 세 DB 의 실력 순위로 읽으면 안 됩니다. 벡터가 많아질 때의 속도 차이는 실습 5에서 잽니다.
코드 설명
- 2단계에서 세 DB 가 법인카드 규정의 같은 세 조각(#0, #2, #1)을 같은 순서로 찾았습니다. 같은 임베딩을 넣고 같은 거리(코사인)로 견줬기 때문입니다. 넣는 시간은 FAISS 가 0.01초도 안 걸렸고, Chroma 와 Qdrant 는 0.3~0.4초였습니다. 원문과 메타데이터를 함께 저장하는 데 드는 시간입니다.
- 3단계에서 협력사 직원(등급 1)으로 거르자 세 DB 모두 법인카드 규정(등급 2)을 빼고 경비 정산 기준과 휴가 규정을 내놓았습니다. Chroma 는
where, Qdrant 는Filter로 메타데이터에 조건을 걸지만, FAISS 는 메타데이터가 없어서 볼 수 있는 id 목록(IDSelectorBatch)을 우리가 만들어 넘깁니다. 거른 뒤에 남은 조각이 질문과 상관없다는 점은, 실습 6에서 모델이 "찾을 수 없습니다"로 답해야 하는 까닭이 됩니다. - 4단계에서 VPN 안내의 4조각을 지우자 셋 다 47개, 다시 넣자 51개가 되었습니다. FAISS 는
IndexIDMap으로 감싸 id 를 정해 넣었기 때문에 그 id 로 지울 수 있습니다. 감싸지 않은IndexFlatIP에서 지우면 뒤의 번호가 앞당겨져 우리가 들고 있는 원문 목록과 어긋납니다. - 저장 크기는 FAISS 77KB, Qdrant 229KB, Chroma 1,049KB 였습니다. FAISS 는 벡터만, 나머지는 원문과 메타데이터와 자체 인덱스까지 저장합니다. Chroma 는 교안을 만들며 처음 돌렸을 때 548KB 였는데, 같은 폴더에서 컬렉션을 지우고 다시 만드는 실행을 되풀이한 뒤에는 커졌습니다. 막은 주소는 실습 1의
huggingface.co하나뿐이라, 세 DB 모두 바깥을 찾지 않았습니다.