pgvector로 PostgreSQL에 벡터 검색 더하기

pgvector로 PostgreSQL에 벡터 검색 더하기

요즘 LLM을 활용한 서비스가 늘면서 “벡터 데이터베이스”라는 말을 자주 듣게 됩니다. 의미 검색이나 RAG를 구현하려면 임베딩 벡터를 저장하고 빠르게 찾아줄 곳이 필요하기 때문인데요. Pinecone, Weaviate, Qdrant처럼 벡터 검색에 특화된 제품도 있고요. 그런데 기존에 PostgreSQL을 쓰고 있는 팀이라면 굳이 새로운 데이터베이스를 하나 더 운영하는 건 부담스러울 수 있죠.

이때 좋은 선택지가 pgvector입니다. PostgreSQL 확장(extension) 형태로 동작하기 때문에 별도 서버를 띄울 필요 없이 기존 DB에서 바로 벡터를 다룰 수 있어요. 이미 익숙한 psql이나 ORM으로 그대로 쿼리를 짤 수 있다는 점도 매력적이고요.

이번 글에서는 pgvector를 설치하고 임베딩을 저장한 다음, 유사도 검색을 직접 돌려보겠습니다. 인덱스나 거리 함수 같은 실무에서 자주 마주치는 주제도 같이 다뤄볼게요.

pgvector가 뭔가요?

pgvector는 PostgreSQL에 벡터 타입과 벡터 연산을 추가해주는 확장입니다. 한마디로 표현하면 “PostgreSQL을 벡터 데이터베이스로 만들어주는 플러그인”이에요. C로 작성된 네이티브 확장이라 성능도 꽤 괜찮습니다.

PostgreSQL 자체가 다양한 데이터 타입을 지원하긴 하지만 벡터처럼 수백~수천 차원의 부동소수점 배열을 다루기에는 부족한 부분이 있었어요. pgvector는 vector(N)이라는 새로운 타입을 추가하고, 벡터 사이의 거리를 계산하는 연산자(<->, <#>, <=>)와 근사 최근접 이웃 검색을 위한 인덱스(IVFFlat, HNSW)를 제공합니다.

비슷한 역할을 하는 전용 벡터 DB와 비교해서 pgvector의 가장 큰 장점은 “기존 PostgreSQL 인프라를 그대로 쓸 수 있다”는 점이에요. SQL 트랜잭션 안에서 일반 컬럼과 벡터 컬럼을 함께 조회할 수 있고, 백업이나 복제, 권한 관리 같은 운영 노하우도 모두 재사용됩니다. 별도 시스템 사이의 데이터 동기화를 고민하지 않아도 되고요.

물론 단점도 있어요. 수억 개 이상의 벡터를 다루거나 초저지연 검색이 필요한 경우에는 전용 벡터 DB가 더 적합할 수 있습니다. 하지만 대부분의 일반적인 RAG 시스템이나 시맨틱 검색 정도라면 pgvector로 충분합니다.

설치

설치 방법은 크게 세 가지입니다. 직접 빌드하거나, Docker 이미지를 쓰거나, 클라우드 매니지드 서비스를 이용하는 방법이죠.

가장 간단한 방법은 공식 Docker 이미지를 쓰는 거예요. pgvector/pgvector 이미지는 PostgreSQL에 pgvector가 미리 설치되어 있어서 바로 사용할 수 있습니다.

$ docker run -d \
    --name pgvector-demo \
    -e POSTGRES_PASSWORD=secret \
    -p 5432:5432 \
    pgvector/pgvector:pg17

macOS에서 Homebrew로 PostgreSQL을 쓰고 있다면 다음과 같이 추가 설치할 수 있어요.

$ brew install pgvector

AWS RDS, Google Cloud SQL, Supabase, Neon 같은 매니지드 PostgreSQL은 pgvector를 기본 지원하니까 별도 설치 없이 바로 활성화만 하면 됩니다.

설치를 마쳤다면 데이터베이스에 접속해서 확장을 활성화해야 해요. 한 번만 실행하면 되는 명령입니다.

CREATE EXTENSION vector;

제대로 활성화됐는지 확인하려면 \dx 메타 명령을 사용합니다.

postgres=# \dx
                             List of installed extensions
  Name   | Version |   Schema   |                     Description
---------+---------+------------+------------------------------------------------------
 plpgsql | 1.0     | pg_catalog | PL/pgSQL procedural language
 vector  | 0.7.4   | public     | vector data type and ivfflat and hnsw access methods
(2 rows)

테이블 만들기

이제 벡터를 저장할 테이블을 만들어볼게요. 문서를 임베딩해서 검색하는 시나리오를 가정하겠습니다.

CREATE TABLE documents (
  id BIGSERIAL PRIMARY KEY,
  content TEXT NOT NULL,
  embedding VECTOR(384)
);

여기서 VECTOR(384)가 핵심인데요. 괄호 안의 숫자는 벡터의 차원을 의미합니다. 384는 all-MiniLM-L6-v2 같은 작은 임베딩 모델이 만들어내는 차원 수이고, OpenAI의 text-embedding-3-small은 1536, text-embedding-3-large는 3072차원을 사용해요. 어떤 모델을 쓸지 정해놓고 그에 맞춰 차원을 선언합니다.

차원을 명시하지 않은 VECTOR 타입도 사용할 수 있지만, 명시하는 편이 좋아요. 차원이 다르면 거리 계산이 무의미하니까 스키마 차원에서 막아두는 게 안전합니다.

이제 데이터를 넣어볼까요? 실제로는 임베딩 모델을 호출해서 벡터를 얻겠지만, 여기서는 간단하게 3차원 벡터로 감을 잡아봅시다.

CREATE TABLE items (
  id BIGSERIAL PRIMARY KEY,
  name TEXT NOT NULL,
  embedding VECTOR(3)
);

INSERT INTO items (name, embedding) VALUES
  ('사과',  '[1, 0, 0]'),
  ('배',    '[0.9, 0.1, 0]'),
  ('바나나', '[0.1, 0.9, 0]'),
  ('당근',  '[0, 0.1, 0.9]');

벡터는 따옴표로 감싼 JSON 배열 형태의 문자열로 넣습니다. PostgreSQL이 알아서 vector 타입으로 파싱해줘요.

거리 함수와 유사도 검색

pgvector의 진가는 여기서 드러납니다. 두 벡터가 얼마나 가까운지 계산하는 연산자가 세 가지 있어요.

  • <-> — 유클리드(L2) 거리. 두 점 사이의 직선 거리를 잰다고 생각하면 됩니다
  • <#> — 내적(inner product)의 음수. 값이 작을수록 유사도가 높아요
  • <=> — 코사인 거리. 1에서 코사인 유사도를 뺀 값입니다

어떤 걸 써야 할지는 임베딩 모델이 권장하는 거리에 따라 갈립니다. OpenAI 임베딩은 코사인 거리를 권장하고요, Sentence Transformers의 많은 모델도 코사인을 씁니다. 임베딩이 단위 벡터로 정규화되어 있다면 코사인과 유클리드가 사실상 같은 순위를 만들어주기도 해요.

사과와 가장 비슷한 항목 3개를 코사인 거리 기준으로 찾아볼게요.

SELECT name, embedding <=> '[1, 0, 0]' AS distance
FROM items
ORDER BY embedding <=> '[1, 0, 0]'
LIMIT 3;

결과는 다음과 같습니다.

 name |      distance
------+---------------------
 사과 |                   0
   | 0.00617283950617281
 바나나 | 0.9876543209876543
(3 rows)

사과 자기 자신은 거리 0, 는 매우 가깝고, 바나나는 거의 직각이라 1에 가깝죠. 직관과 잘 맞습니다.

ORDER BY에 같은 식을 두 번 쓰는 게 어색해 보일 수 있는데요. 거리값을 결과에 같이 보여주고 싶지 않다면 SELECT 절에서 빼면 됩니다. PostgreSQL이 알아서 정렬에 쓴 식의 결과를 캐싱해주기 때문에 성능 걱정은 안 해도 돼요.

인덱스로 검색 속도 높이기

데이터가 적을 때는 ORDER BY ... LIMIT N 패턴이 그냥 잘 동작합니다. 하지만 벡터가 수만, 수십만 개로 늘어나면 매번 모든 벡터와 거리를 계산하는 건 비효율적이죠. 이때 근사 최근접 이웃(ANN, Approximate Nearest Neighbor) 인덱스를 만들어줍니다.

pgvector는 두 가지 인덱스를 지원해요. IVFFlat은 데이터를 여러 클러스터로 나눠두고 검색 시 일부 클러스터만 훑는 방식이고, HNSW는 그래프 기반 구조로 더 빠르고 정확하지만 메모리를 더 씁니다. 보통 새로 시작하는 프로젝트라면 HNSW를 먼저 고려하는 편이에요.

HNSW 인덱스는 다음과 같이 만듭니다. 거리 함수에 맞는 연산자 클래스(vector_cosine_ops, vector_l2_ops, vector_ip_ops)를 함께 지정해야 한다는 점에 주의하세요.

CREATE INDEX ON documents
USING hnsw (embedding vector_cosine_ops);

IVFFlat을 쓰려면 데이터를 미리 넣은 다음에 인덱스를 만드는 게 좋아요. 클러스터링을 위해 데이터 분포를 학습하기 때문이죠. lists 파라미터로 클러스터 개수를 정하는데, 보통 sqrt(행 수)행 수 / 1000 정도를 기준으로 잡습니다.

CREATE INDEX ON documents
USING ivfflat (embedding vector_cosine_ops)
WITH (lists = 100);

검색 시 정확도와 속도의 트레이드오프를 조절하는 파라미터도 있어요. HNSW는 hnsw.ef_search, IVFFlat은 ivfflat.probes를 세션 단위로 키우면 더 정확한 결과를 얻을 수 있습니다. 다만 그만큼 검색이 느려져요.

SET hnsw.ef_search = 100;
SET ivfflat.probes = 10;

인덱스를 만든 후에는 EXPLAIN ANALYZE로 실제로 인덱스 스캔이 일어나는지 확인하는 습관을 들이면 좋습니다. 거리 함수와 연산자 클래스가 맞지 않으면 인덱스를 안 타고 풀스캔이 되거든요.

Python에서 임베딩 만들고 저장하기

이제 실전적인 예제로 넘어가볼게요. Sentence Transformers로 문장을 임베딩한 다음 pgvector에 저장해보겠습니다. PostgreSQL 드라이버는 psycopg를 쓰고, pgvector의 Python 헬퍼는 pgvector 패키지로 설치합니다.

$ pip install psycopg[binary] pgvector sentence-transformers

다음 코드는 문장 몇 개를 임베딩해서 테이블에 넣고, 새로운 질문과 가장 비슷한 문장을 찾아주는 예제입니다.

search.py
import psycopg
from pgvector.psycopg import register_vector
from sentence_transformers import SentenceTransformer

model = SentenceTransformer("all-MiniLM-L6-v2")

with psycopg.connect("postgresql://postgres:secret@localhost/postgres") as conn:
    register_vector(conn)
    conn.execute("CREATE EXTENSION IF NOT EXISTS vector")
    conn.execute("DROP TABLE IF EXISTS notes")
    conn.execute("""
        CREATE TABLE notes (
            id BIGSERIAL PRIMARY KEY,
            content TEXT NOT NULL,
            embedding VECTOR(384)
        )
    """)

    sentences = [
        "오늘 저녁은 김치찌개를 먹었어요.",
        "주말에 자전거를 타고 한강에 다녀왔습니다.",
        "리액트로 새 프로젝트를 시작했어요.",
        "타입스크립트 덕분에 버그가 많이 줄었네요.",
    ]
    embeddings = model.encode(sentences)
    for content, vec in zip(sentences, embeddings):
        conn.execute(
            "INSERT INTO notes (content, embedding) VALUES (%s, %s)",
            (content, vec),
        )

    question = "프런트엔드 개발 이야기"
    q_vec = model.encode(question)
    rows = conn.execute(
        """
        SELECT content, embedding <=> %s AS distance
        FROM notes
        ORDER BY embedding <=> %s
        LIMIT 2
        """,
        (q_vec, q_vec),
    ).fetchall()

    for content, distance in rows:
        print(f"{distance:.4f}  {content}")

실행해보면 프런트엔드와 관련된 문장이 위쪽에 올라옵니다.

결과
0.6234  리액트로 프로젝트를 시작했어요.
0.6951  타입스크립트 덕분에 버그가 많이 줄었네요.

register_vector(conn)을 호출하면 NumPy 배열이나 리스트를 그대로 파라미터로 넘길 수 있어서 편해요. 호출하지 않으면 직접 '[1, 2, 3]' 같은 문자열로 변환해서 넘겨야 합니다.

Node.js에서 같은 일 하기

웹 개발자라면 똑같은 작업을 Node.js로 하고 싶을 텐데요. PostgreSQL 드라이버는 pg(node-postgres), pgvector 헬퍼는 pgvector 패키지, 임베딩은 의미 검색 글에서도 썼던 Transformers.js로 처리합니다. 임베딩 모델은 영어 위주인 all-MiniLM-L6-v2 대신 한국어를 포함한 다국어 MiniLM을 골랐어요. 차원은 384로 같아서 테이블 구조는 그대로 둬도 됩니다.

설치
npm install pg pgvector @huggingface/transformers
search.js
import pg from "pg";
import pgvector from "pgvector/pg";
import { pipeline } from "@huggingface/transformers";

const extractor = await pipeline(
  "feature-extraction",
  "Xenova/paraphrase-multilingual-MiniLM-L12-v2",
);
const embed = async (text) => {
  const out = await extractor(text, { pooling: "mean", normalize: true });
  return Array.from(out.data); // 384차원 숫자 배열
};

const client = new pg.Client("postgresql://postgres:secret@localhost/postgres");
await client.connect();
await pgvector.registerTypes(client); // vector 타입 ↔ JS 배열 자동 변환

await client.query("CREATE EXTENSION IF NOT EXISTS vector");
await client.query("DROP TABLE IF EXISTS notes");
await client.query(`
  CREATE TABLE notes (
    id BIGSERIAL PRIMARY KEY,
    content TEXT NOT NULL,
    embedding VECTOR(384)
  )
`);

const sentences = [
  "오늘 저녁은 김치찌개를 먹었어요.",
  "주말에 자전거를 타고 한강에 다녀왔습니다.",
  "리액트로 새 프로젝트를 시작했어요.",
  "타입스크립트 덕분에 버그가 많이 줄었네요.",
];
for (const content of sentences) {
  const vec = pgvector.toSql(await embed(content));
  await client.query("INSERT INTO notes (content, embedding) VALUES ($1, $2)", [
    content,
    vec,
  ]);
}

const question = "프런트엔드 개발 이야기";
const qVec = pgvector.toSql(await embed(question));
const { rows } = await client.query(
  `SELECT content, embedding <=> $1 AS distance
   FROM notes
   ORDER BY embedding <=> $1
   LIMIT 2`,
  [qVec],
);
for (const { content, distance } of rows) {
  console.log(`${Number(distance).toFixed(4)}  ${content}`);
}

await client.end();
결과
0.5652  리액트로 프로젝트를 시작했어요.
0.7355  타입스크립트 덕분에 버그가 많이 줄었네요.

모델이 다르니 거리의 절댓값은 파이썬 결과와 다르지만, “프런트엔드 개발 이야기”라는 질문에 리액트·타입스크립트 문장이 나란히 1·2위로 올라오는 결과는 똑같습니다. 테이블 구조와 <=> 쿼리는 언어와 무관하게 그대로고, 바뀌는 건 임베딩을 만들어 드라이버로 넘기는 애플리케이션 코드뿐이에요.

RAG에서 자주 마주치는 패턴

pgvector를 가장 많이 쓰는 분야는 역시 RAG(Retrieval-Augmented Generation)입니다. 사용자의 질문을 임베딩한 다음 가장 관련 있는 문서 조각을 가져와서 LLM 프롬프트에 컨텍스트로 넣어주는 패턴이죠. AWS Bedrock이나 Google Vertex AI 같은 매니지드 LLM 서비스와도 잘 어울려요.

실무에서 자주 보이는 RAG 쿼리는 단순히 벡터 거리만 보는 게 아니라, 메타데이터 필터링과 함께 쓰는 경우가 많아요. 예를 들어 “특정 사용자가 작성한 문서 중에서, 질문과 유사한 상위 5개”를 찾으려면 다음과 같이 작성합니다.

SELECT id, content
FROM documents
WHERE user_id = $1
  AND created_at >= NOW() - INTERVAL '30 days'
ORDER BY embedding <=> $2
LIMIT 5;

이런 식의 하이브리드 쿼리가 pgvector의 강점이에요. 전용 벡터 DB로 같은 일을 하려면 보통 메타데이터를 별도로 동기화하거나 후처리 필터링을 거쳐야 하거든요.

또 하나 흔한 패턴은 거리에 임계값을 두는 거예요. 너무 동떨어진 결과는 차라리 안 내보내는 편이 RAG 품질에 좋습니다. 코사인 거리 기준으로 0.3 정도를 임계값으로 잡는 식이죠.

SELECT content
FROM documents
WHERE embedding <=> $1 < 0.3
ORDER BY embedding <=> $1
LIMIT 5;

문서가 너무 길어서 한 번에 임베딩하기 어려울 때는 청크(chunk) 단위로 나누는 게 일반적이에요. 500~1000토큰 정도로 쪼개서 각각을 별도 행으로 저장하고, 원본 문서 ID와 청크 순서를 같이 컬럼으로 두면 나중에 인용 출처를 보여주거나 인접한 청크를 함께 가져오기도 좋습니다.

알아두면 좋은 운영 팁

벡터 컬럼은 일반 텍스트 컬럼보다 훨씬 무거워요. 1536차원 float4 벡터 하나가 약 6KB를 차지하니까 100만 개를 넣으면 그것만으로 6GB입니다. 게다가 HNSW 인덱스는 본체보다 더 큰 경우도 있어요. 디스크와 메모리 산정을 너무 낙관적으로 하지 않는 편이 안전합니다.

쿼리 시 SELECT * 대신 필요한 컬럼만 명시하는 것도 의외로 효과가 커요. 거리 계산은 인덱스가 처리해주지만, 결과로 벡터 컬럼까지 가져오면 네트워크와 메모리 부담이 늘어납니다. RAG에서 실제로 필요한 건 보통 텍스트와 메타데이터지 벡터 자체가 아니거든요.

벡터를 대량으로 적재할 때는 COPY를 쓰면 INSERT보다 훨씬 빠릅니다. 인덱스를 먼저 만들지 말고, 데이터를 다 넣은 다음에 인덱스를 생성하는 것도 흔한 패턴이고요. 특히 IVFFlat은 데이터가 어느 정도 쌓여야 의미 있는 클러스터링이 가능합니다.

마지막으로, 차원이 다른 벡터를 한 컬럼에 섞지 마세요. 임베딩 모델을 바꾸면 차원도 같이 바뀌는 경우가 많은데, 기존 데이터와 호환되지 않으니 새 컬럼을 추가하거나 새 테이블로 분리하는 편이 깔끔합니다.

마치며

pgvector는 “기존 PostgreSQL에 벡터 검색을 더한다”는 단순한 아이디어로 출발했지만, 의외로 많은 RAG 시스템에서 충분히 잘 동작합니다. 별도의 벡터 DB를 운영할 만큼 규모가 크지 않거나, 메타데이터와 벡터를 한꺼번에 다루고 싶다면 우선 pgvector부터 시도해보는 게 합리적이에요.

이번 글에서는 설치, 거리 함수, 인덱스, Python 연동, RAG 패턴까지 훑어봤는데요. 실제로 서비스에 적용할 때는 청킹 전략, 임베딩 모델 선택, 하이브리드 검색(BM25와의 결합) 같은 주제가 더 깊이 있게 다가올 거예요. 작은 데이터로 한 번 직접 돌려보고 나면 감이 잡힐 겁니다.

더 자세한 내용은 pgvector 공식 저장소를 참고하세요.

This work is licensed under CC BY 4.0 CC BY

개발자를 위한 뉴스레터

달레가 정리한 AI 개발 트렌드와 직접 만든 콘텐츠를 전해드립니다.

Discord