본문으로 건너뛰기

© 2026 Molayo

Dev.to헤드라인2026. 06. 19. 10:41

벡터 데이터베이스는 마법이 아닙니다, 내부에서 실제로 어떤 일이 일어나고 있는지 알아보겠습니다

요약

벡터 데이터베이스의 내부 작동 원리인 HNSW와 IVF 인덱싱 구조를 심층 분석합니다. 단순 API 호출을 넘어 프로덕션 환경에서 발생할 수 있는 재현율 저하 및 지연 시간 문제를 해결하기 위한 핵심 파라미터와 거리 측정 지표 활용법을 다룹니다.

핵심 포인트

  • HNSW의 다층 그래프 구조와 탐욕적 검색 원리 이해
  • m, ef_construct 등 인덱스 빌드 시 핵심 파라미터의 중요성
  • 데이터 분포 변화에 따른 IVF 클러스터 중심점 최적화 필요성
  • 임베딩 특성에 맞는 적절한 거리 측정 지표(Cosine 등) 선택

여러분은 튜토리얼을 보았습니다. Pinecone을 실행하고, .upsert()를 호출하고, 유사도 검색 (similarity search)을 수행한 뒤 배포합니다. 모두가 박수를 칩니다. 데모는 작동합니다.

그런데 이를 프로덕션 (production) 환경에 적용하면 데이터베이스가 거짓말을 하기 시작합니다.

의미론적으로 관련 있어 보이지만 실제로는 그렇지 않은 결과들. 무언가와 매칭되어야 할 쿼리 (query)가 아무것도 반환하지 않는 현상. 사용자가 앱이 충돌했다고 생각하게 만드는 지연 시간 (latency). 그리고 가장 최악인 점은 — 왜 그런 일이 발생하는지 알 수 없다는 것입니다. 왜냐하면 벡터 데이터베이스 (vector database)가 화려한 API를 가진 블랙박스처럼 느껴지기 때문입니다.

이 글은 그 상자를 여는 것에 관한 것입니다.

벡터 데이터베이스의 실제 정체

이것은 대부분의 현대적인 벡터 DB(Qdrant, Weaviate, Milvus, 적절한 확장이 포함된 pgvector)가 기본적으로 사용하는 방식입니다. HNSW는 다음과 같은 **다층 그래프 (multi-layer graph)**를 구축합니다:

  • 최상위 레이어는 희소(sparse)합니다 - 연결성이 매우 높은 소수의 "허브 (hub)" 노드만 존재합니다.
  • 각 하위 레이어로 내려갈수록 점진적으로 밀도가 높아집니다.
  • 쿼리(Querying)는 최상위에서 시작하여 가장 가까운 이웃(nearest neighbor)을 향해 탐욕적(greedily)으로 내려갑니다.

고속도로 시스템을 생각해보세요. 고속도로(최상위 레이어)에 진입하여 목적지를 향해 달리다가, 적절한 인터체인지에서 빠져나온 뒤, 정밀한 이동을 위해 국도(최하위 레이어)를 이용하는 것과 같습니다.

알아야 할 주요 파라미터 (Key parameters):

# Qdrant 예시
from qdrant_client.models import VectorParams, Distance

...

mef_construct는 빌드 시점에 설정되며, 인덱스를 다시 구축하지 않고는 변경할 수 없습니다. 만약 운영 환경에서 재현율 (recall)이 낮게 나타나는데 메모리를 아끼기 위해 m=4로 설정했다면, 그것이 바로 원인입니다.

IVF (Inverted File Index, 역색인 파일)

FAISS에서 사용되며 pgvector의 옵션으로도 제공됩니다. 벡터 공간을 보로노이 셀 (Voronoi cells, 클러스터)로 나누고, 벡터를 가장 가까운 중심점 (centroid)에 할당한 다음, 쿼리 시점에 셀의 일부 하위 집합만 검색합니다.

# FAISS IVF 예시
import faiss
import numpy as np
...

