벡터 데이터베이스, 딥 인덱싱(Deep Indexing) 및 토큰 경제학: 전체 이야기 (phase 3)
요약
벡터 데이터베이스의 물리적 저장 구조와 효율적인 인덱싱, 그리고 비용 최적화 방법을 다룹니다. 메타데이터 관리를 위한 jsonb 활용법과 중복 임베딩 방지를 위한 청크 해싱의 중요성을 설명합니다.
핵심 포인트
- 벡터 데이터는 ID, 메타데이터, 해시값 등과 함께 테이블 행으로 저장됨
- 유연한 데이터 구조를 위해 메타데이터는 jsonb 컬럼 활용 권장
- 중복 임베딩 비용 절감을 위해 청크 단위의 content_hash 관리 필요
- 확장 가능한 벡터 검색을 위해서는 인덱싱 원리 이해가 필수적임
"우리는 벡터를 가지고 있다"에서 "이것은 실제로 확장 가능하며, 우리를 파산시키지 않는다"로.
이야기의 시작: 하나의 질문에 담긴 두 가지 의문
👦 조카: 삼촌, Phase 2가 끝났어요. 토큰화 (tokenization), 임베딩 (embeddings), 코사인 유사도 (cosine similarity)는 이해했어요. 하지만 두 가지가 저를 괴롭혀요. 첫째 — 이 벡터들이 물리적으로 실제로 어디에 존재(live) 하나요? 둘째 — 벡터가 500만 개가 되면, 모든 벡터를 하나씩 다 확인하지 않고도 어떻게 검색 속도를 빠르게 유지할 수 있죠? 지금은 마치 마법처럼 느껴져요.
👨🦳 삼촌: 좋아 — 왜냐하면 이건 마법이 아니거든. 만약 마법처럼 취급한다면 나중에 잘못된 결정을 내리게 될 거야. 어떤 인덱스 (index)를 사용할지, 메모리가 얼마나 필요할지, 왜 벡터를 100만 개 추가한 뒤에 검색이 갑자기 느려졌는지, 왜 임베딩 (embedding) 비용이 생각보다 훨씬 더 많이 나오는지 — 이 모든 것들은 밑바닥에서 실제로 어떤 일이 일어나고 있는지 이해하기 전까지는 전혀 말이 되지 않아. 순서대로 짚어보자: 먼저 데이터가 어디에 사는지, 그다음 검색이 어떻게 빠르게 유지되는지, 그리고 — 거의 모든 튜토리얼이 건너뛰는 부분인 — 이 모든 과정을 수행하면서 돈을 낭비하는 것을 어떻게 멈출 수 있는지 말이야.
오늘의 지도
Phase 2: Embeddings & Semantic Search ✅
↓
TODAY ← WE ARE HERE
...
파트 1: 임베딩이 실제로 저장되는 곳
👨🦳 삼촌: 인덱싱 (indexing)에 대해 이야기하기 전에, 기초부터 정리하자. 벡터 그 자체만으로는 쓸모가 없어. 네가 실제로 저장하는 것은 하나의 **행 (row)**이야 — 즉, 벡터와 더불어 나중에 그것을 사용하고, 다시 찾아내고, 중복 비용을 지불하지 않기 위해 필요한 모든 것들이지.
테이블: document_chunks
| 컬럼 (Column) | 용도 (Purpose) |
|---|---|
id | 고유 식별자 (Unique identifier) |
| ... |
👦 조카: 왜 메타데이터 (metadata)를 그냥... 더 많은 컬럼으로 만들지 않고 별도의 jsonb 컬럼으로 만드는 건가요?
👨🦳 삼촌: 왜냐하면 메타데이터 (metadata)는 문서에 따라 형태가 변하기 때문이야. 인사 정책 (HR policy) 청크 (chunk)에는 { department: "HR", doc_type: "policy" }가 있을 수 있고, 고객 지원 티켓 (support ticket) 청크에는 { customer_id: 4521, priority: "high" }가 있을 수 있지. 모든 문서 유형에 걸쳐 가능한 모든 필드에 대해 경직된 컬럼 (column)을 만들려고 한다면, 매주 테이블을 다시 설계해야 할 거야. jsonb는 그러한 유연성을 제공해. 그리고 — 사람들이 놓치는 부분이 바로 이건데 — jsonb 컬럼 _내부_를 인덱싱 (indexing) 할 수도 있어. 그건 7부에서 제대로 다뤄볼 거야.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE document_chunks (
...
👦 조카: content_hash가 또 나오네요 — 이거 1단계의 파일 중복 제거 (file dedup)에서 나왔던 SHA-256 아이디어와 같은 거죠?
👨🦳 삼촌: 같은 아이디어지만, 한 단계 더 깊어진 거야. 1단계에서는 동일한 파일을 두 번 저장하지 않기 위해 _파일 전체_를 해싱 (hashing) 했지. 여기서는 각 개별 _청크 (chunk)_를 해싱해. 동일한 청크를 두 번 임베딩 (embedding) 하는 것을 방지하기 위해서야. 왜냐하면 완전히 다른 두 파일이 정확히 같은 문단을 포함할 수 있기 때문이지. 예를 들어 공유되는 표준 법적 조항 (boilerplate legal clause), 반복되는 면책 조항 (disclaimer), 또는 50개의 문서에 복사해서 붙여넣은 표준 회사 소개 문단 같은 것들 말이야. 이 내용은 일단 기억해 둬. 10부에서 실제 돈과 직결되는 내용이 될 테니까.
2부: 왜 단순히 "모든 것을 확인할" 수 없는가
👨🦳 삼촌: 이제 검색 속도 문제로 넘어가 보자. 네가 1536 차원 (dimensions)을 가진 500만 개의 청크 벡터 (chunk vectors)를 가지고 있다고 가정해 봐. 사용자가 질문을 하면, 너는 그것을 쿼리 벡터 (query vector)로 임베딩해. 가장 유사한 청크를 찾는 가장 멍청한 방법은 무엇일까?
👦 조카: 500만 개의 벡터 각각에 대해 쿼리 벡터와 비교하고, 각각의 유사도 (similarity)를 계산한 다음, 정렬해서 상위 5개를 뽑는 거요.
👨🦳 삼촌: 정확해 — 그걸 플랫 인덱스 (flat index) 또는 전수 조사 (exhaustive search)라고 불러. 막연한 설명 말고, 실제 수치로 비용이 얼마나 드는지 확인해 보자.
5,000,000 vectors × 1536 dimensions each
각 쿼리 (EACH query) 당:
...
👦 조카: 30초라고요?! 챗봇의 답변을 기다리며 30초를 버틸 사람은 아무도 없어요.
👨🦳 삼촌: 바로 그 점 때문에 브루트 포스 (Brute Force) 방식은 아주 작은 규모, 예를 들어 몇 천 개의 벡터 정도에서만 작동하는 거야. 그 규모를 넘어서면 근본적으로 다른 전략이 필요해. 모든 것을 확인하는 게 아니라, 가능성이 높은 후보들만 확인하는 거지. 이 트레이드오프 (Trade-off)에는 이름이 있어: 바로 근사 최근접 이웃 (Approximate Nearest Neighbor) 검색, 또는 ANN이라고 불러.
👦 조카: "근사 (Approximate)"라고요? 그럼 틀린 답을 줄 수도 있다는 건가요?
👨🦳 삼촌: 가끔 실제 1순위로 가장 가까운 것 대신 4순위로 가까운 매칭 결과를 줄 수도 있어. 하지만 그 대가는 거의 항상 치를 만해. 왜냐하면 그 대안은 매 쿼리마다, 영원히, 500만 개의 벡터 전체를 정확하게 확인하는 것이니까. 아주 약간의 정확도를 희생하는 대신 엄청난 속도를 얻는 셈이지.
👦 조카: 이게 예전에 이메일 조회 기능을 만들 때 일반 데이터베이스 컬럼에 사용했던 B-트리 (B-tree) 인덱스와 비슷한 건가요?
👨🦳 삼촌: 모든 것을 스캔하는 것을 피한다는 정신 (Spirit) 은 같지만, 메커니즘은 완전히 달라. 이 차이 때문에 사람들이 끊임없이 혼란을 겪곤 하지. B-트리 인덱스는 값들이 자연스러운 정렬 순서를 갖기 때문에 작동해. "이메일 = x"를 찾는 작업은 정렬된 순서 내에서 이진 탐색 (Binary Search)을 할 수 있지. "a"가 "b"보다 앞에 오고, "b"가 "c"보다 앞에 오니까 말이야. 하지만 벡터는 1536차원 공간에서 의미 있게 정렬될 수 없어. 공간상의 두 방향 사이에는 "작다"라는 개념이 존재하지 않거든.
그래서 벡터 데이터베이스는 완전히 다른 계열의 기술들을 사용해. 이름이 아무리 화려하든 간에, 모든 실제 ANN 기술은 다음 두 가지 중 하나를 수행해: 유사한 벡터들을 함께 그룹화 (Group) 하여 작은 그룹 내에서만 검색하게 하거나, 모든 데이터를 일일이 거치지 않고 올바른 이웃 지역으로 바로 뛰어넘을 수 있는 지름길 지도 (Shortcut map) 를 만드는 거지. 이제 이 두 가지를 제대로 살펴보자.
파트 3: IVF (Inverted File Index) — 이웃 접근 방식
👨🦳 삼촌: 구글 맵을 생각해 봐. 뱅갈로르를 찾기 위해 전 세계를 다 뒤지는 게 아니라, 국가 → 주 → 도시 순으로 범위를 좁혀가잖아. IVF도 벡터에 대해 똑같은 방식으로 작동해.
**1단계 — 학습 (Training): 벡터를 "이웃 (Neighborhoods)" (클러스터, Clusters)로 그룹화하기
검색이 시작되기 전에, IVF는 k-means와 같은 알고리즘을 사용하여 모든 벡터를 살펴보고 이를 K개의 클러스터 (Clusters)로 그룹화합니다. 500만 개의 벡터가 1,000개의 클러스터로 그룹화된다고 가정해 봅시다:
| 클러스터 (Cluster) | 중심 주제 (Centroid Topic) | 벡터 (Vectors) |
|---|---|---|
| 1 | 프로그래밍 주제 (Programming topics) | 5,200 |
| ... |
각 클러스터에는 해당 그룹의 모든 것을 대표하는 "평균" 벡터인 **중심점 (Centroid)**이 있습니다.
2단계 — 쿼리 시점 (Query time): 가장 가까운 클러스터만 검색하기
쿼리 (Query): "우리의 통지 기간은 얼마인가요?"
↓
쿼리를 오직 1,000개의 중심점 (Centroids)하고만 비교 (빠름 — 단 1,000번의 비교)
...
// IVF 검색의 개념적 예시 (실제 구현은 C++/Rust로 되어 있지만,
// 이 코드는 로직을 명확하게 보여줍니다)
function ivfSearch(queryVector, centroids, clusters, nProbe = 5) {
...
👦 조카: 그러니까 500만 번의 비교를 하는 대신, 1,000번(클러스터 찾기) + 약 25,000번(클러스터 내부 검색) ≈ 26,000번만 하면 되는 거네요. 연산량이 대략 200배나 줄어드는 거군요!
👨🦳 삼촌: 정확한 계산이야. 그리고 조절 가능한 노브(tuning knob)인 nProbe를 주목해봐. 얼마나 많은 클러스터를 검색할지를 결정하는 값이지:
| nProbe | 결과 |
|---|---|
| 1 | 가장 빠르지만, 실제 최적의 일치 항목을 놓칠 수 있음 (낮은 재현율 (low recall)) |
| ... |
이것이 바로 **재현율 대 속도의 트레이드오프 (recall-vs-speed tradeoff)**이며, 존재하는 모든 벡터 인덱스(vector index)가 가진 핵심적인 갈등 요소야.
실무적인 세부 사항을 하나 더 덧붙이자면, 애초에 클러스터(lists, pgvector의 용어로는 lists)를 얼마나 만들어야 할까? 일반적인 경험 법칙(rule of thumb)은 다음과 같아:
lists ≈ sqrt(행의 수 (number of rows))
500만 행의 경우: sqrt(5,000,000) ≈ 2,236, 따라서 대략 2,000~2,500개의 리스트(lists)
...
CREATE INDEX ON document_chunks
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 2000);
파트 4: HNSW (Hierarchical Navigable Small World) — 고속도로 접근 방식
👨🦳 삼촌: IVF는 항목들을 이웃(neighborhoods)으로 그룹화해. 반면 HNSW는 완전히 다른 접근 방식을 취하지. 마치 고속도로 시스템처럼 다층적인 지름길 지도(multi-level shortcut map)를 구축하는 거야.
👦 조카: 고속도로 시스템이라고요?
👨🦳 삼촌: 800km 떨어진 작은 마을까지 실제로 운전해서 간다고 생각해 봐. 가는 내내 마을 길로만 다니지는 않잖아.
Layer 2 (고속도로): Bangalore ─────────────────→ Big City X
│
Layer 1 (주도): Bangalore ──→ Town A ──→ Town B ──┤
...
👨🦳 삼촌: 일단 고속도로를 타고 빠르게 목적지 근처까지 간 다음, 마지막 구간에서만 작은 길로 들어서는 거지. HNSW는 당신의 벡터(vectors) 위에 정확히 이런 구조를 구축해. 즉, 여러 개의 계층(layers)을 만드는데, 최상위 계층에는 "장거리" 연결이 아주 적고, 그 아래 각 계층으로 내려갈수록 점진적으로 더 많고 세밀한(finer-grained) 지역적 연결을 갖게 되는 거야.
HNSW 검색이 실제로 작동하는 방식:
Layer 2 (적은 노드, 긴 연결):
진입점(entry point)에서 시작 → 이 희소한(sparse) 계층에서 가장 가까운 노드로 점프
↓ (해당 노드를 새로운 시작점으로 사용하여 한 계층 아래로 내려감)
...
// HNSW 검색의 개념적 예시
function hnswSearch(queryVector, graph, entryPoint, topK = 5) {
let currentNode = entryPoint;
...
👦 조카: 그러니까 HNSW는... 고차원 공간에서의 벡터를 위한 스킵 리스트(skip list) 같은 거네요?
👨🦳 삼촌: 정말 훌륭한 비유야. 자료 구조에서 스킵 리스트(skip lists)를 본 적이 있다면, HNSW는 바로 그 아이디어를 정렬된 1차원 리스트에서 1536차원 공간의 그래프로 일반화한 것이라고 보면 돼. 상단은 큰 도약을 위해 희소하게(sparse), 하단은 정밀도를 위해 조밀하게(dense) 구성하는 거지.
IVF vs HNSW — 언제 무엇을 사용할 것인가
| IVF | HNSW | |
|---|---|---|
| 검색 속도 | 빠름 | 보통 더 빠름 |
| ... |
👦 조카: 그럼 HNSW가 보통 더 빠르고 정확하다면, 왜 사람들이 IVF를 사용하는 거죠?
👨🦳 삼촌: 진짜 이유는 두 가지야. 첫째는 메모리(Memory) 문제야. 대규모 규모(수억 개의 벡터)에서는 HNSW의 그래프 연결(graph connections)이 IVF의 더 단순한 클러스터 리스트(cluster-list) 구조보다 눈에 띄게 더 많은 RAM을 차지하거든. 둘째는 업데이트 패턴(update patterns)이야. 만약 데이터셋에 새로운 벡터가 끊임없이 추가된다면(예를 들어, 모든 메시지를 기록하는 라이브 채팅 시스템 같은 경우), IVF가 HNSW 그래프의 일부를 다시 구축하는 것보다 이를 더 유연하게 처리할 수 있어. 대부분의 RAG(Retrieval-Augmented Generation) 시스템—지속적이기보다는 주기적으로 업데이트되는 수십만에서 수백만 개의 청크(chunks)—의 경우, 오늘날 HNSW가 일반적인 기본값(default)이며, 대부분의 관리형 벡터 데이터베이스(managed vector databases)가 기본적으로 사용하는 방식이야.
제5부: 제품 양자화 (Product Quantization) — HNSW조차 너무 커질 때 벡터를 압축하는 방법
👦 조카: HNSW의 메모리 사용량조차 문제가 되는 상황을 위한 방법도 있나요?
👨🦳 삼촌: 응, 바로 제품 양자화 (Product Quantization), 또는 PQ야. 이것은 IVF나 HNSW를 대체하는 것이 아니라, 벡터 자체를 압축함으로써 이들과 함께 작동해.
원본 벡터: 1536 차원 × 각 4바이트 = 벡터당 6,144바이트
제품 양자화 (Product Quantization):
...
👨🦳 삼촌: 예상할 수 있듯이, 트레이드오프(tradeoff)는 정밀도(precision)의 약간의 손실이야. 이제는 정확한 벡터가 아니라 압축된 근사치(approximations)를 비교하게 되는 거지. 하지만 1억 개 이상의 벡터를 다룰 때는, 그 메모리 절약 효과가 RAM에 맞느냐 아니냐를 결정짓는 차이가 될 수 있어. 실제 대규모 시스템은 종종 이 세 가지 기술을 모두 결합해. 이웃 범위를 좁히기 위한 IVF, 메모리 내에서 모든 것을 컴팩트하게 유지하기 위한 PQ, 그리고 정확도를 회복하기 위해 소수의 후보군(candidate set)에 대해 수행하는 최종적인 정밀 재순위화(re-ranking) 단계가 그것이지.
제6부: 거리 측정 지표 (Distance Metrics) — "유사함"의 실제 의미
👦 조카: 지금까지 계속 "코사인 유사도 (cosine similarity)"라고 말해왔는데, 근접함을 측정하는 방법이 그것뿐인가요?
👨🦳 삼촌: 아니, 세 가지 지표가 끊임없이 등장해. 그리고 사용 사례에 맞지 않는 잘못된 지표를 선택하면 결과에 정말 큰 악영향을 미치게 돼.
| 지표 (Metric) | 측정 대상 (Measures) | 최적 용도 (Best For) | 공식 (Formula) |
|---|---|---|---|
| 코사인 유사도 (Cosine Similarity) | 벡터 사이의 각도, 크기(magnitude)는 무시함 | 텍스트 임베딩 (RAG에서 가장 흔히 사용됨) | `(A · B) / (\ |
| ... |
👨🦳 삼촌: OpenAI, Cohere 또는 대부분의 현대적인 임베딩 모델 (embedding models)에서 생성된 텍스트 임베딩의 경우, 코사인 유사도 (cosine similarity)가 표준이야. 왜냐하면 이 모델들은 크기가 아닌 방향이 의미를 전달하도록 훈련되었기 때문이지. 하지만 항상 임베딩 제공업체의 문서를 확인해야 해. 어떤 모델들은 대신 내적 (dot-product) 비교를 위해 특별히 훈련되기도 하거든. 잘못된 지표를 사용하면 왜 그런지 알려주는 에러 메시지도 없이 조용히 더 나쁜 결과를 내놓게 될 거야.
제7부: 메타데이터 인덱싱 (Metadata Indexing) — 모두가 잊어버리는 나머지 절반
👨🦳 삼촌: 여기 벡터만으로는 ANN 인덱스 (ANN index)가 아무리 좋아도 절대 해결할 수 없는 문제가 있어. 네 RAG 시스템이 회사 전체를 대상으로 서비스한다고 가정해 보자. 인사(HR) 문서, 엔지니어링 문서, 재무 문서가 모두 하나의 테이블에 있고, 모두 동일한 방식으로 임베딩되어 있어. 재무팀 직원이 이렇게 물어봐.
"우리 경비 환급 규정이 어떻게 되나요?"
👦 조카: 순수 벡터 검색 (Pure vector search)을 하면 500만 개의 청크 (chunks)를 전부 다 검색하나요? 인사 문서, 엔지니어링 위키 페이지, 전부 다요?
👨🦳 삼촌: 바로 그게 문제야. 벡터 유사도 (Vector similarity)만으로는 "이 사용자는 재무 문서에만 접근 권한이 있다"라거나 "최근 6개월 이내의 문서만 검색하라"는 것을 알 수 없어. 이건 의미(meaning)의 문제가 아니라, 필터링 (filtering) 문제야. 이것이 바로 메타데이터 인덱싱 (metadata indexing)이 해결하는 문제이며, 이는 HNSW나 IVF와는 완전히 독립적으로 작동해.
프리 필터링 (Pre-Filtering) vs 포스트 필터링 (Post-Filtering)
👨🦳 삼촌: "메타데이터로 필터링하기"와 "벡터 유사도로 검색하기"를 결합하는 방법에는 두 가지가 있어. 그리고 잘못된 방법을 선택하면 에러를 발생시키지 않고 조용히 검색 결과를 망가뜨리게 되지.
포스트 필터링 (Post-filtering, 대부분의 경우 잘못된 기본 설정):
AI 자동 생성 콘텐츠
본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.
원문 바로가기