PostgreSQL 하나로 Elasticsearch를 대체하는 하이브리드 검색 — VectorChord-BM25와 RRF 적용하기
검색 기능 하나 붙이자고 Elasticsearch 클러스터를 별도로 띄워본 경험이 있다면, 그 운영 부담이 얼마나 만만치 않은지 체감했을 겁니다. 스키마 동기화, 색인 지연, 두 시스템 사이의 트랜잭션 일관성 문제... 솔직히 PostgreSQL에 이미 데이터가 있는데 굳이 Elasticsearch까지 옆에 붙여야 하나 싶을 때가 한두 번이 아니었죠. 실제로 Elasticsearch를 걷어낸 팀들이 공통으로 이야기하는 것 중 하나가 "동기화 버그가 사라졌다"는 겁니다. 배포할 때마다 색인 파이프라인 상태를 확인하고, 트랜잭션 불일치가 생기면 어느 쪽이 맞는지 디버깅하던 그 시간이요.
2025년 3월, tensorchord 팀이 Rust로 만든 vchord_bm25 확장을 공개하면서 이 불편함에 꽤 현실적인 대안이 생겼습니다. 단순히 전문 검색(BM25)만 들어온 게 아니라, VectorChord의 벡터 검색과 결합해 하이브리드 검색까지 PostgreSQL 안에서 완결할 수 있게 됐습니다.
이 글에서는 VectorChord-BM25가 어떻게 동작하는지, 그리고 BM25 키워드 검색과 벡터 유사도 검색을 RRF(Reciprocal Rank Fusion)로 결합해 PostgreSQL 단일 스택에서 하이브리드 검색을 구현하는 방법을 다룹니다. 실제 동작하는 SQL과 Python 코드, 장단점 분석, 처음 시도할 때 빠지기 쉬운 함정까지 함께 짚어보겠습니다. 코드 예시는 영어 코퍼스 기준입니다. 한국어 텍스트를 위한 pg_tokenizer 설정이 궁금하다면 이 글 마지막의 후속 주제 예고를 확인해 주세요.
핵심 개념
BM25가 TF-IDF보다 나은 이유
BM25(Best Match 25)는 Elasticsearch가 기본 랭킹 알고리즘으로 채택할 만큼 정보 검색 분야의 표준입니다. 단어 빈도(TF)와 역문서 빈도(IDF)를 결합한다는 점은 TF-IDF와 같지만, 결정적인 차이가 있습니다.
긴 문서에서 어떤 단어가 100번 등장했다고 해서 짧은 문서에서 10번 등장한 것보다 반드시 더 관련성이 높다고 볼 수 없잖아요. BM25는 파라미터 k1(단어 빈도 포화도)과 b(문서 길이 정규화 강도)로 이 문제를 다룹니다. k1이 높을수록 단어 반복의 영향이 커지고, b가 1에 가까울수록 문서 길이 정규화가 강해집니다.
BM25(d, q) = Σ IDF(t) × [TF(t,d) × (k1 + 1)] / [TF(t,d) + k1 × (1 - b + b × |d|/avgdl)]저도 처음엔 이 공식이 복잡해 보여서 그냥 TF-IDF랑 비슷하겠지 싶었는데, 실제로 긴 문서가 많은 코퍼스에서는 체감 차이가 꽤 납니다.
VectorChord-BM25가 PostgreSQL에 BM25를 들여오는 방식
vchord_bm25는 PostgreSQL 확장으로서 몇 가지 핵심 구성요소를 제공합니다.
| 구성요소 | 역할 |
|---|---|
bm25vector |
토크나이저가 생성한 희소 벡터(sparse vector)를 저장하는 전용 타입 |
| BM25 인덱스 | USING bm25 (embedding bm25_ops) 형태의 커스텀 인덱스 |
<&> 연산자 |
BM25 점수를 반환하는 거리 연산자 (음수값, 낮을수록 관련성 높음) |
| Block WeakAnd | 상위 K개 결과만 효율적으로 탐색하는 Early Termination 알고리즘 |
bm25vector 타입에 대해 잠깐 짚고 넘어가면, 이건 pgvector(PostgreSQL 벡터 확장)의 vector(밀집 벡터, dense vector)와는 다릅니다. 각 차원이 어휘 ID에 대응하고 대부분의 값이 0인 **희소 벡터(sparse vector)**입니다. 문서에 등장한 단어 인덱스만 값을 가지고 나머지는 0이라서, 어휘 크기가 수만이어도 실제 저장 공간은 훨씬 적습니다.
한 가지 알아두면 유용한 설계 포인트가 있습니다. to_bm25query 함수가 첫 번째 인자로 인덱스 이름을 받는 이유는, BM25 인덱스 자체가 어떤 토크나이저를 쓸지 내포하고 있기 때문입니다. 덕분에 문서 색인 시와 쿼리 변환 시 항상 같은 토크나이저를 쓰도록 강제됩니다. 이 점을 모르면 뒤에서 설명할 실수 2번에 빠지기 쉽습니다.
Block WeakAnd란? 전체 문서를 다 훑지 않고, 상위 K개 후보를 유지하면서 그보다 낮은 점수가 나올 게 확실한 문서 블록은 건너뛰는 기법입니다. Elasticsearch 내부에서도 이 알고리즘을 씁니다. 덕분에 전체 인덱스를 스캔하지 않아도 정확한 상위 결과를 빠르게 뽑을 수 있습니다.
왜 벡터 검색만으로는 부족한가
벡터 유사도 검색(Semantic Search)은 "겨울에 따뜻한 재킷"처럼 의도 기반 쿼리에 강합니다. 그런데 "아이폰 16 프로 256GB"나 특정 함수명, API 엔드포인트처럼 철자 자체가 중요한 쿼리에서는 의외로 힘을 못 쓸 때가 많습니다. 임베딩 공간에서 "iPhone 16 Pro"와 "Galaxy S25 Ultra"가 의미적으로 가까울 수 있거든요.
반대로 BM25만 쓰면 "좋은 노트북 추천해줘"처럼 '좋은'이나 '추천'이라는 단어가 들어간 문서를 찾아오지, 실제로 노트북 리뷰가 담긴 문서를 제대로 못 잡을 수 있습니다. 두 방식이 서로의 약점을 보완하는 구조입니다.
RRF — 스케일이 다른 두 점수를 하나로 합치는 방법
BM25 점수와 코사인 유사도는 단위 자체가 달라서 직접 합산하면 한쪽이 다른 쪽을 압도합니다. RRF(Reciprocal Rank Fusion)는 이 문제를 절대 점수 대신 순위(rank)만 사용해 우회합니다.
RRF_score(d) = Σ 1 / (k + rank(d))k=60이 기본값인데, 이 상수가 상위 랭크와 하위 랭크 간의 점수 차이를 완충합니다. 예를 들어 BM25에서 1위(1/61 ≈ 0.016)와 10위(1/70 ≈ 0.014)의 차이는 크지 않고, 벡터 검색에서 1위인 문서가 BM25에서 5위라도 합산 점수는 꽤 높아집니다.
왜 k=60인가? Cormack et al.의 원 논문(SIGIR 2009)에서 실험적으로 검증된 값입니다. 대부분의 도메인에서 무난하게 동작하지만, A/B 테스트로 최적값을 찾아보는 것을 권장합니다.
실전 적용
기본 세팅: BM25 인덱스 생성
Docker로 바로 띄울 수 있는 통합 이미지가 있어서 시작 자체는 어렵지 않습니다. tensorchord/vchord-suite:pg18-latest에 VectorChord, BM25, pg_tokenizer가 모두 담겨 있습니다.
-- 필요한 확장 설치
CREATE EXTENSION IF NOT EXISTS pg_tokenizer CASCADE;
CREATE EXTENSION IF NOT EXISTS vchord_bm25 CASCADE;
-- BERT 기반 토크나이저 생성 (영어 기준)
SELECT create_tokenizer('bert_tok', $$ model = "bert_base_uncased" $$);
-- 문서 테이블 생성
CREATE TABLE documents (
id SERIAL PRIMARY KEY,
body TEXT,
embedding bm25vector -- 희소 벡터 저장 전용 타입
);
-- 원본 텍스트를 토크나이징해서 bm25vector로 변환 후 저장
INSERT INTO documents (body, embedding)
SELECT body, tokenize(body, 'bert_tok')::bm25vector
FROM raw_texts;
-- BM25 인덱스 생성
CREATE INDEX doc_bm25_idx ON documents USING bm25 (embedding bm25_ops);인덱스를 만든 뒤 검색은 이렇게 됩니다.
-- BM25 검색: 점수는 음수이므로 ORDER BY ASC = 관련성 높은 순
SELECT
id,
body,
embedding <&> to_bm25query('doc_bm25_idx', tokenize('database search', 'bert_tok')) AS score
FROM documents
ORDER BY score ASC
LIMIT 10;| 코드 요소 | 설명 |
|---|---|
<&> |
BM25 거리 연산자. 반환값이 음수라 낮을수록 관련성 높음 |
to_bm25query |
검색 쿼리를 BM25 쿼리 벡터로 변환. 첫 번째 인자인 인덱스 이름이 토크나이저를 결정함 |
tokenize(...) |
쿼리 문자열을 인덱스와 동일한 토크나이저로 변환 |
RRF 결합 쿼리: BM25와 벡터 검색 병합
실무에서 가장 자주 쓰게 되는 패턴입니다. vec_embedding 컬럼에 pgvector나 VectorChord로 생성한 임베딩이 이미 있다고 가정합니다.
한 가지 중요한 구현 포인트가 있습니다. CTE 안에서 점수 순 상위 20개를 뽑을 때, ROW_NUMBER() OVER (ORDER BY score)와 LIMIT 20을 같은 레벨에 두면 기대한 대로 동작하지 않습니다. PostgreSQL은 ORDER BY 없는 LIMIT을 만나면 임의의 행을 반환하고, 윈도우 함수가 그 임의의 행들에 대해서만 순위를 매기게 됩니다. 소규모 테스트에서는 눈에 안 띄다가 데이터가 많아지면 RRF 결과가 재현되지 않는 원인이 됩니다. 실제 상위 20개를 가져오려면 서브쿼리로 먼저 정렬·제한한 뒤 바깥에서 순위를 매겨야 합니다.
WITH bm25_results AS (
-- 서브쿼리로 실제 상위 20개를 먼저 추출한 뒤 순위 부여
SELECT id, ROW_NUMBER() OVER (ORDER BY score) AS rank
FROM (
SELECT id,
embedding <&> to_bm25query('doc_bm25_idx', tokenize(:query, 'bert_tok')) AS score
FROM documents
ORDER BY score
LIMIT 20
) top_bm25
),
vector_results AS (
-- 벡터 유사도로 상위 20개 추출, 순위 부여
SELECT id, ROW_NUMBER() OVER (ORDER BY score) AS rank
FROM (
SELECT id,
vec_embedding <=> :query_vector AS score
FROM documents
ORDER BY score
LIMIT 20
) top_vec
),
rrf AS (
-- FULL OUTER JOIN으로 한쪽에만 있는 문서도 포함
SELECT
COALESCE(b.id, v.id) AS id,
COALESCE(1.0 / (60 + b.rank), 0) +
COALESCE(1.0 / (60 + v.rank), 0) AS rrf_score
FROM bm25_results b
FULL OUTER JOIN vector_results v ON b.id = v.id
)
SELECT d.id, d.body, r.rrf_score
FROM rrf r
JOIN documents d ON d.id = r.id
ORDER BY r.rrf_score DESC
LIMIT 10;FULL OUTER JOIN을 쓰는 게 포인트입니다. BM25에서는 상위 20개에 못 들었지만 벡터 검색에서 1위인 문서가 있다면, 그 문서를 버리지 않고 포함시킵니다. COALESCE(..., 0)은 한쪽 결과에만 있는 경우 해당 방식의 점수를 0으로 처리하는 역할입니다.
RAG 파이프라인 적용
LLM 기반 RAG 시스템에서 검색 품질은 생성 품질에 직결됩니다. 이 파이프라인에 하이브리드 검색을 붙여봤을 때 처음 놀랐던 부분은, 제품 버전 번호나 함수명처럼 정확한 철자가 중요한 쿼리에서 벡터 단독 검색이 생각보다 자주 엉뚱한 문서를 가져온다는 점이었습니다. "v2.3.1 마이그레이션 이슈"를 물으면 "v2.2 업그레이드 방법"이나 "v3.0 변경 사항"이 섞여 나오는 식으로요. RRF를 붙이고 나서 이런 케이스에서 검색 결과가 눈에 띄게 달라졌습니다.
import asyncpg # Python 비동기 PostgreSQL 드라이버
import numpy as np
# embed 함수 출력 차원은 vec_embedding 컬럼의 차원과 반드시 일치해야 합니다
from your_embedding_model import embed
async def hybrid_search(query: str, top_k: int = 10) -> list[dict]:
query_vector = embed(query)
# 실제 서비스에서는 asyncpg.create_pool()을 사용하는 것을 권장합니다
conn = await asyncpg.connect(DATABASE_URL)
results = await conn.fetch("""
WITH bm25_results AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY score) AS rank
FROM (
SELECT id,
embedding <&> to_bm25query('doc_bm25_idx', tokenize($1, 'bert_tok')) AS score
FROM documents
ORDER BY score
LIMIT 20
) top_bm25
),
vector_results AS (
SELECT id, ROW_NUMBER() OVER (ORDER BY score) AS rank
FROM (
SELECT id,
vec_embedding <=> $2 AS score
FROM documents
ORDER BY score
LIMIT 20
) top_vec
),
rrf AS (
SELECT COALESCE(b.id, v.id) AS id,
COALESCE(1.0 / (60 + b.rank), 0) +
COALESCE(1.0 / (60 + v.rank), 0) AS rrf_score
FROM bm25_results b
FULL OUTER JOIN vector_results v ON b.id = v.id
)
SELECT d.id, d.body, r.rrf_score
FROM rrf r JOIN documents d ON d.id = r.id
ORDER BY r.rrf_score DESC
LIMIT $3
""", query, query_vector.tolist(), top_k)
await conn.close()
return [dict(r) for r in results]이렇게 가져온 컨텍스트를 LLM 프롬프트에 넣으면, 특히 고유명사나 버전 번호가 포함된 쿼리에서 벡터 단독 검색 대비 검색 결과가 달라지는 걸 체감할 수 있습니다.
장단점 분석
장점
| 항목 | 내용 |
|---|---|
| 운영 단순화 | PostgreSQL 하나로 검색 + 벡터 + 관계형 데이터를 통합 관리. Elasticsearch 동기화 파이프라인 불필요 |
| ACID 보장 | 트랜잭션 일관성이 기본. Elasticsearch의 near-realtime 색인은 짧은 반영 지연이 생길 수 있음 |
| BM25 성능 | Block WeakAnd 기반으로 Elasticsearch 대비 QPS 약 3배 (공식 벤치마크 링크 기준, 특정 하드웨어·데이터 조건에서 측정된 수치) |
| 벡터 인덱싱 속도 | VectorChord는 1억 벡터 인덱스를 20분 이내 완료 (공식 벤치마크 기준. pgvector의 HNSW 인덱스 대비 수십 시간 단축) |
| SQL 친화적 | 기존 JOIN, 필터, 집계와 자연스럽게 결합 가능. 별도 쿼리 DSL 불필요 |
| 비용 절감 | 검색 엔진 클러스터를 별도로 운영하지 않아도 됨 |
near-realtime이란? Elasticsearch는 색인 후 문서가 검색에 반영되기까지 기본 1초의 refresh interval이 있습니다. 이 짧은 시간 동안 방금 삽입한 데이터가 검색에 나오지 않을 수 있는데, PostgreSQL + vchord_bm25는 트랜잭션 커밋 즉시 검색에 반영됩니다.
단점 및 주의사항
| 항목 | 내용 | 대응 방안 |
|---|---|---|
| 언어 지원 | 영어 위주로 검증됨. 한국어 등 비영어권은 별도 토크나이저 설정 필요 | pg_tokenizer에서 다국어 BPE 모델 검토. 형태소 분석기 기반 옵션도 존재 |
| 성숙도 | 2025년 3월 출시, 프로덕션 사례 축적 중 | 중요도 높은 서비스는 Elasticsearch와 병렬 운영 후 단계적으로 트래픽 전환 |
| BM25 의미론적 한계 | 동의어, 문맥적 뉘앙스 파악 불가 | 벡터 검색과 반드시 병행. 정밀도가 중요한 경우 RRF 이후 크로스인코더 리랭킹 추가 검토 |
| 리소스 오버헤드 | BM25 인덱스 + 벡터 인덱스 동시 유지 시 스토리지·메모리 증가 | 실제 쿼리 패턴 분석 후 사용 빈도 낮은 인덱스는 선택적으로 구성 |
| 대규모 확장 한계 | 수백 TB급 분산 인덱싱이 필요한 시나리오는 전용 검색 엔진이 유리 | 단일 노드 한계를 넘는 규모라면 파티셔닝 또는 분산 전용 솔루션과 병행 검토 |
운영 팁 —
bm25_limit파라미터: 기본 결과 수 제한을 초과하는 쿼리에서는bm25_catalog.bm25_limitGUC 파라미터를 확인하고 필요에 따라 조정하시면 됩니다.
실무에서 가장 흔한 실수
-
BM25 점수가 음수인 걸 잊고
ORDER BY score DESC로 쿼리하기 —<&>연산자 반환값은 음수라서ORDER BY score ASC가 관련성 높은 순입니다. RRF를 쓰지 않고 BM25 단독으로 쓸 때 특히 혼동하기 쉽습니다. -
쿼리 토크나이저와 인덱스 토크나이저를 다르게 설정하기 —
to_bm25query가 인덱스 이름을 첫 번째 인자로 받는 이유가 바로 여기 있습니다. 인덱스 자체가 토크나이저를 내포하고 있어서, 같은 인덱스 이름을 쓰면 색인 시와 검색 시 동일한 토크나이저가 보장됩니다. 만약tokenize()에 다른 토크나이저 이름을 넣은 채로to_bm25query에 연결하면 점수가 의미 없어집니다. -
RRF의 LIMIT을 너무 작게 설정하기 — BM25와 벡터 각각에서 LIMIT을 너무 작게 잡으면, RRF 병합 이후 좋은 결과가 이미 잘려 있는 상황이 생깁니다. 최종 결과의 3~5배 정도를 각 단계에서 가져오는 게 일반적으로 안전합니다.
마치며
VectorChord-BM25와 RRF를 결합하면, Elasticsearch 없이도 PostgreSQL 단일 스택에서 하이브리드 검색을 완결할 수 있습니다. 스키마 동기화 파이프라인, 트랜잭션 불일치 디버깅, 두 시스템 사이를 오가는 운영 부담 — 이 모든 것을 PostgreSQL 하나로 줄일 수 있다는 게 이 조합의 핵심 가치입니다.
물론 수백 테라바이트급 분산 검색이나 Elasticsearch의 성숙한 에코시스템이 꼭 필요한 상황이라면 단순 대체는 어렵습니다. 하지만 많은 프로덕션 서비스는 그 수준까지 가지 않으면서도 복잡한 이중 시스템을 운영하고 있습니다. 그런 팀이라면 이 조합을 검토해볼 가치가 충분히 있습니다.
지금 바로 시작해볼 수 있는 3단계입니다.
-
Docker로 환경 세팅 —
docker run --rm -e POSTGRES_PASSWORD=password -p 5432:5432 tensorchord/vchord-suite:pg18-latest명령으로 VectorChord + BM25 + pg_tokenizer가 모두 포함된 PostgreSQL을 바로 띄워볼 수 있습니다. -
기존 텍스트 컬럼에 BM25 인덱스 추가 실험 — 이미 운영 중인 PostgreSQL 테이블이 있다면,
embedding bm25vector컬럼을 추가하고tokenize(body, 'bert_tok')::bm25vector로 채운 뒤 BM25 인덱스를 생성해 기존LIKE검색이나tsvector검색과 결과를 직접 비교해볼 수 있습니다. 다만 운영 DB에서ALTER TABLE ... ADD COLUMN이후 대량 업데이트를 실행하면 테이블 잠금이 발생할 수 있으니, 테스트 DB에서 먼저 검증한 뒤 적용하는 것을 권장합니다. -
RAG 파이프라인에 RRF 쿼리 교체 적용 — 벡터 검색만 쓰던 RAG retriever를 위 예시의 RRF 쿼리로 교체해보시면, 고유명사·버전·코드 관련 쿼리에서 검색 품질 변화를 체감할 수 있습니다.
참고 자료
- VectorChord-BM25 공식 GitHub | tensorchord
- VectorChord-BM25: Revolutionize PostgreSQL Search — 3x Faster than Elasticsearch | VectorChord Blog
- Hybrid Search with Postgres Native BM25 and VectorChord | VectorChord Docs
- Hybrid Search with Postgres Native BM25 and VectorChord | VectorChord Blog
- Bringing Search-Engine Ranking to PostgreSQL with VectorChord-BM25 | VectorChord Blog
- VectorChord-BM25: Introducing pg_tokenizer | VectorChord Blog
- Benchmark with Elasticsearch | VectorChord Docs
- Hybrid Search in PostgreSQL: The Missing Manual | ParadeDB
- BM25 in PostgreSQL: Full-Text Search Without Elastic | Tiger Data
- Elasticsearch's Hybrid Search, Now in Postgres (BM25 + Vector + RRF) | Tiger Data
- Hybrid Search in 100 Lines: BM25 + pgvector with RRF Merge | DEV Community
- BM25 Search in PostgreSQL: The Missing Piece for Hybrid Search | Pedro Alonso
- VectorChord-BM25 공식 문서 | EnterpriseDB
- What is Reciprocal Rank Fusion? | ParadeDB
- pg_tokenizer.rs GitHub | tensorchord