1,000만 벡터까지는 PostgreSQL 하나로 — pgvector·pgrag·pgai로 벡터 DB 없애기
Pinecone 계정 새로 만들고, LangChain 설정 파일 만지고, 임베딩 생성 스크립트 따로 돌리고, 그게 또 Redis 큐랑 붙어 있고... RAG 파이프라인 처음 만들 때 저도 이 구성을 당연하게 받아들였습니다. 그러다 B2B SaaS 프로젝트에서 고객 문서 수만 건을 처음 임베딩할 때 Pinecone 인덱스 비용이 예상의 세 배로 나왔고, 거기다 벡터 DB와 원본 DB 사이에 데이터 불일치까지 생기면서 "이거 진짜 다 필요한 건가?" 라는 생각이 처음 들었습니다.
이 글에서는 PostgreSQL 단독 RAG 파이프라인의 구조, pgvector·pgrag·pgai 각 확장의 역할, 그리고 실제로 어느 규모까지 믿고 쓸 수 있는지를 다룹니다. SQL은 어느 정도 익숙하고, PostgreSQL을 운용해본 적 있는 백엔드·풀스택 개발자를 주 독자로 가정합니다. 핵심은 "운영할 시스템 하나를 줄이는 대신 SQL 표현력을 최대한 활용하는" 선택입니다. 약 1,000만 벡터 이하의 서비스라면, 전용 벡터 DB와 외부 오케스트레이션을 조합하는 것보다 훨씬 낮은 복잡도와 비용으로 충분한 성능을 낼 수 있습니다.
결론부터 말하면, 됩니다. 그것도 꽤 잘. pgvector 0.6의 HNSW 지원 안정화, pgai Vectorizer GA, pgrag 출시를 거치면서 이제는 psql을 벗어나지 않고 전체 파이프라인을 완결하는 게 현실적인 선택지가 됐습니다.
핵심 개념
RAG 파이프라인 한눈에 보기
RAG(Retrieval-Augmented Generation)는 LLM에 질문을 던지기 전에, 사용자 데이터에서 관련 컨텍스트를 먼저 꺼내서 프롬프트에 끼워 넣는 패턴입니다. "GPT한테 우리 회사 문서 기반으로 답하게 하고 싶다"는 요구사항의 대부분이 이 패턴으로 해결됩니다.
문서 입력 (PDF, HTML, DOCX...)
↓
청킹 — 적절한 크기로 분할 (pgrag: chunks_by_token_count)
↓
임베딩 생성 — 텍스트 → 벡터 (pgrag / pgai Vectorizer)
↓
벡터 저장·인덱싱 (pgvector: HNSW 인덱스)
↓
검색 — 벡터 유사도 + FTS + 트라이그램 (RRF 앙상블)
↓
리랭킹 — 후보 정밀 재정렬 (pgrag: rerank_score)
↓
LLM 호출 → 응답 생성 (pgai: openai_chat)이 파이프라인을 PostgreSQL 하나로 처리하면 어떻게 달라지는지 비교해보면 이렇습니다.
| 단계 | 전통적 도구 | PostgreSQL 단독 |
|---|---|---|
| 청킹 | LangChain TextSplitter | pgrag 토큰 기반 청킹 함수 |
| 임베딩 생성 | OpenAI API + Python 스크립트 | pgai / pgrag SQL 함수 |
| 벡터 저장·인덱싱 | Pinecone, Qdrant, Weaviate | pgvector HNSW 인덱스 |
| 검색 | 벡터 DB 쿼리 API | ORDER BY embedding <=> query_vec |
| 리랭킹 | Cohere Rerank API | pgrag 리랭킹 SQL 함수 |
| 생성 | LangChain / LlamaIndex | pgai LLM 호출 SQL 함수 |
세 확장, 한 줄 정의
| 확장 | 만든 곳 | 역할 |
|---|---|---|
| pgvector | 오픈소스 커뮤니티 | 벡터 타입(vector, halfvec) + HNSW·IVFFlat 인덱스 |
| pgrag | Neon (실험적) | PDF 파싱, 청킹, 로컬 임베딩, 리랭킹, LLM 호출을 SQL 함수로 |
| pgai Vectorizer | Timescale | 테이블 트리거 방식 자동 임베딩 동기화 |
pgrag 성숙도 주의: pgrag는 Neon이 공개한 실험적 확장으로, 2025년 기준 주로 Neon 서버리스 환경에서 검증되어 있습니다. pgvector에 비해 프로덕션 안정성이 낮으므로, 자체 호스팅 환경에 도입할 때는 충분한 테스트가 필요합니다.
pgvector — 가장 기본이 되는 벽돌
pgvector는 PostgreSQL에 벡터 타입과 인덱스를 추가하는 확장입니다. 설치하고 나면 컬럼 타입으로 vector(1536) 같은 걸 선언할 수 있고, <=> 연산자로 코사인 유사도 검색이 됩니다.
CREATE EXTENSION IF NOT EXISTS vector;
CREATE TABLE document_chunks (
id BIGSERIAL PRIMARY KEY,
content TEXT NOT NULL,
embedding vector(1536)
);
-- HNSW 인덱스: 데이터 없어도 먼저 생성 가능
CREATE INDEX ON document_chunks
USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);HNSW vs IVFFlat: HNSW(Hierarchical Navigable Small World)는 그래프 기반 인덱스로, 데이터 삽입 전에도 인덱스를 만들 수 있습니다. IVFFlat은 클러스터 분할을 위해 데이터가 먼저 있어야 해서 블루·그린 배포에 잘 맞지 않습니다. pgvector 0.6 이후 공식 문서도 HNSW를 기본으로 권장합니다.
실전 적용
예시 1: psql만으로 완결하는 RAG — pgrag + DeepSeek
저도 처음엔 "SQL로 PDF를 읽는다고?" 싶었는데, 실제로 돌려보면 꽤 자연스럽습니다. Neon 서버리스 PostgreSQL이나 pgrag가 설치된 로컬 인스턴스에서 바로 써볼 수 있습니다.
-- 1. 확장 설치
CREATE EXTENSION IF NOT EXISTS rag CASCADE;
CREATE EXTENSION IF NOT EXISTS rag_bge_small_en_v15;
CREATE EXTENSION IF NOT EXISTS rag_jina_reranker_v1_tiny_en;
-- 2. 청크 저장 테이블
CREATE TABLE chunks (
id BIGSERIAL PRIMARY KEY,
content TEXT NOT NULL,
embedding vector(384) -- bge-small-en-v1.5 차원
);
-- 3. PDF 읽어서 청킹 후 삽입
-- pg_read_binary_file은 superuser 또는 pg_read_server_files 역할 필요
INSERT INTO chunks (content)
SELECT chunk
FROM chunks_by_token_count(
convert_pdf_to_text(pg_read_binary_file('/data/manual.pdf')),
192, -- max_tokens
8 -- overlap
);
-- 4. 배치로 임베딩 생성 (passage 모드 — 저장용)
UPDATE chunks
SET embedding = embedding_for_passage(content)
WHERE embedding IS NULL;
-- 5. 검색 + 리랭킹: ORDER BY rerank_score DESC 필수
WITH candidates AS (
SELECT content
FROM chunks
ORDER BY embedding <=> embedding_for_query('HNSW 인덱스 튜닝')
LIMIT 10
),
reranked AS (
SELECT content,
rerank_score(content, 'HNSW 인덱스 튜닝') AS score
FROM candidates
WHERE rerank_score(content, 'HNSW 인덱스 튜닝') > 0.3
ORDER BY score DESC
LIMIT 5
)
-- 6. 컨텍스트 주입 후 LLM 호출
SELECT openai_chat(
'deepseek-chat',
prompt_with_context(
'PostgreSQL HNSW 인덱스 튜닝 방법은?',
(SELECT string_agg(content, E'\n---\n') FROM reranked)
)
);| 함수 | 역할 |
|---|---|
convert_pdf_to_text() |
PDF 바이너리 → 텍스트 추출 |
chunks_by_token_count() |
토큰 기준 슬라이딩 윈도우 청킹 |
embedding_for_passage() |
저장용 임베딩 (BGE asymmetric 패딩 포함) |
embedding_for_query() |
질의용 임베딩 (passage와 반드시 구분해야 함) |
rerank_score() |
jina-reranker로 관련도 점수 계산 |
prompt_with_context() |
검색 결과를 프롬프트 템플릿에 주입 |
pg_read_binary_file권한: 이 함수는 superuser 또는pg_read_server_files역할이 있어야 실행됩니다. 일반 앱 계정으로 호출하면 권한 오류가 납니다. 프로덕션에서는 파일 접근 권한을 가진 별도 마이그레이션 계정을 쓰거나, 파일 내용을 애플리케이션에서 읽어BYTEA로 직접 넘기는 방식을 고려해볼 수 있습니다.
예시 2: pgai Vectorizer로 자동 임베딩 동기화
documents 테이블에 글이 새로 올라올 때마다 임베딩도 자동으로 만들고 싶다면 이렇게 하면 됩니다. 이 방법을 처음 써봤을 때 가장 놀랐던 부분은, 애플리케이션 코드를 한 줄도 바꾸지 않아도 된다는 점이었습니다.
-- Vectorizer 등록 — SQL 한 줄
SELECT ai.create_vectorizer(
'documents'::regclass,
embedding => ai.embedding_openai('text-embedding-3-small', 1536),
chunking => ai.chunking_recursive_character_text_splitter(
'content',
512, -- chunk_size (토큰)
50 -- overlap
),
destination => 'documents_embedding_store'
);등록 이후 documents 테이블에 INSERT가 일어나면, 별도로 띄운 pgai 워커 컨테이너가 비동기로 임베딩을 생성해 documents_embedding_store에 씁니다. 워커 없이 위 SQL만 실행하면 임베딩이 생성되지 않으니 주의가 필요합니다.
# pgai 워커 컨테이너 실행 (Docker 예시)
docker run -d \
-e PGAI_VECTORIZER_WORKER_DB_URL="postgres://user:pass@host:5432/db" \
-e OPENAI_API_KEY="sk-..." \
timescale/pgai-vectorizer-worker:latestLSN(Log Sequence Number) 추적: pgai Vectorizer는 PostgreSQL의 WAL(Write-Ahead Log) 위치를 추적해서 중복 처리를 방지합니다. 워커가 죽었다 살아나도 마지막으로 처리한 위치를 정확히 알기 때문에 임베딩 유실이나 중복 생성이 생기지 않습니다.
예시 3: 멀티테넌트 RAG에서 SQL JOIN 활용
전용 벡터 DB의 메타데이터 필터링은 생각보다 불편합니다. 테넌트 ID를 필터로 걸면서 날짜 범위도 보고 싶다면 별도 처리가 필요한 경우가 많죠. PostgreSQL에서는 그냥 JOIN입니다. 실무에서 가장 자주 맞닥뜨리는 상황인데, 이게 단독 구성의 가장 큰 실용적 이점이라고 생각합니다.
SELECT
c.content,
c.embedding <=> $2 AS distance
FROM document_chunks c
JOIN documents d ON c.document_id = d.id
WHERE d.tenant_id = $1
AND d.created_at > NOW() - INTERVAL '30 days'
AND d.category = ANY($3)
ORDER BY distance
LIMIT 5;테넌트 격리, 날짜 필터, 카테고리 필터가 인덱스 프리디케이트로 표현되어 하나의 트랜잭션 안에서 처리됩니다. 전용 벡터 DB의 post-filter 방식보다 훨씬 효율적입니다. 성능을 직접 확인하고 싶다면 EXPLAIN (ANALYZE, BUFFERS)를 앞에 붙여서 Index Scan이 제대로 타는지 볼 수 있습니다.
예시 4: 하이브리드 검색으로 Recall 향상
벡터 유사도만으로 검색하면 키워드가 정확히 일치하는 케이스를 놓치는 경우가 있습니다. 반대로 BM25 전문 검색만 쓰면 의미적으로 유사한 표현을 못 잡고요. 셋을 섞으면 영어 QA 벤치마크 기준 Recall@10이 ~0.62에서 ~0.84까지 올라가는 경향이 있습니다.
처음이라면: 이 쿼리는 CTE 세 개 + FULL OUTER JOIN + COALESCE 조합이라 처음 보면 부담스럽습니다. 먼저 벡터 검색만 구현하고, 이후 FTS와 트라이그램을 순차적으로 추가하면서 Recall 변화를 직접 체감해보는 것을 권장합니다.
-- RRF(Reciprocal Rank Fusion) 기반 하이브리드 검색
-- 한국어 문서 적용 시: plainto_tsquery('korean', ...) 기본 파서는
-- 형태소 분리가 안 됨 — pg_bigm 설치를 고려해볼 수 있습니다
WITH
vector_ranked AS (
SELECT id, content,
ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS rank
FROM document_chunks
LIMIT 60
),
fts_ranked AS (
SELECT id, content,
ROW_NUMBER() OVER (ORDER BY ts_rank(fts_vector, query) DESC) AS rank
FROM document_chunks,
plainto_tsquery('korean', $2) query
WHERE fts_vector @@ query
LIMIT 60
),
trigram_ranked AS (
SELECT id, content,
ROW_NUMBER() OVER (ORDER BY similarity(content, $2) DESC) AS rank
FROM document_chunks
WHERE content % $2
LIMIT 60
)
SELECT
COALESCE(v.id, f.id, t.id) AS id,
COALESCE(v.content, f.content, t.content) AS content,
-- RRF 점수: 각 순위의 역수 합산 (k=60이 일반적 기본값)
COALESCE(1.0 / (60 + v.rank), 0) +
COALESCE(1.0 / (60 + f.rank), 0) +
COALESCE(1.0 / (60 + t.rank), 0) AS rrf_score
FROM vector_ranked v
FULL OUTER JOIN fts_ranked f ON v.id = f.id
FULL OUTER JOIN trigram_ranked t ON v.id = t.id
ORDER BY rrf_score DESC
LIMIT 10;RRF(Reciprocal Rank Fusion): 여러 검색 전략의 순위를
1 / (k + rank)형태로 합산해서 최종 순위를 결정하는 앙상블 방법입니다. k=60이 일반적으로 잘 동작하는 기본값입니다.
장단점 분석
장점
가장 자주 받는 질문이 "그냥 Pinecone 쓰면 되지 않나요?"인데, 비용과 운영 복잡도 두 축에서 PostgreSQL 단독 구성이 실질적으로 유리한 경우가 많습니다.
| 항목 | 내용 |
|---|---|
| 운영 단순성 | 벡터 DB, 메시지 큐, 별도 오케스트레이션 서비스가 없어도 됩니다. 배포·모니터링 대상이 하나로 줄어듭니다. |
| 트랜잭션 일관성 | 임베딩과 원본 데이터가 동일 트랜잭션 안에서 관리되어, 부분 실패로 인한 데이터 불일치가 생기지 않습니다. |
| SQL JOIN 필터링 | 테넌트, 날짜, 카테고리 등 복잡한 필터를 인덱스 프리디케이트로 표현할 수 있어, 전용 벡터 DB의 메타데이터 필터링보다 유연합니다. |
| 비용 | 추가 인프라 비용이 없습니다. 기존 PostgreSQL 인스턴스를 그대로 활용할 수 있습니다. |
| 보안 | 민감 데이터가 DB 경계 밖으로 나가지 않아 규정 준수가 쉬워집니다. |
| 하이브리드 검색 내장 | tsvector, pg_trgm, pgvector를 SQL로 자유롭게 조합할 수 있습니다. |
단점 및 주의사항
반대로 HNSW 메모리 문제는 실제로 가장 자주 맞닥뜨리는 운영 이슈입니다. 처음엔 잘 되다가 데이터가 쌓이면서 갑자기 쿼리가 느려지는 패턴이 전형적입니다.
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 수평 확장 한계 | PostgreSQL은 기본적으로 단일 노드 구조입니다. 벡터가 수천만 건을 넘어서면 분산 샤딩이 필요해집니다. | ~1,000만 벡터까지는 PostgreSQL, 그 이상이면 Qdrant·Weaviate 검토 |
| HNSW 메모리 요구 | HNSW 인덱스가 RAM에 상주해야 최고 성능이 납니다. 인덱스 크기가 가용 메모리를 초과하면 I/O 레이턴시가 급등합니다. | pgvectorscale의 StreamingDiskANN 사용, 또는 halfvec 양자화로 메모리 절반 절감 |
| 초기 임베딩 생성 부하 | 대량 데이터를 처음 임베딩할 때 CPU·API 비용이 집중됩니다. | pgai Vectorizer의 배치 처리와 속도 제한 핸들링 활용 |
| 인덱스 빌드 시간 | 수백만 벡터의 HNSW 인덱스 빌드는 수십 분~수 시간이 걸릴 수 있습니다. | SET maintenance_work_mem = '4GB' 튜닝, 블루·그린 배포로 서비스 중단 없이 전환 |
halfvec: 벡터를 16비트 부동소수점으로 저장하는 pgvector의 타입입니다. 기본
vector(32비트)와 비교해 RAM 사용량이 절반으로 줄어들고, Matryoshka 임베딩과 조합하면 단일 서버에서 수백만 건 처리 시에도 10ms 이하 쿼리 레이턴시를 유지할 수 있습니다.
빠지기 쉬운 함정 3가지
실무에서 직접 겪었거나, 동료들에게 가장 많이 들었던 이슈들입니다.
-
embedding_for_passage()와embedding_for_query()를 혼동하는 경우 — BGE 계열 모델은 저장용 임베딩(passage)과 질의용 임베딩(query)에 서로 다른 프리픽스를 붙이는 asymmetric 방식을 씁니다. 저장할 때는 문서 전체 맥락을, 검색할 때는 질문 패턴을 최적화한다고 이해하면 됩니다. 둘 다 같은 함수로 처리하면 유사도 점수가 크게 왜곡됩니다. -
HNSW 인덱스를 데이터 삽입 후에 생성하는 경우 — 수백만 건이 이미 들어있는 테이블에 인덱스를 뒤늦게 만들면 서버 메모리와 시간이 많이 필요합니다. 가능하다면 테이블 생성 직후, 데이터 로드 전에 인덱스를 먼저 만들어 두는 것이 좋습니다.
-
벡터 검색만 쓰고 하이브리드 검색을 건너뛰는 경우 — 솔직히 처음 프로토타입을 만들 때는 벡터 검색만으로도 충분해 보입니다. 그런데 정확한 제품명, 코드명, 약어 같은 키워드가 섞이면 BM25와 트라이그램 검색 없이는 Recall이 눈에 띄게 떨어집니다. 처음부터 하이브리드로 구성해 두는 것을 권장합니다.
마치며
PostgreSQL 단독 RAG 파이프라인은 "운영할 시스템 하나를 줄이는 대신 SQL 표현력을 최대한 활용하는" 선택입니다. 약 1,000만 벡터 이하의 서비스라면, 전용 벡터 DB와 외부 오케스트레이션을 조합하는 것보다 훨씬 낮은 복잡도와 비용으로 충분한 성능을 낼 수 있습니다.
저는 여기서부터 시작했고, 이 순서대로 하면 삽질이 줄어듭니다.
- Neon 무료 계정 생성 후
CREATE EXTENSION IF NOT EXISTS vector;실행 — pgvector, pgrag, pgai가 모두 사전 설치되어 있어서 예시 1을 바로 따라볼 수 있습니다. 로컬 환경이라면 pgrag와 pgai는 별도 빌드가 필요하므로, 처음엔 Neon으로 시작하는 게 훨씬 편합니다. SELECT ai.create_vectorizer('documents'::regclass, ...)한 줄로 자동 임베딩 연결 — 이후 INSERT마다 임베딩이 자동 생성됩니다. pgai 워커 컨테이너를 함께 띄워야 실제로 동작하니, 예시 2의 Docker 명령도 같이 참고하면 됩니다.ORDER BY embedding <=> query_vec LIMIT 10으로 기본 검색 구현 → RRF 하이브리드로 점진 전환 — 각 단계에서EXPLAIN (ANALYZE, BUFFERS)로 성능을 확인하면서,tsvector와pg_trgm을 추가해 Recall 변화를 직접 체감해볼 수 있습니다.
참고 자료
- Build an end-to-end RAG pipeline entirely in psql using pgrag and DeepSeek | Neon
- GitHub — neondatabase/pgrag
- pgai: Transforming PostgreSQL into a Production-Ready AI Retrieval Engine | BrightCoding
- GitHub — timescale/pgai
- Building Hybrid Search for RAG: Combining pgvector and Full-Text Search with RRF | DEV Community
- pg_trgm + pgvector Hybrid Retrieval: Build Better RAG in Postgres (2026) | CallSphere
- pgai Vectorizer: Automate AI Embeddings With One SQL Command | DEV Community
- pgvector vs Pinecone: Which Vector Database to Choose in 2026 | Encore
- pgvector, pgvectorscale and the Postgres Vector Search Stack Explained | SoftwareSeni
- Boring RAG: When similarity is just a SQL query | Red Hat Developer
- Building a RAG Server with PostgreSQL - Part 1 | pgEdge