Supabase vs FAISS Chroma 하이브리드 검색
개요
요약
의미 검색과 키워드 검색을 하나로 합치는 하이브리드 검색을 Postgres 한 대로 구현하는 Supabase 조합 (pgvector·tsvector·RRF)과 FAISS·Chroma 같은 전용 벡터 DB 중 어느 쪽으로 만들지 비교한다. 인덱스 구조의 동작 원리부터 실제 코드·성능 수치·워크로드별 선택 기준까지 정리한다.
목차
- 1. 왜 하이브리드 검색인가
- 2. Supabase의 pgvector 인덱스 구조
- 3. Postgres 풀텍스트 검색
- 4. Supabase에서 RRF로 하이브리드 검색 구현하기
- 5. FAISS와 Chroma의 인덱스 알고리즘
- 6. 인메모리 환경에서의 하이브리드 검색
- 7. 성능과 운영 부담
- 8. 워크로드별 선택 가이드
- 9. 마이그레이션에서 자주 하는 실수
- 10. 결론, 통합형 대 전문형
- 참고 문헌
1. 왜 하이브리드 검색인가
사내 RAG 시스템을 만들면서 가장 먼저 부딪힌 벽은 이거였다. "검색 결과가 왜 이렇게 들쭉날쭉하지." 사용자가 "결제 실패 코드 E402"를 검색하면 E402를 다룬 문서가 1순위로 나와야 한다. 그런데 의미 기반 검색(임베딩으로 뜻이 비슷한 문서를 찾는 방식)은 "결제 오류 처리 일반론" 같은 문서를 위로 올렸다.
반대로 "주문 취소 후 환불이 안 돼요"처럼 의도가 중요한 질문에서는 키워드 검색이 "취소", "환불"이라는 단어만 보고 관련 없는 약관 문서를 끌어왔다. 두 검색 방식이 각자 다른 사각지대를 가진다는 걸 체감한 뒤로는, 검색 단계를 한 가지 방식으로만 만들지 않는다.
키워드 검색, 특히 BM25(단어 빈도로 순위를 매기는 대표적인 방식)나 Postgres의 tsvector는 토큰 단위 매칭에 강하다. 제품 코드, 에러 메시지, 함수 이름처럼 정확한 식별자를 다룰 때는 의미 검색이 따라올 수 없는 정밀도를 보여준다. 하지만 동의어("환불" 대 "리펀드")나 의도 추론("일정 비교해줘" → 캘린더 조회) 같은 영역은 거의 손을 못 댄다.
의미 검색은 정반대다. 임베딩 모델이 학습한 의미 공간에서 가까운 벡터를 찾기 때문에 동의어와 우회 표현에 강하다. 그러나 정확한 용어 매칭은 약하다. "Spring Boot 3.2 actuator endpoint"를 검색하면 "Spring Boot 모니터링"이라는 더 일반적인 글이 위로 올라오곤 한다.
하이브리드 검색은 두 방식을 결합해 사각지대를 메우는 전략이다. 쇼핑 앱에서는 정확한 제품명과 의도 기반 추천이 모두 필요하다. 기술 문서와 RAG에서는 함수 이름과 자연어 질문이 함께 있다. 둘 중 하나만 골라서는 충분히 좋은 검색을 만들 수 없다.
이 글이 답하려는 질문은 하나다. 검색 인프라를 Postgres 하나로 통합할 것인가, 별도의 벡터 엔진과 키워드 엔진을 조합할 것인가.
Supabase의 pgvector + tsvector + RRF 조합을 쓰면 트랜잭션, 행 단위 보안, 백업이 모두 하나의 Postgres 안에서 함께 동작한다. 반면 FAISS나 Chroma에 BM25를 조합하면 두 인덱스를 직접 운영해야 하지만, 훨씬 극한의 성능과 압축을 얻을 수 있다. 어느 쪽을 고를지는 데이터 규모, 운영 인력, 트랜잭션 요구에 따라 갈린다.
2. Supabase의 pgvector 인덱스 구조
Supabase에서 벡터 검색을 한다고 하면 실제로 동작하는 엔진은 pgvector다. Postgres 확장이며 벡터를 저장하는 여러 타입을 지원한다. 인덱스 없이도 정확한 검색이 가능하지만, 데이터가 수만 건을 넘으면 전체를 훑는 방식이 무거워져서 ANN 인덱스가 사실상 필수다. pgvector가 제공하는 ANN 인덱스는 두 종류다.
HNSW, 다층 그래프 기반
HNSW(Hierarchical Navigable Small World)는 데이터를 여러 층의 그래프로 구성한다. 최상위 층은 멀리 떨어진 노드를 잇는 고속도로 역할을 하고 아래층으로 갈수록 더 촘촘하게 연결된다. 검색할 때는 위에서 시작해 점점 좁혀가며 가까운 이웃을 찾는다. 속도와 정확도의 균형이 좋고 데이터가 없어도 인덱스를 미리 만들 수 있다는 운영상 큰 장점이 있다.
create index on documents
using hnsw (embedding vector_cosine_ops)
with (m = 16, ef_construction = 64);파라미터는 세 가지를 본다.
| 파라미터 | 기본값 | 역할 | 트레이드오프 |
|---|---|---|---|
m |
16 | 층마다 최대 연결 수 | 클수록 정확도와 메모리가 함께 오른다 |
ef_construction |
64 | 빌드 시 후보 목록 크기 | 클수록 정확도가 오르지만 빌드가 느려진다 |
hnsw.ef_search |
40 | 검색 시 후보 목록 크기 | 클수록 정확도가 오르지만 응답이 느려진다 |
ef_search는 세션이나 트랜잭션 단위로 즉시 조정할 수 있다는 게 중요하다. 정확도가 낮다고 느끼면 인덱스를 다시 만들 필요 없이 쿼리 시점에 값을 올리면 된다.
begin;
set local hnsw.ef_search = 100;
select id, content
from documents
order by embedding <=> $1
limit 10;
commit;IVFFlat, 클러스터 기반
IVFFlat은 데이터를 k-means로 여러 클러스터로 나눠두고 검색할 때는 가장 가까운 클러스터 몇 개만 본다. HNSW보다 빌드가 빠르고 메모리도 적게 쓰지만, 정확도는 한 단계 아래다. 무엇보다 데이터가 어느 정도 쌓인 뒤에 만들어야 한다. 빈 테이블에 만들면 클러스터를 나눌 기준이 없어서 정확도가 무너진다.
pgvector가 권장하는 값은 다음과 같다.
- 클러스터 수는 행 수가 100만 미만이면
행 수 ÷ 1000정도 - 100만 이상이면
행 수의 제곱근정도 - 검색 시 탐색할 클러스터 수는
클러스터 수의 제곱근부터 시작
create index on documents
using ivfflat (embedding vector_cosine_ops)
with (lists = 1000);
set ivfflat.probes = 32;거리 연산자와 인덱스를 맞추기
가장 흔한 실수다. 인덱스를 만들 때 쓴 거리 방식과 쿼리에 쓴 연산자가 다르면 인덱스를 안 타고 전체 스캔으로 빠진다. 다음 대응표를 외워두는 게 낫다.
| 거리 | 연산자 | 인덱스 옵션 |
|---|---|---|
| L2(유클리드) | <-> |
vector_l2_ops |
| 내적 | <#> |
vector_ip_ops |
| 코사인 거리 | <=> |
vector_cosine_ops |
| L1(맨해튼) | <+> |
vector_l1_ops |
정규화된 벡터를 쓴다면 내적(<#>)이 코사인보다 빠르다. 정규화 여부를 모르거나 여러 모델을 섞어 쓴다면 코사인이 안전한 기본값이다.
운영 팁, 빌드 시간 줄이기
HNSW 빌드 시간이 문제가 되는 지점은 보통 벡터 100만 개, 차원 768에서 1536 근처다. 다음 두 설정을 키우면 빌드 시간이 절반 이상 줄어든다.
set maintenance_work_mem = '8GB';
set max_parallel_maintenance_workers = 7;
create index on documents
using hnsw (embedding vector_cosine_ops);이 값이 부족하면 빌드 중 디스크로 넘어가면서 시간이 크게 늘어난다는 경고가 뜬다. 큰 인덱스를 만들기 전에 인스턴스를 잠깐 키우고 빌드가 끝나면 다시 내리는 방식이 흔하다.
3. Postgres 풀텍스트 검색
벡터 검색만큼 자주 간과되는 게 키워드 검색이다. Postgres는 별도 검색 엔진 없이도 꽤 정교한 풀텍스트 검색을 제공한다. 핵심 자료구조는 tsvector, 핵심 인덱스는 GIN, 매칭 연산자는 @@다.
tsvector, 정규화된 토큰 집합
tsvector는 텍스트를 토큰화하고 소문자로 바꾸고 불필요한 단어를 뺀 뒤 어간만 남긴 결과다. 예를 들어 "Running queries quickly"는 다음처럼 정리된다.
select to_tsvector('english', 'Running queries quickly');
-- 결과: 'queri':2 'quick':3 'run':1GENERATED 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)
);
create index on documents using gin (fts);tsvector는 단어에서 문서 목록을 찾는 구조라 GIN(범용 역색인) 인덱스와 짝이 맞는다. 인덱스가 없으면 매번 전체 테이블을 훑으며 토큰화를 다시 계산해서 느리다.
순위 함수와 쿼리 파서
매칭된 결과를 점수 순으로 정렬하려면 순위 함수가 필요하다.
| 함수 | 보는 것 |
|---|---|
ts_rank |
단어가 얼마나 자주 나왔는지만 본다 |
ts_rank_cd |
빈도뿐 아니라 단어들이 얼마나 가깝게 모였는지도 본다 |
ts_rank_cd는 함수 자체가 인덱스를 못 타기 때문에, 반드시 where fts @@ ...로 후보를 먼저 좁힌 뒤에 적용해야 한다.
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가 거의 정답이다. 따옴표, OR, 제외(-)를 사용자가 흔히 쓰는 방식 그대로 받아준다.
select * from documents
where fts @@ websearch_to_tsquery('simple', '"결제 실패" -환불 OR 오류');한국어는 기본 사전으로는 제대로 처리되지 않는다. 한국어가 중요한 서비스라면 pgroonga 확장이나 별도 형태소 분석기를 거친 결과를 저장하는 방식을 고려해야 한다.
4. Supabase에서 RRF로 하이브리드 검색 구현하기
두 검색을 각자 돌리는 데까지 왔다면 이제 결과를 합쳐야 한다. 여러 방법이 있지만 실무에서 가장 안정적인 건 **RRF(순위 융합)**다.
각 검색 결과에서 문서의 순위를 보고 1 / (k + 순위)를 모두 더한 값이 최종 점수다. 점수가 아니라 순위를 쓰는 게 핵심이다. 두 검색이 서로 다른 점수 체계(BM25 점수 대 코사인 거리)를 가지더라도, 순위는 같은 정수라서 별도 정규화가 필요 없다.
예를 들어 문서 A가 키워드 검색에서 3위, 의미 검색에서 9위라면 점수는 1/(60+3) + 1/(60+9) = 0.03036이 된다.
k는 완만하게 만드는 상수다. 보통 50에서 60을 쓴다. 작으면 1위 결과의 영향력이 커지고 크면 결과가 평평해진다.
Supabase 공식 문서의 패턴에 weight 파라미터를 더한 형태가 실무에서 가장 무난하다.
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**은 한쪽 검색에만 있는 결과도 살린다. 한쪽 점수가 0이어도 다른 쪽 점수가 충분히 높으면 살아남는다. - **
limit ... * 2**로 각 검색의 후보를 최종 개수의 2배 정도로 넓게 잡는다. 좁게 잡으면 두 리스트의 겹치는 부분이 너무 적어져 RRF의 장점이 사라진다. - **
coalesce(..., 0)**로 한쪽에 없는 결과는 그 항목의 점수를 0으로 둔다.
클라이언트에서는 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,
});가중치는 도메인마다 다르다. 다음이 시작점으로 괜찮았다.
| 도메인 | 시작 가중치 |
|---|---|
| 기술 문서 API | 키워드 1.5, 의미 1.0 |
| 일반 RAG, 정책·매뉴얼 | 1.0, 1.0 |
| 상담, 자연어 질의응답 | 키워드 0.7, 의미 1.5 |
| 쇼핑, 제품 검색 | 키워드 1.2, 의미 1.0 |
이 값은 반드시 자체 평가 데이터로 실험해서 정해야 한다. 감으로 조정하면 안 된다.
Supabase 통합형 접근의 장점은 명확하다. 문서 삽입과 인덱스 갱신이 한 트랜잭션에서 처리된다. 행 단위 보안이 그대로 적용돼 사용자별 접근 제어가 자동으로 따라온다. 시점 복구가 벡터에도 그대로 적용된다. 검색 결과를 다른 테이블과 즉시 조인할 수 있다.
단점은 한 가지로 요약된다. 모든 인덱스가 Postgres 한 대의 메모리와 CPU에서 동작한다. 5천만 행이 넘으면 인덱스 크기와 부담이 동시에 오른다. 읽기 전용 복제본, 부분 인덱스, 파티셔닝을 직접 설계해야 한다.
5. FAISS와 Chroma의 인덱스 알고리즘
반대편을 보자. FAISS는 인메모리 벡터 검색 라이브러리의 표준이다. Chroma는 그보다 한 단계 위, 사용자 친화적인 임베디드 벡터 DB다. 둘 다 데이터를 RAM에 올려두고 ANN 알고리즘을 돌린다는 점에서 같은 부류다.
IndexFlat, 정확한 기준선
IndexFlatL2나 IndexFlatIP는 압축도 클러스터링도 없는 정확한 검색이다. 학습 단계가 없다. 수만에서 수십만 벡터까지는 이 인덱스만으로도 밀리초 단위로 끝난다. 더 화려한 인덱스를 쓰기 전에 항상 기준선으로 먼저 재본다.
import faiss, numpy as np
d = 768
index = faiss.IndexFlatIP(d) # 내적, 정규화된 벡터면 코사인과 같다
index.add(vectors) # vectors: (n, d) float32
D, I = index.search(query, k=10)FAISS IVF 계열
데이터가 많아지면 k-means 클러스터링 기반 IVF로 넘어간다.
nlist = 4 * int(np.sqrt(n)) # 100만 미만은 4배에서 16배 사이의 제곱근
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)) # 시작값| 데이터 규모 | 권장 구성 |
|---|---|
| 100만 미만 | 표준 IVF |
| 100만에서 1000만 | IVF에 HNSW로 클러스터 배정 |
| 1000만에서 1억 | 학습이 매우 느림, 2단계 클러스터링 고려 |
| 1억에서 10억 | GPU로 학습만 분리 |
FAISS HNSW
pgvector의 HNSW와 같은 알고리즘이다. 메모리는 벡터당 차원 × 4 + M × 2 × 4 바이트로 계산된다. 768차원, M=16이면 벡터당 약 3.2킬로바이트다. 100만 벡터면 약 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는 ID를 직접 지정하는 기능이 없어서 ID가 필요하면 별도로 감싸야 한다.
index = faiss.IndexIDMap(faiss.IndexHNSWFlat(d, M))
index.add_with_ids(vectors, ids)압축, PQ와 RaBitQ
벡터가 수억 개로 늘면 메모리가 가장 큰 제약이 된다. 압축 인덱스가 필수다.
| 압축 방식 | 정확도 손실 | 특징 |
|---|---|---|
| PQ | 중간 | 표준 압축 방식 |
| OPQ + PQ | 중간보다 조금 낮음 | 회전을 먼저 적용해 정확도를 올린다 |
| RaBitQ | 큼 | 차원당 1비트, 극한 압축 |
10억 벡터, 768차원이라면 압축 없이는 3TB가 필요하지만, 압축을 적용하면 벡터당 64바이트, 총 64GB 정도로 줄어든다.
Chroma, 쓰기 편한 대신 키워드 검색은 약하다
Chroma는 내부적으로 HNSW를 쓰고 Python, TypeScript, Rust 클라이언트를 제공해 진입 장벽이 낮다.
import chromadb
client = chromadb.PersistentClient(path="./chroma_db")
col = client.get_or_create_collection(
name="docs",
metadata={"hnsw:space": "cosine"}
)
col.add(
ids=["d1", "d2"],
documents=["pgvector는 Postgres 확장이다.", "FAISS는 인메모리 라이브러리다."],
embeddings=[emb1, emb2],
metadatas=[{"src": "blog"}, {"src": "wiki"}],
)
res = col.query(query_embeddings=[q_emb], n_results=10, where={"src": "blog"})운영상 중요한 차이가 있다. 인메모리 클라이언트는 프로세스가 끝나면 데이터가 사라진다. PersistentClient는 로컬 디스크에 저장한다. 서버 모드는 HTTP API로 분리해 여러 클라이언트가 접근하게 한다.
Chroma의 where_document로 하는 필터는 풀텍스트 검색처럼 보이지만 실제로는 단순 문자열 포함 여부만 본다. BM25 같은 순위 계산이 아니라서 한국어 형태소 분석이나 점수가 필요하면 부족하다. 진짜 키워드 순위가 필요하면 외부 인덱스를 함께 써야 한다.
6. 인메모리 환경에서의 하이브리드 검색
FAISS나 Chroma 쪽에서 하이브리드를 하려면 키워드 인덱스를 따로 두고 애플리케이션 코드에서 RRF로 합친다. SQL 함수 하나로 끝나는 Supabase와 달리 코드를 직접 써야 한다.
가장 단순한 구성을 코드로 보자.
import numpy as np
import faiss
from rank_bm25 import BM25Okapi
from collections import defaultdict
class HybridIndex:
def __init__(self, dim, m=16):
self.dim = dim
self.faiss = faiss.IndexHNSWFlat(dim, m)
self.faiss.hnsw.efSearch = 64
self.bm25 = None
self.tokenized_corpus = []
self.docs = []
self.id_map = {}
def add(self, docs, embeddings, 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) # 넣을 때마다 다시 학습해야 한다
def search(self, query, q_emb, tokenize, k=10, rrf_k=60):
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}
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}
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]이 코드의 문제점도 함께 짚어야 한다. BM25 라이브러리는 새 문서를 넣을 때마다 전체를 다시 학습한다. 한국어를 다루려면 별도 형태소 분석기가 필요하다. 검색과 추가가 동시에 들어오면 결과가 깨질 수 있어 직접 순서를 맞춰야 한다. 프로세스가 재시작되면 처음부터 다시 만들어야 한다.
rank_bm25는 순수 Python이라 100만 건이 넘으면 느려진다. Rust로 만든 Tantivy는 한 자릿수 이상 빠르고 Python 바인딩도 있다. 실서비스에서는 이쪽으로 옮기는 게 표준이다.
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이 모든 걸 직접 만들어야 한다는 게 인메모리 진영의 본질이다. 두 인덱스를 동기화하고 재인덱싱 정책을 세우고 재시작 시 워밍업을 하고 백업과 동시 접근 관리를 직접 해야 한다. 코드는 처음엔 가벼워 보이지만, 실서비스로 만들려면 이 모든 생명주기를 책임지는 서비스 하나가 새로 생긴다.
7. 성능과 운영 부담
수치를 비교할 때는 하드웨어와 차원, 데이터셋이 다르면 의미가 없다는 걸 먼저 전제해야 한다. 아래는 100만 건, 768차원 데이터셋을 기준으로 흔히 보고되는 범위다. 자기 환경에서 직접 재보지 않으면 믿을 수 없다.
| 방식 | 응답 속도 |
|---|---|
| FAISS HNSW, 같은 프로세스 | 1에서 5ms |
| FAISS IVFPQ, 압축 적용 | 1에서 3ms |
| Chroma, 같은 프로세스 | 3에서 10ms |
| Chroma HTTP | 8에서 20ms |
| pgvector HNSW | 5에서 20ms |
| pgvector IVFFlat | 10에서 40ms |
하이브리드를 더하면 pgvector 쪽은 5에서 15ms, FAISS 쪽은 3에서 10ms가 추가된다.
메모리는 HNSW 공식을 기준으로 잡으면 감이 잡힌다.
| 차원 | M | 벡터당 바이트 | 100만 개 | 1000만 개 |
|---|---|---|---|---|
| 384 | 16 | 1664 | 1.66GB | 16.6GB |
| 768 | 16 | 3200 | 3.2GB | 32GB |
| 1536 | 16 | 6272 | 6.27GB | 62.7GB |
IVFFlat은 그래프 오버헤드가 없어 더 작지만 정확도가 낮다. PQ나 OPQ로 압축하면 100만 개, 768차원이 64MB 수준까지 줄어든다.
쓰기와 운영 부담은 이렇게 나뉜다.
| 항목 | pgvector | FAISS·Chroma |
|---|---|---|
| 트랜잭션 | 보장됨 | 없음, 직접 구현 |
| 백업, 시점 복구 | 플랫폼 기본 제공 | 직접 설계 |
| 권한 관리 | Postgres 행 단위 보안 | 애플리케이션에서 구현 |
| 모니터링 | 내장 통계 뷰 | 직접 수집기 구성 |
비용도 한 줄로 비교하긴 어렵지만, Supabase는 인스턴스와 저장 비용, FAISS 자체 호스팅은 대용량 메모리 인스턴스와 운영 인력 비용이 큰 비중을 차지한다. "FAISS는 공짜다"는 라이선스 얘기지 총비용 얘기가 아니다. 실제로는 운영 시간이 가장 비싸다.
8. 워크로드별 선택 가이드
| 시나리오 | 추천 | 이유 |
|---|---|---|
| Postgres 사용 중, 문서 1천만 건 이하 | pgvector HNSW + tsvector + RRF | 단일 인프라, 트랜잭션, 조인 |
| 1천만에서 1억 벡터, 여러 조직이 함께 씀 | pgvector + 파티셔닝 | 행 단위 보안으로 분리, 운영 단순 |
| 수억에서 수십억 벡터, 강한 압축 필요 | FAISS IVFPQ·OPQ + 별도 BM25 | 메모리와 압축이 핵심 |
| 프로토타입, 연구 | Chroma 인메모리 또는 FAISS Flat | 진입 장벽 최소화 |
| 다국어, 형태소 분석 중요 | Elasticsearch·OpenSearch + 벡터 DB | tsvector 한국어 한계 회피 |
| 강한 응답 속도 요구 | 같은 프로세스의 FAISS | 네트워크 구간 자체를 없앤다 |
| 트랜잭션 필수, 커머스 카탈로그 | pgvector | 인메모리 쪽은 트랜잭션이 약하다 |
새 프로젝트를 시작할 때 다음 질문을 차례로 던지면 답이 거의 나온다.
- 데이터 규모: 100만 미만이면 무엇을 써도 된다. 1억 이상이면 압축이 핵심이다.
- 트랜잭션 필요성: 결제나 재고처럼 ACID가 필요하면 pgvector가 거의 무조건이다.
- 운영 인력: 한 명이 다 본다면 Supabase 통합형이 맞다.
- 응답 속도 기준: 밀리초 단위로 빡빡하면 같은 프로세스의 FAISS를 쓴다.
- 이미 깔린 인프라: 이미 Postgres가 있으면 pgvector로 시작해 한계를 직접 보고 옮기는 게 위험이 가장 적다.
9. 마이그레이션에서 자주 하는 실수
거리 계산 방식 불일치. FAISS에서 내적으로 쓰던 정규화된 벡터를 pgvector로 옮기면서 코사인 인덱스로 만들면 동작은 하지만 느리다. 반대로 내적 인덱스에 정규화 안 된 벡터를 넣으면 거리 계산 자체가 잘못된다. 다음을 항상 확인한다.
- 기존 임베딩이 정규화되어 있었는가
- 기존 거리 방식이 무엇이었는가
- pgvector의 인덱스 옵션을 같은 방식으로 맞췄는가
- 쿼리 연산자가 인덱스 옵션과 같은가
인덱스 없이 시작했다가 전체 스캔에 빠지는 패턴. 가장 흔하다. 데이터가 1만 건일 땐 잘 돌다가 100만 건을 넘으면 모든 쿼리가 몇 초씩 걸린다. HNSW 인덱스는 데이터가 없어도 만들 수 있으니, 테이블을 만들 때 함께 만들어두는 게 안전하다.
RRF 후보를 너무 좁게 잡는 것. 최종 개수만큼만 가져오면 두 검색의 겹치는 부분이 비어 결과가 빈약해진다. 최종 개수의 2배 이상으로 넓혀야 한다.
임베딩 모델을 바꿨을 때 조용히 실패하는 것. 같은 차원의 다른 모델로 바꾸면 에러 없이 잘못된 결과를 준다. 컬럼 차원을 강제하고 어떤 모델로 만들었는지 메타데이터로 함께 저장해야 한다.
alter table documents
alter column embedding type extensions.vector(1536);
alter table documents
add column embedding_model text not null default 'text-embedding-3-small';인메모리 DB를 영원히 띄워두면 된다는 착각. 메모리 부족, 서버 패치, 재배포로 재시작은 반드시 일어난다. 주기적인 스냅샷 백업, 부팅 시 복구 절차, 트래픽을 조금씩 옮기는 방식을 미리 설계해야 한다.
10. 결론, 통합형 대 전문형
결론은 한 줄이다. Supabase pgvector와 tsvector는 통합형, FAISS와 Chroma는 전문형이다.
Supabase pgvector와 tsvector는 검색 요구의 90퍼센트를 단일 Postgres로 해결한다. 트랜잭션, 행 단위 보안, 백업, 조인이 모두 플랫폼 차원에서 따라온다. 운영 인력이 작은 팀, 이미 Postgres를 쓰는 팀에는 거의 항상 정답에 가깝다.
FAISS와 Chroma는 극한의 성능과 압축이 필요한 워크로드용이다. 수억 벡터, GPU 가속, 밀리초 단위 응답이 필요한 추천이나 이미지 검색에서 빛난다. 대신 저장, 복제, 동기화, 모니터링을 모두 직접 짜야 한다.
알고리즘은 양쪽 모두 같다. HNSW는 어디서 쓰든 HNSW고 RRF도 마찬가지다. 차이는 결국 운영 모델과 인프라 응집도다.
내가 새 프로젝트를 시작한다면 이렇게 하겠다. pgvector HNSW와 tsvector, RRF 함수로 시작하고 데이터 규모와 비용, 응답 속도의 한계가 명확히 보일 때 FAISS나 Chroma로 부분 이전한다. 대부분의 팀은 그 한계까지 가지 않는다. 그래서 통합형이 거의 항상 정답이다.
참고 문헌
- 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)