- Published on
PostgreSQL + pgvector 벡터 검색 실전 가이드: RAG부터 하이브리드 검색까지
- Authors
- Name
- 1. 왜 PostgreSQL + pgvector인가
- 2. 설치 및 설정
- 3. 기본 사용법
- 4. 인덱스: HNSW vs IVFFlat
- 5. 하이브리드 검색: 벡터 + 전문 검색
- 6. RAG 파이프라인 통합
- 7. 성능 튜닝
- 8. 퀴즈
1. 왜 PostgreSQL + pgvector인가
별도 벡터 DB(Pinecone, Qdrant, Weaviate) 대신 PostgreSQL + pgvector를 선택하는 이유:
| 특성 | 전용 벡터 DB | PostgreSQL + pgvector |
|---|---|---|
| 추가 인프라 | ✅ 필요 | ❌ 기존 PostgreSQL 활용 |
| ACID 트랜잭션 | ❌ 제한적 | ✅ 완전 지원 |
| JOIN/관계형 쿼리 | ❌ 불가 | ✅ 자유롭게 조합 |
| 하이브리드 검색 | ⚠️ 제한적 | ✅ tsvector + vector |
| 운영 복잡도 | 높음 | 낮음 (기존 DBA 활용) |
| 확장성 | ✅ 수십억 벡터 | ⚠️ 수천만 수준 |
결론: 벡터 수가 수천만 이하이고, 관계형 데이터와 함께 관리해야 한다면 pgvector가 최적.
2. 설치 및 설정
2.1 pgvector 설치
# Ubuntu/Debian
sudo apt install postgresql-17-pgvector
# macOS (Homebrew)
brew install pgvector
# Docker
docker run -d --name pgvector \
-e POSTGRES_PASSWORD=secret \
-p 5432:5432 \
pgvector/pgvector:pg17
2.2 확장 활성화
-- pgvector 확장 설치
CREATE EXTENSION IF NOT EXISTS vector;
-- 버전 확인
SELECT extversion FROM pg_extension WHERE extname = 'vector';
-- 0.8.0
3. 기본 사용법
3.1 테이블 생성
-- 문서 테이블 (1536차원 = OpenAI text-embedding-3-small)
CREATE TABLE documents (
id BIGSERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
embedding vector(1536), -- 벡터 컬럼!
metadata JSONB DEFAULT '{}',
created_at TIMESTAMPTZ DEFAULT NOW()
);
-- 384차원 (sentence-transformers/all-MiniLM-L6-v2)
CREATE TABLE chunks (
id BIGSERIAL PRIMARY KEY,
doc_id BIGINT REFERENCES documents(id),
chunk_text TEXT NOT NULL,
embedding vector(384),
chunk_index INT
);
3.2 데이터 삽입
-- 단일 삽입
INSERT INTO documents (title, content, embedding)
VALUES (
'Kubernetes RBAC Guide',
'RBAC is a method of regulating access...',
'[0.1, 0.2, 0.3, ...]'::vector -- 1536차원 벡터
);
-- Python에서 배치 삽입
import psycopg2
from pgvector.psycopg2 import register_vector
import numpy as np
conn = psycopg2.connect("dbname=mydb user=postgres password=secret")
register_vector(conn)
cur = conn.cursor()
# OpenAI 임베딩 생성
from openai import OpenAI
client = OpenAI()
texts = ["Kubernetes RBAC guide", "Docker networking basics", ...]
response = client.embeddings.create(
model="text-embedding-3-small",
input=texts
)
# 배치 삽입
for text, emb_data in zip(texts, response.data):
embedding = np.array(emb_data.embedding)
cur.execute(
"INSERT INTO documents (title, content, embedding) VALUES (%s, %s, %s)",
(text, text, embedding)
)
conn.commit()
3.3 유사도 검색
-- 코사인 유사도 (가장 일반적)
SELECT id, title,
1 - (embedding <=> '[0.1, 0.2, ...]'::vector) AS similarity
FROM documents
ORDER BY embedding <=> '[0.1, 0.2, ...]'::vector
LIMIT 10;
-- L2 거리
SELECT id, title,
embedding <-> '[0.1, 0.2, ...]'::vector AS distance
FROM documents
ORDER BY embedding <-> '[0.1, 0.2, ...]'::vector
LIMIT 10;
-- 내적 (Inner Product) — 정규화된 벡터에서 코사인과 동일
SELECT id, title,
(embedding <#> '[0.1, 0.2, ...]'::vector) * -1 AS similarity
FROM documents
ORDER BY embedding <#> '[0.1, 0.2, ...]'::vector
LIMIT 10;
연산자 정리:
| 연산자 | 의미 | 용도 |
|---|---|---|
<-> | L2 거리 | 유클리드 거리 기반 |
<=> | 코사인 거리 | 방향 유사도 (가장 일반적) |
<#> | 내적 (음수) | 정규화 벡터에서 사용 |
4. 인덱스: HNSW vs IVFFlat
4.1 IVFFlat
-- IVFFlat 인덱스 생성
CREATE INDEX ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);
-- 검색 시 probe 수 설정 (정확도 vs 속도 트레이드오프)
SET ivfflat.probes = 10;
| 파라미터 | 설명 | 권장값 |
|---|---|---|
lists | 클러스터 수 | √(row수) ~ row수/1000 |
probes | 검색 클러스터 수 | lists/10 ~ lists/5 |
4.2 HNSW (권장)
-- HNSW 인덱스 생성 (빌드 시간 길지만 검색 빠름)
CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 200);
-- 검색 시 ef_search 설정
SET hnsw.ef_search = 100;
| 파라미터 | 설명 | 권장값 |
|---|---|---|
m | 연결 수 | 16~64 |
ef_construction | 빌드 품질 | 200+ |
ef_search | 검색 품질 | 40~200 |
4.3 HNSW vs IVFFlat 비교
| 특성 | IVFFlat | HNSW |
|---|---|---|
| 빌드 속도 | ✅ 빠름 | ❌ 느림 |
| 검색 속도 | 보통 | ✅ 빠름 |
| Recall | 보통 (probe 의존) | ✅ 높음 |
| 메모리 | ✅ 적음 | ❌ 많음 |
| 업데이트 | ❌ 재빌드 필요 | ✅ 실시간 가능 |
권장: 대부분의 경우 HNSW. 데이터가 자주 변경되거나 메모리가 제한적이면 IVFFlat.
5. 하이브리드 검색: 벡터 + 전문 검색
5.1 Full-Text Search + Vector Search
-- tsvector 컬럼 추가
ALTER TABLE documents ADD COLUMN tsv tsvector
GENERATED ALWAYS AS (to_tsvector('english', title || ' ' || content)) STORED;
CREATE INDEX ON documents USING gin(tsv);
-- 하이브리드 검색 함수
CREATE OR REPLACE FUNCTION hybrid_search(
query_text TEXT,
query_embedding vector(1536),
match_count INT DEFAULT 10,
vector_weight FLOAT DEFAULT 0.7,
text_weight FLOAT DEFAULT 0.3
)
RETURNS TABLE (id BIGINT, title TEXT, score FLOAT) AS $$
BEGIN
RETURN QUERY
WITH vector_results AS (
SELECT d.id, d.title,
1 - (d.embedding <=> query_embedding) AS vector_score
FROM documents d
ORDER BY d.embedding <=> query_embedding
LIMIT match_count * 3
),
text_results AS (
SELECT d.id, d.title,
ts_rank(d.tsv, plainto_tsquery('english', query_text)) AS text_score
FROM documents d
WHERE d.tsv @@ plainto_tsquery('english', query_text)
LIMIT match_count * 3
),
combined AS (
SELECT
COALESCE(v.id, t.id) AS id,
COALESCE(v.title, t.title) AS title,
COALESCE(v.vector_score, 0) * vector_weight +
COALESCE(t.text_score, 0) * text_weight AS score
FROM vector_results v
FULL OUTER JOIN text_results t ON v.id = t.id
)
SELECT c.id, c.title, c.score
FROM combined c
ORDER BY c.score DESC
LIMIT match_count;
END;
$$ LANGUAGE plpgsql;
-- 사용
SELECT * FROM hybrid_search(
'Kubernetes RBAC security',
'[0.1, 0.2, ...]'::vector(1536)
);
5.2 Reciprocal Rank Fusion (RRF)
CREATE OR REPLACE FUNCTION rrf_search(
query_text TEXT,
query_embedding vector(1536),
match_count INT DEFAULT 10,
rrf_k INT DEFAULT 60
)
RETURNS TABLE (id BIGINT, title TEXT, rrf_score FLOAT) AS $$
BEGIN
RETURN QUERY
WITH vector_ranked AS (
SELECT d.id, d.title,
ROW_NUMBER() OVER (ORDER BY d.embedding <=> query_embedding) AS rank
FROM documents d
LIMIT match_count * 5
),
text_ranked AS (
SELECT d.id, d.title,
ROW_NUMBER() OVER (
ORDER BY ts_rank(d.tsv, plainto_tsquery('english', query_text)) DESC
) AS rank
FROM documents d
WHERE d.tsv @@ plainto_tsquery('english', query_text)
LIMIT match_count * 5
),
fused AS (
SELECT
COALESCE(v.id, t.id) AS id,
COALESCE(v.title, t.title) AS title,
COALESCE(1.0 / (rrf_k + v.rank), 0) +
COALESCE(1.0 / (rrf_k + t.rank), 0) AS rrf_score
FROM vector_ranked v
FULL OUTER JOIN text_ranked t ON v.id = t.id
)
SELECT f.id, f.title, f.rrf_score
FROM fused f
ORDER BY f.rrf_score DESC
LIMIT match_count;
END;
$$ LANGUAGE plpgsql;
6. RAG 파이프라인 통합
6.1 Python 전체 예제
import psycopg2
from pgvector.psycopg2 import register_vector
from openai import OpenAI
import numpy as np
client = OpenAI()
conn = psycopg2.connect("dbname=ragdb user=postgres password=secret")
register_vector(conn)
def embed(text: str) -> list[float]:
resp = client.embeddings.create(
model="text-embedding-3-small", input=text
)
return resp.data[0].embedding
def rag_query(question: str, top_k: int = 5) -> str:
query_vec = embed(question)
cur = conn.cursor()
cur.execute("""
SELECT title, content,
1 - (embedding <=> %s::vector) AS similarity
FROM documents
WHERE 1 - (embedding <=> %s::vector) > 0.7
ORDER BY embedding <=> %s::vector
LIMIT %s
""", (query_vec, query_vec, query_vec, top_k))
results = cur.fetchall()
context = "\n\n".join([
f"[{r[0]}] (similarity: {r[2]:.3f})\n{r[1]}"
for r in results
])
response = client.chat.completions.create(
model="gpt-4o",
messages=[
{"role": "system", "content": f"Answer based on context:\n{context}"},
{"role": "user", "content": question}
]
)
return response.choices[0].message.content
# 사용
answer = rag_query("Kubernetes RBAC에서 ClusterRole과 Role의 차이는?")
print(answer)
7. 성능 튜닝
7.1 핵심 설정
-- 작업 메모리 (인덱스 빌드/검색 시)
SET maintenance_work_mem = '2GB'; -- HNSW 빌드 시
SET work_mem = '256MB'; -- 검색 시
-- 병렬 처리
SET max_parallel_workers_per_gather = 4;
SET max_parallel_maintenance_workers = 4;
-- HNSW 빌드 최적화
SET maintenance_work_mem = '4GB';
-- 빌드 후 원복
7.2 벤치마크
100만 벡터 (1536차원), PostgreSQL 17 + pgvector 0.8.0:
| 인덱스 | 빌드 시간 | 검색 지연 (p50) | Recall@10 | 메모리 |
|---|---|---|---|---|
| 없음 (brute) | - | 850ms | 100% | 0 |
| IVFFlat (lists=1000, probes=50) | 45s | 8ms | 95% | 1.2GB |
| HNSW (m=16, ef=200) | 12min | 3ms | 99% | 2.8GB |
8. 퀴즈
Q1. pgvector의 <=> 연산자는 무엇을 계산하는가?
코사인 거리 (1 - cosine_similarity). 값이 작을수록 유사하다. 유사도로 변환하려면 1 - (a <=> b).
Q2. HNSW와 IVFFlat 중 검색 속도가 빠른 것은?
HNSW. 빌드는 느리지만 검색은 빠르고 Recall도 높다. 대부분의 프로덕션 환경에서 권장.
Q3. 하이브리드 검색이 순수 벡터 검색보다 나은 이유는?
벡터 검색은 의미적 유사도에 강하지만 키워드 정확 매칭에 약함. 전문 검색과 결합하면 의미적 유사도 + 키워드 정확도 모두 확보.
Q4. RRF(Reciprocal Rank Fusion)의 원리는?
각 검색 결과의 순위의 역수를 합산. 점수 스케일이 다른 검색 결과를 정규화 없이 결합할 수 있는 장점.
Q5. IVFFlat의 lists와 probes 파라미터의 관계는?
lists는 클러스터 수, probes는 검색 시 탐색할 클러스터 수. probes가 클수록 정확하지만 느림. 보통 probes = lists/10 ~ lists/5.
Q6. pgvector를 전용 벡터 DB 대신 선택해야 하는 경우는?
(1) 관계형 데이터와 JOIN 필요 (2) ACID 트랜잭션 필요 (3) 벡터 수 수천만 이하 (4) 추가 인프라 운영 부담 최소화.