SSD Nodes Learn Hosting plans →
가이드 Matt Connor작성자 Matt Connor · 업데이트됨 2026-10-06

VPS에서 벡터 데이터베이스 운영 및 성능 최적화 가이드

VPS 환경에서 pgvector, Qdrant, Chroma 성능을 비교하고 RAM 용량을 산정하는 방법을 다룹니다. 네트워크 지연이 아닌 임베딩 모델의 CPU 점유율이 병목 현상을 일으키는 원인과 이를 해결하기 위한 서버 리소스 최적화 전략을 상세히 설명합니다.

VPS에서 벡터 데이터베이스를 운영할 때 발생하는 실제 비용

VPS(가상 사설 서버)에서 벡터 데이터베이스를 운영하면 관리형 서비스 업체들이 해결책이라며 판매하는 문제들이 사라집니다. 애플리케이션과 인덱스가 같은 머신에 위치하므로, 검색 요청은 네트워크를 거치지 않고 루프백 소켓을 통해 전달됩니다. 결국 남는 것은 항상 실질적인 비용이었던 텍스트를 벡터로 변환하는 작업입니다. 그 이면에는 인덱스를 구축하는 시간과 인덱스가 서비스되는 동안 점유하는 RAM이라는 두 가지 비용이 더 존재합니다.

이로 인해 중요하게 고려해야 할 결정 사항이 달라집니다. 리전이나 엔드포인트 왕복 시간은 더 이상 고민할 대상이 아닙니다. 이제는 벡터 개수, 차원 수, 그리고 4바이트를 곱한 값이 중요해집니다. 이 값이 매달 임대하는 메모리에 인덱스를 수용할 수 있을지를 결정하기 때문입니다.

단일 서버에서 밀리초 단위의 지연 시간이 발생하는 지점

자체 호스팅 스택에서 유사도 검색 쿼리가 처리되는 과정을 살펴봅니다.

  1. 쿼리 텍스트가 임베딩 모델을 통해 벡터로 변환됩니다. CPU 환경에서는 짧은 문자열이라도 수십에서 수백 밀리초가 소요됩니다. GPU 환경에서는 한 자릿수 밀리초가 소요됩니다.
  2. 벡터가 저장소로 전송됩니다. 루프백 TCP나 Unix domain socket을 통할 경우 이 과정은 1밀리초 미만이 소요됩니다.
  3. 저장소가 인덱스를 탐색하여 가장 유사한 행을 반환합니다.
  4. 코드가 일치하는 텍스트를 읽어 프롬프트를 구성합니다.

일반적으로 1단계에서 가장 많은 시간이 소요됩니다. 2단계는 호스팅 업체들이 경쟁하는 지점이지만, 단일 서버 환경에서는 거의 무시할 수 있는 수준입니다. 지연 시간의 원인을 추측하지 마십시오. 서버에서 양쪽 끝단의 시간을 모두 측정해야 합니다.

curl http://127.0.0.1:11434/api/embed -s -o /dev/null \
  -w 'embed: %{time_total}s\n' \
  -d '{"model": "nomic-embed-text", "input": "how do I rotate the api key"}'

검색 쿼리를 실행하기 전에 \timing on을 psql에서 실행하십시오. 첫 번째 명령이 embed: 0.184312s를 출력하고 psql이 Time: 4.201 ms를 반환한다면, 인덱스 튜닝은 잘못된 접근입니다. 지연 시간의 주원인은 임베딩 모델입니다. Ollama를 사용하여 로컬에서 임베딩 모델 실행하기는 1단계를 2단계 및 3단계와 동일한 CPU에서 수행하게 하므로, 양쪽 작업이 동일한 코어와 RAM을 두고 경쟁하게 됩니다. 이 저장소를 중심으로 구성된 데이터 수집 및 검색 루프는 자체 호스팅 RAG 파이프라인 가이드에서 다룹니다. RAG는 검색 증강 생성(Retrieval Augmented Generation)을 의미하며, 자신의 문서를 검색하여 가장 적합한 결과를 프롬프트에 삽입하는 방식입니다.

