RAG · Vector Search · Postgres
키워드와 의미, 둘 다 필요한 이유
키워드 검색은 정확한 용어에 강하지만 동의어·의도를 놓치고, 시맨틱 검색은 의미는 잡지만 잡음과 false positive가 늘어난다. 'Italian recipes with tomato sauce' → 키워드는 '마리나라'를 놓치고, 시맨틱은 'Mexican salsa'까지 추천하는 식.
쇼핑(정확 매칭 + 유사 상품), 기술 문서(에러 코드 + 개념 검색), 사내 RAG(고유명사 + 자연어 질문), 법률·의료(고유 용어 + 의도 파악). 코드 검색은 거의 항상 키워드 우위, 멘탈헬스 포럼은 시맨틱 우위.
HNSW와 IVFFlat의 동작 원리와 튜닝
Hierarchical Navigable Small World는 위층에서 빠르게 후보를 좁히고 아래층에서 정밀 탐색하는 그래프 알고리즘. 데이터 없이도 인덱스 생성 가능. 메모리 사용 = (d4 + M2*4) bytes/vector. pgvector 0.7+ 권장 기본값.
CREATE INDEX ON documents USING hnsw (embedding vector_ip_ops) WITH (m = 16, ef_construction = 64); SET maintenance_work_mem = '8GB'; SET max_parallel_maintenance_workers = 7; BEGIN; SET LOCAL hnsw.ef_search = 100; SELECT id FROM documents ORDER BY embedding <#> $1 LIMIT 10; COMMIT;
k-means로 lists 개 클러스터를 만들고 쿼리 시 probes 개만 탐색. 데이터를 먼저 적재한 뒤 빌드해야 의미가 있다. lists = rows/1000 (≤1M) 또는 sqrt(rows) (>1M), probes = sqrt(lists)부터 시작.
HNSW 선택
IVFFlat 선택
Postgres가 이미 가진 키워드 엔진
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);
ts_rank는 빈도 기반, ts_rank_cd는 cover density(근접도)까지 반영. ts_rank_cd는 인덱서블하지 않으므로 반드시 WHERE @@ 으로 좁힌 집합에만 적용. 쿼리 파서는 websearch_to_tsquery — 사용자 입력 그대로 처리 가능.
두 검색을 SQL 한 함수로 합치기
score = Σ 1 / (k + rank_i). 각 검색 리스트에서의 순위 역수를 더한다. k가 작을수록 상위 노출 영향력 ↑, k=50~60이 일반적. 한쪽 리스트에 없으면 그 항목의 기여는 0. 단순하지만 다양한 평가에서 매우 강건.
CREATE OR REPLACE FUNCTION hybrid_search( query_text text, query_embedding extensions.vector(512), match_count int, full_text_weight float DEFAULT 1, semantic_weight float DEFAULT 1, 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 USING (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); $$;
const { data } = await supabase.rpc('hybrid_search', { query_text: '대출 상환 일정 조정', query_embedding: await embed(query), match_count: 10, full_text_weight: 1.0, semantic_weight: 1.0, rrf_k: 50, });
인메모리 벡터 DB의 인덱스 선택지
Flat(정확·압축 없음·baseline) → IVFFlat(클러스터 + 정확) → IVF + PQ/OPQ(클러스터 + 압축) → HNSW(그래프) → RaBitQ(1bit/dim 극한 압축). index_factory 문자열로 조합 가능. GPU 지원은 Flat/IVF/PQ 등 일부.
규모별 추천 clustering
압축 정도별
import faiss, numpy as np d = 768 index = faiss.IndexHNSWFlat(d, 32) # M=32 index.hnsw.efConstruction = 200 index.hnsw.efSearch = 64 index.add(embeddings.astype('float32')) D, I = index.search(query_vec, k=10)
기본 인덱스는 hnswlib 기반 HNSW. Python/TypeScript/Rust 클라이언트 제공. in-memory client는 휘발성 — production은 persistent client나 client-server, 또는 Chroma Cloud로 가야 함. metadata where 필터와 $contains substring 필터는 있지만 BM25는 아니라는 점 주의.
BM25 + FAISS를 애플리케이션 레이어에서 합치기
from rank_bm25 import BM25Okapi import faiss, numpy as np bm25 = BM25Okapi([tokenize(d) for d in docs]) index = faiss.IndexHNSWFlat(768, 32) index.add(embeddings) def hybrid(query, k=10, rrf_k=50): bm25_scores = bm25.get_scores(tokenize(query)) bm25_rank = np.argsort(-bm25_scores)[: k * 2] qv = embed(query).reshape(1, -1).astype('float32') _, dense_rank = index.search(qv, k * 2) dense_rank = dense_rank[0] fused = {} for rank, doc_id in enumerate(bm25_rank, start=1): fused[doc_id] = fused.get(doc_id, 0) + 1.0 / (rrf_k + rank) for rank, doc_id in enumerate(dense_rank, start=1): fused[doc_id] = fused.get(doc_id, 0) + 1.0 / (rrf_k + rank) return sorted(fused, key=fused.get, reverse=True)[:k]
rank_bm25(순수 Python, 수만 건까지), Whoosh(Pure Python, 디스크), Tantivy(Rust, Python 바인딩 tantivy-py, 한 자릿수~두 자릿수 빠름), Elasticsearch/OpenSearch(분산·운영기능 풍부). Production 규모면 Tantivy 또는 ES.
수치로 보는 트레이드오프
Supabase pgvector HNSW
FAISS HNSW (in-process)
메모리(HNSW M=16)
운영 부담
무엇을 어디에 쓸 것인가
이미 Postgres·Supabase를 쓰고 있다면 pgvector HNSW + tsvector + RRF SQL 함수가 최적. 트랜잭션·RLS·백업·조인을 그대로 활용. 별도 인프라 도입 없이도 한 자릿수~수십 ms 응답 가능.
FAISS IVF + PQ/OPQ/RaBitQ로 압축·샤딩. 키워드는 ES/OpenSearch나 Tantivy로 별도 운영. 운영 인력과 모니터링 비용을 충분히 산정. Chroma Cloud나 Pinecone/Qdrant 같은 매니지드 서비스도 검토.
Chroma in-memory 또는 FAISS Flat으로 시작. 모델·청킹·평가 지표를 빠르게 반복. 영속성·동시성·HA가 필요해지는 순간 곧바로 다른 옵션으로 이동할 계획을 미리 세워둘 것.
tsvector는 영어 외 언어에서 형태소 분석이 약함. 한국어가 핵심이면 mecab/khaiii 기반 토크나이저로 직접 토큰화해 tsvector를 만들거나, Elasticsearch + nori 같은 분석기를 별도 운영하는 편이 안전. 임베딩 모델은 다국어 모델 선택.
흔히 빠지는 함정
거리 metric(L2/IP/Cosine)·정규화 여부 확인 → 임베딩 모델·차원 고정 및 메타데이터 기록 → 인덱스 빌드용 maintenance_work_mem과 max_parallel_maintenance_workers 사전 설정 → RRF k와 weight를 A/B로 튜닝 → Recall@k·nDCG로 회귀 테스트.
"알고리즘은 모두 공개돼 있다. 차이는 운영 모델에서 난다."
통합형 vs 전문형, 그리고 시작점
대부분의 팀에 답은 명확하다 — pgvector HNSW + tsvector + RRF SQL 함수로 시작하라. 트래픽·데이터 규모·도메인 요구사항이 한계를 넘었다는 측정값이 나올 때만 FAISS/Chroma·전용 검색 엔진으로 부분 이전한다. 인프라 분기를 미리 만들지 말 것.