Vector DB

파이썬 벡터 DB 하이브리드 검색 쿡북

2026.06.07 · 11분 읽기 · 2,707 단어
📊 슬라이드

개요

요약

의미 검색과 키워드 검색을 함께 쓰는 하이브리드 검색을 Supabase 계열 조합(tsvector·pgvector·RRF)과 Chroma·Milvus·Qdrant·Weaviate·FAISS 같은 전용 벡터 DB로 각각 구현하는 방법을 코드 수준에서 비교한다. 인덱싱 알고리즘·메모리 사용량 추정·저장 방식·보안 모델·워크로드별 선택 기준과 자주 하는 실수까지 정리한 쿡북이다.

목차

1. 왜 하이브리드 검색인가

검색 품질이 LLM 응답 품질의 상한선을 정한다는 건 이제 RAG 실무자에게 상식이다. 그런데 한 가지 검색 방식만 쓰면 어떤 모델을 갈아 끼워도 답이 어긋난다. 키워드 검색과 의미 검색은 서로 다른 형태로 실패하고 그 실패가 정확히 서로를 보완하기 때문에 하이브리드 검색이 표준이 됐다.

한 줄로 요약하면 이렇다. 키워드는 정확한 단어를 잡고 의미 검색은 뜻을 잡는다. RRF(순위 융합)는 두 결과의 순위 역수만 더해서 점수를 맞추는 작업 없이 둘을 합친다.

1.1 두 검색의 실패 패턴

검색 방식 잘하는 것 못하는 것 실패 예시
키워드(BM25, tsvector) 정확한 단어, 식별자, 코드 동의어, 어순 변형, 다국어 "결제 실패"로 검색해도 "payment error"는 못 찾는다
의미(임베딩) 뜻, 맥락, 다국어 정확한 식별자, 숫자 에러 코드 ERR_2048이 비슷한 다른 코드와 섞인다

코드 검색에서 임베딩이 자주 틀리는 이유는, 토크나이저가 getUserByIdfindUserById를 거의 같은 벡터로 만들기 때문이다. 반대로 상담 FAQ에서 BM25가 틀리는 이유는 "환불 받고 싶어요"와 "결제 취소 가능한가요?"가 같은 단어를 거의 안 쓰기 때문이다. 두 결함은 성격이 완전히 다르다. 그래서 두 결과를 합치면 평균적으로 좋아진다.

1.2 RRF, 점수 대신 순위만 본다

BM25 점수는 상한이 없고 코사인 유사도는 -1에서 1 사이고 내적은 모델마다 분포가 다르다. 이걸 그대로 더하려면 정규화가 필요한데, 쿼리마다 분포가 달라져서 일관성이 깨진다. RRF는 이 문제를 순위만 보는 방식으로 우회한다.

$$ \text{score}(d) = \sum_{r \in R(d)} \frac{1}{k + \text{rank}_r(d)} $$

핵심은 점수가 아니라 순위만 본다는 점이다. 키워드 결과에서 3등, 의미 검색 결과에서 9등이면 1/(60+3) + 1/(60+9), 약 0.0303이 된다. 한쪽 리스트에만 있으면 그 점수만 더해진다. k는 완만하게 만드는 상수로 보통 50에서 60을 쓴다. k가 크면 위쪽 항목들 간의 점수 차이가 작아져 두 리스트의 합의가 더 중요해지고 작으면 1등의 영향력이 커진다.

k 값 효과
1에서 10 상위 항목의 영향력이 매우 크다. 한쪽 검색을 압도적으로 믿을 때 쓴다
50에서 60 표준값이다. 일반적인 RAG나 검색에 쓴다
100 이상 두 리스트의 합의에 가까운 결과가 나온다. 노이즈가 많을 때 쓴다

1.3 워크로드별 적합도

경험적으로, 한 시스템에 정확 매칭과 의미 매칭이 섞인 질문이 들어오면 거의 항상 하이브리드가 낫다.

  • 코드나 로그 검색은 키워드가 우세하다
  • 상담이나 뉴스 추천은 의미 검색이 우세하다
  • 이커머스나 문서 RAG, 지원 지식베이스는 하이브리드가 거의 항상 우세하다
  • 다국어 검색은 의미 검색이 우세하지만 고유명사에는 키워드가 필요하다