약 10만 개 미만의 벡터라면 전체를 스캔하십시오

전체 스캔은 쿼리를 저장된 모든 벡터와 비교합니다. 정의상 재현율(recall)은 완벽합니다. 인덱스나 빌드 단계가 필요 없으며, 데이터가 변경되어도 인덱스가 최신 상태와 어긋날(drift) 일이 없습니다.

산술적으로 계산해보면 언제 이 방식이 한계에 다다르는지 알 수 있습니다. 스캔은 쿼리당 n * d * 4 바이트를 읽으며, 여기서 n은 벡터 개수, d은 차원 수입니다. 768차원 벡터 10만 개라면 쿼리당 307 MB를 읽게 되며, 최신 CPU는 이를 수십 밀리초 만에 스트리밍할 수 있습니다. 500만 개가 되면 쿼리당 15 GB가 되는데, 이는 더 이상 일반적인 쿼리라고 볼 수 없습니다.

따라서 벡터를 SQLite에 저장하고 NumPy에서 비교를 수행하십시오.

import sqlite3, numpy as np

db = sqlite3.connect("docs.db")
db.execute("CREATE TABLE IF NOT EXISTS docs (id INTEGER PRIMARY KEY, body TEXT, vec BLOB)")

def add(body, vec):
    v = np.asarray(vec, dtype=np.float32)
    v /= np.linalg.norm(v)
    db.execute("INSERT INTO docs (body, vec) VALUES (?, ?)", (body, v.tobytes()))
    db.commit()

def search(query_vec, k=5):
    rows = db.execute("SELECT id, body, vec FROM docs").fetchall()
    mat = np.frombuffer(b"".join(r[2] for r in rows), dtype=np.float32).reshape(len(rows), -1)
    q = np.asarray(query_vec, dtype=np.float32)
    q /= np.linalg.norm(q)
    scores = mat @ q
    return [(rows[i][0], rows[i][1], float(scores[i])) for i in np.argsort(-scores)[:k]]

양쪽 모두 단위 길이로 스케일링되므로 내적(dot product)은 곧 코사인 유사도(cosine similarity)가 되며, 점수가 높을수록 더 가까운 일치 항목입니다. mat을 쿼리마다 로드하지 말고 시작 시 한 번만 로드하면, SQLite 읽기 작업은 핫 패스(hot path)에서 완전히 제외됩니다.

이 방식을 거부하기 전에 본인의 장비에서 직접 시간을 측정해 보십시오.

import time
t = time.perf_counter()
search(q)
print(f"{(time.perf_counter() - t) * 1000:.1f} ms")

현실적인 한계는 다음과 같습니다. 이 방식은 전체 행렬을 RAM에 유지하는 단일 프로세스이며, 메타데이터 필터링 기능이 없고 동시 쓰기 작업도 처리할 수 없습니다. 이러한 문제 중 하나라도 해당되어 만족스럽지 않다면 다른 방안으로 옮겨야 합니다. SQLite 자체는 진지한 서버 측 저장소이며, SQLite 운영 환경 가이드에서 이를 다룹니다. 만약 실제 작업 부하가 행을 제공하는 것이 아니라 열을 스캔하는 것이라면 DuckDB와 SQLite 비교 문서를 읽는 것이 더 유용합니다.

이미 Postgres를 운영 중인 경우의 pgvector

애플리케이션에서 이미 Postgres 데이터베이스를 사용 중이라면, pgvector를 도입하는 것이 새로운 운영 요소를 최소화하는 방법입니다. pgvector는 별도의 서비스가 아닌 확장 기능(extension)입니다. 벡터 데이터는 설명 대상이 되는 행과 함께 일반 테이블에 저장되므로, 필터링된 검색은 별도의 시스템을 동기화할 필요 없이 WHERE 절을 통해 수행할 수 있습니다.

Ubuntu 24.04는 universe 저장소에서 이 패키지를 제공합니다.

sudo apt update
sudo apt install -y postgresql-16-pgvector
sudo -u postgres psql -d yourdb -c 'CREATE EXTENSION vector;'

