파이썬 벡터 DB 하이브리드 검색 쿡북
개요
요약
의미 검색과 키워드 검색을 함께 쓰는 하이브리드 검색을 Supabase 계열 조합(tsvector·pgvector·RRF)과 Chroma·Milvus·Qdrant·Weaviate·FAISS 같은 전용 벡터 DB로 각각 구현하는 방법을 코드 수준에서 비교한다. 인덱싱 알고리즘·메모리 사용량 추정·저장 방식·보안 모델·워크로드별 선택 기준과 자주 하는 실수까지 정리한 쿡북이다.
목차
- 1. 왜 하이브리드 검색인가
- 2. Supabase hybridsearch 함수 해부
- 3. 벡터 DB별 하이브리드 구현
- 4. 인덱싱 알고리즘 비교
- 5. 저장 방식과 인메모리 모드
- 6. 인메모리 사용량 추정
- 7. 시장 채택 현황
- 8. 보안 비교
- 9. 워크로드별 가이드와 자주 하는 실수
- 참고 문헌
1. 왜 하이브리드 검색인가
검색 품질이 LLM 응답 품질의 상한선을 정한다는 건 이제 RAG 실무자에게 상식이다. 그런데 한 가지 검색 방식만 쓰면 어떤 모델을 갈아 끼워도 답이 어긋난다. 키워드 검색과 의미 검색은 서로 다른 형태로 실패하고 그 실패가 정확히 서로를 보완하기 때문에 하이브리드 검색이 표준이 됐다.
한 줄로 요약하면 이렇다. 키워드는 정확한 단어를 잡고 의미 검색은 뜻을 잡는다. RRF(순위 융합)는 두 결과의 순위 역수만 더해서 점수를 맞추는 작업 없이 둘을 합친다.
1.1 두 검색의 실패 패턴
| 검색 방식 | 잘하는 것 | 못하는 것 | 실패 예시 |
|---|---|---|---|
| 키워드(BM25, tsvector) | 정확한 단어, 식별자, 코드 | 동의어, 어순 변형, 다국어 | "결제 실패"로 검색해도 "payment error"는 못 찾는다 |
| 의미(임베딩) | 뜻, 맥락, 다국어 | 정확한 식별자, 숫자 | 에러 코드 ERR_2048이 비슷한 다른 코드와 섞인다 |
코드 검색에서 임베딩이 자주 틀리는 이유는, 토크나이저가 getUserById와 findUserById를 거의 같은 벡터로 만들기 때문이다. 반대로 상담 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 이 함수가 잘하는 것 세 가지
ts_rank_cd는 인덱스를 못 탄다. 그래서where fts @@ ...로 GIN 인덱스를 먼저 태워 후보를 좁힌 뒤에만 점수를 계산한다.FULL OUTER JOIN은 한쪽 리스트에만 있는 결과도 남긴다. 양쪽 다 걸려야만 점수를 받는 실수를 피한다.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 = 324.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년치 마이그레이션 비용을 막아준다.
참고 문헌
- Supabase Docs, Hybrid Search (tsvector + pgvector + RRF)
- pgvector, Open-source vector similarity search for Postgres (GitHub)
- Qdrant Documentation, Hybrid and Multi-Stage Queries (RRF, DBSF)
- Weaviate Documentation, Hybrid Search (alpha, Relative Score Fusion)
- Milvus Documentation, Multi-Vector Hybrid Search (RRFRanker, WeightedRanker)
- Chroma Documentation, Getting Started & Persistent Client
- FAISS Wiki, Guidelines to choose an index (HNSW, IVF, PQ, RaBitQ)
- DB-Engines Ranking, Vector DBMS category
- PostgreSQL Documentation, Full Text Search (tsvector, GIN, ts_rank_cd, websearch_to_tsquery)
- OWASP Top 10 for LLM Applications