# RAG# PostgreSQL

벡터 검색, Qdrant 대신 pgvector에 그냥 넣으면 안 될까?

@Hong2026-10-03

이 글에 담긴 코드와 결과는 직접 실행해 측정한 값을 바탕으로 작성했습니다. 실측은 Hong과 팀원들이 진행했고, 그 결과를 바탕으로 AI가 글을 쓰고 Hong이 검토합니다.

실습 코드 내려받기

질문
RAG 벡터 검색에 Qdrant 같은 벡터 DB 대신 PostgreSQL pgvector를 써도 될까?
답
같은 HNSW 색인 설정에 탐색 폭 100이면 정확한 상위 10개를 찾는 비율이 pgvector 0.985~0.989, Qdrant 0.984~0.985로 비슷했고, pgvector는 기본 탐색 폭 40을 올려야 한다.
조건
2026-10-03, PostgreSQL 17 + pgvector 0.8.7, Qdrant 1.19.1, 384차원 벡터 35,140개, 질의 500개, 서버마다 CPU 1개

환경

측정일
2026년 10월 3일
PostgreSQL
17 + pgvector 0.8.7(pgvector/pgvector:0.8.7-pg17 이미지)
Qdrant
1.19.1
임베딩
intfloat/multilingual-e5-small(384차원)
데이터
문장 벡터 35,140개, 질의 500개
자원
PostgreSQL과 Qdrant에 CPU 1개씩, 메모리 1.25GB와 768MB
반복
색인은 세 번 새로 만들어 잼
목차
  1. 실습 준비
  2. 실습 1: 같은 벡터를 두 곳에 넣기
  3. 실습 2: 색인을 만들고 recall과 지연 재기
  4. 실습 3: WHERE와 JOIN을 붙이면
  5. 정리

이번 시간에는 같은 문장 벡터 3만 5천여 개를 PostgreSQL의 pgvector와 Qdrant에 똑같이 넣고, 색인·검색·필터 검색을 직접 측정하며 배워가는 시간입니다.

예를 들어서 서비스 DB가 PostgreSQL인 팀에서 RAG 검색을 붙이기로 했다고 해볼게요. 문서 원문과 글 정보는 이미 PostgreSQL에 들어 있어요.

이때 꼭 나오는 질문이 있습니다. "벡터만 따로 Qdrant 같은 벡터 DB에 넣어야 하나요, 아니면 pgvector로 PostgreSQL에 그냥 넣으면 되나요?"

그래서 이번에는 세 가지를 볼게요.

  • 색인을 만드는 데 걸리는 시간과 색인 크기
  • 정확한 검색을 정답으로 놓았을 때 근사 검색(HNSW처럼 이웃을 이은 그래프를 따라 가까운 후보만 훑는 색인 검색)의 recall@10(정답 상위 10개 가운데 찾아낸 비율)과 지연
  • 벡터 검색에 WHERE와 JOIN을 붙였을 때 결과가 LIMIT보다 적게 나오는 경우와 그 해결

실습 준비

이 글의 명령은 모두 실습 코드의 pgvector-vs-qdrant/ 디렉터리에서 실행합니다.

  • compose.yaml: PostgreSQL(pgvector), Qdrant, Python 클라이언트 컨테이너 정의
  • requirements.txt: 클라이언트 패키지
  • prep.py: 글 목록 파일(매니페스트)에 등록된 글을 문장 단위로 나누고 질의 500개를 뽑는 스크립트
  • embed.py: 문장과 질의를 multilingual-e5-small로 임베딩해 out/에 저장하는 스크립트
  • pv.py: 하위 명령으로 두 저장소를 채우고 재는 실습 코드

compose.yaml은 PostgreSQL, Qdrant, Python 클라이언트 컨테이너를 띄워요. PostgreSQL은 pgvector가 미리 들어 있는 pgvector/pgvector:0.8.7-pg17 이미지를 씁니다.

문서 디렉터리는 ARTICLES_DIR로 받아 읽기 전용으로 붙이고, 임베딩 모델은 hf 볼륨에 받아요. Qdrant 저장소 볼륨은 클라이언트에도 읽기 전용으로 붙였습니다.