해당 패키지는 2026년 8월 기준 pgvector 0.6.0 버전이며, 이는 업스트림보다 상당히 뒤처져 있습니다. 특히 반복적 인덱스 스캔(iterative index scans) 기능은 0.8 버전 이상이 필요하므로, PostgreSQL 프로젝트의 공식 저장소에서 패키지를 가져와야 합니다.

sudo apt install -y postgresql-common
sudo /usr/share/postgresql-common/pgdg/apt.postgresql.org.sh
sudo apt install -y postgresql-17-pgvector

17 부분을 sudo -u postgres psql -tAc 'SHOW server_version' 명령어로 확인한 서버의 메인 버전으로 교체하십시오. 잘못된 메인 버전용으로 빌드된 확장 패키지를 설치하면 CREATE EXTENSION 오류가 발생합니다. Postgres는 현재 실행 중인 버전의 share 디렉터리만 참조하기 때문입니다.

ERROR:  could not open extension control file "/usr/share/postgresql/16/extension/vector.control": No such file or directory

스키마는 새로운 타입이 하나 추가된 일반적인 SQL과 같습니다.

CREATE TABLE chunks (
  id        bigserial PRIMARY KEY,
  doc_id    bigint NOT NULL,
  body      text   NOT NULL,
  embedding vector(768)
);

SELECT id, body FROM chunks
ORDER BY embedding <=> '[0.013, -0.021, 0.004]'
LIMIT 5;

<=>은 코사인 거리(cosine distance), <->는 L2(유클리드) 거리, <#>는 음의 내적(negative inner product)입니다. 사용 중인 임베딩 모델이 학습된 방식에 맞는 것을 선택하십시오. 잘못된 방식을 선택해도 오류는 발생하지 않지만, 검색 결과의 품질이 저하됩니다.

인덱스가 없는 상태에서의 쿼리는 모든 행을 대상으로 하는 완전 탐색(exact search)을 수행합니다. 이는 앞서 언급한 무차별 대입(brute force) 방식의 Postgres 버전이며, 동일하게 완벽한 재현율(recall)을 보장합니다. max_parallel_workers_per_gather 값을 높이면 더 많은 코어를 활용할 수 있습니다. 인덱스를 생성하기 전에 이 작업을 먼저 수행하여, 인덱스 성능을 측정할 수 있는 재현율 기준값을 확보하십시오.

Qdrant, 인덱스가 데이터베이스 크기를 초과할 때

Qdrant는 Rust로 작성된 전용 벡터 저장소입니다. 인덱스 크기가 커져서 인덱스 구축 작업이 애플리케이션의 Postgres와 자원을 두고 경쟁하지 않기를 원하거나, pgvector가 제공하지 않는 페이로드 필터링 및 양자화 기능이 필요할 때 Qdrant를 도입합니다.

docker run -d --name qdrant \
  -p 127.0.0.1:6333:6333 -p 127.0.0.1:6334:6334 \
  -e QDRANT__SERVICE__API_KEY="$(openssl rand -hex 32)" \
  -v "$(pwd)/qdrant_storage:/qdrant/storage:z" \
  qdrant/qdrant

포트 6333은 REST API와 /dashboard의 대시보드를 제공하며, 6334는 gRPC를 처리합니다. 공개 VPS 환경에서는 두 가지 세부 사항이 중요합니다. Qdrant 공식 문서에 따르면 이 서비스는 기본적으로 "암호화나 인증 없이" 실행됩니다. 또한 퀵스타트의 -p 6333:6333은 모든 인터페이스에 바인딩되는데, Docker는 자체 포워딩 규칙을 작성하므로 ufw 규칙을 우회하여 포트를 노출합니다. 반드시 127.0.0.1에 바인딩하고 API 키를 설정하십시오. 키 없이 공인 IP에서 접근 가능한 Qdrant 인스턴스는 귀하의 문서를 누구나 열람할 수 있는 상태로 만드는 것과 같습니다.

curl -s http://127.0.0.1:6333/collections -H "api-key: $QDRANT_API_KEY"

