3단계
pgvector + HNSW
0회 조회
목차
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