PostgreSQL은 병렬 작업자와 공유 메모리를 /dev/shm에서 잡습니다. Docker 기본값 64MB로는 뒤에서 maintenance_work_mem을 크게 잡고 HNSW 색인을 병렬로 만들 때 모자라서 1GB로 늘렸어요.

패키지 버전은 requirements.txt에 고정했어요. 임베딩 모델을 돌릴 패키지에 PostgreSQL 드라이버 psycopg와 pgvector Python 어댑터 pgvector를 더했습니다.

--extra-index-url https://download.pytorch.org/whl/cpu
torch==2.14.1+cpu
sentence-transformers==6.1.0
transformers==5.18.0
qdrant-client==1.19.1
numpy==2.5.3
psycopg[binary]==3.3.6
pgvector==0.5.0

이제 명령을 짧게 쓰려고 셸 함수 두 개를 만들고 컨테이너를 띄울게요.

dc는 이 실습의 docker compose를, py는 클라이언트 컨테이너의 Python을 부르는 함수라고 보시면 됩니다.

아래 블록은 파일로 만들지 말고 실습 디렉터리에서 연 터미널에 그대로 붙여 넣어 주세요. 이후 명령은 모두 이 터미널에서 실행하고, 터미널을 새로 열면 다시 붙여 넣으시면 됩니다.

export ARTICLES_DIR=/path/to/articles

dc() { docker compose -p pgvec "$@"; }

py() { dc exec -T client python "$@"; }

dc up -d
dc exec -T client pip install -q -r requirements.txt

문장 단위 데이터

임베딩 모델은 intfloat/multilingual-e5-small(384차원)입니다.

비교할 벡터가 수만 개는 돼야 색인 차이가 보이기 때문에 이번에는 글을 문장 단위로 잘랐어요. prep.py는 매니페스트에 등록된 글만 읽어 세 종류의 단위를 만듭니다.

  • sentence: 코드 블록을 뺀 산문을 빈 줄로 문단을 나누고, ., ?, ! 뒤 공백에서 자른 문장(10자 이상)
  • code: 코드 블록과 명령 출력의 줄(15자 이상)
  • pair: 같은 문단에서 이웃한 두 문장을 이은 것

문서는 이 사이트의 발행 글 66편(매니페스트 JSON과 MDX 본문)이라 글에 넣지 않았어요. prep.py는 ARTICLES_DIR의 *.json에서 아래 같은 항목(발췌)을 읽고 bodyFile의 본문을 엽니다.

{
  "slug": "mysql-deadlock",
  "category": "article",
  "title": "Deadlock이 나면 MySQL은 누구를 롤백할까? InnoDB deadlock 기록 읽는 법",
  "tags": [
    "MySQL",
    "InnoDB",
    "Deadlock",
    "트랜잭션"
  ],
  "bodyFile": "mysql-deadlock.mdx"
}

같은 텍스트는 하나만 남겨요. 같은 벡터가 여럿이면 순위에 동점이 생겨 recall 계산이 흔들리기 때문입니다.

그리고 sentence 가운데 500개를 시드 42로 뽑아 질의로 쓰고 색인 대상에서 뺐어요. 자기 자신이 1위로 나오지 않게 하려는 거예요.

py prep.py
문서 66편
sentence 13,777개  평균 48자
code     12,722개  평균 56자
pair      9,141개  평균 101자
중복 제거 전 39,116개, 뒤 35,640개
질의 500개를 빼고 색인 대상 35,140개

색인 대상은 35,140개입니다. embed.py는 문장 앞에 passage: , 질의 앞에 query: 를 붙여 정규화된 벡터로 바꾸고 out/vectors.npy, out/qvecs.npy에 저장해요.

모델은 처음 불러올 때 Hugging Face에서 hf 볼륨으로 받습니다.

임베딩은 한 번만 하면 되니까, 그동안 두 서버를 멈추고 클라이언트에 CPU 2.5개와 메모리 2GB를 줬어요. 끝나면 --force-recreate로 컨테이너를 다시 만들어 compose에 적은 제한으로 되돌립니다. 컨테이너를 다시 만들었으니 패키지도 다시 설치해 주세요.