정상적인 응답은 {"result":{"collections":[]},"status":"ok","time":0.00002}과 같습니다. {"status":{"error":"Unauthorized"}}이 반환된다면 헤더 이름이나 키가 잘못된 것이며, 아무런 응답이 없다면 컨테이너가 실행 중이지 않거나 다른 곳에 바인딩된 상태입니다. 해당 컨테이너가 서버 환경에 적합한지는 일반적인 상태 유지 서비스(stateful service)의 고려 사항과 동일하므로, Docker와 호스트 데이터베이스 비교 내용을 그대로 적용하면 됩니다.

Chroma와 그 용도

Chroma는 아무것도 없는 상태에서 작동하는 검색 데모를 가장 빠르게 구현할 수 있는 방법입니다.

pip install chromadb
chroma run --path /srv/chroma

이 서비스는 8000번 포트에서 실행되며, chromadb.HttpClient(host="localhost", port=8000)가 여기에 연결합니다. Chroma는 기본 임베딩 함수를 제공하므로, 초기 프로토타입 단계에서는 별도의 모델 서버가 전혀 필요하지 않습니다.

이 선택에 따른 장단점을 명확히 이해해야 합니다. Chroma는 이 가이드에서 다루는 거리 측정 방식이나 RAM 점유량과 같은 복잡한 결정을 숨겨주기 때문에 편리합니다. 이는 프로토타입에는 적합하지만, 장애 발생 시 호출을 받아야 하는 운영 환경에는 적절하지 않을 수 있습니다. 만약 데이터가 이미 Postgres에 저장되어 있다면, Chroma로 옮기는 것은 pgvector에는 없는 프로세스 관리와 데이터 동기화 문제를 추가하는 결과를 초래합니다.

인덱스에 필요한 RAM 용량

원시 벡터에서 시작하십시오. 이는 최소 기준이며 어떤 튜닝으로도 이보다 줄일 수 없습니다.

bytes = number_of_vectors * dimensions * 4

4바이트는 차원당 32비트 부동 소수점 하나를 의미합니다. Qdrant의 용량 계획 문서에서는 메타데이터와 최적화 과정에서 생성되는 임시 세그먼트를 고려하여 1.5배의 배수를 적용합니다.

memory_size = number_of_vectors * vector_dimension * 4 bytes * 1.5

다음은 실제 임베딩 모델이 생성하는 차원을 기준으로 100만 개의 벡터에 해당 공식을 적용한 결과입니다.

ChartRAM for 1 million vectors, by embedding dimension
The data behind this chart
[
  {
    "label": "384 dims",
    "raw_gib": 1.43,
    "with_overhead_gib": 2.15
  },
  {
    "label": "768 dims",
    "raw_gib": 2.86,
    "with_overhead_gib": 4.29
  },
  {
    "label": "1024 dims",
    "raw_gib": 3.81,
    "with_overhead_gib": 5.72
  },
  {
    "label": "1536 dims",
    "raw_gib": 5.72,
    "with_overhead_gib": 8.58
  },
  {
    "label": "3072 dims",
    "raw_gib": 11.44,
    "with_overhead_gib": 17.17
  }
]

이 수치는 공식의 출력값이며 기비바이트(GiB) 단위입니다. 실제 측정값이 아니므로 메모리에서 확보해야 할 공간의 크기로 이해하십시오. 768차원 모델(예: nomic-embed-text)로 100만 개의 청크를 처리할 경우 약 4.29 GiB가 필요하며, 이는 8 GB 플랜에서 Postgres를 위한 여유 공간을 남기고 수용 가능합니다. 동일한 말뭉치를 3072차원으로 처리하면 17.17 GiB가 필요하여 수용할 수 없습니다.

핵심은 마지막 열이 아니라 첫 번째 열입니다. 차원을 절반으로 줄이면 그 이후의 모든 바이트가 영구적으로 절반이 됩니다. 공개 리더보드에서 점수가 조금 낮더라도 768차원 모델을 선택하는 것이 VPS 환경에서는 더 나은 엔지니어링 결정인 경우가 많습니다. pgvector의 halfvec 타입은 16비트 부동 소수점을 저장하므로 바이트를 다시 절반으로 줄일 수 있으며, 다음과 같은 표현식을 통해 인덱싱합니다.

