11장. 폐쇄망 문서 파이프라인과 폐쇄형 RAG
인덱스의 속도와 정확도
책 한 권을 찾을 때 서가를 처음부터 끝까지 훑으면 반드시 찾지만 느립니다. 분류 번호가 같은 서가 몇 개만 보면 빠르지만 다른 서가에 꽂힌 책은 놓칩니다. 사서에게 물어 "그 책 옆에 이런 책이 있어요"를 따라가면 빠르고 대개 맞습니다. 벡터의 인덱스도 이 셋입니다.
왼쪽 Flat 은 모든 벡터와 견주어 정확하지만, 벡터가 많아지면 느립니다. 가운데 IVF 는 벡터를 칸(nlist)으로 나눠 두고 질문과 가까운 몇 칸(nprobe)만 봅니다. 오른쪽 HNSW 는 이웃끼리 이은 그물을 따라 건너가며, 한 번에 살펴볼 후보 수(efSearch)로 속도와 정확도를 고릅니다. 여기서 서가 훑기가 Flat, 분류 서가만 보기가 IVF, 사서에게 물어 옮겨 가기가 HNSW 입니다.
손잡이 견주기
| 인덱스 | 미리 하는 일 | 손잡이 | 올리면 |
|---|---|---|---|
| Flat | 없음 | 없음 | 늘 정확하다(재현율 1) |
| IVF | 칸 나누기 학습(train) | nprobe: 볼 칸의 수 | 정확해지고 느려진다 |
| HNSW | 그물 만들기(넣을 때 오래 걸린다) | efSearch: 살펴볼 후보 수 | 정확해지고 느려진다 |
IVF 의 생김새
import faiss
import numpy as np
rng = np.random.default_rng(0)
X = rng.normal(size=(2000, 32)).astype("float32") # 연습용 벡터 2,000개
ivf = faiss.IndexIVFFlat(faiss.IndexFlatL2(32), 32, 20) # 32차원, 칸 20개
ivf.train(X) # 칸을 나누는 기준을 배운다
ivf.add(X)
ivf.nprobe = 4 # 가까운 칸 4개만 본다
D, I = ivf.search(X[:1], 3)
print(I)코드에 나오는 함수
| 코드 | 쉬운 뜻 |
|---|---|
faiss | Facebook AI Similarity Search(페이스북 AI 유사도 검색)의 머리글자다. 벡터 검색 라이브러리다 |
np.random.default_rng(0) | 씨앗 0 으로 난수 생성기를 만든다. rng 는 random number generator(난수 생성기)다 |
rng.normal(size=(2000, 32)).astype("float32") | 정규분포 난수로 32차원 벡터 2,000개를 만들고, FAISS 가 받는 float32 꼴로 바꾼다 |
faiss.IndexFlatL2(32) | 32차원 벡터를 모두와 견주는 인덱스다. L2 는 보통의 직선 거리(유클리드 거리)다. 여기서는 칸의 중심을 찾는 데 쓴다 |
faiss.IndexIVFFlat(…, 32, 20) | IVF(Inverted File, 역색인 파일) 인덱스다. 벡터 32차원, 칸 20개(nlist)로 나눈다 |
ivf.train(X) | 벡터를 보고 칸 20개의 중심을 배운다 |
ivf.add(X) | 벡터를 넣는다. 벡터마다 가장 가까운 칸에 들어간다 |
ivf.nprobe = 4 | number of probes(살펴볼 수)의 줄임이다. 질문과 가까운 칸 4개만 본다 |
D, I = ivf.search(X[:1], 3) | 첫 벡터와 가까운 3개를 찾는다. D 는 distance(거리), I 는 index(번호)다 |
이 코드가 뜻하는 것
도서관 서가로 읽어 봅니다. 책을 꽂기 전에 분류 번호대로 서가 20개를 정해 두고(train), 책을 맞는 서가에 꽂습니다(add). 찾을 때는 찾는 책과 가까운 서가 4개만 둘러봅니다(nprobe = 4). 여기서 서가가 칸, 책이 벡터, 둘러볼 서가의 수가 nprobe 입니다.
| 줄 | 하는 일 | 쉽게 말하면 |
|---|---|---|
X = rng.normal(...) | 연습용 벡터 2,000개를 만든다 | 책 2,000권 |
ivf = faiss.IndexIVFFlat(..., 32, 20) | 칸 20개짜리 인덱스를 만든다 | 서가 20개를 둔다 |
ivf.train(X) | 칸을 나누는 기준을 배운다 | 서가마다 분류 번호를 정한다 |
ivf.add(X) | 벡터를 칸에 넣는다 | 책을 서가에 꽂는다 |
ivf.nprobe = 4 | 볼 칸의 수를 정한다 | 가까운 서가 4개만 둘러보기로 한다 |
D, I = ivf.search(X[:1], 3) | 가까운 3개를 찾는다 | 비슷한 책 세 권을 꺼낸다 |
교안을 만든 PC 에서는 [[ 0 581 1119]] 가 나왔습니다. 맨 앞의 0 은 질문으로 쓴 첫 벡터 자기 자신입니다. 넣어 둔 벡터로 물었으니 자기 자신이 가장 가깝습니다. 나머지 둘이 정말 가장 가까운 이웃인지는 이 출력만으로는 알 수 없고, 모두 견주는 Flat 의 답과 대 봐야 압니다.
작게 줄여서 눈으로 보기
Flat 의 답을 정답으로 두고, nprobe 를 바꿔 가며 IVF 가 정답을 얼마나 찾는지(재현율) 봅니다. 질문 50개는 넣어 둔 벡터를 조금 흔들어 만든 연습용입니다.
import faiss
import numpy as np
rng = np.random.default_rng(0)
X = rng.normal(size=(2000, 32)).astype("float32")
Q = X[:50] + rng.normal(scale=0.5, size=(50, 32)).astype("float32") # 조금 흔든 질문 50개
flat = faiss.IndexFlatL2(32)
flat.add(X)
_, truth = flat.search(Q, 5) # 모두 견준 정답 5개씩
ivf = faiss.IndexIVFFlat(faiss.IndexFlatL2(32), 32, 20)
ivf.train(X)
ivf.add(X)
for nprobe in [1, 4, 20]:
ivf.nprobe = nprobe
_, found = ivf.search(Q, 5)
recall = np.mean([len(set(a) & set(b)) / 5 for a, b in zip(truth, found)])
print(f"nprobe {nprobe:2d} → 재현율 {recall:.3f}")nprobe 1 → 재현율 0.396
nprobe 4 → 재현율 0.708
nprobe 20 → 재현율 1.000| 결과 | 읽는 법 |
|---|---|
nprobe 1 → 재현율 0.396 | 가장 가까운 칸 하나만 보면 정답 5개 가운데 평균 2개쯤만 찾는다 |
nprobe 4 → 재현율 0.708 | 칸 4개를 보면 정답의 70% 쯤을 찾는다 |
nprobe 20 → 재현율 1.000 | 칸 20개를 모두 보면 Flat 과 똑같다. 대신 빠르게 하려던 뜻이 없어진다 |
set(a) & set(b) 는 두 번호 묶음에 함께 있는 것, 곧 맞힌 정답입니다. 이 연습용 벡터는 뭉친 데 없이 고르게 흩어진 무작위 벡터라서, 정답 이웃이 이웃 칸에 나뉘어 있는 일이 잦습니다. 실제 문서의 임베딩은 비슷한 것끼리 뭉쳐 있어 같은 nprobe 에서도 재현율이 다를 수 있으니, 실습 5처럼 우리 데이터로 재서 고릅니다.
주의할 점
- 벡터가 몇만 개 이하면 Flat 이 충분히 빠릅니다. 빠른 인덱스는 재현율을 조금 잃는 대신 속도를 얻는 것이라, 규모가 커질 때 꺼냅니다.
- 재현율은 데이터의 생김새에 따라 다릅니다. 손잡이 값은 우리 데이터의 질문 묶음으로 재어 고릅니다(실습 5).