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

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

벡터DB 와 인덱스

도서관에서 책 한 권을 찾을 때 모든 책장을 처음부터 끝까지 훑지 않습니다. 분야별로 나뉜 서가로 먼저 가거나, 사서가 "이 책 옆에 비슷한 책이 있어요" 하고 이어 줍니다. 벡터DB 의 인덱스가 이런 정리 방법입니다.

모두 견주기(Flat), 구역 나눠 찾기(IVF), 이웃 따라 찾기(HNSW)의 세 인덱스를 카드로 놓은 그림

왼쪽 Flat 은 질문을 모든 벡터와 견주어 늘 정확하지만, 벡터가 많아지면 느립니다. 가운데 IVF 는 벡터를 구역으로 나눠 가까운 구역 몇 곳만 봅니다. 오른쪽 HNSW 는 가까운 벡터끼리 길을 이어 두고 길을 따라 찾아갑니다. 뒤의 둘은 아주 가끔 가장 가까운 것을 놓치는 대신 훨씬 빠릅니다. 여기서 책장 훑기가 Flat, 분야별 서가가 IVF, 사서의 "옆에 비슷한 책"이 HNSW 입니다.

벡터DB 가 해 주는 일

일내용
저장임베딩과 원문, 메타데이터를 함께 디스크에 둔다
찾기질문 벡터와 가까운 k 개를 빠르게 돌려준다
거르기"제목이 ○○인 것만"처럼 메타데이터로 거른다
고치기문서가 바뀌면 그 조각만 지우고 다시 넣는다

정확한 검색과 빠른 검색

Python
import numpy as np
import faiss

rng = np.random.default_rng(0)
data = rng.normal(size=(5000, 64)).astype("float32")    # 64차원 벡터 5,000개(설명용 난수)
faiss.normalize_L2(data)                                 # 길이를 1 로
query = data[:5] + 0.05 * rng.normal(size=(5, 64)).astype("float32")
faiss.normalize_L2(query)

flat = faiss.IndexFlatIP(64)          # IP = 내적. 모든 벡터와 견준다
flat.add(data)
hnsw = faiss.IndexHNSWFlat(64, 16, faiss.METRIC_INNER_PRODUCT)   # 이웃 16개씩 잇는 HNSW
hnsw.add(data)
print("Flat 1등:", flat.search(query, 1)[1].ravel())
print("HNSW 1등:", hnsw.search(query, 1)[1].ravel())

두 인덱스 모두 질문 다섯 개의 1등으로 [0 1 2 3 4], 곧 질문을 만든 원래 벡터를 찾습니다. 이 장의 문서처럼 조각이 수십, 수천 개면 Flat 으로 충분합니다. HNSW 는 수십만 개를 넘을 때 고려합니다.

코드에 나오는 함수

코드쉬운 뜻
.astype("float32")숫자를 32비트 실수로 바꾼다. FAISS 는 float32 만 받는다
faiss.normalize_L2(data)줄마다 길이를 1 로 맞춘다. L2 는 앞에서 본 "제곱해 더하고 제곱근" 길이를 부르는 이름이다. 새 배열을 돌려주지 않고 data 를 그 자리에서 바꾼다
faiss.IndexFlatIP(64)64차원 벡터를 담는 Flat 인덱스다. IP 는 Inner Product(내적)의 머리글자로, 내적이 클수록 가깝다고 본다
index.add(data)벡터를 인덱스에 넣는다. 넣은 순서대로 0, 1, 2 … 번호가 붙는다
faiss.IndexHNSWFlat(64, 16, faiss.METRIC_INNER_PRODUCT)64차원, 벡터마다 이웃 16개를 잇는 HNSW 인덱스다. 셋째 인자는 거리 기준을 내적으로 정한다. metric 은 "재는 법"이라는 뜻이다
index.search(query, 1)질문마다 가까운 1개를 찾는다. 점수 표와 번호 표 두 개를 돌려준다
[1].ravel()둘 가운데 번호 표([1])만 꺼내 한 줄로 편다. ravel 은 "실타래를 풀다"라는 뜻이다

이 코드가 뜻하는 것

같은 책 5,000권을 두 방법으로 찾아 결과가 같은지 견주는 코드입니다. 한쪽은 책장을 처음부터 끝까지 훑고(Flat), 다른 쪽은 "옆에 비슷한 책" 길을 따라갑니다(HNSW).

줄하는 일쉽게 말하면
data = rng.normal(...)64차원 벡터 5,000개를 만든다책 5,000권
query = data[:5] + 0.05 * ...앞의 다섯 벡터에 잡음을 조금 더해 질문 다섯 개를 만든다0~4번 책과 닮은 물음 다섯 개
flat = ...; flat.add(data)모두 견주는 인덱스에 넣는다책장 훑기
hnsw = ...; hnsw.add(data)이웃을 잇는 인덱스에 넣는다사서가 이어 둔 길
search(query, 1)질문마다 1등의 번호를 찾는다손님마다 가장 맞는 책 한 권

두 줄이 모두 [0 1 2 3 4] 라는 것은, 질문 다섯 개 모두 자기를 만든 원래 벡터를 1등으로 찾았다는 뜻입니다. 이 정도 규모에서는 HNSW 도 Flat 과 같은 답을 냈습니다.

작게 줄여서 눈으로 보기

search 가 돌려주는 두 표를 눈으로 보려고 2차원 벡터 3개만 넣어 봅니다.

Python
import numpy as np
import faiss

vecs = np.array([[1.0, 0.0], [0.0, 1.0], [0.6, 0.8]], dtype="float32")   # 벡터 3개(설명용 2차원)
index = faiss.IndexFlatIP(2)          # 2차원 벡터를 담는 Flat 인덱스
index.add(vecs)                       # 넣은 순서대로 0, 1, 2 번이 된다
print("담긴 벡터 수:", index.ntotal)

q = np.array([[0.8, 0.6]], dtype="float32")   # 질문 1개
D, I = index.search(q, 2)                     # 가까운 2개
print("점수 D:", D.round(3))
print("번호 I:", I)
출력
담긴 벡터 수: 3
점수 D: [[0.96 0.8 ]]
번호 I: [[2 0]]
결과읽는 법
담긴 벡터 수 3ntotal 은 인덱스에 든 벡터의 개수입니다. n 은 number(개수), total 은 "모두"라는 뜻입니다
점수 D 0.96, 0.8질문과의 내적입니다. 2번 벡터와는 0.8 × 0.6 + 0.6 × 0.8 = 0.96, 0번 벡터와는 0.8 × 1.0 = 0.8 입니다
번호 I 2, 01등이 2번, 2등이 0번 벡터라는 뜻입니다. 1번 벡터와의 내적은 0.6 이라 2개 안에 들지 못했습니다

D 는 Distance(거리)에서, I 는 Index(번호)에서 따온 관례적인 이름입니다. 내적 인덱스에서는 D 가 거리가 아니라 점수라 클수록 가깝습니다. 실습 3에서는 I 의 번호로 조각 원문을 찾아갑니다.

주의할 점

  • 벡터DB 는 "검색을 잘하게" 해 주지 않고 "빨리, 많이" 찾게 해 줍니다. 검색의 품질은 임베딩 모델과 조각 나누기가 정합니다.
  • 거리 기준(내적, 코사인, 유클리드)을 임베딩에 맞게 고릅니다. 정규화한 임베딩에는 내적과 코사인이 같습니다.
← 이전 절7장 · 임베딩 모델 고르기다음 절 →7장 · FAISS 와 Chroma