CREATE INDEX ON chunks USING hnsw ((embedding::halfvec(768)) halfvec_cosine_ops);

모델을 선택하기 전에 알아야 할 한계가 있습니다. pgvector의 vector 타입은 최대 16,000차원까지 허용하지만, HNSW 및 IVFFlat 인덱스는 2,000차원까지만 지원합니다. 그 이상의 차원에서는 halfvec 캐스트를 사용하여 인덱싱하면 4,000차원까지 가능하며, 그렇지 않으면 인덱싱을 할 수 없습니다.

빌드 시점의 m과 ef_construction 비용

HNSW(hierarchical navigable small world)는 pgvector와 Qdrant가 모두 사용하는 인덱스 방식입니다. 이는 계층형 그래프 구조입니다. 모든 벡터는 주변 노드와 연결된 하나의 노드가 되며, 검색 시에는 모든 데이터를 읽는 대신 해당 링크를 따라 쿼리 방향으로 이동합니다.

m은 각 노드가 유지하는 링크의 개수입니다. Faiss 문서에 따르면 HNSW 메모리 사용량은 벡터당 (d * 4 + m * 2 * 4) 바이트이며, m 값을 4에서 64 사이로 유지할 것을 권장합니다. 이를 768차원, 100만 개의 벡터 환경에서 실행해 보십시오.

ChartCost of raising m at 768 dimensions, 1 million vectors
The data behind this chart
[
  {
    "label": "m = 8",
    "link_bytes_per_vector": 64,
    "total_gib": 2.92
  },
  {
    "label": "m = 16 (default)",
    "link_bytes_per_vector": 128,
    "total_gib": 2.98
  },
  {
    "label": "m = 32",
    "link_bytes_per_vector": 256,
    "total_gib": 3.1
  },
  {
    "label": "m = 64",
    "link_bytes_per_vector": 512,
    "total_gib": 3.34
  }
]

흥미로운 점은 그 차이의 크기입니다. 기본값인 m = 16에서 m = 64로 변경하면 벡터 데이터 3072바이트에 대해 벡터당 512 바이트의 링크가 추가되므로, 전체 크기는 2.98 GiB에서 3.34 GiB로 변합니다. 이는 약 12퍼센트 증가한 수치입니다. 이러한 차원에서는 m가 메모리 점유의 주된 원인이 아닙니다. 벡터 데이터 자체가 주된 원인입니다.

m이 실제로 소모하는 비용은 빌드 시간과 삽입 시간입니다. 노드를 배치한다는 것은 그만큼 많은 이웃 노드를 찾고 연결해야 함을 의미하기 때문입니다. ef_construction는 노드를 배치할 때 빌더가 고려하는 후보 리스트의 크기입니다. 이 값을 높이면 더 나은 그래프가 생성되지만 빌드 속도는 느려지며, 완성된 인덱스 크기에는 전혀 영향을 주지 않습니다.

SET maintenance_work_mem = '4GB';
SET max_parallel_maintenance_workers = 7;
CREATE INDEX ON chunks USING hnsw (embedding vector_cosine_ops)
  WITH (m = 16, ef_construction = 64);

maintenance_work_mem는 빌드에 몇 분이 걸릴지 혹은 몇 시간이 걸릴지를 결정하는 설정입니다. pgvector는 메모리에 적재 가능한 경우 메모리 내에서 그래프를 조립하기 때문입니다. 메모리가 부족해지면 pgvector는 다음과 같이 알림을 출력합니다.

NOTICE:  hnsw graph no longer fits into maintenance_work_mem after 100000 tuples
DETAIL:  Building will take significantly more time.
HINT:  Increase maintenance_work_mem to speed up builds.

이 알림은 pgvector가 출력하는 메시지 중 가장 유용한 정보입니다. 빌드 과정이 훨씬 느린 경로로 전환되었음을 의미하므로, 작업을 취소하고 위에서 계산한 RAM 수치보다 설정을 높인 뒤 다시 시작하십시오. 두 번째 세션에서 진행 상황을 모니터링하십시오.

