본문으로 바로가기

3단계

pgvector + HNSW

7회 조회

목차

PostgreSQL 이 벡터 검색까지 처리. 전용 벡터 DB 없이 "충분히 좋음".

1. 설치

docker run -d -e POSTGRES_PASSWORD=x -p 5432:5432 pgvector/pgvector:pg16

기존 PG:

CREATE EXTENSION IF NOT EXISTS vector;

2. 테이블

CREATE TABLE document_chunks (
  id          BIGSERIAL PRIMARY KEY,
  document_id BIGINT NOT NULL,
  chunk_index INT NOT NULL,
  content     TEXT NOT NULL,
  embedding   vector(768),
  created_at  TIMESTAMPTZ DEFAULT now()
);

vector(N) 차원은 임베딩 모델과 일치. 변경 시 재빌드.

3. 인덱스 선택 — HNSW vs IVFFlat

인덱스 빌드 쿼리 정확도 용도
HNSW 느림 빠름 매우 높음 읽기 중심 RAG
IVFFlat 빠름 중간 튜닝 필요 대량 적재 + 주기 재빌드

4. HNSW 생성

CREATE INDEX ON document_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);
  • m — 그래프 이웃 수 (기본 16). 높이면 정확 · 메모리 ↑
  • ef_construction — 빌드 시 탐색 폭 (64). 높이면 느리지만 정확 ↑

전체 적재 후 한 번에 생성이 빠름.

5. 검색

SELECT id, content, 1 - (embedding <=> $1::vector) AS similarity
FROM document_chunks
ORDER BY embedding <=> $1::vector
LIMIT 10;
  • <=> — 코사인 거리 (0 = 동일)
  • <-> — L2
  • <#> — 음수 내적

반드시 ORDER BY <거리> ASC + LIMIT k 여야 인덱스 사용.

6. 런타임 튜닝

SET hnsw.ef_search = 100;   -- 기본 40

높이면 정확 · 느림. 세션 단위.

필터 검색은 후보를 잃을 수 있다

HNSW 같은 ANN 인덱스는 기본적으로 근사 후보를 먼저 찾고 WHERE 필터를 나중에 적용합니다. 전역 후보 40개 중 특정 사용자·컬렉션에 속한 행이 없으면, 해당 범위에 벡터가 실제로 있어도 결과가 0건일 수 있습니다. ef_search를 무조건 크게 고정하면 모든 요청의 CPU·I/O 비용이 증가합니다.

pgvector 0.8 이상에서는 필터가 충분한 결과를 찾을 때까지 인덱스를 점진적으로 더 읽는 반복 스캔을 질의 트랜잭션에만 적용할 수 있습니다.

BEGIN;
SET LOCAL hnsw.iterative_scan = strict_order;

SELECT id, content
FROM document_chunks
WHERE collection_id = $1
ORDER BY embedding <=> $2::vector
LIMIT 10;
COMMIT;
  • strict_order는 거리 순서를 정확히 유지합니다.
  • relaxed_order는 더 높은 재현율과 성능을 얻는 대신 순서가 조금 어긋날 수 있습니다.
  • 탐색 비용은 hnsw.max_scan_tuples로 제한합니다.
  • 검증은 응답 시간만 보지 말고 필터별 반환 건수와 Recall@K를 함께 측정합니다.

7. 배치 적재

async function indexChunks(chunks: { id: number; embedding: number[] }[]) {
  const values = chunks.map((_, i) =>
    `($${i*2+1}, $${i*2+2}::vector)`
  ).join(", ");
  const params = chunks.flatMap(c => [c.id, JSON.stringify(c.embedding)]);
  await pool.query(
    `UPDATE document_chunks SET embedding = v.e
     FROM (VALUES ${values}) AS v(id, e)
     WHERE document_chunks.id = v.id::bigint`,
    params
  );
}

50 ~ 100 개 batch. API rate limit 주의.

8. 하이브리드 검색 — pg_trgm + vector

SELECT *, similarity(content, $1) AS text_score,
       1 - (embedding <=> $2::vector) AS vec_score
FROM document_chunks
ORDER BY (similarity(content, $1) * 0.3 + (1 - (embedding <=> $2::vector)) * 0.7) DESC
LIMIT 10;

텍스트 정확 일치 + 의미 유사도 혼합. 정확도 보정 효과.

9. 백업

pg_dump 가 벡터를 텍스트 덤프. 복원 시 인덱스 재빌드 필요 (시간 소요). 대용량은 pg_restore --jobs 4 병렬.

10. 자주 걸리는 자리

  • CREATE EXTENSION vector 누락
  • similarity DESC 로 정렬 — HNSW 인덱스 미사용. 거리 ASC 로
  • 차원 불일치 — 모델 바꿨는데 컬럼 그대로
  • EXPLAIN 에서 Seq Scan — 인덱스 작동 안 함. 쿼리 패턴 점검
  • 필터된 HNSW 결과 부족ef_search 후보를 필터한 뒤 K개가 남지 않음. 반복 스캔과 필터별 Recall@K 점검

11. 한계 — 언제 전용 벡터 DB?

규모 권장
~10M 벡터 pgvector 여유
10M ~ 100M pgvector 튜닝 (파티셔닝 · 샤딩)
100M+ Qdrant · Milvus · Vespa

대부분의 프로젝트는 10M 미만에서 끝.

하고픈 말

pgvector 로 시작하고 병목 측정 후 전환이 올바른 순서. "처음부터 Qdrant" 는 대개 과잉.

Next

  • 04-redis-five-roles

이 글에서 만나는 용어