VPS에서 벡터 데이터베이스 운영 및 성능 최적화 가이드
pgvector, Qdrant, Chroma 성능을 비교하고 VPS RAM 용량 산정법을 확인하십시오. 네트워크 지연이 아닌 임베딩 모델의 CPU 병목 현상을 해결하여 검색 속도를 최적화하는 실무적인 방법을 제시합니다.
VPS에서 벡터 데이터베이스를 운영할 때 발생하는 실제 비용
VPS(가상 사설 서버)에서 벡터 데이터베이스를 운영하면 관리형 서비스 업체들이 해결책이라며 판매하는 문제들이 사라집니다. 애플리케이션과 인덱스가 같은 머신에 위치하므로, 검색 요청은 네트워크를 거치지 않고 루프백 소켓을 통해 전달됩니다. 결국 남는 것은 항상 존재했던 실제 비용인 텍스트를 벡터로 변환하는 작업입니다. 그 뒤에는 인덱스를 구축하는 시간과 인덱스가 서비스되는 동안 점유하는 RAM이라는 두 가지 비용이 더 숨어 있습니다.
이로 인해 중요하게 고려해야 할 결정 사항이 달라집니다. 리전과 엔드포인트 간의 왕복 시간은 더 이상 고민할 대상이 아닙니다. 이제는 벡터 개수, 차원, 그리고 4바이트를 곱한 값이 중요해집니다. 이 값이 매달 임대하는 메모리에 인덱스를 수용할 수 있을지를 결정하기 때문입니다.
단일 서버에서 밀리초 단위의 지연 시간이 발생하는 지점
자체 호스팅 스택에서 유사도 쿼리가 처리되는 과정을 추적합니다.
- 쿼리 텍스트가 임베딩 모델을 통해 벡터로 변환됩니다. 짧은 문자열이라도 CPU에서는 수십에서 수백 밀리초가 소요됩니다. GPU를 사용하면 한 자릿수 밀리초로 단축됩니다.
- 벡터가 저장소로 전송됩니다. 루프백 TCP나 Unix 도메인 소켓을 통하면 1밀리초 미만의 시간이 소요됩니다.
- 저장소가 인덱스를 탐색하여 가장 유사한 행을 반환합니다.
- 코드가 일치하는 텍스트를 읽어 프롬프트를 구성합니다.
보통 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-pgvector17 부분을 서버의 메인 버전으로 교체하십시오. 메인 버전은 sudo -u postgres psql -tAc 'SHOW server_version' 명령으로 확인할 수 있습니다. 잘못된 메인 버전용으로 빌드된 확장 패키지를 설치하면 CREATE EXTENSION가 실패합니다. Postgres는 현재 실행 중인 버전의 공유 디렉터리만 참조하기 때문입니다.
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;<=>은 코사인 거리, <->는 L2(유클리드) 거리, <#>는 음의 내적입니다. 임베딩 모델이 학습된 방식에 맞는 것을 선택하십시오. 잘못된 방식을 선택해도 오류는 발생하지 않지만, 검색 결과의 품질이 조용히 저하됩니다.
인덱스가 없는 상태에서의 쿼리는 모든 행을 대상으로 하는 정확한 검색을 수행합니다. 이는 앞서 언급한 무차별 대입(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 * 44바이트는 차원당 32비트 부동 소수점 하나에 해당합니다. Qdrant의 용량 계획 문서에서는 메타데이터와 최적화 과정에서 생성되는 임시 세그먼트를 고려하여 1.5배의 승수를 추가합니다.
memory_size = number_of_vectors * vector_dimension * 4 bytes * 1.5다음은 실제 임베딩 모델이 생성하는 차원을 기준으로 100만 개의 벡터에 대해 해당 공식을 적용한 결과입니다.
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가 필요하며, 이는 Postgres를 위한 여유 공간을 포함해 8 GB 플랜에 적합합니다. 동일한 말뭉치를 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만 개의 벡터 환경에서 실행해 보십시오.
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로 변경하면 벡터당 512 바이트의 링크 데이터가 추가되며, 3072바이트의 벡터 데이터와 비교했을 때 전체 크기는 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를 먼저 생성하는 것이 좋습니다.
ef_search: 빌드 후 조정하는 설정값
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와 애플리케이션이 메모리를 동시에 점유하면서 커널의 OOM(Out-of-Memory) 킬러가 작동하게 됩니다. sudo dmesg -T | grep -i 'killed process'에서 postgres을 지목하는 로그를 확인할 수 있습니다. 설정을 낮추거나, 더 큰 사양의 인스턴스에서 인덱스를 생성한 뒤 덤프를 복원하십시오.
도구 선택하기
이미 Postgres를 운영 중이고 벡터 데이터가 수백만 개 미만이라면 pgvector를 사용하십시오. 인덱스가 데이터와 함께 위치하며, 필터링은 WHERE 절을 통해 수행되고, 기존 백업 체계로도 충분히 관리할 수 있습니다. 인덱스 크기가 커서 별도의 메모리 제한이 필요하거나 복잡한 페이로드 필터링이 요구된다면, Qdrant를 별도로 운영하는 방안을 고려하십시오. 이 경우 서비스 하나를 추가로 관리해야 하는 부담은 감수해야 합니다.
벡터 데이터가 대략 10만 개 미만이라면, 새로운 도구를 설치하기 전에 브루트 포스(brute-force) 스캔 성능을 먼저 측정해 보십시오. 해당 규모에서는 완벽한 재현율을 보장하며 인덱스 빌드 단계가 없는 전수 조사가 결코 성능 저하를 의미하지 않습니다. 오히려 이것이 올바른 해결책일 수 있습니다. 굳이 근사 인덱스를 도입하는 것은 불필요한 튜닝 과정과 RAM 점유율 상승을 초래할 뿐이며, 얻게 되는 밀리초 단위의 성능 향상은 실제로는 체감하기 어렵습니다.
FAQ
Do I need a dedicated vector database, or is Postgres enough?
If your data already lives in Postgres, pgvector is enough far longer than most comparisons suggest. It stores vectors in an ordinary column, so a filtered search is a WHERE clause and your existing backup covers the index. Move to a dedicated store like Qdrant when the vector workload needs its own memory ceiling, or when you need payload filtering and quantisation that pgvector does not provide.
How many vectors can one VPS hold?
Work it out rather than guessing, using number_of_vectors * dimensions * 4 bytes * 1.5. One million 768-dimension vectors comes to roughly 4.3 GiB, so an 8 GB plan holds that with room for Postgres. One million 3072-dimension vectors comes to roughly 17 GiB and needs a much larger plan. The number that moves this most is the dimension of your embedding model, so choose that model with the memory bill in view.
Why is my vector search slow when the index is on the same machine?
The network is not your problem on one box, so look at the two things that are. First, time the embedding call on its own, because generating the query vector on CPU often takes far longer than the search itself. Second, check the index is in RAM. An HNSW search hops randomly through a graph, so once the graph spills to disk each hop can become a disk read, and Qdrant's own guidance is that halving the vectors held in RAM roughly doubles search latency.
Should I build an HNSW index at all?
Not below roughly a hundred thousand vectors. An exhaustive scan reads n * d * 4 bytes per query, which is 307 MB at 100,000 vectors of 768 dimensions, and a modern CPU streams that in tens of milliseconds with perfect recall and no build step. Measure the scan on your own hardware first. Build the index when the measured scan time is genuinely too slow, not because a benchmark article said so.
What does raising m actually cost me?
Build time and insert time, far more than memory. At 768 dimensions, going from the default m = 16 to m = 64 adds 512 bytes of graph links per vector against 3072 bytes of vector data, so total memory rises by about 12 percent. Every insert, though, has to find and link four times as many neighbours. Tune ef_search first, since it costs nothing to change and needs no rebuild.