SELECT phase, round(100.0 * blocks_done / nullif(blocks_total, 0), 1) AS pct
FROM pg_stat_progress_create_index;

HNSW는 initializing을 보고한 뒤 loading tuples을 보고합니다. 디스크 사용량이 많지 않은데도 낮은 퍼센트에서 오랫동안 멈춰 있다면, 이는 쿼리가 멈춘 것이 아니라 maintenance_work_mem 문제입니다.

계획을 세울 때 고려해야 할 두 가지 사실이 있습니다. pgvector의 README에 따르면 HNSW는 IVFFlat보다 "빌드 시간이 더 길고 더 많은 메모리를 사용"하지만, 그 대가로 속도와 재현율(recall) 사이에서 더 나은 균형을 제공합니다. 또한 HNSW는 빈 테이블에서도 생성할 수 있는 반면, IVFFlat은 먼저 대표 데이터를 대상으로 k-means를 실행해야 하므로 빈 테이블에서 생성하면 재현율이 낮아집니다. 새로운 스키마를 구성할 때는 HNSW를 먼저 생성하는 것이 좋습니다.

m와 ef_construction은 인덱스 생성 시점에 고정됩니다. 하지만 ef_search은 그렇지 않습니다. 이 설정은 그래프를 탐색하는 동안 유지할 후보의 수를 결정하며, 세션 단위나 쿼리 단위로 변경할 수 있습니다.

SET hnsw.ef_search = 100;
SELECT id, body FROM chunks ORDER BY embedding <=> $1 LIMIT 5;

기본값은 40입니다. 이 값을 높이면 재현율(recall)이 상승하지만 지연 시간(latency)도 함께 증가합니다. 값을 낮추면 둘 다 감소합니다. 이는 인덱스를 다시 빌드하지 않고도 변경할 수 있는 유일한 재현율 제어 설정이므로, 정답을 이미 알고 있는 고정된 쿼리 집합을 대상으로 튜닝하고 재현율 향상이 멈추는 지점에서 조정을 마쳐야 합니다.

한 가지 주의할 점이 있습니다. ef_search는 선택성이 높은 WHERE 절과 함께 사용할 때 문제가 발생할 수 있습니다. 인덱스는 고정된 수의 후보를 반환하고 필터는 그 이후에 적용되기 때문입니다. 대부분의 행을 제외하는 필터를 사용하면, 테이블에는 일치하는 행이 있음에도 불구하고 결과가 LIMIT개 미만으로 반환될 수 있습니다. pgvector 0.8 버전은 반복 스캔(iterative scans)을 통해 이 문제를 해결합니다.

SET hnsw.iterative_scan = relaxed_order;

인덱스는 제한된 개수를 만족할 때까지 후보를 찾기 위해 재스캔을 수행하며, 최대 hnsw.max_scan_tuples까지 시도합니다(기본값은 20000). strict_order은 정확한 거리 순서를 유지하지만 더 많은 비용이 듭니다. Ubuntu 0.6.0 패키지에는 이 기능이 포함되어 있지 않으며, 필터 적용 시 행이 누락되는 현상을 통해 해당 버전의 한계를 확인할 수 있습니다.

인덱스가 RAM에 적재되어야 하는 이유

HNSW 검색은 그래프를 탐색하는 과정입니다. 각 홉(hop)은 이전 노드와 무관한 위치에 저장된 노드를 읽어오므로, 접근 패턴이 무작위(random)에 가까워 읽기 미리 가져오기(read-ahead)가 효과가 없습니다. 그래프가 RAM에 있으면 모든 홉은 메모리 참조로 처리됩니다. 하지만 RAM을 벗어나면 홉 하나가 디스크 읽기가 될 수 있으며, 수백 개의 노드를 거치는 검색은 수백 번의 디스크 읽기로 이어집니다.

Qdrant 문서에서는 이를 다음과 같이 정의합니다. "RAM에 저장된 벡터의 수를 절반으로 줄이면 검색 지연 시간은 대략 두 배가 됩니다." 이 문장을 기준으로 계획을 수립하십시오.

