Vector DB 하이브리드 검색

Supabase vs FAISS·Chroma

LHG 2026-06-04

CONTENTS

  1. 핵심 용어와 하이브리드 검색
  2. pgvector와 풀텍스트 검색
  3. RRF로 하이브리드 구현하기
  4. FAISS·Chroma와 인메모리 패턴
  5. 성능 비교와 의사결정 가이드

이 글에서 쓰는 용어

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

  • HNSW: 다층 그래프로 벡터를 연결해 빠르게 탐색하는 인덱스
  • IVFFlat: 벡터를 클러스터로 나눠두고 가까운 클러스터만 찾는 인덱스
  • tsvector·GIN: Postgres가 제공하는 키워드 검색용 토큰 저장 방식과 인덱스
  • RRF(순위 융합): 여러 검색 결과의 순위를 합쳐 하나의 점수로 만드는 방법
  • BM25: 단어 빈도로 문서 순위를 매기는 대표적인 키워드 검색 점수 방식

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

키워드 검색과 의미 검색은 서로 다른 사각지대를 가진다

두 검색 방식의 사각지대

한쪽만 쓰면 어느 한 종류의 질문에서 반드시 틀린다

키워드 검색

  • 에러 코드, 함수명 같은 정확한 식별자에 강하다
  • 동의어와 표현 변형에는 거의 손을 못 댄다

의미 검색

  • 동의어와 우회 표현에 강하다
  • 정확한 용어 매칭에는 약해서 관련 없는 문서를 끌어온다

이 글이 답하려는 질문 하나

검색 인프라를 Postgres 하나로 통합할까, 별도 벡터·키워드 엔진을 조합할까
  • Supabase(pgvector + tsvector + RRF)는 트랜잭션, RLS, 백업이 한 인스턴스 안에서 함께 동작한다
  • FAISS·Chroma + BM25 조합은 두 인덱스를 직접 운영해야 하지만 극한의 성능과 압축을 얻는다

2. pgvector 인덱스, HNSW와 IVFFlat

Supabase의 벡터 검색은 실제로 pgvector라는 Postgres 확장이 처리한다

HNSW, 다층 그래프로 빠르게 좁혀간다

데이터가 없어도 인덱스를 미리 만들 수 있다는 게 운영상 큰 장점이다

m(연결 수) · ef_construction(빌드 후보) · ef_search(검색 후보) 세 값이 recall과 속도를 조절한다
  • ef_search는 세션 단위로 즉시 조정할 수 있다. 인덱스를 다시 만들 필요가 없다

HNSW 인덱스 만들기와 검색 시 조정

create index on documents
using hnsw (embedding vector_cosine_ops)
with (m = 16, ef_construction = 64);

begin;
set local hnsw.ef_search = 100;
select id, content from documents
order by embedding <=> $1 limit 10;
commit;

IVFFlat, 클러스터로 먼저 나눠둔다

빌드는 빠르지만 데이터를 어느 정도 채운 뒤에만 만들 수 있다

HNSW

  • 빈 테이블에도 미리 생성 가능
  • 빌드는 느리지만 정확도-속도 균형이 좋다

IVFFlat

  • 데이터가 쌓인 뒤에 만들어야 한다
  • 빌드는 빠르지만 정확도는 한 단계 아래다

거리 연산자 맞추기, 운영 팁