dc stop postgres qdrant
docker update --cpus 2.5 --memory 2g --memory-swap 2g pgvec-client-1
dc exec -T -e OMP_NUM_THREADS=2 client python embed.py
dc up -d --force-recreate
dc exec -T client pip install -q -r requirements.txt
임베딩 35,140개 + 질의 500개, 2210초
vectors.npy (35140, 384), 53,975,168바이트 (계산값 53,975,040 + 헤더)

CPU 2.5개(PyTorch 스레드 2개)로 37분 걸렸어요. 이후의 비교는 모두 이 벡터 파일 하나로 합니다.

실습 1: 같은 벡터를 두 곳에 넣기

PostgreSQL에는 테이블 세 개를 만들어요.

  • articles: 글 정보. 매니페스트의 slug, title, category, tags와 첫 번째 태그 first_tag
  • chunks: 색인 대상 문장. id, slug(articles 참조), kind, body, embedding vector(384)
  • queries: 질의 500개와 그 벡터.

pv.py는 실습 코드를 모은 파일이고 python pv.py <명령> 형태로 실행해요.

load는 COPY ... (FORMAT BINARY)로 PostgreSQL에 넣고, Qdrant에는 같은 id와 벡터로 컬렉션 chunks를 만듭니다.

여기서 하나 눈여겨볼 게 있어요. PostgreSQL에서는 조인으로 가져올 글 정보를, Qdrant에서는 점마다 payload(점에 붙여 두는 부가 데이터)로 복제해 둬야 합니다.

Qdrant는 기본값으로 CPU 수에 맞춰 세그먼트(점을 나눠 담는 저장 단위)를 여러 개 만들어요. PostgreSQL의 HNSW 색인은 테이블 하나에 그래프 하나이므로, 맞추려고 default_segment_number를 1로 고정했습니다.

거리는 두 쪽 모두 코사인이에요. pgvector에서는 코사인 거리 연산자 <=>와 연산자 클래스 vector_cosine_ops를 씁니다.

load는 끝에 두 저장소의 크기도 찍어요. 출력은 적재와 크기 부분만 옮겼어요(발췌).

py pv.py load
PostgreSQL 적재 10.7초
Qdrant 적재 33.3초
deleted_threshold=0.2 vacuum_min_vector_number=1000 default_segment_number=1 max_segment_size=None memmap_threshold=None indexing_threshold=10000 flush_interval_sec=5 max_optimization_threads=None prevent_unoptimized=None
PostgreSQL chunks 행 35,140개
  테이블 68.1MB, TOAST 0.1MB, 인덱스 0.8MB, 합계 69.1MB
Qdrant chunks 점 35,140개, 세그먼트 2개, indexed_vectors_count 35000
  vector_index/links_compressed.bin    1.3MB
  vector_storage/matrix.dat            51.3MB

셋째 줄은 Qdrant 컬렉션의 최적화 설정이에요. 세그먼트 수만 1로 바꿨고 나머지는 기본값입니다.

PostgreSQL 테이블 68.1MB 가운데 벡터가 약 52MB이고 나머지는 본문과 행 헤더입니다. 아직 벡터 색인은 없고, 0.8MB는 기본 키 인덱스예요.

세그먼트를 1개로 정했는데 2개예요. 35,000개가 든 색인된 세그먼트와 나머지 140개가 든 작은 세그먼트입니다.

근사 검색의 recall을 재려면 정답이 있어야겠죠. 정답은 numpy로 질의 500개와 35,140개 벡터의 내적을 모두 계산해 고른 상위 10개입니다.

같은 정답이 두 시스템의 정확한 검색에서도 나오는지 먼저 확인해 볼게요.

PostgreSQL은 아직 벡터 색인이 없으니 ORDER BY embedding <=> $1 LIMIT 10이 전수 비교가 되고, Qdrant는 exact=True로 HNSW를 건너뜁니다.