인덱스가 물리적으로 RAM에 들어가지 않는다면, 모든 선택지는 의도적으로 감수해야 하는 트레이드오프입니다.

  • 벡터를 메모리 매핑(memory-map)하여 운영체제가 자주 사용하는 페이지는 캐시하고, 사용하지 않는 페이지는 디스크에 남겨두도록 합니다. 이 방식이 허용 가능한 수준이 되려면 하부에 빠른 NVMe(non-volatile memory express) 스토리지가 필요합니다.
  • 양자화(quantise)를 수행하여 각 차원을 4바이트 대신 1바이트로 저장합니다. 이는 측정 가능한 수준의 작은 재현율(recall) 손실을 대가로 벡터 크기를 4분의 1로 줄입니다.
  • pgvector에서 halfvec로 캐스팅합니다. 이는 1바이트 양자화보다 재현율 손실은 적으면서 바이트 크기를 절반으로 줄여줍니다.
  • 더 작은 모델로 임베딩합니다. 이는 가장 저렴한 해결책이지만 사람들이 가장 기피하는 방법이기도 합니다. 전체 코퍼스를 다시 임베딩해야 하기 때문입니다.

실패 유형 및 확인되는 메시지

could not open extension control file. 실행 중인 Postgres 메인 버전용 pgvector 패키지가 설치되지 않았습니다. sudo -u postgres psql -tAc 'SHOW server_version'로 버전을 확인하고 일치하는 postgresql-NN-pgvector을 설치하십시오.

ERROR: expected 768 dimensions, not 1536. 컬럼 타입과 모델이 일치하지 않습니다. 임베딩 모델을 변경했으나 다시 임베딩하지 않은 경우입니다. 서로 다른 모델에서 생성된 벡터는 비교가 불가능하므로 부분적인 수정은 불가능하며, 모든 행을 다시 생성해야 합니다.

쿼리가 느리고 EXPLAIN에서 sequential scan이 확인됩니다. 인덱스 연산자 클래스와 쿼리 연산자가 일치하지 않는 상태입니다. vector_cosine_ops은 <=>에만 대응합니다. 쿼리에 EXPLAIN ANALYZE를 실행하여 Index Scan using ... on chunks이 있는지 확인하십시오. 만약 Seq Scan on chunks이 보인다면, 실제 쿼리에 사용하는 연산자와 일치하는 연산자 클래스로 인덱스를 다시 빌드하십시오.

LIMIT보다 적은 행이 반환되고 WHERE 절이 포함되어 있습니다. 이는 앞서 언급한 필터링 함정입니다. hnsw.ef_search 값을 높이거나, pgvector 0.8 버전으로 업그레이드한 뒤 hnsw.iterative_scan을 설정하십시오.

인덱스 빌드가 프로세스 종료로 끝나고 psql에 오류가 기록되지 않습니다. maintenance_work_mem을 시스템 메모리 대부분으로 설정하면, shared_buffers 및 애플리케이션이 메모리를 요구할 때 커널의 out-of-memory killer가 작동하게 됩니다. sudo dmesg -T | grep -i 'killed process'에서 postgres을 지목하는 줄을 확인하십시오. 설정을 낮추거나, 더 큰 사양의 플랜에서 인덱스를 빌드한 뒤 덤프를 복원하십시오.

도구 선택하기

이미 Postgres를 운영 중이고 벡터 데이터가 수백만 개 미만이라면 pgvector를 사용하십시오. 인덱스가 데이터와 함께 위치하며, 필터링은 WHERE 절을 통해 수행되고, 기존 백업 체계로도 충분히 대응 가능합니다. 인덱스 크기가 커서 별도의 메모리 제한이 필요하거나 복잡한 페이로드 필터링이 요구된다면, Qdrant를 별도로 운영하는 방안을 고려하고 두 번째 서비스를 관리하는 운영 부담을 감수하십시오.