IVF 주의사항 (IVF gotcha): 클러스터 중심점은 학습 (training) 과정 중에 학습됩니다. 만약 데이터 분포가 크게 변한다면 (새로운 문서 유형, 다른 주제 등), 중심점 구조가 최적 상태를 벗어나게 되어 재현율 (recall)이 급락합니다. 이때 에러가 발생하는 것이 아니라, 그냥 조용히 결과가 나빠질 뿐입니다.

거리 측정 지표 (Distance Metrics): 아마도 잘못된 것을 사용하고 계실 겁니다

대부분의 사람들은 튜토리얼에서 시키는 대로 코사인 유사도 (cosine similarity)를 사용합니다. 하지만 이것이 잘못되는 경우가 있습니다.

지표 (Metric)공식 (Formula)사용 시점
코사인 (Cosine)1 - (A·B / ‖A‖‖B‖)방향이 중요하고 크기 (magnitude)가 중요하지 않을 때. 정규화된 텍스트 임베딩 (normalized text embeddings)에 적합
...

OpenAI의 text-embedding-3-* 임베딩은 단위 길이 (unit length)로 정규화되어 있습니다. 단위 벡터에 대한 코사인 유사도는 수학적으로 내적 (dot product)과 동일합니다. 따라서 코사인을 사용하는 것은 순수한 오버헤드(overhead)인 정규화 단계를 추가하는 셈입니다.

만약 OpenAI 임베딩을 사용 중이라면, 내적 (dot product)을 사용하세요

Qdrant의 경우:

VectorParams(size=1536, distance=Distance.DOT)
...

낮은 규모에서는 지연 시간 (latency)의 차이가 미미합니다. 하지만 1,000만 개 이상의 벡터가 되면 그 차이를 측정할 수 있습니다.

아무도 말하지 않는 재현율 (Recall) 문제

당신을 괴롭힐 사실이 하나 있습니다: 당신의 ANN 검색이 항상 실제 최근접 이웃 (true nearest neighbors)을 반환하는 것은 아닙니다.

그것은 근사적 (approximate) 최근접 이웃을 반환합니다. 그것이 바로 ANN의 A (Approximate)입니다. 정의상, 당신은 top-K 순위에 포함되었어야 할 결과들을 놓칠 수 있습니다.

얼마나 심각할까요? 이는 인덱스 설정 (index config)과 데이터에 따라 다릅니다. 다음과 같이 측정할 수 있습니다:

import numpy as np
from qdrant_client import QdrantClient

...

운영 환경의 목표: recall@10 ≥ 0.95. 이보다 낮으면 당신의 RAG 파이프라인은 GPT-4가 확인하기도 전에 관련 컨텍스트를 조용히 놓치고 있는 것입니다.

하이브리드 검색 (Hybrid Search): 당신이 실제로 사용해야 하는 아키텍처

순수 벡터 검색 (pure vector search)에는 잘 알려진 실패 모드 (failure mode)가 있습니다: 희귀 용어 (rare terms)를 잘 처리하지 못한다는 점입니다.

만약 당신의 코퍼스 (corpus)에 "RFC 7807 Problem Details"나 E_INVALIDARG_0x80070057와 같은 특정 에러 코드가 포함되어 있다면, 임베딩 유사도 (embedding similarity)는 의미론적으로 인접한 개념들 사이로 매칭을 희석시켜 버립니다. 정확한 문자열을 쿼리하는 사용자는 모호한 결과들을 받게 됩니다.

해결책은 **하이브리드 검색 (hybrid search)**입니다: 밀집 벡터 검색 (dense vector search)과 희소 BM25 스타일의 키워드 검색 (sparse BM25-style keyword search)을 결합한 다음, 그 순위들을 융합 (fuse)하는 것입니다.

from qdrant_client import QdrantClient
from qdrant_client.models import (
    SparseVectorParams, VectorParams,
...

**RRF (Reciprocal Rank Fusion)**는 점수 정규화 (score normalization) 없이도 순위 리스트들을 결합합니다. 공식은 간단합니다:

RRF_score(d) = Σ 1 / (k + rank_i(d))

여기서 k는 상수(보통 60)이며, rank_i(d)는 각 결과 리스트에서의 문서 순위입니다. 두 리스트 모두에 나타나는 문서들은 상당한 가산점을 받습니다.

하이브리드 검색은 실제 코퍼스에서 순수 밀집 검색 (pure dense search)보다 NDCG@10 기준 5~15% 일관되게 더 나은 성능을 보입니다. 특히 도메인 특화 콘텐츠나 기술적인 콘텐츠에서 더욱 그러합니다.

메타데이터 필터링 (Metadata Filtering): 성능의 함정

벡터 데이터베이스(Vector DB)는 ANN 검색 전후에 메타데이터로 필터링할 수 있게 해줍니다. 이는 간단하게 들리지만, 실제로는 가장 흔한 성능 함정 중 하나입니다.

사전 필터링 (Pre-filtering) (ANN 전에 필터링): 먼저 메타데이터 필터를 적용하여 후보군(candidate set)을 줄인 다음, 그 작은 집합에 대해 ANN을 실행합니다.

문제점: 만약 필터가 매우 선택적이라면(예: 다중 테넌트 시스템에서 user_id = "abc123"), 후보군은 아주 작을 수 있습니다. HNSW 그래프 탐색은 크고 연결된 그래프를 가정합니다. 희소한 서브그래프는 재현율(recall)을 파괴합니다.

사후 필터링 (Post-filtering) (ANN 후 필터링): 전체 코퍼스에 대해 ANN을 실행하고, 상위 N개를 검색한 다음, 필터를 적용합니다. 필터링된 결과를 보상하기 위해 상당히 많은 양의 데이터를 가져와야 합니다.

# Qdrant는 "indexed" 페이로드 필드를 사용하여 이를 처리합니다
# 필터링에 사용하는 모든 필드는 인덱싱해야 합니다
client.create_payload_index(
...

일반적인 규칙: 만약 필터가 코퍼스를 약 1000개 벡터 이하로 줄인다면, 사실상 브루트 포스 검색을 수행하는 것입니다. 이는 괜찮지만, 그 사실을 알고 기대치를 설정해야 합니다.

재검토해야 할 청킹 전략 (The Chunking Strategy You Need to Revisit)

이것은 벡터 DB 내부 구조에 관한 내용은 아니지만, 너무 깊게 관련되어 있어서 건너뛰는 것은 부적절합니다.

검색 품질은 청킹 품질에 의해 제한됩니다. 벡터 DB가 반환할 수 있는 것은 사용자가 제공한 것뿐입니다.

대부분의 튜토리얼은 다음과 같이 보여줍니다:

# 모두가 복사하는 순진한 접근 방식
text_splitter = RecursiveCharacterTextSplitter(chunk_size=500, chunk_overlap=50)
chunks = text_splitter.split_text(document)

문제점:

  • 고정 크기 청크는 의미적 단위를 임의로 분리합니다.
  • 청크 경계를 가로지르는 문장은 두 개의 고아 같은 절반으로 분리됩니다.
  • 500 토큰은 정밀한 검색에는 너무 클 수 있고, 필요한 컨텍스트를 담기에는 너무 작을 수 있습니다.

더 나은 방법: 의미적 청킹 (semantic chunking)

from langchain_experimental.text_splitter import SemanticChunker
from langchain_openai import OpenAIEmbeddings

...

이것은 문장을 임베딩하고, 인접한 문장 쌍 간의 코사인 거리를 계산하며, 의미적으로 중요한 변화가 있을 때 분할합니다.

더욱 효과적인 방법: 청크(chunk)와 부모 문서(parent document)를 모두 저장하기

# "Small-to-big" 또는 "Parent Document Retrieval"
# 정밀한 매칭을 위해 작은 청크(small chunks)를 저장
# 하지만 컨텍스트(context)로는 부모 문서(parent document) (또는 더 큰 윈도우)를 반환
...

작은 청크들은 높은 정밀도로 매칭됩니다. 반환되는 컨텍스트는 더 큰 부모 문서이므로, LLM이 올바르게 추론할 수 있는 충분한 주변 정보를 얻게 됩니다.

관측 가능성 (Observability): 무엇을 로깅해야 하는가

이러한 요소들을 측정하지 않고 있다면, 당신은 눈을 가리고 비행하는 것과 같습니다:

import time
from dataclasses import dataclass
from typing import Optional
...

모니터링해야 할 항목:

  • score_spread가 0에 가깝다는 것은 모든 결과가 똑같이 유사해 보인다는 의미입니다. 이는 쿼리가 무엇과도 잘 매칭되지 않았을 가능성이 높음을 나타냅니다.
  • top_score가 설정한 임계값(threshold)보다 낮다면(모델마다 튜닝이 필요하지만, 코사인 유사도(cosine similarity)의 경우 ~0.75가 합리적인 시작점입니다), 노이즈를 반환하고 있다는 뜻입니다.
  • 임베딩 지연 시간(Embedding latency)의 급증은 종종 임베딩 제공업체의 스로틀링(throttling) 오류가 발생하기 전에 나타납니다.

스택 결정 (The Stack Decision)

2026년을 위한 빠르고 주관적인 가이드입니다:

시나리오권장 사항
프로토타입 / 취미ChromaDB (인프로세스(in-process), 인프라 불필요))
...

모든 것에 벡터 데이터베이스(vector DB)를 사용하지 마세요. 만약 코퍼스(corpus)가 약 10,000개 미만의 문서라면, np.dot을 사용한 인메모리(in-memory) numpy 배열에서의 코사인 검색(cosine search)만으로도 충분히 빠르며, 인프라 의존성 전체를 제거할 수 있습니다.

import numpy as np

corpus_embeddings = np.load("embeddings.npy")  # shape: (N, 1536)
...

데이터베이스도 필요 없고, 네트워크 호출도 필요 없습니다. 운영 부담(ops burden)도 없습니다. 오직 수학뿐입니다.

이것이 당신의 RAG 파이프라인에 의미하는 바

이 모든 것을 종합하면 RAG 실패를 진단하기 위한 멘탈 모델(mental model)을 얻을 수 있습니다:

  1. 올바른 문서가 있음에도 LLM이 틀린 답을 내놓나요? → 검색(Retrieval) 문제가 아니라 생성(Generation) 문제입니다.
  2. 올바른 문서가 검색된 컨텍스트(Context)에 전혀 나타나지 않나요? → 재현율(Recall)을 확인하고, 청킹(Chunking) 방식과 거리 측정 지표(Distance metric)를 점검하세요.
  3. 결과가 의미론적으로는 맞지만 사실관계가 틀린가요? → 청크(Chunk)가 너무 큽니다. 정밀도(Precision)가 떨어지고 있는 것입니다.
  4. 결과에서 정확한 용어가 누락되었나요? → 하이브리드 검색(Hybrid search)이 필요합니다.
  5. 멀티 테넌트(Multi-tenant) 데이터가 사용자 간에 유출되나요? → 메타데이터 필터(Metadata filter)가 잘못되었거나 인덱싱(Indexed)되지 않았습니다.
  6. 개발 환경에서는 작동하지만 운영 환경에서 깨지나요? → 데이터 분포 변화(Data distribution shift)입니다. 인덱스를 재학습/재구축하거나 ef/nprobe 값을 조정하세요.

벡터 데이터베이스는 마법 같은 검색 신탁(Oracle)이 아닙니다. 제품 형태의 래퍼(Wrapper)를 입힌 근사 공간 인덱스(Approximate spatial indexes)일 뿐입니다. 근사치(Approximation), 트레이드오프(Trade-offs), 그리고 실패 모드(Failure modes)를 이해하고 나면, 실제로 이를 활용해 신뢰할 수 있는 시스템을 구축할 수 있습니다.

이 내용이 유익했다면, 저는 dev.to에서 Python 백엔드와 AI 엔지니어링에 대해 글을 씁니다. 진짜 중요한 내용은 디테일에 있습니다.

AI 자동 생성 콘텐츠

본 콘텐츠는 Dev.to AI tag의 원문을 AI가 자동으로 요약·번역·분석한 것입니다. 원 저작권은 원저작자에게 있으며, 정확한 내용은 반드시 원문을 확인해 주세요.

원문 바로가기
0

댓글

0