py pv.py exact
numpy 전수 비교 정답과 상위 10개 집합이 같은 질의 (500개 중, 10위 동점 질의 7개)
  PostgreSQL 색인 없음  499개  중앙값 16.92ms p95 21.95ms
  Qdrant exact=True     500개  중앙값 6.51ms p95 13.99ms
  PostgreSQL와 Qdrant 순서까지 같은 질의 498개

PostgreSQL에서 하나가 다르게 나왔는데, 10위 자리에서 점수가 똑같은 두 문장 가운데 다른 하나를 고른 거예요.

동점을 빼면 세 결과가 모두 같습니다. 그래서 이 numpy 결과를 이후 recall의 정답으로 쓸게요.

지연은 클라이언트에서 질의를 보내고 결과를 받을 때까지의 왕복 시간이에요. PostgreSQL은 psycopg로, Qdrant는 gRPC로 쟀습니다.

다만 이 값은 엔진 성능 비교가 아니라 이 구성에서의 왕복 시간으로 읽어 주세요. 프로토콜과 드라이버가 다르고, 다른 작업과 CPU를 나눠 쓰는 머신이라 실행마다 흔들립니다.

실습 2: 색인을 만들고 recall과 지연 재기

bench는 색인을 만들고 시간과 크기를 잰 다음, 탐색 폭을 바꿔 가며 질의 500개로 recall@10과 지연을 재고 색인을 지웁니다.

용어를 먼저 잡고 갈게요. m은 HNSW 그래프에서 점 하나가 잇는 이웃 수, ef_construction(Qdrant는 ef_construct)은 색인을 만들 때 살펴보는 후보 수, IVFFlat(벡터를 여러 목록으로 나눠 질의와 가까운 목록만 훑는 색인)의 lists는 목록 수입니다. 만드는 색인은 이렇습니다.

  • pgvector HNSW 기본값(m 16, ef_construction 64): maintenance_work_mem을 기본값 64MB와 1GB로 한 번씩
  • pgvector HNSW m 16, ef_construction 100: Qdrant 기본값에 맞춘 것
  • pgvector IVFFlat lists 35: pgvector 문서가 100만 행까지 권하는 "행 수 / 1000"
  • Qdrant m 16, ef_construct 100: Qdrant 기본값

Qdrant는 색인 시간을 따로 재려고 indexing_threshold를 0으로 두고(색인을 만들지 않음) 점을 모두 넣었어요. 그다음 임곗값을 10,000으로 되돌리고 컬렉션 상태가 green(색인을 마친 정상 상태)이 될 때까지 걸린 시간을 쟀습니다.

아래는 세 번 반복한 출력 가운데 1회차의 색인 생성 부분이에요. DROP INDEX IF EXISTS가 내는 알림 줄은 뺐고, Qdrant 줄은 지연을 gRPC로 다시 잰 실행에서 가져왔습니다.

Qdrant 지연은 처음에 qdrant-client의 REST 방식으로 쟀는데, ef와 상관없이 중앙값이 43~47ms로 나왔어요. 그래서 pv.py는 gRPC(포트 6334)로 잽니다.

py pv.py bench
max_parallel_maintenance_workers = 2
=== 1회차
[HNSW m16 ef_construction64 mwm64MB]
  NOTICE: hnsw graph no longer fits into maintenance_work_mem after 28360 tuples
  생성 51.5초, 크기 68.5MB
[HNSW m16 ef_construction64 mwm1GB]
  생성 31.9초, 크기 68.5MB
[HNSW m16 ef_construction100 mwm1GB]
  생성 38.4초, 크기 68.5MB
[IVFFlat lists35]
  생성 1.3초, 크기 55.1MB
[Qdrant m16 ef_construct100]
  생성 9.8초, 세그먼트 2개, 그래프 파일 1.3MB

세 번 잰 범위는 이렇습니다.

  • pgvector HNSW 기본값, maintenance_work_mem 64MB: 32.2~51.5초. 세 번 모두 28,360~28,376번째 행에서 "그래프가 더는 maintenance_work_mem에 들어가지 않는다"는 NOTICE가 나왔어요.
  • 같은 색인, 1GB: 20.7~31.9초. NOTICE는 없었고, 회차마다 64MB일 때보다 9~28초 빨랐습니다.
  • pgvector HNSW ef_construction 100, 1GB: 30.7~38.4초
  • pgvector IVFFlat: 1.3~1.7초
  • Qdrant HNSW: 9.8~15.6초