2. Supabase hybrid_search 함수 해부

Supabase 공식 문서의 hybrid_search 함수는 Postgres의 두 인덱스(tsvector용 GIN, pgvector용 HNSW)와 RRF를 SQL 함수 하나에 담은 레퍼런스 구현이다. 다른 벡터 DB의 하이브리드를 이해하려면 이 함수부터 뜯어보는 게 가장 빠르다.

2.1 스키마, 생성 컬럼과 두 인덱스

create table documents (
  id bigint primary key generated always as identity,
  content text,
  fts tsvector generated always as (to_tsvector('english', content)) stored,
  embedding extensions.vector(512)
);

create index on documents using gin(fts);
create index on documents using hnsw (embedding vector_ip_ops);

fts 컬럼은 저장형 생성 컬럼이라 삽입이나 수정 시 자동으로 갱신된다. 연산자와 인덱스 옵션은 반드시 짝이 맞아야 한다. 안 맞으면 인덱스가 조용히 무시되고 전체 스캔이 돈다.

2.2 GIN 인덱스를 쓰는 이유

인덱스 특징 tsvector 적합도
GIN 역색인 구조, 검색은 빠르지만 빌드는 느리다 표준 선택
GiST 갱신이 빠르지만 검색은 조금 느리다 자주 바뀌는 작은 테이블에서만

문서 검색은 거의 항상 읽기가 많으므로 GIN이 기본이다.

2.3 hybrid_search 함수 전체

