사내 RAG를 pgvector로 시작했다. 처음 수십만 벡터까지는 PostgreSQL 한 대로 충분했다. 그런데 문서가 쌓이고 벡터가 수백만 단위로 늘자 인덱스 빌드 시간이 길어지고, 검색 지연이 들쭉날쭉해지고, 메모리 압박으로 다른 쿼리까지 느려지는 상황이 왔다. 이쯤 되면 벡터를 관계형 DB 안에 계속 둘지, 전용 벡터 DB로 뺄지 한 번 고민하게 된다. 이 글은 그 경계에서 Qdrant로 넘어가는 과정을 정리한 메모다.
결론부터 말하자면, 벡터 수가 PostgreSQL 인스턴스 메모리로 감당하기 어려워지고 필터링을 곁들인 검색 품질이 중요해지는 시점이 Qdrant로 옮길 때다. Qdrant는 컬렉션 단위로 HNSW 인덱스를 관리하고, 페이로드(메타데이터)에 인덱스를 걸어 필터를 걸면서도 근사 최근접 탐색을 빠르게 수행한다. 아래에서 이관 판단 기준, 컬렉션 생성, HNSW 파라미터 튜닝, 페이로드 필터링, pgvector 대비 트레이드오프를 차례로 살펴본다.
1. 용어부터: HNSW와 페이로드
HNSW(Hierarchical Navigable Small World)는 벡터 검색에서 가장 많이 쓰는 근사 최근접 탐색(ANN) 인덱스다. 벡터들을 여러 계층의 그래프로 연결해 두고, 상위 계층에서 대략적인 방향을 잡은 뒤 하위 계층으로 내려가며 가까운 이웃을 찾는다. 모든 벡터를 전부 비교하는 완전 탐색보다 훨씬 빠른 대신, 정확한 최근접이 아니라 근사치를 돌려준다. 그래서 품질(recall)과 속도의 균형을 파라미터로 맞춰야 한다.
페이로드(payload)는 Qdrant에서 벡터에 붙이는 JSON 메타데이터다. 문서 ID, 작성자, 카테고리, 작성일 같은 값을 여기에 저장하고, 검색할 때 이 값으로 필터를 건다. pgvector였다면 같은 테이블의 일반 컬럼에 해당하는 것이다.
2. 언제 pgvector를 떠나나
pgvector는 PostgreSQL을 이미 운영 중이라면 벡터 검색을 가장 적은 운영 부담으로 시작하는 방법이다. 트랜잭션, 조인, 백업 체계를 그대로 쓸 수 있다. 다음 신호가 겹치면 전용 벡터 DB를 검토할 때다.
- 벡터 수가 수백만을 넘어가고 HNSW 인덱스가 메모리에 다 올라가지 않아 검색이 디스크를 타기 시작한다.
- 인덱스 재빌드(
CREATE INDEX ... USING hnsw)가 수십 분에서 시간 단위로 늘어 운영 중 적재가 부담스럽다. - 벡터 검색이 같은 인스턴스의 OLTP 쿼리 메모리를 잠식해 서비스 전체가 느려진다.
- 필터를 섞은 검색에서 정확도와 지연을 세밀하게 튜닝하고 싶은데 pgvector의 조절 손잡이가 부족하게 느껴진다.
반대로 벡터가 수십만 수준이고 트랜잭션 일관성이 더 중요하다면 굳이 옮길 이유는 없다. pgvector에 그대로 두는 편이 운영이 단순하다.
3. Qdrant 띄우기
개발 환경에서는 바이너리나 공식 이미지로 바로 띄우는 게 빠르다. Docker를 쓰지 않는 환경이라면 릴리스 바이너리를 받아 실행해도 된다. 여기서는 로컬 확인용으로 컨테이너 예시를 적는다.
$ docker run -p 6333:6333 -p 6334:6334 \
-v $(pwd)/qdrant_storage:/qdrant/storage \
qdrant/qdrant:latest
_ _
__ _ __| |_ __ __ _ _ __ | |_
/ _` |/ _` | '__/ _` | '_ \| __|
| (_| | (_| | | | (_| | | | | |_
\__, |\__,_|_| \__,_|_| |_|\__|
|_|
Version: 1.x.x
Access web UI at http://localhost:6333/dashboard
6333은 REST와 웹 대시보드, 6334는 gRPC 포트다. 버전 번호는 받은 이미지 상태에 따라 다를 수 있다. 파이썬 클라이언트를 설치한다.
$ pip install qdrant-client $ python -c "import qdrant_client; print(qdrant_client.__version__)"
4. 컬렉션 만들기
Qdrant의 데이터 단위는 컬렉션이다. 컬렉션 하나가 pgvector의 테이블 하나에 대응한다고 보면 이해가 쉽다. 컬렉션을 만들 때 벡터 차원과 거리 함수를 지정한다.
from qdrant_client import QdrantClient, models
client = QdrantClient(url="http://localhost:6333")
client.create_collection(
collection_name="docs",
vectors_config=models.VectorParams(
size=1024, # 임베딩 차원에 맞춘다
distance=models.Distance.COSINE,
),
hnsw_config=models.HnswConfigDiff(
m=16,
ef_construct=100,
),
optimizers_config=models.OptimizersConfigDiff(
indexing_threshold=20000,
),
)
거리 함수는 임베딩 모델이 무엇으로 학습됐는지에 맞춘다. 대부분의 문장 임베딩은 코사인 유사도를 쓰므로 Distance.COSINE이 무난하다. 내적으로 정규화된 모델이면 DOT, 좌표 거리 기반이면 EUCLID를 쓴다. pgvector에서 vector_cosine_ops로 인덱스를 만들던 선택과 같은 맥락이다.
차원(size)은 임베딩 모델 출력 차원과 정확히 일치해야 한다. 여기서 틀리면 적재 시점에 거부당한다. 막상 옮겨보면 이 차원 불일치로 첫 삽입이 막히는 경우가 흔하다.
5. HNSW 파라미터 튜닝
HNSW의 핵심 손잡이는 세 개다. 각각 역할이 다르니 끊어서 정리한다.
m - 그래프 연결 수
각 노드가 유지하는 양방향 간선의 최대 개수다. 기본값은 16이다. 값을 키우면 그래프가 촘촘해져 검색 품질이 올라가지만 메모리 사용과 인덱스 크기가 함께 늘어난다. 고차원 임베딩에서 recall이 아쉬우면 32까지 올려볼 만하다.
ef_construct - 빌드 시 후보 크기
인덱스를 만들 때 탐색하는 동적 후보 리스트의 크기다. 기본값은 100이다. 키우면 인덱스 품질이 좋아지는 대신 빌드가 느려진다. 적재를 한 번 해두고 검색이 많은 워크로드라면 이 값을 넉넉히 줘도 된다.
ef - 검색 시 후보 크기
검색할 때의 후보 리스트 크기로, 이 값만 쿼리마다 바꿀 수 있다. 크게 줄수록 recall이 오르고 지연이 늘어난다. recall과 지연의 실시간 균형점을 여기서 잡는다.
hits = client.query_points(
collection_name="docs",
query=query_vector,
limit=10,
search_params=models.SearchParams(hnsw_ef=128),
).points
튜닝 순서는 단순하다. 먼저 m과 ef_construct를 정해 인덱스를 만들고, 서비스에서는 hnsw_ef를 64, 128, 256으로 올려가며 목표 recall을 만족하는 가장 낮은 값을 찾는다. recall 측정은 소규모 완전 탐색 결과를 정답으로 두고 비교하면 된다.
대규모 컬렉션에서 메모리가 빠듯하면 HNSW 그래프 자체를 디스크에 둘 수 있다. hnsw_config의 on_disk=True를 주면 되는데, 메모리는 아끼지만 검색 지연이 늘 수 있어 SSD가 전제다.
한 가지 더. optimizers_config의 indexing_threshold는 세그먼트가 이 개수를 넘어야 HNSW 인덱스를 만들기 시작한다는 기준이다. 대량 초기 적재를 할 때는 이 값을 0으로 잠시 꺼서 인덱싱 부담 없이 쏟아붓고, 적재가 끝난 뒤 다시 켜는 방법을 쓴다.
6. 페이로드 필터링
RAG에서 벡터 검색만 하는 경우는 드물다. "이 사용자의 문서 중에서", "공개 상태인 글 중에서"처럼 메타데이터 필터가 거의 항상 붙는다. Qdrant는 이걸 페이로드 필터로 처리한다.
필터를 걸 필드에는 반드시 페이로드 인덱스를 먼저 만들어야 한다. 인덱스 없이 필터를 걸면 동작은 하지만 매 검색이 느려진다.
client.create_payload_index(
collection_name="docs",
field_name="category",
field_schema=models.PayloadSchemaType.KEYWORD,
)
client.create_payload_index(
collection_name="docs",
field_name="created_ts",
field_schema=models.PayloadSchemaType.INTEGER,
)
필터는 must(AND), should(OR), must_not(NOR) 세 절을 조합해 만든다. 벡터 검색과 필터를 한 번에 던진다.
hits = client.query_points(
collection_name="docs",
query=query_vector,
limit=10,
query_filter=models.Filter(
must=[
models.FieldCondition(
key="category",
match=models.MatchValue(value="guide"),
),
models.FieldCondition(
key="created_ts",
range=models.Range(gte=1700000000),
),
],
must_not=[
models.FieldCondition(
key="status",
match=models.MatchValue(value="draft"),
),
],
),
).points
여기서 Qdrant가 pgvector와 갈리는 지점이 나온다. Qdrant는 필터 조건의 카디널리티를 추정해, 조건에 맞는 후보가 적으면 완전 탐색으로, 많으면 HNSW 그래프 탐색으로 전략을 알아서 고른다. 그리고 페이로드 인덱스가 적재 전에 존재하면 HNSW 그래프에 필터를 인지한 간선을 함께 만들어, 필터를 건 상태에서도 그래프 탐색이 끊기지 않게 한다.
그래서 순서가 중요하다. 페이로드 인덱스는 데이터를 넣기 전에 만들어라. 적재를 다 한 뒤에 인덱스를 추가하면 필터 인지 간선을 쓰려고 HNSW를 다시 빌드해야 한다. 나도 처음엔 데이터부터 넣고 인덱스를 나중에 걸었다가 재빌드 비용을 치렀다.
7. 데이터 적재
벡터와 페이로드를 묶어 포인트로 올린다. 대량이면 배치로 나눠 upsert한다.
client.upsert(
collection_name="docs",
points=[
models.PointStruct(
id=1,
vector=emb,
payload={"category": "guide", "created_ts": 1710000000,
"status": "public", "title": "Qdrant 시작하기"},
),
# ...
],
)
적재 상태는 컬렉션 정보로 확인한다.
$ curl -s http://localhost:6333/collections/docs | python -m json.tool
{
"result": {
"status": "green",
"points_count": 1250000,
"indexed_vectors_count": 1250000,
...
}
}
status가 green이면 인덱싱이 끝난 상태다. 적재 직후에는 yellow로 떠서 아직 최적화가 진행 중임을 알린다. indexed_vectors_count가 points_count를 따라잡으면 완료다.
8. pgvector 대비 트레이드오프
옮기면 좋아지는 것과 포기하는 것이 분명하다. 한쪽만 보고 결정하면 나중에 후회한다.
얻는 것. 벡터 검색 전용으로 설계된 메모리 관리와 세그먼트 최적화 덕분에 수백만 벡터 규모에서 지연이 안정적이다. hnsw_ef를 쿼리마다 바꿔 recall과 지연을 실시간으로 조절할 수 있다. 양자화(scalar, product quantization)로 메모리를 더 줄이는 손잡이도 있다. 필터를 건 벡터 검색의 전략 선택이 자동이라 손이 덜 간다.
포기하는 것. 시스템이 하나 더 늘어난다. 백업, 모니터링, 버전 업그레이드를 PostgreSQL과 별도로 운영해야 한다. 벡터와 원본 데이터가 두 저장소로 쪼개지므로 일관성은 애플리케이션이 책임져야 한다. pgvector라면 한 트랜잭션에서 끝났을 "원본 수정과 벡터 갱신"이 이제 두 시스템에 걸친 동기화 문제가 된다. 조인도 없다. 메타데이터 필터는 되지만 관계형 조인이 필요하면 애플리케이션에서 조립해야 한다.
그래서 권장은 이렇다. 벡터가 수십만 수준이고 트랜잭션 일관성이 핵심이면 pgvector에 남아라. 벡터가 수백만을 넘고 검색 지연과 필터 품질이 서비스 체감을 좌우하기 시작하면 Qdrant로 옮겨라. 중간이라면 pgvector로 버티면서 적재 패턴과 지연을 측정해 두고, 경계에 닿았을 때 이 글의 순서대로 넘기면 된다.