pgvector 문서도 "그래프가 maintenance_work_mem에 들어가면 색인이 훨씬 빨리 만들어진다"고 설명해요. 3만 5천 행에 64MB면 4분의 3 지점에서 넘쳤습니다.

그러니 행이 이보다 많은 테이블에서 색인을 만들 때는 세션에서 maintenance_work_mem을 올리고 만들어 주세요.

크기는 pgvector HNSW 색인이 68.5MB로 테이블과 비슷하고, Qdrant 그래프 파일은 1.3MB입니다. 왜 이렇게 차이가 날까요?

차이는 벡터를 어디에 두느냐에서 나요. pgvector HNSW 색인은 색인 항목마다 벡터 사본을 함께 저장해서, 벡터 52MB가 테이블과 색인에 한 번씩 들어갑니다.

Qdrant는 원본 벡터 파일(matrix.dat, 51.3MB)을 따로 두고 그래프 파일에는 이웃 연결만 압축해 담아요. IVFFlat 색인 55.1MB도 목록마다 벡터 사본을 담고 있어서 그만큼 큽니다.

그래서 PostgreSQL에 벡터를 넣을 때는 벡터 크기의 두 배 정도를 디스크와 버퍼 캐시에 잡아 주셔야 합니다.

같은 bench의 검색 부분이에요. pgvector는 트랜잭션 안에서 SET LOCAL로 탐색 폭을 바꾸고, 실행 계획에 색인이 쓰였는지 먼저 확인한 뒤 질의 500개를 돌립니다.

  • hnsw.ef_search: HNSW 검색 때 후보를 몇 개까지 들고 다닐지. 기본값 40
  • Qdrant hnsw_ef: hnsw.ef_search와 같은 값. 지정하지 않으면 ef_construct(100)를 씁니다.

1회차의 PostgreSQL 검색 출력입니다(발췌).

[HNSW m16 ef_construction64 mwm1GB]
  hnsw.ef_search=40   recall@10 0.9660  중앙값 1.69ms p95 3.51ms
  hnsw.ef_search=100  recall@10 0.9854  중앙값 3.01ms p95 5.83ms
  hnsw.ef_search=200  recall@10 0.9920  중앙값 4.97ms p95 8.86ms
[HNSW m16 ef_construction100 mwm1GB]
  hnsw.ef_search=40   recall@10 0.9688  중앙값 1.19ms p95 2.08ms
  hnsw.ef_search=100  recall@10 0.9892  중앙값 1.94ms p95 2.66ms
  hnsw.ef_search=200  recall@10 0.9944  중앙값 3.48ms p95 4.96ms

Qdrant 검색 출력입니다(gRPC).

[Qdrant m16 ef_construct100]
  hnsw_ef=40   recall@10 0.9602  중앙값 1.52ms p95 2.81ms
  hnsw_ef=100  recall@10 0.9854  중앙값 1.65ms p95 2.25ms
  hnsw_ef=200  recall@10 0.9910  중앙값 2.19ms p95 3.69ms
  hnsw_ef=None recall@10 0.9854  중앙값 1.76ms p95 2.42ms

색인을 세 번 새로 만들어 잰 recall@10의 범위를 같은 탐색 폭끼리 모았어요. HNSW 그래프는 만들 때마다 조금씩 달라서 recall도 회차마다 조금 다릅니다.

  • 탐색 폭 40: pgvector(ef_construction 64) 0.9640~0.9668, pgvector(ef_construction 100) 0.9644~0.9704, Qdrant 0.9570~0.9602
  • 탐색 폭 100: pgvector(64) 0.9854~0.9872, pgvector(100) 0.9848~0.9892, Qdrant 0.9836~0.9854
  • 탐색 폭 200: pgvector(64) 0.9920~0.9932, pgvector(100) 0.9934~0.9944, Qdrant 0.9904~0.9910

