Vector DB 하이브리드 검색

파이썬 벡터 DB 쿡북

LHG 2026-06-07

CONTENTS

  1. 핵심 용어와 RRF 원리
  2. Supabase와 벡터 DB별 구현
  3. 인덱싱 알고리즘과 메모리 추정
  4. 영속성과 시장 채택
  5. 보안과 워크로드 가이드

이 글에서 쓰는 용어

처음 보는 사람도 이해되게 먼저 풀어둔다

  • ANN(근사 최근접 이웃): 정확도를 조금 포기하고 검색 속도를 크게 높이는 방식
  • sparse vector: 대부분이 0인 벡터. BM25 같은 키워드 점수를 벡터 형태로 표현할 때 쓴다
  • RRF(순위 융합): 여러 검색 결과의 순위를 합쳐 하나의 점수로 만드는 방법
  • RLS(행 단위 보안): 한 테이블 안에서 사용자마다 볼 수 있는 행을 나누는 Postgres 기능
  • PITR: 특정 시점으로 데이터베이스를 되돌릴 수 있는 백업 방식

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

키워드와 의미 검색은 서로 다른 형태로 실패한다. RRF가 그 실패를 메운다

두 검색의 실패 패턴은 서로 다르다

키워드(BM25·tsvector)

  • 토큰, 식별자, 코드에 강하다
  • 동의어와 어순 변형에는 약하다

시맨틱(의미 벡터)

  • 의미와 맥락, 다국어에 강하다
  • 정확한 식별자나 숫자에는 약하다
  • 코드 검색에서는 getUserById와 findUserById가 거의 같은 벡터로 매핑돼 의미 검색이 흔들린다

RRF, 점수 대신 순위만 더한다

점수 스케일이 다른 두 검색을 정규화 없이 합치는 방법이다

score = 1 / (k + rank), 여러 검색의 순위를 이 식으로 더한다
  • k가 작으면 1위의 영향력이 커지고 k가 크면 두 리스트의 합의가 더 중요해진다
  • 보통 k는 50~60을 쓴다

워크로드마다 키워드·의미 비중이 다르다

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

2. Supabase hybrid_search 함수 해부

GIN, HNSW, RRF를 SQL 함수 하나에 담은 레퍼런스 구현이다

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

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);

연산자와 인덱스 방식이 짝이 안 맞으면 인덱스가 조용히 무시된다.

