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에서 배치 삽입
from pgvector.psycopg2 import register_vector
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 전체 예제
from pgvector.psycopg2 import register_vector
from openai import OpenAI
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. 퀴즈
**코사인 거리** (1 - cosine_similarity). 값이 작을수록 유사하다. 유사도로 변환하려면 `1 - (a <=> b)`.
**HNSW**. 빌드는 느리지만 검색은 빠르고 Recall도 높다. 대부분의 프로덕션 환경에서 권장.
벡터 검색은 의미적 유사도에 강하지만 키워드 정확 매칭에 약함. 전문 검색과 결합하면 **의미적 유사도 + 키워드 정확도** 모두 확보.
각 검색 결과의 **순위의 역수**를 합산. 점수 스케일이 다른 검색 결과를 정규화 없이 결합할 수 있는 장점.
`lists`는 클러스터 수, `probes`는 검색 시 탐색할 클러스터 수. probes가 클수록 정확하지만 느림. 보통 probes = lists/10 ~ lists/5.
(1) 관계형 데이터와 JOIN 필요 (2) ACID 트랜잭션 필요 (3) 벡터 수 수천만 이하 (4) 추가 인프라 운영 부담 최소화.
현재 단락 (1/262)
별도 벡터 DB(Pinecone, Qdrant, Weaviate) 대신 PostgreSQL + pgvector를 선택하는 이유: