pgvector와 Qdrant로 RAG 벡터 검색 만들기: 임베딩 저장, HNSW 인덱스, 하이브리드 검색

이 글의 핵심

이미 PostgreSQL을 쓰고 있다면 확장 하나로 벡터 검색을 붙일지, 별도 벡터 DB를 둘지부터 고민하게 됩니다. 두 방식의 설치와 삽입·검색 코드를 나란히 비교하고, dense·sparse 멀티 벡터와 배치 처리, 인덱스가 느릴 때나 메모리가 부족할 때의 트러블슈팅까지 짚어 선택 기준을 세우게 합니다.

pgvector 시작

설치 (PostgreSQL)

# PostgreSQL 16+
sudo apt-get install postgresql-16-pgvector

# macOS (Homebrew)
brew install pgvector

# Docker
docker run -d -p 5432:5432 -e POSTGRES_PASSWORD=password pgvector/pgvector:pg16

확장 활성화

CREATE EXTENSION IF NOT EXISTS vector;

테이블 생성

CREATE TABLE documents (
  id SERIAL PRIMARY KEY,
  content TEXT,
  embedding vector(1536)  -- OpenAI text-embedding-3-small 차원
);

CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops);
  • vector(1536): 1536차원 벡터
  • hnsw: HNSW 인덱스 (빠른 검색)
  • vector_cosine_ops: cosine similarity

여기서 1536이라는 숫자는 임의로 정하는 값이 아니라 임베딩 모델이 고정으로 결정하는 값입니다. 컬럼을 만들 때 사용한 임베딩 모델(text-embedding-3-small)과 실제로 벡터를 넣을 때 쓰는 모델이 다르면 — 예를 들어 나중에 text-embedding-3-large(3072차원)로 바꾸면 — 차원 수 불일치로 INSERT 자체가 실패합니다. 모델을 바꾸려면 컬럼을 다시 만들고 기존 데이터를 전부 재임베딩해야 하므로, 나중에 더 좋은 모델이 나왔다고 가볍게 교체할 수 있는 설정이 아니라는 점을 프로젝트 초기에 인지하고 있어야 합니다.

데이터 삽입

import openai
import psycopg2

client = openai.OpenAI()

def get_embedding(text: str) -> list[float]:
    response = client.embeddings.create(
        model="text-embedding-3-small",
        input=text
    )
    return response.data[0].embedding

conn = psycopg2.connect("postgresql://user:pass@localhost/db")
cur = conn.cursor()

docs = [
    "FastAPI는 Python 웹 프레임워크입니다.",
    "pgvector는 PostgreSQL 벡터 확장입니다.",
    "Qdrant는 Rust 기반 벡터 DB입니다.",
]

for doc in docs:
    embedding = get_embedding(doc)
    cur.execute(
        "INSERT INTO documents (content, embedding) VALUES (%s, %s)",
        (doc, embedding)
    )

conn.commit()

유사도 검색

query = "Python 웹 프레임워크"
query_embedding = get_embedding(query)

cur.execute(
    """
    SELECT content, 1 - (embedding <=> %s::vector) AS similarity
    FROM documents
    ORDER BY embedding <=> %s::vector
    LIMIT 5
    """,
    (query_embedding, query_embedding)
)

for content, similarity in cur.fetchall():
    print(f"{similarity:.3f}: {content}")
  • <=>: cosine distance (1 - cosine similarity)
  • ORDER BY embedding <=> query: 가장 가까운 벡터 순

HNSW 인덱스가 실제로 쓰이려면 쿼리 모양이 정해진 규칙을 따라야 합니다. 인덱스를 vector_cosine_ops로 만들었다면 ORDER BY에도 같은 연산자 <=>를 써야 하고, <->(L2 거리)로 정렬하면 인덱스를 못 타고 전체 테이블을 훑습니다. 또 ORDER BY 1 - (embedding <=> q) DESC처럼 거리식을 가공해 정렬해도 인덱스가 쓰이지 않습니다. 위 쿼리가 SELECT에서만 유사도로 바꾸고 ORDER BY에는 원래 거리식을 그대로 둔 이유가 이것이며, LIMIT이 있어야 플래너가 “가까운 몇 개만 찾는” 인덱스 스캔을 선택합니다. 인덱스가 쓰이는지는 EXPLAIN에서 Index Scan using documents_embedding_idx가 보이는지로 확인합니다.

psycopg2는 Python 리스트를 PostgreSQL 배열(numeric[])로 보내고, pgvector가 이를 vector로 캐스팅해 주기 때문에 위 코드가 동작합니다. 편하지만 1536개의 숫자가 긴 텍스트 리터럴로 전송되므로, 대량으로 다룰 때는 pgvector 파이썬 패키지의 register_vector(conn)을 호출해 NumPy 배열을 직접 주고받는 편이 깔끔합니다. 이 어댑터를 쓰면 조회 결과의 embedding 컬럼도 문자열이 아니라 배열로 돌아옵니다.

for doc in docs: get_embedding(doc) 패턴을 실제 문서 수천 개로 돌려보면 바로 문제가 드러납니다. 문서 하나당 OpenAI API를 한 번씩 순차 호출하니, 문서가 몇 개일 땐 눈치채기 힘들지만 수천 개 규모로 가면 API 레이턴시가 그대로 누적되고 rate limit에도 훨씬 쉽게 걸립니다. 저는 초기 프로토타입을 이 방식으로 짜뒀다가 실제 문서 컬렉션(약 5천 건)을 처음 임베딩하던 날 몇 시간이 걸리는 걸 보고서야 문제를 알아챘습니다. OpenAI 임베딩 API는 input에 문자열 리스트를 한 번에 넘길 수 있으므로, 수십~수백 개씩 배치로 묶어 호출하는 것만으로도 처리 시간이 극적으로 줄어듭니다.

Qdrant 시작

설치

# Docker
docker run -d -p 6333:6333 -p 6334:6334 qdrant/qdrant

# 또는 바이너리 다운로드
wget https://github.com/qdrant/qdrant/releases/download/v1.10.0/qdrant-x86_64-unknown-linux-gnu.tar.gz
tar -xzf qdrant-x86_64-unknown-linux-gnu.tar.gz
./qdrant

Python 클라이언트

pip install qdrant-client

Collection 생성

from qdrant_client import QdrantClient
from qdrant_client.models import Distance, VectorParams, PointStruct

client = QdrantClient("localhost", port=6333)

client.create_collection(
    collection_name="documents",
    vectors_config=VectorParams(size=1536, distance=Distance.COSINE),
)

데이터 삽입

import openai
from qdrant_client.models import PointStruct

openai_client = openai.OpenAI()

def get_embedding(text: str) -> list[float]:
    response = openai_client.embeddings.create(
        model="text-embedding-3-small",
        input=text
    )
    return response.data[0].embedding

docs = [
    {"id": 1, "text": "FastAPI는 Python 웹 프레임워크입니다.", "category": "python"},
    {"id": 2, "text": "pgvector는 PostgreSQL 벡터 확장입니다.", "category": "db"},
    {"id": 3, "text": "Qdrant는 Rust 기반 벡터 DB입니다.", "category": "db"},
]

points = [
    PointStruct(
        id=doc["id"],
        vector=get_embedding(doc["text"]),
        payload={"text": doc["text"], "category": doc["category"]}
    )
    for doc in docs
]

client.upsert(collection_name="documents", points=points)

Qdrant의 포인트 ID는 부호 없는 정수나 UUID만 허용됩니다. 원본 문서의 ID가 "doc-001" 같은 문자열이라면 그대로 넣을 수 없어 Unable to parse UUID 계열의 에러가 나므로, uuid.uuid5(namespace, "doc-001")처럼 문자열에서 결정적으로 UUID를 만들어 쓰는 방법이 흔히 쓰입니다. 같은 입력에서 항상 같은 UUID가 나오므로 문서를 다시 넣을 때 upsert가 중복 대신 덮어쓰기로 동작합니다. 반대로 매번 uuid4()로 새 ID를 만들면, 재색인할 때마다 같은 문서가 컬렉션에 계속 쌓여 검색 결과에 똑같은 문장이 여러 번 나오는 문제가 생깁니다.

검색

query = "Python 웹 프레임워크"
query_embedding = get_embedding(query)

results = client.search(
    collection_name="documents",
    query_vector=query_embedding,
    limit=5,
)

for result in results:
    print(f"{result.score:.3f}: {result.payload['text']}")

client.search는 오래 쓰인 API지만 최신 qdrant-client에서는 폐기 예정(deprecated) 경고가 나오고, 같은 일을 client.query_points(collection_name=..., query=query_embedding, limit=5).points로 합니다. 뒤의 하이브리드 검색도 query_points 기반이므로, 새로 작성하는 코드라면 처음부터 이쪽으로 통일하는 편이 좋습니다. 점수는 Distance.COSINE에서 코사인 유사도(클수록 가까움)로 나오고, pgvector의 <=>는 거리(작을수록 가까움)를 돌려준다는 점도 두 시스템을 오갈 때 자주 헷갈리는 부분입니다.

필터링

from qdrant_client.models import Filter, FieldCondition, MatchValue

results = client.search(
    collection_name="documents",
    query_vector=query_embedding,
    query_filter=Filter(
        must=[
            FieldCondition(key="category", match=MatchValue(value="python"))
        ]
    ),
    limit=5,
)

“python” 카테고리만 검색. Qdrant의 payload 필터링이 pgvector 대비 갖는 실질적 장점은, 벡터 유사도 계산과 메타데이터 필터를 같은 인덱스 순회 안에서 동시에 처리하도록 최적화되어 있다는 점입니다. pgvector에서 WHERE category = 'python'을 HNSW 검색과 같이 걸면(post-filtering), 필터 조건을 만족하는 후보가 적을 경우 HNSW가 애초에 충분한 후보를 찾지 못해 결과가 비거나 부정확해지는 경우가 있습니다 — 이게 트러블슈팅 섹션의 “검색 결과 부정확” 항목이 나오는 실제 원인 중 하나이고, 필터가 걸린 쿼리가 많다면 Qdrant처럼 필터링을 인덱스 탐색 자체에 통합한 엔진이 유리한 이유이기도 합니다.

pgvector 쪽에서 이 증상은 “LIMIT 5를 줬는데 2개만 나온다”는 형태로 나타납니다. HNSW 스캔이 hnsw.ef_search(기본 40)개의 후보만 찾은 뒤 WHERE 조건을 적용하기 때문에, 조건에 맞는 문서가 전체의 1%라면 40개 후보 중 평균 0.4개만 살아남습니다. pgvector 0.8.0부터는 SET hnsw.iterative_scan = relaxed_order;로 결과가 모자라면 인덱스를 더 탐색하게 할 수 있고, 카테고리 값이 몇 개로 고정돼 있다면 WHERE category = 'python' 부분 인덱스를 따로 만들거나 파티셔닝하는 방법도 있습니다. Qdrant에서도 필터에 쓰는 필드에는 client.create_payload_index(collection_name, field_name="category", field_schema="keyword")로 payload 인덱스를 만들어 두어야 필터링이 인덱스 탐색과 제대로 결합됩니다. 이 인덱스를 빼먹으면 필터 조건을 검사하느라 검색이 눈에 띄게 느려집니다.

RAG 파이프라인 (LangChain + pgvector)

pip install langchain langchain-openai langchain-postgres langchain-text-splitters "psycopg[binary]"
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain_postgres import PGVector
from langchain.chains import RetrievalQA
from langchain_text_splitters import RecursiveCharacterTextSplitter

# 1. 문서 분할
text_splitter = RecursiveCharacterTextSplitter(chunk_size=1000, chunk_overlap=200)
docs = text_splitter.create_documents([
    "FastAPI는 Python의 고성능 웹 프레임워크입니다...",
    "pgvector는 PostgreSQL에 벡터 검색을 추가합니다...",
])

# 2. 임베딩 + 저장
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")
CONNECTION_STRING = "postgresql+psycopg://user:pass@localhost/db"  # psycopg 3 드라이버

vectorstore = PGVector.from_documents(
    documents=docs,
    embedding=embeddings,
    collection_name="documents",
    connection=CONNECTION_STRING,
)

# 3. RAG 체인
llm = ChatOpenAI(model="gpt-4o-mini")
qa = RetrievalQA.from_chain_type(
    llm=llm,
    chain_type="stuff",
    retriever=vectorstore.as_retriever(search_kwargs={"k": 3}),
)

# 4. 질문
answer = qa.invoke("FastAPI의 장점은?")
print(answer["result"])

chunk_size=1000, chunk_overlap=200이라는 값은 정답이 있는 숫자가 아니라 콘텐츠 특성에 맞춰 실험적으로 정해야 하는 값입니다. 청크가 너무 크면 관련 없는 내용까지 LLM 프롬프트에 딸려 들어가 답변 품질이 떨어지고, 너무 작으면 문맥이 잘려서 “정답의 절반만 검색되는” 문제가 생깁니다. chunk_overlap은 청크 경계에서 문장이 잘리는 걸 완화하는 용도인데, 이 값을 0으로 두면 마침 문장이 청크 경계에 걸쳐 있을 때 양쪽 청크 어디에서도 완전한 맥락을 못 찾는 경우가 생기므로, 어느 정도의 중복은 의도된 것이지 낭비가 아닙니다.

langchain-postgres의 PGVector는 psycopg2가 아니라 psycopg 3 드라이버를 쓰므로, 연결 문자열이 postgresql+psycopg://로 시작해야 합니다. 앞 절의 psycopg2 코드와 같은 postgresql:// 문자열을 그대로 넘기면 SQLAlchemy가 psycopg2 드라이버를 찾으려다 모듈이 없다는 에러를 내거나, 둘 다 설치돼 있으면 의도와 다른 드라이버로 연결됩니다. 또 이 클래스는 앞에서 직접 만든 documents 테이블을 쓰지 않고 langchain_pg_collection, langchain_pg_embedding이라는 자체 테이블을 만들어 저장합니다. “SQL로 넣은 데이터가 LangChain 검색에 안 나온다”는 문의가 많은데, 두 방식이 같은 테이블을 보고 있지 않기 때문입니다. RetrievalQA는 최신 LangChain에서 폐기 예정으로 분류되어 create_retrieval_chain 같은 새 API나 LCEL 파이프라인으로 옮겨 가는 추세이지만, 검색 결과를 프롬프트에 붙인다는 동작 자체는 같습니다.

RAG 파이프라인 (Qdrant)

pip install langchain langchain-qdrant
from langchain_qdrant import QdrantVectorStore
from langchain_openai import OpenAIEmbeddings, ChatOpenAI
from langchain.chains import RetrievalQA
from qdrant_client import QdrantClient

client = QdrantClient("localhost", port=6333)
embeddings = OpenAIEmbeddings(model="text-embedding-3-small")

vectorstore = QdrantVectorStore(
    client=client,
    collection_name="documents",
    embedding=embeddings,
    content_payload_key="text",  # 앞에서 직접 넣은 payload 키에 맞춤
)

llm = ChatOpenAI(model="gpt-4o-mini")
qa = RetrievalQA.from_chain_type(
    llm=llm,
    retriever=vectorstore.as_retriever(search_kwargs={"k": 3}),
)

answer = qa.invoke("Qdrant의 특징은?")
print(answer["result"])

직접 만든 컬렉션을 LangChain으로 읽을 때 가장 흔한 함정이 payload 키입니다. LangChain의 Qdrant 연동은 문서 본문을 기본적으로 page_content라는 payload 키에서 찾는데, 앞 절에서는 text 키로 저장했습니다. 키를 맞추지 않으면 검색은 성공하지만 모든 문서의 page_content가 빈 문자열로 돌아오고, LLM은 빈 컨텍스트를 받아 “관련 정보가 없습니다”라고 답합니다. 에러가 나지 않아 원인을 찾기 어려운 대표적인 사례라서, 위처럼 content_payload_key를 명시해 둡니다. 예전 자료의 langchain_qdrant.Qdrant 클래스는 폐기 예정이며, 현재는 QdrantVectorStore가 권장됩니다.

Hybrid Search (키워드 + 벡터)

벡터 검색은 “의미가 비슷한 문서”를 잘 찾지만, 제품 코드(ERR_CONN_RESET), 사람 이름, 버전 번호처럼 글자가 정확히 일치해야 하는 질의에는 약합니다. 임베딩 공간에서는 v1.10과 v1.12가 거의 같은 점으로 찍히기 때문입니다. 하이브리드 검색은 BM25 같은 키워드 기반 sparse 벡터 검색과 dense 벡터 검색을 각각 수행한 뒤, 두 순위 목록을 합쳐 이런 약점을 메웁니다.

Qdrant의 query_points/prefetch 기반 하이브리드 검색 API는 1.10 전후로 크게 개편된 영역이라, 예전 자료의 클래스 이름과 다를 수 있습니다. 아래 코드는 qdrant-client 1.10 이후 기준이며, 다음 절에서 만드는 dense·sparse 이름 있는 벡터 컬렉션(hybrid)을 대상으로 합니다.

Qdrant

from qdrant_client import models

# dense 검색과 sparse(키워드) 검색을 각각 수행한 뒤 RRF로 합침
results = client.query_points(
    collection_name="hybrid",
    prefetch=[
        models.Prefetch(query=query_embedding, using="dense", limit=20),
        models.Prefetch(
            query=models.SparseVector(indices=sparse_indices, values=sparse_values),
            using="sparse",
            limit=20,
        ),
    ],
    query=models.FusionQuery(fusion=models.Fusion.RRF),  # Reciprocal Rank Fusion
    limit=5,
).points

RRF(Reciprocal Rank Fusion)는 점수가 아니라 순위만으로 두 결과를 합칩니다. 코사인 유사도(0~1)와 BM25 점수(상한 없음)는 척도가 달라 단순히 더하면 한쪽이 결과를 지배하는데, RRF는 각 목록에서의 순위 r에 대해 1/(k + r)를 더하므로 척도 문제를 피할 수 있습니다. prefetch가 하나뿐이라면 합칠 대상이 없어 fusion은 의미가 없습니다. sparse 벡터는 직접 만들어야 하므로, 보통 FastEmbed의 BM25나 SPLADE 모델로 문서와 질의를 각각 인코딩합니다. pgvector라면 PostgreSQL 전문 검색(tsvector)의 결과와 벡터 검색 결과를 각각 순위로 뽑아 SQL이나 애플리케이션에서 같은 RRF 공식으로 합치는 방식이 일반적입니다. 다만 PostgreSQL 기본 전문 검색은 한국어 형태소 분석을 지원하지 않으므로 한국어 문서라면 별도 확장이나 n-gram 기반 검색을 검토해야 합니다.

멀티 벡터 (dense + sparse)

from qdrant_client.models import VectorParams, SparseVectorParams

client.create_collection(
    collection_name="hybrid",
    vectors_config={
        "dense": VectorParams(size=1536, distance=Distance.COSINE),
    },
    sparse_vectors_config={
        "sparse": SparseVectorParams(),
    },
)

# 삽입 시 dense + sparse 벡터 모두 제공
client.upsert(
    collection_name="hybrid",
    points=[
        PointStruct(
            id=1,
            vector={
                "dense": dense_embedding,
                "sparse": {"indices": [0, 5, 10], "values": [0.8, 0.6, 0.3]},
            },
            payload={"text": "..."},
        )
    ],
)

배치 처리

pgvector

from psycopg2.extras import execute_values

embeddings = [get_embedding(doc) for doc in docs]
data = [(doc, emb) for doc, emb in zip(docs, embeddings)]

execute_values(
    cur,
    "INSERT INTO documents (content, embedding) VALUES %s",
    data,
)

Qdrant

client.upsert(
    collection_name="documents",
    points=[
        PointStruct(id=i, vector=emb, payload={"text": doc})
        for i, (doc, emb) in enumerate(zip(docs, embeddings))
    ],
)

두 배치 삽입 방식 모두 “임베딩 API 호출을 배치로 묶는 것”과 “DB/컬렉션에 쓰는 것을 배치로 묶는 것”은 서로 다른 최적화라는 점을 알아두면 좋습니다. execute_values나 upsert의 리스트 인자는 후자만 해결해줄 뿐, get_embedding을 여전히 문서마다 한 번씩 호출하고 있다면 앞서 언급한 API 호출 병목은 그대로 남아 있습니다. 두 가지를 각각 배치화해야 실제로 파이프라인 전체가 빨라집니다.

성능 최적화

pgvector

-- HNSW 파라미터 튜닝
CREATE INDEX ON documents USING hnsw (embedding vector_cosine_ops)
WITH (m = 16, ef_construction = 64);

-- 검색 시
SET hnsw.ef_search = 100;
  • m: 그래프 연결 수 (높을수록 정확하지만 느림)
  • ef_construction: 인덱스 구축 품질
  • ef_search: 검색 품질 (높을수록 정확하지만 느림)

이 세 파라미터가 나타내는 트레이드오프는 결국 하나입니다 — HNSW는 애초에 근사(approximate) 최근접 이웃 알고리즘이라서, 정확도와 속도를 맞바꾸는 손잡이가 여러 개 있을 뿐입니다. m과 ef_construction은 인덱스를 만들 때 한 번 지불하는 비용(인덱싱 시간·메모리)이고, ef_search는 검색할 때마다 지불하는 비용(쿼리 레이턴시)입니다. “검색이 부정확하다”는 문제를 겪으면 무조건 두 값을 다 올리기보다, 인덱싱 시간 예산이 있는지·쿼리 레이턴시 예산이 있는지에 따라 어느 쪽을 올릴지 구분해서 접근하는 게 낫습니다.

Qdrant

from qdrant_client.models import HnswConfigDiff, OptimizersConfigDiff

client.update_collection(
    collection_name="documents",
    hnsw_config=HnswConfigDiff(m=16, ef_construct=100),
    optimizers_config=OptimizersConfigDiff(
        indexing_threshold=20000,  # KB 단위: 이 크기를 넘는 세그먼트부터 HNSW 인덱스 생성
    ),
)

indexing_threshold는 메모리 한도가 아니라 “세그먼트가 이 크기(KB)를 넘으면 HNSW 인덱스를 만든다”는 기준입니다. 그보다 작은 세그먼트는 전수 비교로 검색하는데, 데이터가 적을 때는 그쪽이 더 빠르기 때문입니다. 수백만 건을 처음 적재할 때는 Qdrant 문서가 권하는 대로 이 값을 0으로 두어 적재 중 인덱싱을 끄고, 적재가 끝난 뒤 원래 값으로 되돌리면 인덱스를 한 번만 만들게 되어 적재가 훨씬 빨라집니다. 되돌리는 것을 잊으면 인덱스가 영영 만들어지지 않아 검색이 전수 비교로 느려지므로 주의해야 합니다.

Docker Compose (Qdrant + PostgreSQL)

services:
  postgres:
    image: pgvector/pgvector:pg16
    environment:
      POSTGRES_USER: user
      POSTGRES_PASSWORD: password
      POSTGRES_DB: vectordb
    ports:
      - "5432:5432"
    volumes:
      - pgdata:/var/lib/postgresql/data
  
  qdrant:
    image: qdrant/qdrant
    ports:
      - "6333:6333"
      - "6334:6334"
    volumes:
      - qdrant_storage:/qdrant/storage

volumes:
  pgdata:
  qdrant_storage:

트러블슈팅

pgvector: 인덱스가 느림

  • 테이블이 작으면 플래너가 순차 스캔이 더 싸다고 판단해 인덱스를 쓰지 않을 수 있음 (EXPLAIN으로 확인)
  • ORDER BY의 연산자가 인덱스 opclass와 다르거나 거리식을 가공하면 인덱스를 못 탐
  • HNSW 인덱스 생성 자체가 오래 걸리면 maintenance_work_mem을 늘리고 max_parallel_maintenance_workers로 병렬 생성

HNSW 인덱스는 그래프 전체가 메모리에 올라와 있어야 빠릅니다. 1536차원 float 벡터는 하나에 약 6KB이므로 100만 건이면 벡터만 6GB를 넘고, 인덱스까지 합치면 shared_buffers와 OS 캐시에 다 들어가지 않는 순간 검색이 디스크 I/O에 묶여 급격히 느려집니다. 이때는 파라미터 튜닝보다 halfvec(16비트 부동소수) 타입으로 저장 크기를 절반으로 줄이거나, 차원을 줄일 수 있는 모델(text-embedding-3 계열의 dimensions 옵션)을 쓰는 편이 효과가 큽니다.

Qdrant: OOM

  • 벡터를 on_disk=True로 두어 메모리 대신 mmap 파일에 저장
  • scalar quantization으로 메모리에 올리는 벡터 크기 축소
  • 대량 적재 중에는 indexing_threshold=0으로 인덱싱을 미뤘다가 적재 후 복구

임베딩 차원 불일치

  • 모델 변경 시 DB 스키마도 변경 필요
  • OpenAI text-embedding-3-large는 3072차원

검색 결과 부정확

  • ef_search·m 높이기
  • 더 나은 임베딩 모델 사용
  • Hybrid search (키워드 + 벡터) 조합

마무리

pgvector와 Qdrant는 RAG·의미 검색·추천 시스템에서 가장 흔히 비교되는 두 선택지입니다. pgvector의 가장 큰 장점은 성능이 아니라 운영 단순성입니다. 문서 메타데이터, 권한, 원본 텍스트가 이미 PostgreSQL에 있다면 벡터를 같은 트랜잭션 안에서 저장하고, 조인과 백업, 권한 관리를 기존 방식 그대로 쓸 수 있습니다. 별도 벡터 DB를 두면 “PostgreSQL에서는 지웠는데 벡터 DB에는 남아 있는” 동기화 문제를 직접 해결해야 합니다. Qdrant는 필터가 복잡하게 걸린 검색, 멀티 테넌시, 양자화와 디스크 저장 같은 벡터 전용 기능이 필요하거나, 벡터 검색 부하를 주 DB에서 떼어 내야 할 때 장점이 커집니다. 처음에는 pgvector로 시작해 검색 품질과 트래픽을 측정하고, 필터 성능이나 메모리가 실제 병목으로 확인됐을 때 전용 DB로 옮기는 순서가 가장 후회가 적습니다.


자주 묻는 질문 (FAQ)

Q. 임베딩 모델을 바꾸면 기존 벡터 데이터는 어떻게 해야 하나요?

A. 임베딩 모델마다 벡터 차원과 의미 공간이 다르기 때문에, 모델을 바꾸면 컬럼이나 컬렉션의 차원 설정을 새로 맞추고 기존 문서를 모두 다시 임베딩해야 합니다. 예를 들어 OpenAI text-embedding-3-large는 3072차원이라 다른 차원으로 만든 테이블에는 넣을 수 없습니다. 서로 다른 모델로 만든 벡터를 한 인덱스에 섞으면 차원이 같더라도 유사도 비교가 의미를 잃습니다.


같이 보면 좋은 글