벡터 데이터가 대략 10만 개 미만이라면, 별도의 도구를 설치하기 전에 브루트 포스(brute-force) 스캔 성능을 먼저 측정하십시오. 해당 규모에서는 완벽한 재현율을 보장하며 별도의 빌드 단계가 필요 없는 전수 조사가 결코 타협안이 아닙니다. 이것이 올바른 해결책이며, 굳이 근사 인덱스를 도입하는 것은 불필요한 튜닝과 RAM 부하를 감수하면서 밀리초 단위의 성능을 얻으려는 것에 불과합니다.

FAQ

전용 벡터 데이터베이스가 필요한가요, 아니면 Postgres로 충분한가요?

데이터가 이미 Postgres에 있다면, 대부분의 비교 자료가 시사하는 것보다 훨씬 오랫동안 pgvector로 충분합니다. 벡터를 일반 컬럼에 저장하므로 필터링된 검색은 WHERE 절을 사용하며, 기존 백업으로 인덱스까지 모두 보호할 수 있습니다. 벡터 워크로드에 별도의 메모리 제한이 필요하거나, pgvector가 제공하지 않는 페이로드 필터링 및 양자화 기능이 필요할 때 Qdrant와 같은 전용 저장소로 이전하십시오.

하나의 VPS에 벡터를 얼마나 많이 저장할 수 있나요?

추측하지 말고 number_of_vectors * dimensions * 4 bytes * 1.5를 사용하여 직접 계산하십시오. 768차원 벡터 100만 개는 대략 4.3 GiB를 차지하므로, 8 GB 플랜이면 Postgres를 위한 여유 공간과 함께 수용 가능합니다. 3072차원 벡터 100만 개는 대략 17 GiB를 차지하므로 훨씬 더 큰 플랜이 필요합니다. 이 수치에 가장 큰 영향을 주는 것은 임베딩 모델의 차원이므로, 메모리 비용을 고려하여 모델을 선택하십시오.

인덱스가 같은 머신에 있는데 왜 벡터 검색이 느린가요?

한 대의 서버에서는 네트워크가 문제가 아니므로, 다음 두 가지를 확인해야 합니다. 첫째, 임베딩 호출 자체의 시간을 측정하십시오. CPU에서 쿼리 벡터를 생성하는 시간이 검색 자체보다 훨씬 오래 걸리는 경우가 많습니다. 둘째, 인덱스가 RAM에 있는지 확인하십시오. HNSW 검색은 그래프를 무작위로 탐색하므로, 그래프가 디스크로 넘어가면 각 탐색이 디스크 읽기 작업이 될 수 있습니다. Qdrant의 가이드에 따르면 RAM에 적재된 벡터가 절반으로 줄어들면 검색 지연 시간은 대략 두 배가 됩니다.

HNSW 인덱스를 반드시 구축해야 하나요?

벡터가 약 10만 개 미만일 때는 필요 없습니다. 전체 스캔은 쿼리당 n * d * 4 바이트를 읽는데, 768차원 벡터 10만 개 기준 307 MB입니다. 최신 CPU는 이를 수십 밀리초 내에 처리하며, 완벽한 재현율을 보장하고 별도의 구축 단계도 필요 없습니다. 먼저 자신의 하드웨어에서 스캔 성능을 측정하십시오. 벤치마크 기사가 그렇다고 해서 구축하는 것이 아니라, 측정된 스캔 시간이 실제로 너무 느릴 때 인덱스를 구축하십시오.

m 값을 높이면 어떤 비용이 발생하나요?

메모리보다 구축 시간과 삽입 시간이 훨씬 더 많이 소요됩니다. 768차원 기준, 기본값인 m = 16에서 m = 64으로 올리면 벡터당 512 바이트의 그래프 링크가 추가됩니다. 이는 3072 바이트인 벡터 데이터 대비 전체 메모리 사용량을 약 12퍼센트 증가시킵니다. 하지만 모든 삽입 작업은 네 배 더 많은 이웃을 찾고 연결해야 합니다. ef_search는 변경 비용이 없고 재구축도 필요 없으므로, 이를 먼저 조정하십시오.

#vector-database#rag#pgvector#qdrant#self-hosting