"전용 벡터 DB가 더 정확하다"는 말은 이 데이터에서는 맞지 않았어요. 두 시스템의 HNSW는 같은 알고리즘이고, m 16에 같은 탐색 폭이면 recall@10 차이가 0.01 안쪽입니다.

pgvector가 조금 높게 나왔지만 차이가 작아요. 세그먼트가 하나 더 있는 Qdrant 쪽 구성 때문일 수도 있어서 어느 쪽이 낫다고 할 근거는 되지 않습니다.

그럼 기본값에서는 왜 차이가 날까요? 기본 탐색 폭이 다르기 때문이에요.

  • pgvector: ef_construction 64, hnsw.ef_search 40. 기본값 그대로면 recall@10이 0.964~0.967입니다.
  • Qdrant: ef_construct 100, 검색 ef도 100. 기본값 그대로면 0.984~0.985입니다.

pgvector에서 hnsw.ef_search만 100으로 올리면 Qdrant 기본값과 같은 수준이 돼요. 색인을 다시 만들 필요도 없습니다.

지연은 탐색 폭을 올리면 두 시스템 모두 늘어났고, 이 구성에서는 HNSW 검색 한 번의 왕복이 1~5ms였어요.

실습 3: WHERE와 JOIN을 붙이면

실제 서비스의 벡터 검색에는 조건이 붙어요. pgvector를 쓰면 이 조건을 SQL로 그대로 쓸 수 있습니다.

매니페스트에 날짜가 없어서 이 글에서는 태그와 글로 조건을 만들고, 남는 행의 비율을 조절했어요.

  • 넓은 조건: JOIN articles a USING (slug) WHERE a.first_tag = 'Kafka'. 전체의 28.8%
  • 좁은 조건: a.slug = 'mysql-deadlock'. 글 한 편, 전체의 0.7%

filt는 HNSW 색인(기본값)을 만들어 두고, 질의 500개 중 앞 20개로 LIMIT 10 검색을 해요.

반복 스캔 설정 hnsw.iterative_scan을 세 가지로 바꿔 가며 반환된 행 수와, 같은 조건으로 numpy 전수 비교한 정답 대비 recall@10을 셉니다. 반환된 거리 순서가 뒤바뀐 질의 수(순서 어긋남)도 세요.

Qdrant는 같은 조건을 payload 필터로 겁니다.

py pv.py filt mysql-deadlock
[넓은 조건 first_tag = 'Kafka'] 해당 행 10,107개 / 35,140개 (28.8%)
  iterative_scan=off           색인 사용 예  10개 미만 13/20  반환 행 0~10개  recall@10 0.525  순서 어긋남 0
  iterative_scan=relaxed_order 색인 사용 예  10개 미만  0/20  반환 행 10~10개  recall@10 0.890  순서 어긋남 9
  iterative_scan=strict_order  색인 사용 예  10개 미만  0/20  반환 행 10~10개  recall@10 0.780  순서 어긋남 0
  Qdrant payload 필터       10개 미만  0/20  반환 행 10~10개  recall@10 0.955
[좁은 조건 slug IN (mysql-deadlock)] 해당 행 236개 / 35,140개 (0.7%)
  iterative_scan=off           색인 사용 예  10개 미만 20/20  반환 행 0~3개  recall@10 0.035  순서 어긋남 0
  iterative_scan=relaxed_order 색인 사용 예  10개 미만  0/20  반환 행 10~10개  recall@10 1.000  순서 어긋남 3
  iterative_scan=strict_order  색인 사용 예  10개 미만  0/20  반환 행 10~10개  recall@10 0.965  순서 어긋남 0
  Qdrant payload 필터       10개 미만  0/20  반환 행 10~10개  recall@10 1.000

반복 스캔을 끈 기본 설정에서는 LIMIT 10을 줘도 10개가 나오지 않았어요. 좁은 조건에서는 20개 질의 모두 0~3개였고, 전체의 29%가 남는 넓은 조건에서도 13개 질의가 10개를 못 채웠습니다.