연산자와 인덱스를 맞춘다

  • L2는 <->, cosine은 <=>
  • 인덱스 만들 때 쓴 방식과 쿼리 연산자가 다르면 인덱스를 안 타고 전체 스캔으로 빠진다
  • 정규화된 벡터라면 내적(<#>)이 cosine보다 빠르다

빌드 시간 줄이는 설정

  • maintenance_work_mem을 8GB 정도로 키운다
  • max_parallel_maintenance_workers도 함께 늘린다
  • 부족하면 디스크로 넘어가 빌드 시간이 폭증한다

3. Postgres 풀텍스트, tsvector와 GIN

별도 검색 엔진 없이도 Postgres만으로 꽤 정교한 키워드 검색이 된다

tsvector 컬럼을 자동으로 유지하기

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

본문이 바뀔 때마다 fts 컬럼이 자동으로 갱신된다.

순위를 매기는 두 함수, 쿼리를 받는 네 파서

ts_rank vs ts_rank_cd

  • ts_rank는 매칭 빈도만 본다
  • ts_rank_cd는 단어들이 얼마나 가깝게 모였는지도 본다

쿼리 파서

  • to_tsquery는 사용자가 문법을 알아야 한다
  • websearch_to_tsquery가 구글식 입력을 그대로 받아 가장 안전하다
  • 한국어는 기본 사전으로 잘 안 되므로 pgroonga나 외부 형태소 분석기를 고려해야 한다

4. RRF로 하이브리드 구현하기

두 검색을 각자 굴렸다면 이제 결과를 하나로 합쳐야 한다

RRF의 핵심, 점수가 아니라 순위를 쓴다

스케일이 다른 두 점수를 그대로 더하면 안 되지만 순위는 바로 더할 수 있다

score = 1 / (k + rank), 두 검색의 순위를 이 식으로 더한다
  • k는 보통 50~60을 쓴다. 작으면 1위가 과도하게 유리해지고 크면 순위 차이가 평준화된다

Supabase 공식 hybrid_search 함수

create or replace function hybrid_search(
  query_text text, query_embedding extensions.vector(512),
  match_count int, full_text_weight float default 1.0,
  semantic_weight float default 1.0, rrf_k int default 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)
  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 documents.id = coalesce(full_text.id, semantic.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);
$$;

이 함수에서 놓치면 안 되는 디테일

  • FULL OUTER JOIN을 써야 한쪽에만 있는 결과도 살아남는다
  • 후보는 match_count의 2배 정도로 넓게 잡아야 두 리스트가 충분히 겹친다
  • 한쪽에 없는 값은 coalesce로 0점 처리한다
  • websearch_to_tsquery로 사용자 입력을 안전하게 받는다

가중치는 도메인마다, 장점은 통합에서 온다

가중치 시작점

  • 기술 문서: 키워드 비중을 높게
  • 상담·Q&A: 의미 검색 비중을 높게
  • 반드시 자체 평가셋으로 A/B 테스트한다

통합형의 장점

  • 문서 삽입과 인덱스 갱신이 한 트랜잭션에서 처리된다
  • Row Level Security가 그대로 적용된다
  • 검색 결과를 다른 테이블과 즉시 조인할 수 있다

5. FAISS와 Chroma의 인덱스 알고리즘

데이터를 RAM에 올려놓고 ANN 알고리즘을 돌리는 라이브러리와 DB다

Flat에서 압축까지, 규모에 맞춰 고른다

수십만 이하
Flat, 정확 검색
→
수백만~
IVF, 클러스터 탐색
→
범용
HNSW, 그래프 탐색
→
수억 이상
PQ 압축
  • 더 화려한 인덱스를 쓰기 전에 항상 Flat을 baseline으로 먼저 재본다

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

1B 벡터·768차원, Flat 3TB → OPQ 압축 64GB
  • PQ, OPQ는 정확도를 중간 정도 잃는 대신 벡터당 몇 바이트로 줄인다
  • RaBitQ는 벡터당 1비트까지 압축하지만 정확도 손실이 가장 크다

Chroma, 쓰기 쉽지만 키워드 검색은 약하다

내부적으로 HNSW와 SQLite를 쓰는 사용자 친화적 데이터베이스다

  • Python, TypeScript, Rust 클라이언트를 제공해 진입 장벽이 낮다
  • where_document의 포함 검색은 BM25가 아니라 단순 문자열 포함 여부만 본다
  • 진짜 키워드 순위가 필요하면 외부 인덱스를 같이 둬야 한다

6. 인메모리 환경의 하이브리드 패턴

Supabase처럼 SQL 함수 한 번으로 끝나지 않는다. 코드를 직접 짜야 한다

FAISS + BM25 + RRF, 직접 짤 때 걸리는 문제

  • BM25 라이브러리 다수는 부분 업데이트가 안 된다. 추가할 때마다 전체 재학습이 필요하다
  • 한국어를 제대로 다루려면 별도 형태소 분석기가 필요하다
  • 검색과 추가가 동시에 들어오면 결과가 깨질 수 있어 직접 직렬화해야 한다
  • 프로세스가 재시작되면 인덱스를 처음부터 다시 만들어야 한다

더 빠른 키워드 엔진, 그리고 운영 부담

Tantivy로 업그레이드

  • 순수 Python BM25는 백만 건이 넘으면 느려진다
  • Rust 기반 Tantivy가 한 자릿수 이상 빠르다
  • 자체 BM25 점수를 그대로 RRF에 쓸 수 있다

직접 짊어질 운영 부담

  • 두 인덱스의 동기화, 재인덱싱 정책
  • 재시작 시 메모리 워밍업
  • 스냅샷, 백업, 동시 쓰기 락 관리

7. 성능·메모리·운영 부담 비교

하드웨어와 차원이 다르면 숫자는 의미가 없다. 감을 잡는 참고용이다

지연시간, 순수 검색 단계 기준

인메모리

  • FAISS HNSW: 1~5ms
  • FAISS IVFPQ: 1~3ms
  • Chroma HTTP: 8~20ms

Postgres

  • pgvector HNSW: 5~20ms
  • pgvector IVFFlat: 10~40ms
  • 하이브리드 RRF 추가 시 +5~15ms

메모리, HNSW 기준

768차원 · M=16 · 1M 벡터 ≈ 3.2GB
  • 차원과 벡터 수에 정비례해서 늘어난다. 1536차원이면 두 배 이상이다
  • IVFFlat은 그래프 오버헤드가 없어 더 작지만 대신 recall이 낮다

쓰기와 운영, 플랫폼이 대신 해주는 것

쓰기 처리량

  • pgvector만 트랜잭션을 보장한다
  • FAISS·Chroma는 동시 쓰기를 외부에서 직접 직렬화해야 한다

운영 부담

  • 백업, HA, 권한 관리는 Supabase가 플랫폼 차원에서 제공한다
  • FAISS·Chroma는 이 모든 걸 직접 설계해야 한다

8. 워크로드별 의사결정 가이드

7가지 질문을 차례로 던지면 거의 답이 나온다

가장 중요한 질문들

  • 데이터 규모: 1M 미만이면 무엇을 써도 된다. 100M 이상이면 압축이 핵심이다
  • 트랜잭션 필요성: 결제·재고처럼 ACID가 필요하면 pgvector가 거의 무조건이다
  • 응답 SLA: p99가 ms 단위로 빡빡하면 인메모리 FAISS, 일반 RAG는 pgvector로 충분하다
  • 운영 인력: 한 명이 다 본다면 Supabase 통합형이 안전하다
  • 이미 깔린 인프라: 이미 Postgres가 있으면 pgvector로 시작해 한계를 직접 보고 옮긴다

9. 자주 하는 실수와 결론

거리 측정법 불일치와 인덱스 없이 시작하는 패턴이 가장 흔하다

실전에서 자주 보는 실수 네 가지

  • 인덱스를 만들 때 쓴 거리 방식과 쿼리 연산자가 다르면 인덱스를 타지 않는다
  • 인덱스 없이 시작했다가 데이터가 늘면서 전체 스캔에 빠진다
  • RRF 후보를 좁게 잡아 두 검색의 겹치는 부분이 사라진다
  • 임베딩 모델을 바꿔도 차원이 같으면 에러 없이 조용히 틀린 결과를 준다

정리

  • Supabase pgvector + tsvector는 통합형, FAISS·Chroma는 전문형이다
  • 알고리즘은 같다. 차이는 결국 운영 모델과 인프라 응집도다
  • pgvector HNSW + tsvector + RRF로 시작하고 한계가 명확히 보일 때만 전문형으로 옮긴다