create or replace function hybrid_search(
  query_text text,
  query_embedding extensions.vector(512),
  match_count int,
  full_text_weight float = 1,
  semantic_weight float = 1,
  rrf_k int = 50
)
returns setof documents
language sql
as $$
with full_text as (
  select
    id,
    row_number() over(order by ts_rank_cd(fts, websearch_to_tsquery(query_text)) desc) as rank_ix
  from documents
  where fts @@ websearch_to_tsquery(query_text)
  order by rank_ix
  limit least(match_count, 30) * 2
),
semantic as (
  select
    id,
    row_number() over(order by embedding <#> query_embedding) as rank_ix
  from documents
  order by rank_ix
  limit least(match_count, 30) * 2
)
select documents.*
from full_text
full outer join semantic on full_text.id = semantic.id
join documents on coalesce(full_text.id, semantic.id) = documents.id
order by
  coalesce(1.0 / (rrf_k + full_text.rank_ix), 0.0) * full_text_weight +
  coalesce(1.0 / (rrf_k + semantic.rank_ix), 0.0) * semantic_weight
  desc
limit least(match_count, 30);
$$;

2.4 이 함수가 잘하는 것 세 가지

  1. ts_rank_cd는 인덱스를 못 탄다. 그래서 where fts @@ ...로 GIN 인덱스를 먼저 태워 후보를 좁힌 뒤에만 점수를 계산한다.
  2. FULL OUTER JOIN은 한쪽 리스트에만 있는 결과도 남긴다. 양쪽 다 걸려야만 점수를 받는 실수를 피한다.
  3. coalesce(..., 0.0)는 한쪽 검색에 없으면 그 점수를 0으로 둔다. RRF의 핵심 논리다.

2.5 튜닝 파라미터

파라미터 기본값 조정 방향
full_text_weight 1.0 식별자 검색이 중요하면 1.5에서 2.0
semantic_weight 1.0 의역이 많으면 1.5에서 2.0
rrf_k 50 노이즈가 많으면 올린다
후보 개수 최종 개수의 2배 정확도가 부족하면 3에서 5배로
hnsw.ef_search 40 정확도가 부족하면 100에서 200으로

2.6 Python에서 호출하기

from supabase import create_client
from openai import OpenAI

sb = create_client(SUPABASE_URL, SUPABASE_KEY)
oai = OpenAI()

query = "환불 절차 알려줘"
emb = oai.embeddings.create(
    model="text-embedding-3-small",
    input=query,
    dimensions=512,
).data[0].embedding

res = sb.rpc("hybrid_search", {
    "query_text": query,
    "query_embedding": emb,
    "match_count": 10,
    "full_text_weight": 1.0,
    "semantic_weight": 1.0,
    "rrf_k": 50,
}).execute()

3. 벡터 DB별 하이브리드 구현

각 벡터 DB가 하이브리드를 다루는 방식은 세 갈래다. 한 요청에서 결합까지 끝내는 방식, 두 검색을 따로 실행하고 서버에서 합치는 방식, 라이브러리만 제공하고 키워드 검색은 외부에 맡기는 방식이다.

3.1 지원 현황

DB 하이브리드 지원 결합 방식 BM25 내장
Supabase(pgvector + tsvector) SQL 함수로 구현 RRF, 직접 작성 tsvector로 대체
Qdrant 네이티브(v1.10 이상) RRF, DBSF 없음, sparse vector로 대체
Weaviate 네이티브 alpha 가중합, RRF 있음(BM25F)
Milvus 네이티브(v2.4 이상) WeightedRanker, RRFRanker 있음(v2.5부터)
Chroma 클라이언트에서 직접 결합 직접 구현 없음
FAISS 라이브러리 전용 직접 구현 없음

3.2 Qdrant, 한 요청에 여러 검색을 담는다

Qdrant는 v1.10부터 한 요청 안에서 여러 하위 검색을 실행하고 마지막에 RRF나 DBSF로 합칠 수 있다.

from qdrant_client import QdrantClient, models

client = QdrantClient(url="http://localhost:6333")

client.query_points(
    collection_name="docs",
    prefetch=[
        models.Prefetch(
            query=models.SparseVector(indices=[1, 42], values=[0.22, 0.8]),
            using="sparse",
            limit=20,
        ),
        models.Prefetch(
            query=[0.01, 0.45, 0.67],
            using="dense",
            limit=20,
        ),
    ],
    query=models.RrfQuery(rrf=models.Rrf(k=60)),
    limit=10,
)

Qdrant는 최상위 순위를 0부터 센다. Supabase는 1부터 센다. 그래서 두 시스템의 점수를 직접 비교하면 안 된다.

3.3 Weaviate, alpha 한 숫자로 비중을 정한다

from weaviate.classes.query import HybridFusion, MetadataQuery

docs = client.collections.use("Docs")
response = docs.query.hybrid(
    query="food",
    alpha=0.5,  # 0이면 BM25, 1이면 의미 검색
    fusion_type=HybridFusion.RELATIVE_SCORE,  # 또는 RANKED, 즉 RRF
    return_metadata=MetadataQuery(score=True, explain_score=True),
    limit=10,
)

alpha 방식은 직관적이지만 두 점수의 분포가 다르면 예상과 다르게 움직일 수 있다. 그래서 Weaviate도 RRF 옵션을 함께 제공한다.

3.4 Milvus, 여러 검색 요청을 Ranker로 합친다

from pymilvus import AnnSearchRequest, RRFRanker, MilvusClient

client = MilvusClient(uri="http://localhost:19530")

dense_req = AnnSearchRequest(
    data=[query_dense_vec], anns_field="dense",
    param={"metric_type": "IP", "params": {"ef": 100}}, limit=20,
)
sparse_req = AnnSearchRequest(
    data=[query_sparse_vec], anns_field="sparse",
    param={"metric_type": "IP"}, limit=20,
)

results = client.hybrid_search(
    collection_name="docs",
    reqs=[dense_req, sparse_req],
    ranker=RRFRanker(k=60),
    limit=10,
)

3.5 Chroma, 클라이언트에서 직접 합친다

import chromadb
from rank_bm25 import BM25Okapi

collection = chromadb.PersistentClient("./data").get_collection("docs")

sem = collection.query(query_embeddings=[q_emb], n_results=40)
sem_ids = sem["ids"][0]

tokenized = [doc.split() for doc in corpus]
bm25 = BM25Okapi(tokenized)
bm_scores = bm25.get_scores(query.split())
bm_ids = [doc_ids[i] for i in sorted(range(len(bm_scores)),
                                       key=lambda i: -bm_scores[i])[:40]]

def rrf(rankings, k=60):
    scores = {}
    for ranked in rankings:
        for rank, doc_id in enumerate(ranked, start=1):
            scores[doc_id] = scores.get(doc_id, 0) + 1.0 / (k + rank)
    return sorted(scores.items(), key=lambda x: -x[1])

merged = rrf([sem_ids, bm_ids])[:10]

3.6 FAISS, ID 매핑을 직접 감싼다

import faiss, numpy as np

base = faiss.IndexHNSWFlat(768, 32)
index = faiss.IndexIDMap2(base)
index.add_with_ids(vectors, doc_ids.astype(np.int64))

D, I = index.search(query_vec.reshape(1, -1), 40)

3.7 공통 함정, 후보를 너무 좁게 잡는 것

각 검색의 상위 K가 최종 K와 같으면 결합이 거의 동작하지 않는다. 두 결과의 겹치는 부분이 너무 적어지기 때문이다.

최종 결과 수 권장 후보 수
10 30에서 50
50 200에서 300
100 이상 500 이상

4. 인덱싱 알고리즘 비교

ANN 알고리즘을 고르는 건 정확도, 응답 속도, 메모리, 빌드 시간, 갱신 비용을 함께 저울질하는 일이다.

알고리즘 자료구조 메모리 빌드 속도 정확도 적합 규모
HNSW 계층 그래프 높음 느림 매우 높음 1억 이하
IVFFlat 클러스터 + 정확 검색 낮음 빠름 중간 1천만 이하
IVF-PQ 클러스터 + 압축 매우 낮음 중간 중하 1억에서 10억
ScaNN 특수 양자화 중간 빠름 높음 10억 이하
DiskANN SSD 기반 그래프 매우 낮음 느림 높음 10억 이상
RaBitQ 1비트 압축 극히 낮음 빠름 중상, 재정렬 필요 10억 이하

4.1 HNSW, 그래프 기반의 표준

m은 노드당 최대 연결 수, ef_construction은 빌드 시 후보 수, ef_search는 검색 시 후보 수다. 셋 다 값을 올리면 정확도가 오르지만 각각 메모리, 빌드 시간, 응답 속도가 함께 늘어난다. FAISS 기준 메모리 사용량은 벡터당 차원 × 4 + M × 2 × 4 바이트다. 768차원, M=16이면 벡터당 3200바이트다.

4.2 IVFFlat, 클러스터 기반의 가성비

데이터를 k-means로 클러스터로 나누고 검색 시 가까운 클러스터 몇 개만 본다. 데이터가 적재된 뒤에 빌드해야 클러스터링이 의미가 있다. HNSW는 빈 테이블에도 만들 수 있지만 IVFFlat은 그렇지 않다.

4.3 IVF-PQ, 메모리를 크게 아끼는 방법

벡터를 여러 조각으로 쪼개고 각 조각을 코드북 인덱스로 대체한다. 768차원을 96조각으로 나누면 벡터당 96바이트만 저장한다.

import faiss

quantizer = faiss.IndexFlatIP(768)
index = faiss.IndexIVFPQ(quantizer, 768, 4096, 96, 8)
index.train(training_vectors)
index.add(vectors)
index.nprobe = 32

4.4 RaBitQ와 DiskANN

RaBitQ는 각 차원을 1비트로 압축해서 768차원 벡터가 104바이트로 줄어든다. 압축 폭이 가장 크지만 정확도 손실도 가장 크다. DiskANN은 그래프 일부만 RAM에 두고 나머지는 SSD에서 찾는다. RAM 비용이 10억 단위에서 감당이 안 될 때 거의 유일한 선택지다.

5. 저장 방식과 인메모리 모드

벡터 DB를 운영에 올릴 때 가장 자주 빠뜨리는 질문은 이거다. "이 DB가 죽으면 데이터가 살아남는가, 특정 시점으로 되돌릴 수 있는가."

DB 저장 방식 트랜잭션 백업
pgvector Postgres 자체 보장됨 시점 복구
Qdrant 디스크 기반, 필요하면 인메모리 최종 일관성 스냅샷
Weaviate 디스크 기반 컬렉션 단위 백업 모듈
Milvus 파일 기반 컬렉션 단위 로그 기반
Chroma SQLite와 파일 제한적 파일 복사
FAISS 인메모리가 기본 없음 파일로 직렬화

Postgres 위에 올라간다는 사실 자체가 다음을 공짜로 준다. 벡터와 메타데이터를 같은 트랜잭션에 묶을 수 있다. 다른 테이블과 바로 조인할 수 있다. 특정 시점으로 되돌릴 수 있다. 행 단위 보안이 멀티테넌트 격리의 표준으로 따라온다. 다른 벡터 DB는 이걸 흉내 내려면 별도 DB와 이중 쓰기를 해야 하고 그 순간 일관성 문제가 들어온다.

인메모리 전용 모드는 단위 테스트, 노트북 데모, 엣지 환경처럼 재시작해도 괜찮은 곳에만 쓴다. 프로덕션에는 반드시 영속 모드로 전환해야 한다.

6. 인메모리 사용량 추정

"이 데이터셋이 메모리에 들어갈까"는 가장 자주 틀리는 계산이다. 벡터 원본 크기, 인덱스 오버헤드, 메타데이터, 여유분을 따로 더해야 한다.

6.1 벡터 원본 크기

정밀도 차원당 바이트 100만 × 768 100만 × 1536
float32 4 2.93GB 5.86GB
float16 2 1.46GB 2.93GB
int8 1 0.73GB 1.46GB
1비트 0.125 92MB 184MB

6.2 인덱스 오버헤드

HNSW는 벡터당 M × 2 × 4 바이트가 추가된다. 768차원, M=16 기준으로 100만 개면 원본 2.93GB에 그래프 오버헤드 약 122MB가 더해져 총 3.05GB 정도다. IVFFlat은 오버헤드가 거의 없다. IVF-PQ나 RaBitQ로 압축하면 100만 개, 768차원이 100MB 안팎까지 줄어든다.

6.3 정확도 손실

압축 방식 메모리 절감 정확도 손실
float16 50% 1퍼센트포인트 미만
int8 75% 1에서 3퍼센트포인트
PQ 약 94% 5에서 10퍼센트포인트, 재정렬로 회복 가능
RaBitQ 약 97% 10에서 20퍼센트포인트, 재정렬로 회복 가능

운영에 올릴 때는 이 수치의 1.5배에서 2배를 안전 여유로 잡는다. Python 객체, 메타데이터, OS 캐시, 동시 쿼리 버퍼가 모두 추가되기 때문이다.

압축은 공짜가 아니다. 압축된 결과는 원본이나 float16으로 다시 정렬해야 정확도가 회복된다. 이때 원본도 어딘가엔 보관해야 한다. 코드북을 찾는 과정에서 캐시 미스가 자주 일어나 CPU 비용도 늘어난다. 데이터 분포가 바뀌면 압축 모델을 다시 학습해야 한다.

7. 시장 채택 현황

벡터 DB 시장은 변동이 크고 정확한 수치가 부족하다. 아래는 일반적인 흐름이며 정확한 최신 수치는 각 서비스에서 직접 확인해야 한다.

GitHub의 관심도(star) 기준으로 FAISS와 Milvus는 안정적으로 높고 Chroma와 Qdrant는 최근 빠르게 늘었다. pgvector도 매우 빠르게 늘고 있다. 다만 관심도 자체가 실제 프로덕션 채택을 정확히 보여주지는 않는다.

Supabase, Cloudflare 같은 회사는 자체 인프라에 pgvector를 쓴다고 공식적으로 밝혔다. 다른 대형 서비스는 자체 인프라를 쓰거나 세부 내용을 공개하지 않는 경우가 많다.

점유율로 벡터 DB를 고르는 건 위험하다. 유명하다는 이유로 고르지 말고 자체 워크로드로 후보 두세 개를 직접 벤치마크하는 쪽이 훨씬 신뢰할 수 있다.

8. 보안 비교

벡터 DB에 임베딩이 들어간다는 건 원본 텍스트와 메타데이터가 한 곳에 모인다는 뜻이다. 개인정보나 내부 문서를 다룬다면 보안 모델을 반드시 비교해야 한다.

DB 인증 권한 관리 격리
pgvector 역할, 비밀번호, mTLS 역할과 행 단위 보안 행 단위 보안이 표준
Qdrant API 키, JWT, mTLS 클라우드에서 제공 payload 기준
Weaviate API 키, OIDC 제공됨 컬렉션당 테넌트
Milvus 사용자와 역할 제공됨 DB나 컬렉션 단위
Chroma 토큰(서버 모드) 제한적 클라우드 워크스페이스
FAISS 없음, 라이브러리라서 없음 호스팅 레이어 책임

pgvector와 행 단위 보안을 함께 쓰면 멀티테넌트 격리가 한 번에 해결된다.

alter table documents enable row level security;

create policy tenant_isolation on documents
  using (tenant_id = current_setting('app.current_tenant')::uuid);

set app.current_tenant = '...uuid...';

임베딩이라서 생기는 위협도 따로 있다. 임베딩에서 원본 텍스트 일부를 복원할 수 있으므로 원본은 별도로 암호화해 저장한다. 검색된 메타데이터에 악성 지시어가 숨어 들어올 수 있으므로 LLM에 넘길 필드를 화이트리스트로 제한한다. 인덱스 파일이 그대로 유출되면 내용이 노출되므로 파일 권한과 디스크 암호화가 필요하다. 멀티테넌트에서 필터를 빠뜨리면 다른 테넌트의 데이터가 섞여 나온다.

FAISS는 라이브러리이므로 인증과 암호화가 전혀 없다. 애플리케이션 레벨 인증, 디스크 암호화, 파일 권한, 네트워크 격리를 전부 호스팅 레이어에서 책임져야 한다.

9. 워크로드별 가이드와 자주 하는 실수

9.1 시나리오별 추천

시나리오 추천 스택
Postgres 운영 중, 데이터 1천만 건 이하 pgvector와 tsvector
1천만에서 1억, 여러 임베딩 모델 Qdrant 또는 Milvus
1억 이상 자체 호스팅 Milvus 분산 또는 Qdrant 클러스터
빠른 프로토타입, Python만 Chroma PersistentClient
메모리 극도로 제약된 엣지 환경 FAISS와 압축 인덱스
멀티테넌트 SaaS pgvector 행 단위 보안 또는 Qdrant payload 격리
10억 단위, SSD 활용 DiskANN 기반 솔루션
코드 검색, 정확 매칭 우세 Postgres tsvector 또는 Tantivy

9.2 구현 전 확인할 것

  • 1년 후 예상 벡터 수와 인덱스 메모리를 추정했는가
  • 메타데이터와 다른 테이블과의 조인이 필요한가
  • 정확 매칭의 비중과 하이브리드의 실제 효과를 검증했는가
  • 평가 데이터셋 50개에서 200개를 준비했는가
  • 백업, 복구, 멀티테넌트 격리, 인증 방식을 정했는가
  • PII가 포함되는지, 임베딩 역추적 위험을 평가했는가

9.3 자주 보는 실수

  • 1백만 건 이하인데 분산 클러스터를 구성한다. 단일 서버나 pgvector로 충분하다.
  • BM25 점수와 코사인 점수를 그냥 더한다. 순위 기반 RRF로 합쳐야 한다.
  • 빈 테이블에 IVFFlat 인덱스를 만든다. HNSW를 쓰거나 데이터를 채운 뒤 만든다.
  • RRF 후보 수를 최종 결과 수와 같게 잡는다. 3배에서 5배로 넓혀야 한다.
  • 검색 메타데이터를 그대로 LLM에 넣는다. 노출 필드를 화이트리스트로 제한해야 한다.
  • 임베딩 모델을 바꾼 뒤 인덱스를 다시 만들지 않는다. 차원이나 분포가 다르면 무조건 다시 만들어야 한다.
  • 두 검색 결과를 INNER JOIN으로 합친다. 한쪽만 있어도 살리려면 FULL OUTER JOIN이 맞다.

9.4 측정 없이 고르지 않는다

벡터 DB 선택의 가장 큰 함정은 "유명하니까", "저 회사가 쓰니까"로 결정하는 것이다. 자체 데이터 1만에서 10만 샘플로 평가셋을 만들고 후보 두세 개에서 recall@10, p99 응답 속도, 메모리, 운영 복잡도를 재본다. 일주일짜리 테스트가 1년치 마이그레이션 비용을 막아준다.

참고 문헌