넓은 조건에서도 모자란 이유가 있어요. 질의 문장과 가까운 문장이 대부분 같은 글이나 비슷한 주제의 글에 있어서, Kafka가 아닌 질의라면 가까운 40개 안에 Kafka 글 문장이 드물기 때문입니다.

HNSW 색인은 hnsw.ef_search(40)개의 가까운 후보를 돌려주고, 필터는 그 뒤에 적용돼요.

pgvector 문서도 "근사 색인에서는 색인을 훑은 뒤에 필터를 적용한다"며, 조건에 맞는 행이 10%면 ef_search 40에서 평균 4행만 남는다고 설명해요.

이 문제는 pgvector 0.8.0부터 있는 반복 스캔이 고쳐 줘요. 결과가 모자라면 색인을 더 훑어 후보를 더 가져옵니다. 설정은 세 가지예요.

  • off: 기본값. 후보 ef_search개에서 끝냅니다.
  • strict_order: 결과가 거리 순서를 정확히 지키게 하면서 더 훑습니다.
  • relaxed_order: 순서가 조금 어긋날 수 있는 대신 recall이 더 좋습니다.
  • 상한: hnsw.max_scan_tuples(기본 20,000)개까지 훑으면 멈춥니다. IVFFlat은 ivfflat.iterative_scan과 ivfflat.max_probes로 같은 일을 합니다.

위 결과에서 반복 스캔을 켜자 두 조건 모두 20개 질의가 10개씩 돌려받았습니다.

다만 relaxed_order는 이름 그대로 넓은 조건에서 9개, 좁은 조건에서 3개 질의의 결과가 거리 순서대로가 아니었어요. 순서가 중요하면 strict_order를 쓰거나, 결과를 바깥 쿼리에서 거리로 다시 정렬해 주세요.

Qdrant는 두 조건 모두 반복 스캔 없이 10개를 돌려줬습니다. payload 인덱스를 검색 중에 함께 쓰기 때문이에요.

대신 Qdrant에서는 조인을 할 수 없어요. 그래서 글의 first_tag처럼 다른 테이블에 있는 값도 점마다 payload로 복제해 둬야 하고, 글의 태그가 바뀌면 그 글의 점을 모두 고쳐야 합니다.

정리

범위: PostgreSQL 17 + pgvector 0.8.7, Qdrant 1.19.1, multilingual-e5-small 384차원 문장 벡터 35,140개와 질의 500개, 두 서버에 CPU 1개씩, 색인은 세 번 새로 만들어 잼.

  • m 16에 탐색 폭 100이면 recall@10이 pgvector 0.985~0.989, Qdrant 0.984~0.985로 비슷했고, 기본값에서 pgvector가 0.964~0.967로 낮은 것은 기본 탐색 폭이 40 대 100이라서였습니다. pgvector를 쓰면 hnsw.ef_search를 기본값 40보다 올려 주세요.
  • pgvector HNSW 색인은 벡터 사본을 담아 68.5MB로 테이블(68.1MB)과 비슷했고, Qdrant의 그래프 파일은 1.3MB였습니다. PostgreSQL에 벡터를 넣을 때는 벡터 크기의 두 배 정도를 디스크와 버퍼 캐시에 잡아 주세요.
  • 반복 스캔을 끈 기본 설정에서 0.7% 조건은 20개 질의 모두 0~3개, 28.8% 조건도 13개 질의가 LIMIT 10을 채우지 못했습니다. 필터가 붙는 벡터 검색에는 hnsw.iterative_scan을 켜고, 순서가 중요하면 strict_order를 쓰거나 바깥 쿼리에서 거리로 다시 정렬해 주세요.
  • 이 글에 싣지 않은 추가 측정에서는 pgvector가 제목, 본문, 벡터를 한 트랜잭션으로 바꿔 다른 세션이 새 본문과 옛 벡터를 섞어 보는 순간이 없었습니다. 벡터 DB를 따로 두면 PostgreSQL 커밋과 Qdrant upsert 사이의 실패를 맞추는 장치를 처음부터 만들어 주세요.
내 링크 만들기