hybrid_search 함수 핵심부

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)
  limit least(match_count, 30) * 2
),
semantic as (
  select id, row_number() over(order by embedding <#> query_embedding) as rank_ix
  from documents 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)*full_text_weight
       + coalesce(1.0/(rrf_k+semantic.rank_ix),0)*semantic_weight desc
limit least(match_count, 30);

이 함수가 영리하게 잘하는 것 세 가지

  • ts_rank_cd는 인덱스를 못 타므로, GIN으로 후보를 먼저 좁힌 뒤에만 점수를 계산한다
  • FULL OUTER JOIN으로 한쪽 리스트에만 있는 결과도 살린다
  • coalesce로 한쪽에 없는 값은 0점 처리한다

튜닝 파라미터, 기억할 값 몇 개

  • full_text_weight, semantic_weight는 기본 1.0. 식별자 검색이면 키워드 쪽을 1.5~2.0으로 올린다
  • rrf_k는 기본 50. 노이즈가 많으면 값을 키운다
  • 후보 limit은 match_count의 2배가 기본. 정확도가 부족하면 3~5배로 넓힌다
  • hnsw.ef_search는 기본 40. recall이 부족하면 100~200으로 올린다

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

네이티브로 지원하는 DB와 직접 코드로 합쳐야 하는 DB로 나뉜다

네이티브 지원 대 직접 구현

네이티브 지원

  • Qdrant, Weaviate, Milvus는 한 요청에서 fusion까지 처리한다
  • Supabase는 SQL 함수로 직접 구현한다

직접 구현

  • Chroma는 클라이언트에서 결과를 합쳐야 한다
  • FAISS는 라이브러리라 키워드 검색을 따로 붙여야 한다

Qdrant, 한 요청에 두 검색을 담는다

client.query_points(
    collection_name="docs",
    prefetch=[
        models.Prefetch(query=sparse_vec, using="sparse", limit=20),
        models.Prefetch(query=dense_vec, using="dense", limit=20),
    ],
    query=models.RrfQuery(rrf=models.Rrf(k=60)),
    limit=10,
)

Qdrant는 최상위 순위를 0부터 세므로 다른 시스템과 점수를 직접 비교하면 안 된다.

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

response = docs.query.hybrid(
    query="food",
    alpha=0.5,  # 0이면 키워드, 1이면 의미 검색
    fusion_type=HybridFusion.RELATIVE_SCORE,  # 또는 RANKED, 즉 RRF
    limit=10,
)

alpha 방식은 직관적이지만 두 점수의 분포가 다르면 예상과 다르게 움직일 수 있다.

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

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

Milvus는 2.5부터 BM25 함수를 컬렉션 스키마에 직접 정의할 수 있다.

Chroma와 FAISS는 직접 합쳐야 한다

  • Chroma는 시맨틱 검색만 하고 BM25는 외부 라이브러리로 따로 돌린 뒤 RRF로 합친다
  • FAISS는 라이브러리이므로 키워드 인덱스와 ID 매핑까지 전부 직접 구현한다
  • 둘 다 결과 리스트만 있으면 되므로, RRF 결합 코드 자체는 짧다

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

최종 결과 10개면 각 검색의 후보는 30~50개가 안전하다
  • 각 검색의 상위 K가 최종 K와 같으면 두 결과의 교집합이 너무 작아 fusion이 거의 동작하지 않는다
  • RAG 컨텍스트용으로 50개를 뽑는다면 후보는 200~300개까지 넓힌다

4. 인덱싱 알고리즘 비교

recall, 속도, 메모리, 빌드 시간의 다차원 트레이드오프다

규모가 커질수록 압축이 필요해진다

~100M
HNSW
→
100M~1B
IVF-PQ
→
1B+
DiskANN
→
극한 압축
RaBitQ
  • IVFFlat은 데이터가 적재된 뒤에만 만들 수 있다. HNSW는 빈 테이블에도 미리 만들 수 있다

HNSW 파라미터가 움직이는 방향

  • m을 올리면 정확도와 메모리가 함께 오르고 빌드는 느려진다
  • ef_construction을 올리면 정확도는 오르지만 빌드가 크게 느려진다
  • ef_search를 올리면 recall은 오르지만 응답 시간도 함께 늘어난다
  • 768차원, m=16 기준 벡터당 약 3,200바이트가 필요하다

압축은 메모리를 극단적으로 줄인다

768차원 벡터, PQ 압축 시 벡터당 96바이트
  • IVF-PQ는 벡터를 여러 조각으로 쪼개 코드북 인덱스로 대체한다
  • RaBitQ는 차원마다 1비트까지 압축하지만 정확도 손실이 가장 크다
  • DiskANN은 그래프 일부만 RAM에 두고 나머지는 SSD에서 찾는다. 십억 단위에서 거의 유일한 선택지다

규모별로 고르는 인덱스, FAISS 권장 기준

  • 검색이 드물거나 정확한 결과가 필수면 Flat을 그대로 쓴다
  • RAM이 충분하고 100만 건 이하면 HNSW가 기본이다
  • RAM을 크게 아껴야 하면 IVF와 PQ를 함께 쓴다
  • 1000만 건을 넘으면 IVF 클러스터 수를 규모에 맞춰 키운다

5. 영속성과 파일 모드

DB가 죽어도 데이터가 살아남는지는 운영에서 가장 먼저 확인해야 한다

트랜잭션 DB부터 순수 인메모리까지

Postgres 기반

  • pgvector는 Postgres 자체이므로 ACID와 시점 복구가 그대로 따라온다

전용 벡터 DB

  • Qdrant, Weaviate, Milvus는 각자의 디스크 저장 방식과 스냅샷 백업을 쓴다

인메모리 라이브러리

  • FAISS는 인메모리가 기본이고 파일로 직렬화해 저장한다

가벼운 임베디드

  • Chroma는 SQLite와 파일에 저장하지만 트랜잭션 보장은 제한적이다

pgvector가 공짜로 얻는 것들

  • 벡터와 메타데이터를 같은 트랜잭션에 묶을 수 있다
  • 다른 테이블과 바로 JOIN할 수 있다
  • WAL 기반으로 특정 시점으로 되돌릴 수 있다
  • Row Level Security로 멀티테넌트 격리가 표준으로 제공된다

6. 인메모리 사용량 추정

이 데이터셋이 RAM에 들어갈지는 가장 자주 틀리는 계산이다

벡터 원본 크기가 출발점이다

float32 · 768차원 · 100만 개 ≈ 2.93GB
  • 정밀도를 절반으로 줄이는 float16은 크기도 절반이 된다
  • int8로 줄이면 4분의 1, 1비트 압축이면 100분의 1 수준까지 줄어든다

인덱스 오버헤드까지 더해야 한다

  • HNSW는 원본 크기에 그래프 연결 정보가 추가된다. 768차원, m=16이면 약 3.05GB
  • IVFFlat은 오버헤드가 거의 없어 원본과 비슷하다
  • PQ나 RaBitQ로 압축하면 100만 개, 768차원이 100MB 안팎까지 줄어든다
  • 운영에 올릴 때는 이 수치의 1.5~2배를 안전 마진으로 잡는다

압축은 공짜 점심이 아니다

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

7. 시장 채택 현황

수치는 변동이 크므로 의사결정의 유일한 근거로 쓰지 않는다

점유율보다 자체 측정을 신뢰한다

  • GitHub star나 순위는 실제 프로덕션 채택을 정확히 반영하지 않는다. 최신 수치는 항상 확인이 필요하다
  • Supabase, Cloudflare 등 자체 인프라에 pgvector를 공식적으로 쓰는 사례는 공개돼 있다
  • "유명해서" 고르지 말고 자체 데이터로 후보 2~3개를 직접 벤치마크하는 쪽이 훨씬 신뢰할 수 있다

8. 보안 비교

임베딩에는 원본 텍스트와 메타데이터가 함께 들어간다

Postgres 기반은 보안 기능을 그대로 물려받는다

pgvector

  • 역할 기반 인증과 RLS를 그대로 쓴다
  • 멀티테넌트 격리가 표준으로 제공된다

전용 벡터 DB

  • API 키, RBAC를 자체적으로 제공한다
  • FAISS는 라이브러리라 인증·암호화가 전혀 없다

RLS로 멀티테넌트 격리하기

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...';

이 패턴 하나로 SQL 인젝션과 테넌트 간 데이터 누수를 함께 막는다.

임베딩이라서 생기는 위협들

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

9. 워크로드별 가이드와 안티패턴

측정 없이 유명한 것을 고르는 게 가장 큰 함정이다

시나리오별로 고르는 스택

  • Postgres를 이미 쓰고 데이터가 1천만 건 이하면 pgvector와 tsvector로 충분하다
  • 여러 임베딩 모델을 같이 쓰면 Qdrant나 Milvus의 named vector가 유리하다
  • 메모리가 극도로 제약된 엣지 환경이면 FAISS와 압축 인덱스가 답이다
  • 십억 단위를 SSD로 감당해야 하면 DiskANN 기반 솔루션을 본다

구현 전에 꼭 확인할 것들

  • 1년 후 예상 벡터 수와 인덱스 메모리를 미리 추정했는가
  • 정확 매칭이 얼마나 필요한지, 하이브리드가 실제로 더 나은지 검증했는가
  • 백업, 복구, 멀티테넌트 격리 방식을 정했는가
  • PII가 포함되는지, 임베딩 역추적 위험을 평가했는가

자주 보는 안티패턴 다섯 가지

  • BM25 점수와 코사인 유사도를 그냥 더한다. 순위 기반 RRF로 합쳐야 한다
  • 빈 테이블에 IVFFlat 인덱스를 만든다. HNSW를 쓰거나 데이터를 채운 뒤 만든다
  • RRF 후보 수를 최종 결과 수와 같게 잡는다. 3~5배로 넓혀야 한다
  • 두 검색 결과를 INNER JOIN으로 합친다. 한쪽만 있어도 살리려면 FULL OUTER JOIN이 맞다
  • 검색 메타데이터를 그대로 LLM에 넣는다. 노출 필드를 화이트리스트로 제한해야 한다

정리

  • 키워드와 의미 검색은 서로 다르게 실패한다. RRF는 순위만으로 그 둘을 정규화 없이 합친다
  • 알고리즘·보안·영속성 모두 Postgres 기반이 가장 적은 운영 부담으로 시작할 수 있다
  • 유명세가 아니라 자체 데이터로 측정한 recall과 latency로 결정한다