Supabase Hybrid Search vs FAISS/Chroma 완벽 비교: pgvector + tsvector와 인메모리 벡터 DB의 모든 것
Supabase의 pgvector(HNSW/IVFFlat)와 tsvector(GIN) 기반 하이브리드 검색을 RRF SQL 함수로 구현하는 방법, FAISS·Chroma 같은 인메모리 벡터 DB의 인덱스 구조와 BM25 결합 패턴을 알고리즘 동작 원리부터 실제 코드, 성능 수치, 워크로드별 의사결정 가이드까지 빠짐없이 비교 정리합니다.
1. 왜 하이브리드 검색인가: 키워드와 의미의 사각지대
사내 RAG 시스템을 구축하면서 가장 먼저 부딪혔던 벽은 "왜 검색 결과가 이렇게 들쭉날쭉한가"였다. 사용자가 "결제 실패 코드 E402"를 검색하면 정확히 E402를 다룬 문서가 1순위로 나와야 하는데, 임베딩 기반 시맨틱 검색은 "결제 오류 처리 일반론" 같은 문서를 위로 올렸다. 반대로 "주문 취소 후 환불이 안 돼요"처럼 의도가 중요한 질의는 키워드 검색이 "취소", "환불"이라는 단어만 보고 무관한 약관 문서를 끌어왔다. 이 두 검색 방식이 각자의 사각지대를 가지고 있다는 사실을 체감한 뒤로는, RAG 파이프라인에서 검색 단계를 단일 방식으로 구성하지 않는다.
키워드 검색, 특히 BM25나 Postgres tsvector는 토큰 단위 매칭에 강하다. 제품 코드, 에러 메시지, 함수 이름, 약품의 분자식 같은 정확한 식별자를 다룰 때 시맨틱 검색이 따라올 수 없는 정밀도를 보여준다. 토큰화·스테밍·불용어 제거를 거친 인덱스가 inverted index 위에서 동작하기 때문에 응답 시간도 안정적이다. 하지만 동의어("환불" vs "리펀드"), 표현 변형("느려요" vs "지연이 발생"), 의도 추론("일정 비교해줘" → 캘린더 조회) 같은 영역은 거의 손을 못 댄다.
시맨틱 검색은 정반대다. 임베딩 모델이 학습한 의미 공간에서 가까운 벡터를 찾기 때문에 동의어와 우회 표현에 강하다. 그러나 정확한 용어 매칭이 약하다. "Spring Boot 3.2 actuator endpoint"를 검색하면 "Spring Boot 모니터링"이라는 더 일반적인 글이 위로 올라오는 false positive가 자주 발생한다. 임베딩 모델이 식별자나 코드를 잘 표현하지 못하는 경우는 더 심하다.
하이브리드 검색은 두 방식을 결합해 사각지대를 메우는 전략이다. 쇼핑 앱에서는 정확한 제품명("AirPods Pro 2세대")과 의도 기반 추천("출퇴근용 노이즈캔슬링")이 모두 필요하다. 기술 문서·RAG에서는 함수 시그니처와 자연어 질문이 공존한다. 둘 중 하나만 골라서는 충분히 좋은 검색 품질을 만들 수 없다.
이 글에서 던지는 핵심 질문은 다음 하나다.
검색 인프라를 Postgres 하나로 통합할 것인가, 별도의 벡터 엔진과 키워드 엔진을 조합할 것인가.
Supabase의 pgvector + tsvector + RRF 조합을 쓰면 모든 것이 단일 Postgres 안에서 트랜잭션·RLS·백업과 함께 동작한다. 반면 FAISS/Chroma + BM25 조합은 두 인덱스를 직접 운영해야 하지만 극한의 성능·압축·커스텀 ANN을 얻을 수 있다. 어느 쪽을 고를지는 데이터 규모, 운영 인력, SLA, 트랜잭션 요구사항에 따라 갈린다. 이 글의 나머지는 그 결정을 내릴 수 있도록 알고리즘 동작 원리부터 실제 구현 코드, 성능 수치까지 빠짐없이 정리한다.
2. Supabase의 pgvector 인덱스 구조와 알고리즘
Supabase에서 벡터 검색을 한다고 했을 때 실제로 동작하는 엔진은 pgvector다. Postgres extension이며 vector, halfvec, sparsevec, bit 타입을 지원한다. 인덱스 없이도 정확한 nearest neighbor 검색이 가능하지만, 데이터가 수만 건을 넘기 시작하면 sequential scan이 무거워지기 때문에 ANN 인덱스를 붙이는 게 사실상 필수다. pgvector가 제공하는 ANN 인덱스는 두 종류다.
HNSW: 다층 그래프 기반 ANN
HNSW(Hierarchical Navigable Small World)는 데이터를 다층 그래프로 구성한다. 최상위 레이어는 멀리 떨어진 노드들 사이의 "고속도로" 역할을 하고, 하위 레이어로 내려갈수록 더 촘촘한 연결이 만들어진다. 검색 시에는 상단 레이어에서 시작해 점점 좁혀 들어가며 가장 가까운 이웃을 찾는다. 속도-정확도 trade-off가 매우 좋고, IVFFlat과 달리 데이터가 없어도 인덱스를 미리 생성할 수 있다는 운영상 큰 장점이 있다.
create index on documents
using hnsw (embedding vector_cosine_ops)
with (m = 16, ef_construction = 64);
파라미터는 세 가지를 본다.
| 파라미터 | 기본값 | 역할 | 트레이드오프 |
|---|---|---|---|
m |
16 | 레이어당 최대 연결 수 | 클수록 recall↑, 메모리↑, 빌드 시간↑ |
ef_construction |
64 | 빌드 시 후보 리스트 크기 | 클수록 recall↑, 빌드 시간↑ |
hnsw.ef_search |
40 | 쿼리 시 후보 리스트 크기 | 클수록 recall↑, query latency↑ |
ef_search만 세션·트랜잭션 단위로 동적으로 조정할 수 있다는 점이 중요하다. recall이 낮다고 느낄 때 인덱스를 재빌드할 필요 없이 다음처럼 쿼리 단위로 올린다.
begin;
set local hnsw.ef_search = 100;
select id, content
from documents
order by embedding <=> $1
limit 10;
commit;
IVFFlat: 클러스터링 기반 ANN
IVFFlat은 데이터를 k-means로 lists 개의 클러스터로 나눠 두고, 쿼리 시 가장 가까운 probes 개 클러스터만 탐색한다. HNSW보다 빌드가 빠르고 메모리도 적게 쓰지만, query speed-recall 곡선은 HNSW보다 한 단계 아래다. 무엇보다 데이터를 어느 정도 적재한 뒤에 만들어야 한다는 제약이 있다. 빈 테이블에 IVFFlat 인덱스를 만들면 학습할 분포가 없어서 recall이 망가진다.
pgvector 공식 권장은 다음과 같다.
lists ≈ rows / 1000(1M rows 미만)lists ≈ sqrt(rows)(1M rows 이상)- 쿼리 시
probes ≈ sqrt(lists)로 시작
create index on documents
using ivfflat (embedding vector_cosine_ops)
with (lists = 1000);
set ivfflat.probes = 32;
거리 연산자와 인덱스 매칭
가장 흔히 실수하는 부분이다. 인덱스를 만들 때 쓴 operator class와 쿼리에 쓴 거리 연산자가 일치하지 않으면 plan이 인덱스 스캔이 아니라 seq scan으로 빠진다. 다음 매핑을 외워두는 게 낫다.
| 거리 | 연산자 | Operator class |
|---|---|---|
| L2 (Euclidean) | <-> |
vector_l2_ops |
| Negative inner product | <#> |
vector_ip_ops |
| Cosine distance | <=> |
vector_cosine_ops |
| L1 (Manhattan) | <+> |
vector_l1_ops |
OpenAI 임베딩처럼 L2 normalize된 벡터를 쓴다면 cosine 대신 negative inner product(<#>)가 더 빠르다. 정규화 여부를 모르거나 다양한 모델을 혼용한다면 cosine이 안전한 기본값이다.
운영 팁: maintenance_work_mem과 parallel workers
HNSW 인덱스 빌드 시간이 큰 문제로 떠오르는 임계점은 보통 1M 벡터, 768~1536 차원 근처다. 다음 두 파라미터를 키워주면 빌드 시간이 체감상 절반 이상 줄어든다.
set maintenance_work_mem = '8GB';
set max_parallel_maintenance_workers = 7;
create index on documents
using hnsw (embedding vector_cosine_ops);
maintenance_work_mem이 부족하면 빌드 도중 다음 NOTICE가 뜨고, 디스크로 스필되면서 시간이 폭증한다.
NOTICE: hnsw graph no longer fits into maintenance_work_mem after 100000 tuples DETAIL: Building will take significantly more time. HINT: Increase maintenance_work_mem to speed up builds.
Supabase Cloud에서는 인스턴스 크기에 따라 이 값을 올릴 수 있는 한도가 다르므로, 큰 인덱스를 만들기 전에 임시로 인스턴스를 업스케일하고 빌드 후 다시 내리는 패턴이 흔하다. 빌드 진행률은 다음 쿼리로 확인할 수 있다.
select phase,
round(100.0 * blocks_done / nullif(blocks_total, 0), 1) as pct
from pg_stat_progress_create_index;
3. Postgres 풀텍스트 검색: tsvector, ts_rank, GIN
벡터 쪽만큼이나 자주 간과되는 게 키워드 검색이다. Postgres는 별도의 검색 엔진을 붙이지 않아도 꽤 정교한 풀텍스트 검색을 제공한다. 핵심 자료구조는 tsvector, 핵심 인덱스는 GIN, 매칭 연산자는 @@이다.
tsvector: 정규화된 토큰 집합
tsvector는 텍스트를 토큰화·소문자화·불용어 제거·스테밍을 거쳐 만든 정규화된 토큰 집합이다. to_tsvector('english', content)처럼 언어 사전을 지정해서 만든다. 예를 들어 "Running queries quickly"는 다음과 같이 정규화된다.
select to_tsvector('english', 'Running queries quickly');
-- 결과: 'queri':2 'quick':3 'run':1
각 토큰 뒤의 숫자는 원문에서의 위치이며, ts_rank_cd가 근접도 점수를 계산할 때 활용된다.
테이블에 컬럼을 직접 들고 다니지 않고 GENERATED ALWAYS AS ... STORED로 자동 생성하면 본문이 바뀔 때마다 자동으로 갱신된다. 이게 운영상 가장 깔끔하다.
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)
);
GIN 인덱스
tsvector는 본질적으로 token → docid posting list 구조를 inverted index로 인덱싱해야 효율적이다. Postgres에서는 GIN(Generalized Inverted Index)이 이 역할을 한다.
create index on documents using gin (fts);
이 인덱스가 있으면 @@ 연산자가 GIN 스캔으로 동작한다. 인덱스가 없으면 전체 테이블을 훑으면서 매번 to_tsvector를 호출하는 셈이 되어 느리다.
ts_rank vs ts_rank_cd
매칭된 row를 점수 순으로 정렬하려면 ranking 함수가 필요하다. Postgres는 두 가지를 제공한다.
| 함수 | 무엇을 보는가 | 인덱스로 추가 가속 가능? |
|---|---|---|
ts_rank |
매칭 빈도(frequency) 중심 | 함수 호출 자체는 비싸지만 적용 row가 적으면 OK |
ts_rank_cd |
빈도 + 근접도(coverage density) | 인덱서블하지 않음, 반드시 필터 후 적용 |
ts_rank_cd는 매칭된 토큰들이 얼마나 가깝게 모여 있는지까지 본다. "machine learning"을 검색했을 때 두 단어가 붙어 있는 문서를 멀리 떨어진 문서보다 위로 올린다. 단, 함수 자체가 인덱서블하지 않으므로 반드시 WHERE fts @@ websearch_to_tsquery(...)로 후보군을 좁힌 다음에 점수를 매겨야 한다.
select id, content,
ts_rank_cd(fts, websearch_to_tsquery('english', $1)) as score
from documents
where fts @@ websearch_to_tsquery('english', $1)
order by score desc
limit 20;
쿼리 파서: websearch_to_tsquery가 가장 안전하다
Postgres에는 to_tsquery, plainto_tsquery, phraseto_tsquery, websearch_to_tsquery 네 가지 쿼리 파서가 있다. production에서는 websearch_to_tsquery가 거의 정답이다.
| 파서 | 특징 | 사용자 입력 허용도 |
|---|---|---|
to_tsquery |
&, ` |
, !` 직접 사용 |
plainto_tsquery |
공백을 모두 AND | 중간, 표현력 부족 |
phraseto_tsquery |
모두 phrase로 처리 | 낮음 |
websearch_to_tsquery |
따옴표 "...", OR, - 지원 |
높음, 구글식 입력 그대로 |
-- "결제 실패" -환불 OR 오류
select * from documents
where fts @@ websearch_to_tsquery('simple', '"결제 실패" -환불 OR 오류');
한국어 토큰화는 기본 사전으로는 제대로 안 된다. 한국어가 중요한 워크로드라면 pgroonga extension이나 외부 형태소 분석기를 거친 결과를 별도 컬럼으로 저장하는 패턴을 고려해야 한다. Supabase에서 pgroonga는 직접 enable 가능 여부를 프로젝트 설정에서 확인 필요.
4. Supabase에서 RRF로 하이브리드 검색 구현하기
두 검색을 따로 굴리는 데까지 왔다면 이제 결과를 합쳐야 한다. 여러 fusion 기법이 있지만 production에서 가장 견고한 건 **RRF(Reciprocal Rank Fusion)**다.
RRF의 직관
각 검색 결과 리스트에서 record의 rank를 보고, 1 / (k + rank)를 모두 더한 값이 최종 점수다. score가 아니라 rank를 쓰는 게 핵심이다. 두 검색이 서로 다른 점수 스케일(BM25 score vs cosine distance)을 가지더라도 rank는 같은 정수이기 때문에 정규화가 필요 없다.
예: record A가 키워드 검색에서 3위, 시맨틱 검색에서 9위라면 score = 1/(60+3) + 1/(60+9) = 0.01587 + 0.01449 = 0.03036
k는 smoothing constant다. 일반적으로 50~60을 쓴다. 작으면 1위 결과의 영향력이 압도적이 되고, 크면 결과가 평준화된다. k=1이면 1위가 0.5점이라는 큰 격차를 만들지만, k=60이면 1위가 1/61 ≈ 0.0164로 다른 순위와 차이가 좁아진다.
Supabase 공식 hybrid_search 함수
Supabase 공식 docs의 패턴을 거의 그대로 가져와서 weight 파라미터를 추가한 형태가 production에서 가장 무난하다.
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)
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 documents.id = coalesce(full_text.id, semantic.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);
$$;
몇 가지 디테일을 짚어보자.
FULL OUTER JOIN한쪽에만 있는 record도 살린다. 한쪽 score가 0이 되더라도 다른 쪽 score가 충분히 높으면 살아남는다.limit ... * 2각 검색에서 후보를 match_count의 2배 정도로 넓게 잡는다. 좁게 잡으면 두 리스트의 교집합이 너무 적어 RRF의 장점이 사라진다.coalesce(..., 0)한쪽에 없는 경우 그 항의 점수는 0이 된다.websearch_to_tsquery사용자 입력을 안전하게 받기 위한 선택.
클라이언트에서 호출
supabase-js에서는 rpc()로 호출한다.
const { data, error } = await supabase.rpc('hybrid_search', {
query_text: '결제 실패 E402 환불 절차',
query_embedding: await embed(query),
match_count: 10,
full_text_weight: 1.0,
semantic_weight: 1.0,
rrf_k: 50,
});
가중치 튜닝
full_text_weight와 semantic_weight는 도메인마다 다르다. 내 경험상 다음 휴리스틱이 시작점으로 괜찮았다.
| 도메인 | 시작 가중치 | 이유 |
|---|---|---|
| 기술 문서·API | full_text 1.5, semantic 1.0 | 함수명·코드명 정확 매칭 비중 ↑ |
| 일반 RAG (정책·매뉴얼) | 1.0 / 1.0 | balanced |
| 상담·자연어 Q&A | full_text 0.7, semantic 1.5 | 의도·동의어 비중 ↑ |
| 쇼핑 (제품 검색) | 1.2 / 1.0 | 정확한 모델명 우선 |
이 값은 반드시 자체 evaluation set으로 A/B 테스트해야 한다. nDCG@10이나 Recall@10 같은 지표로 측정하지 않으면 가중치 조정이 그냥 운에 의지한다.
장단점
Supabase 통합형 접근의 장점은 명확하다.
- 트랜잭션: 문서 삽입과 인덱스 갱신이 한 트랜잭션에서 처리된다.
- RLS: Row Level Security가 그대로 적용된다. 사용자별 접근 제어가 자동으로 따라온다.
- 백업: PITR(Point-in-Time Recovery)이 벡터·인덱스에 그대로 적용된다.
- JOIN: 검색 결과를 다른 비즈니스 테이블과 즉시 조인할 수 있다.
단점은 한 가지로 요약된다. 모든 인덱스가 Postgres 한 인스턴스의 메모리·CPU에서 동작한다. 50M rows 이상으로 가면 HNSW 인덱스 크기, GIN 인덱스 크기, work_mem, vacuum 부담이 동시에 올라간다. read replica, partial index, partitioning을 직접 설계해야 한다.
5. FAISS와 Chroma: 인메모리 벡터 DB의 인덱스 알고리즘
반대편 진영을 보자. FAISS(Facebook AI Similarity Search)는 인메모리 벡터 검색 라이브러리의 표준이다. Chroma는 그보다 한 단계 위, "embedded vector DB"로 좀 더 사용자 친화적인 추상화를 제공한다. 두 도구는 데이터를 RAM에 올려놓고 ANN 알고리즘을 돌리는 점에서 같은 카테고리다.
FAISS IndexFlat: exact baseline
IndexFlatL2 / IndexFlatIP는 압축도 클러스터링도 없는 정확 검색이다. 학습 단계가 없고, GPU도 지원한다. 수만~수십만 벡터까지는 이 인덱스만으로도 ms 단위로 끝난다. 더 화려한 인덱스를 시도하기 전 baseline으로 항상 먼저 측정해야 한다.
import faiss, numpy as np
d = 768
index = faiss.IndexFlatIP(d) # 내적 (정규화된 벡터면 cosine)
index.add(vectors) # vectors: (n, d) float32
D, I = index.search(query, k=10)
FAISS IVF 계열
데이터가 많아지면 k-means 클러스터링 기반 IVF로 넘어간다. IVFK_Flat은 K개 클러스터로 나누고 nprobe개만 탐색한다.
nlist = 4 * int(np.sqrt(n)) # 1M 미만은 4√N ~ 16√N
quantizer = faiss.IndexFlatIP(d)
index = faiss.IndexIVFFlat(quantizer, d, nlist, faiss.METRIC_INNER_PRODUCT)
index.train(training_sample)
index.add(vectors)
index.nprobe = int(np.sqrt(nlist)) # 시작점
데이터 규모별 FAISS 공식 권장 string을 표로 정리하면 다음과 같다.
| 데이터 규모 | index_factory 문자열 | 비고 |
|---|---|---|
| < 1M | IVF{4·sqrt(N)} |
표준 IVF |
| 1M~10M | IVF65536_HNSW32 |
cluster assignment에 HNSW 사용 |
| 10M~100M | IVF262144_HNSW32 |
학습 매우 느림, 2단계 클러스터링 고려 |
| 100M~1B | IVF1048576_HNSW32 |
GPU로 학습만 분리하는 패턴 권장 |
FAISS HNSW
pgvector HNSW와 같은 알고리즘이다. 메모리 식으로는 다음과 같이 정확하게 계산된다.
메모리 사용량 = (d × 4 + M × 2 × 4) bytes / vector
768 차원, M=16이면 vector당 768×4 + 16×8 = 3072 + 128 = 3200 bytes ≈ 3.2KB. 1M 벡터면 약 3.2GB.
M = 16
index = faiss.IndexHNSWFlat(d, M)
index.hnsw.efConstruction = 64
index.add(vectors)
index.hnsw.efSearch = 64 # 쿼리 시 정확도 다이얼
D, I = index.search(query, k=10)
HNSW는 add_with_ids를 지원하지 않으므로 ID가 필요하면 IDMap으로 래핑한다.
index = faiss.IndexIDMap(faiss.IndexHNSWFlat(d, M))
index.add_with_ids(vectors, ids)
압축: PQ, OPQ, RaBitQ
벡터 수가 수억 개로 가면 메모리가 가장 큰 제약이 된다. 압축 인덱스가 필수다.
| 압축 방식 | 코드 크기 | 정확도 손실 | 사용처 |
|---|---|---|---|
| PQ-M | M bytes/vector | 중 | 표준 압축 |
| OPQ + PQ | M bytes/vector | 중하 | 사전에 회전 적용해 정확도 ↑ |
| PQ-Mx4fs (fast scan) | M/2 bytes/vector | 중 | SIMD로 검색 가속 |
| RaBitQ | d/8 + 8 bytes/vector | 큼 | 극한 압축, 1 bit/dim |
1B 벡터·768 차원 시나리오에서 OPQ64_768,IVF1048576,PQ64는 vector당 64 bytes로 압축돼 64GB 정도에 들어간다. Flat이었다면 3TB.
Chroma: 사용자 친화적 추상화
Chroma는 내부적으로 hnswlib(C++ HNSW)을 사용한다. Python/TypeScript/Rust 클라이언트를 제공하며, 진입 장벽이 매우 낮다.
import chromadb
client = chromadb.PersistentClient(path="./chroma_db")
col = client.get_or_create_collection(
name="docs",
metadata={"hnsw:space": "cosine"} # l2, ip, cosine
)
col.add(
ids=["d1", "d2"],
documents=["pgvector는 Postgres extension이다.", "FAISS는 인메모리 라이브러리다."],
embeddings=[emb1, emb2],
metadatas=[{"src": "blog"}, {"src": "wiki"}],
)
res = col.query(query_embeddings=[q_emb], n_results=10,
where={"src": "blog"})
운영상 중요한 차이는 다음과 같다.
- In-memory client (
chromadb.EphemeralClient): 프로세스 종료 시 데이터 휘발. 노트북·테스트 전용. - PersistentClient: 로컬 디스크에 SQLite + hnsw 인덱스 파일로 영속화.
- Client-server (
chroma run): HTTP API 분리, 여러 클라이언트에서 접근. - Chroma Cloud: serverless 매니지드 서비스.
Chroma의 where_document={"$contains": "환불"} 필터는 풀텍스트 검색처럼 보이지만 실제로는 substring match다. BM25/tsvector 같은 토큰 기반 ranking이 아니라 그냥 "포함하는가"만 보기 때문에 한국어 형태소 분석이나 스코어링이 필요하면 부족하다. 진짜 키워드 ranking이 필요하면 외부 인덱스를 병행해야 한다.
6. 인메모리 환경에서의 하이브리드 검색 패턴
FAISS/Chroma 진영에서 하이브리드를 하려면 키워드 인덱스를 따로 들고, 애플리케이션 레이어에서 RRF로 합친다. SQL 함수 한 방으로 끝나는 Supabase와 달리 코드를 직접 써야 한다.
표준 조합: FAISS + rank_bm25 + RRF
가장 단순한 single-process RAG 셋업을 코드로 보자.
import numpy as np
import faiss
from rank_bm25 import BM25Okapi
from collections import defaultdict
class HybridIndex:
def __init__(self, dim: int, m: int = 16):
self.dim = dim
self.faiss = faiss.IndexHNSWFlat(dim, m)
self.faiss.hnsw.efSearch = 64
self.bm25 = None
self.tokenized_corpus = []
self.docs = [] # id별 원문 보관
self.id_map = {} # faiss 내부 idx → external id
def add(self, docs: list[dict], embeddings: np.ndarray, tokenize):
start = len(self.docs)
self.faiss.add(embeddings.astype("float32"))
for i, d in enumerate(docs):
self.id_map[start + i] = d["id"]
self.docs.append(d)
self.tokenized_corpus.append(tokenize(d["content"]))
self.bm25 = BM25Okapi(self.tokenized_corpus) # 매 add마다 재학습 필요
def search(self, query: str, q_emb: np.ndarray,
tokenize, k: int = 10, rrf_k: int = 60) -> list[dict]:
# 1) dense
D, I = self.faiss.search(q_emb.astype("float32").reshape(1, -1), k * 2)
dense_rank = {self.id_map[idx]: rank + 1
for rank, idx in enumerate(I[0]) if idx != -1}
# 2) sparse
scores = self.bm25.get_scores(tokenize(query))
sparse_top = np.argsort(scores)[::-1][: k * 2]
sparse_rank = {self.docs[i]["id"]: rank + 1
for rank, i in enumerate(sparse_top) if scores[i] > 0}
# 3) RRF
fused = defaultdict(float)
for doc_id, r in dense_rank.items():
fused[doc_id] += 1.0 / (rrf_k + r)
for doc_id, r in sparse_rank.items():
fused[doc_id] += 1.0 / (rrf_k + r)
ranked = sorted(fused.items(), key=lambda x: x[1], reverse=True)[:k]
return [{"id": did, "score": s,
"doc": next(d for d in self.docs if d["id"] == did)}
for did, s in ranked]
이 코드의 문제점도 솔직히 같이 보자.
BM25Okapi는 incremental update가 안 된다. add마다 전체 재학습이다. 데이터가 자주 추가되면 비효율적이다.tokenize함수가 한국어를 제대로 다루려면 Mecab/Okt 같은 외부 형태소 분석기가 필요하다.- 동시성 제어가 없다.
faiss.add중 검색이 들어오면 결과가 망가질 수 있어 외부에서 직렬화해야 한다. - 영속화가 없다. 프로세스 재시작 시 처음부터 다시 빌드해야 한다.
Tantivy: 한 단계 더 빠른 키워드 엔진
rank_bm25는 순수 Python이라 100만 건을 넘기면 느려진다. Tantivy(Rust 기반)는 Whoosh보다 한 자릿수 이상 빠르고 Python 바인딩(tantivy-py)을 제공한다. 실서비스에서는 BM25 쪽 병목을 풀기 위해 Tantivy로 옮기는 패턴이 표준이다.
import tantivy
schema_builder = tantivy.SchemaBuilder()
schema_builder.add_text_field("id", stored=True)
schema_builder.add_text_field("content", stored=True, tokenizer_name="default")
schema = schema_builder.build()
index = tantivy.Index(schema, path="./tantivy_idx")
writer = index.writer()
for d in docs:
writer.add_document(tantivy.Document(id=d["id"], content=d["content"]))
writer.commit()
searcher = index.searcher()
query = index.parse_query("결제 실패", ["content"])
hits = searcher.search(query, limit=20).hits # [(score, doc_address), ...]
Tantivy는 자체 BM25 스코어를 반환하므로 RRF에 그대로 넣을 수 있다.
Chroma의 한계와 우회
Chroma는 where_document={"$contains": "..."}로 substring 필터를 제공하지만 BM25가 아니다. 한국어 환경에서 진짜 ranking이 필요하면 결국 위처럼 외부 키워드 인덱스를 같이 둬야 한다. Chroma는 metadata 필터 + dense 검색이 강점이고, 키워드 ranking은 약점이다.
운영 부담
이 모든 걸 직접 짜야 한다는 게 인메모리 진영의 본질이다.
- 두 인덱스 동기화 (문서 삽입·삭제 시 양쪽 모두 갱신)
- 재인덱싱 정책 (BM25는 부분 업데이트가 어렵다)
- 재시작 시 메모리 워밍업
- 스냅샷 / 영속화 / 백업
- 동시 쓰기·읽기 락 관리
- 멀티 노드 시 sharding / replication
코드를 본 직후엔 가벼워 보이지만, production-ready로 만들려면 두 인덱스의 lifecycle을 책임지는 서비스 한 덩어리가 생긴다.
7. 성능·메모리·운영 부담 비교 (실측 가이드라인)
수치를 비교할 때는 하드웨어·차원·데이터셋이 다르면 의미가 없다는 전제를 먼저 깔자. 아래는 1M, 768-dim 데이터셋을 m5.xlarge급 인스턴스 기준으로 돌렸을 때 일반적으로 보고되는 범위다. 자기 환경에서 직접 측정하지 않으면 안 된다.
지연시간 (순수 ANN 단계, k=10)
| 엔진 | latency | 비고 |
|---|---|---|
| FAISS HNSW (in-process) | 1~5 ms | 네트워크 없음 |
| FAISS IVFPQ (1M·M=64) | 1~3 ms | 압축까지 적용 |
| Chroma HNSW (in-process) | 3~10 ms | Python 오버헤드 |
| Chroma HTTP | 8~20 ms | gRPC/HTTP RTT 포함 |
| pgvector HNSW (Supabase) | 5~20 ms | TLS·SQL parsing·RTT 포함 |
| pgvector IVFFlat | 10~40 ms | recall 낮음 |
여기에 하이브리드를 얹으면 다음이 더해진다.
- pgvector + tsvector RRF: 추가 5~15ms (GIN 스캔 + JOIN)
- FAISS + Tantivy + 앱 RRF: 추가 3~10ms
메모리
HNSW의 메모리 식 (d × 4 + M × 2 × 4) bytes/vector를 기준으로 표를 만들면 직관이 잡힌다.
| 차원 | M | bytes/vec | 1M | 10M | 100M |
|---|---|---|---|---|---|
| 384 | 16 | 1664 | 1.66 GB | 16.6 GB | 166 GB |
| 768 | 16 | 3200 | 3.2 GB | 32 GB | 320 GB |
| 1536 | 16 | 6272 | 6.27 GB | 62.7 GB | 627 GB |
| 768 | 32 | 3328 | 3.33 GB | 33.3 GB | 333 GB |
IVFFlat은 그래프 오버헤드가 없어서 더 작지만 recall이 낮다. PQ/OPQ로 압축하면 1M·768-dim이 1.66GB → 64MB(M=64) 수준까지 떨어진다.
쓰기 처리량
| 항목 | pgvector | FAISS HNSW | Chroma |
|---|---|---|---|
| 트랜잭션 보장 | O | X | X |
| 동시 INSERT | 가능 (HNSW는 비쌈) | 외부 직렬화 필요 | 단일 writer 권장 |
| 부분 업데이트 | 가능 | 가능 | 가능 |
| 삭제 후 공간 회수 | VACUUM 필요 | 어려움 | 어려움 |
pgvector HNSW는 동시 insert를 받지만 lock 경합이 커서 batch insert + post-build index 패턴이 권장된다.
운영 부담
| 항목 | Supabase (pgvector+tsvector) | FAISS/Chroma |
|---|---|---|
| 백업/PITR | 플랫폼 기본 제공 | 직접 설계 |
| HA / read replica | 플랫폼 옵션 | 직접 설계 |
| 권한/RLS | Postgres RLS 그대로 | 앱 레이어에서 구현 |
| 인덱스 빌드 | 점진적/parallel worker | concurrent 제어 직접 |
| 모니터링 | pg_stat_* 풀스택 | Prometheus exporter 직접 |
| 영속화 | 자동 | 스냅샷·저장소 설계 |
비용
직접 한 줄로 비교하긴 어렵지만 대략적인 감각은 다음과 같다.
- Supabase Cloud: Postgres 인스턴스(CPU/RAM) 비용 + 스토리지. 1M 벡터·하이브리드면 Pro 플랜 + compute add-on에서 충분히 굴러간다.
- FAISS self-hosted: 라이브러리 자체는 무료. 대신 (a) 대용량 RAM/GPU 인스턴스, (b) 영속화·복제용 스토리지, (c) 운영 인력 비용이 큰 비중을 차지한다.
- Chroma Cloud: 사용량 기반. 작게 시작하기 쉬움.
"FAISS는 공짜다"는 라이브러리 라이선스 얘기지 TCO 얘기가 아니다. 실제로는 운영 시간이 가장 비싸다.
8. 워크로드별 의사결정 가이드
위 모든 정보를 한 표로 압축하면 다음과 같다.
| 시나리오 | 추천 | 이유 |
|---|---|---|
| 이미 Postgres/Supabase 사용, 문서 1천만 건 이하 RAG | pgvector HNSW + tsvector + RRF | 단일 인프라, 트랜잭션, RLS, JOIN |
| 1~10M 벡터, multi-tenant SaaS | pgvector + partitioning | RLS로 tenant 분리, 운영 단순 |
| 수억~수십억 벡터, 강한 압축 필요 (이미지·추천) | FAISS IVFPQ/OPQ/RaBitQ + 별도 BM25 | 메모리·압축이 핵심 |
| 프로토타입·연구·노트북 | Chroma in-memory / FAISS Flat | 진입 장벽 최소화 |
| 다국어, 형태소 분석 중요 | Elasticsearch/OpenSearch + 벡터 DB | tsvector 한국어 한계 회피 |
| 강한 SLA (p99 5ms 이하) | 인메모리 FAISS in-process | 네트워크 hop 자체를 제거 |
| 트랜잭션 필수 (커머스 카탈로그) | pgvector | 인메모리 진영은 트랜잭션 약함 |
의사결정 체크리스트
새 프로젝트를 시작할 때 다음 7개 질문을 차례로 던지면 거의 답이 나온다.
- 데이터 규모: 1M 미만이면 무엇을 써도 된다. 100M 이상이면 압축이 핵심 변수다.
- 읽기/쓰기 비율: 쓰기가 자주 일어나면 pgvector의 트랜잭션이 유리하다. 한 번 빌드하고 거의 읽기만이면 FAISS Flat/IVFPQ가 빠르다.
- 트랜잭션 필요성: 결제·재고처럼 ACID가 필요하면 pgvector가 거의 무조건이다.
- 운영 인력: 한 명이 다 본다면 Supabase 통합형. 전담 SRE/ML infra가 있으면 FAISS도 가능.
- 응답 SLA: p99가 ms 단위로 빡빡하면 in-process FAISS. 일반 RAG는 50~100ms p99로 충분하다.
- 언어: 한국어·일본어·중국어 비중이 크면 tsvector의 사전 한계를 미리 고려.
- 이미 깔린 인프라: 이미 Postgres가 있으면 pgvector로 시작해서 한계를 직접 보고 옮기는 게 가장 risk가 적다.
9. 마이그레이션 노트와 안티패턴
직접 RAG 프로젝트를 운영하면서 가장 자주 본 실수들을 정리한다.
Distance metric 불일치
FAISS에서 IndexFlatIP로 쓰던 정규화된 벡터를 pgvector로 옮기면서 vector_cosine_ops로 인덱스를 만들면 동작은 한다. 하지만 inner product가 더 빠른데 cosine을 고른 비효율이 발생한다. 반대로 IP 인덱스에 정규화 안 된 벡터를 넣으면 거리 계산 자체가 잘못된다. 이행 시 다음 체크리스트가 필수다.
- 기존 임베딩이 L2 normalize 되었는가?
- 기존 metric이 L2 / IP / Cosine 중 무엇이었나?
- pgvector ops 클래스를 같은 metric으로 맞췄는가?
- 쿼리 연산자(
<->,<#>,<=>)가 ops 클래스와 같은가?
"인덱스 없이 시작했다가 seq scan에 빠지는" 패턴
가장 흔하다. 개발 단계에서 1만 건 정도일 땐 잘 돌다가, 데이터가 100만 건 넘으면 갑자기 모든 쿼리가 5초씩 걸린다. EXPLAIN ANALYZE를 찍으면 Seq Scan이 보인다. HNSW 인덱스는 데이터가 없어도 만들 수 있으니 테이블 생성 시점에 같이 만들어두는 게 안전하다.
RRF 후보군이 너무 좁다
hybrid_search 함수에서 limit match_count로 끝내면 두 검색의 교집합이 비어 결과가 빈약해진다. limit match_count * 2 이상으로 후보군을 넓혀서 RRF에 충분한 ranking 정보가 들어가게 해야 한다. 공식 docs 예제가 * 2인 이유다.
임베딩 모델 변경 시 silent 실패
text-embedding-3-small(1536-dim)로 만든 인덱스에 text-embedding-3-large(3072-dim) 쿼리를 넣으면 에러가 난다. 다행이다. 그런데 같은 1536-dim의 다른 모델로 바꾸면 차원이 맞아서 에러 없이 잘못된 결과를 준다. 이게 진짜 무섭다. 방어책은 두 가지다.
-- 1. 컬럼 차원을 강제하고
alter table documents
alter column embedding type extensions.vector(1536);
-- 2. 모델 버전 메타데이터를 같이 저장
alter table documents
add column embedding_model text not null default 'text-embedding-3-small';
검색 시점에 where embedding_model = $current_model로 필터하면 모델 혼용이 일어나지 않는다. 이행 기간에는 두 모델로 임베딩을 동시에 들고 있다가 점진적으로 전환한다.
인메모리 DB의 무중단 재시작 환상
"FAISS를 RAM에 올려두고 영원히 띄워두면 된다"는 발상은 거의 실패한다. OOM, OS 패치, 컨테이너 재배포, 인스턴스 교체 등 재시작은 반드시 일어난다. 미리 다음을 설계해두지 않으면 1시간씩 다운된다.
- 주기적 스냅샷 (
faiss.write_index)을 S3/GCS에 백업 - 부팅 시 스냅샷 로드 + delta 재인덱싱 절차
- warm-up 트래픽 (cold cache 회피)
- blue-green으로 새 노드 워밍 후 트래픽 스위치
10. 결론: 통합형 vs 전문형
길게 짚었지만 결론은 한 줄이다.
Supabase pgvector + tsvector는 통합형, FAISS/Chroma는 전문형이다.
Supabase pgvector + tsvector는 RAG·검색 요구사항의 90%를 단일 Postgres로 해결한다. 트랜잭션, RLS, 백업, JOIN, 점진적 인덱스 빌드가 모두 플랫폼 차원에서 따라온다. 운영 인력이 작은 팀, 이미 Postgres가 돌고 있는 팀, multi-tenant SaaS에는 거의 항상 정답에 가깝다.
FAISS·Chroma는 극한의 성능·압축·커스텀 ANN이 필요한 워크로드용이다. 수억 벡터, GPU 가속, OPQ/RaBitQ 같은 압축, 1~5ms 단위 p99가 요구되는 추천·이미지 검색에서 빛난다. 대신 영속화·복제·동기화·재인덱싱·모니터링을 모두 직접 짜야 한다. 운영 부담이 늘어나는 만큼 인프라 인력이 받쳐줘야 한다.
알고리즘은 양쪽 모두 같다. HNSW는 어디서 쓰든 HNSW이고, IVF는 어디서 쓰든 IVF다. RRF, Recall@k, nDCG도 마찬가지다. 차이는 결국 운영 모델과 인프라 응집도다.
내가 새 프로젝트를 시작한다면 추천 시작점은 다음 한 줄이다.
pgvector HNSW + tsvector + RRF SQL function으로 시작하고, 데이터 규모·비용·latency 한계가 명확히 보일 때 FAISS/Chroma 또는 전용 검색 엔진으로 부분 이전하라.
대부분의 팀은 "한계가 명확히 보이는 시점"까지 가지 않는다. 그래서 통합형이 거의 항상 정답이다. 그 한계까지 가야 하는 팀이라면, 그쯤 되면 이 글이 아니라 직접 측정한 자기 데이터의 grafana 그래프를 보고 결정하게 될 것이다.
다음에 다룰 것은 RRF를 넘어선 fusion 기법(weighted RRF, score-based fusion, learned fusion)과, 한국어 형태소 분석을 tsvector·BM25에 제대로 붙이는 방법이다.
참고 문헌
- Hybrid search | Supabase Docs
- Semantic search | Supabase Docs
- Keyword search | Supabase Docs
- Vector indexes | Supabase Docs
- pgvector — Open-source vector similarity search for Postgres (GitHub)
- FAISS — Guidelines to choose an index (Wiki)
- FAISS — The index factory
- Chroma — Getting Started
- PostgreSQL — Full Text Search
- Reciprocal Rank Fusion outperforms Condorcet and individual Rank Learning Methods (Cormack et al., 2009)
- HNSW: Efficient and robust approximate nearest neighbor search using Hierarchical Navigable Small World graphs (Malkov & Yashunin, 2016)
- rank_bm25 — A Collection of BM25 Algorithms in Python (GitHub)
- Tantivy — A full-text search engine library inspired by Apache Lucene (GitHub)