Elasticsearch 없이 하이브리드 검색: pg_search + pgvector 완전 가이드
한동안 "검색 기능 = Elasticsearch"라는 공식에서 벗어나지 못한 시기가 있었습니다. 새 프로젝트마다 RDS 옆에 EC2 클러스터를 하나 더 띄우고, 데이터가 바뀔 때마다 ETL 파이프라인을 짜고, 보안 정책을 두 곳에서 관리하는 것이 당연한 흐름처럼 여겨졌습니다. 그런데 2025년 말부터 커뮤니티 분위기가 달라졌습니다. TigerData가 It's 2026, Just Use Postgres를 올리고, ParadeDB가 pg_search V2를 발표하면서 Elasticsearch의 핵심 세 축인 BM25, 벡터 검색, RRF가 모두 PostgreSQL 확장으로 네이티브 구현됐다는 흐름이 본격화됐습니다.
이 글은 pg_search와 pgvector 두 확장을 조합해 하이브리드 검색을 PostgreSQL 단일 스택에서 구현하는 방법을 다룹니다. 키워드 검색이 놓치는 동의어는 벡터가 잡고, 벡터 검색이 틀리는 고유명사는 BM25가 잡아주는 구조를 SQL 한 방에 표현하는 것이 핵심입니다. TREC-COVID·BEIR류 벤치마크에서 순수 벡터 대비 nDCG@10이 대략 15~25%p 개선된다는 리포트가 이 접근을 뒷받침합니다(ParadeDB — Hybrid Search 가이드, Instaclustr — pgvector Hybrid Search).
핵심 개념
BM25: Elasticsearch가 쓰는 그 알고리즘, 이제 PostgreSQL에서
BM25(Best Matching 25)는 TF-IDF를 발전시킨 확률 기반 랭킹 알고리즘입니다. 용어 빈도(TF), 역문서 빈도(IDF), 문서 길이 정규화를 함께 고려하기 때문에 "짧은 문서에 키워드가 한 번 나오는 것"과 "긴 문서에 열 번 나오는 것"을 동일하게 취급하지 않습니다. Elasticsearch가 기본 랭킹 알고리즘으로 채택한 것과 같은 알고리즘입니다.
pg_search는 ParadeDB가 Lucene의 설계를 차용한 Rust 기반 전문 검색 라이브러리인 Tantivy 위에 pgrx로 구축한 PostgreSQL 확장입니다. CREATE INDEX ... USING bm25 문법으로 BM25 인덱스를 만들고, @@@ 연산자로 쿼리합니다. Neon 벤치마크 기준으로 기존 PostgreSQL tsvector 대비 100만 행 인덱싱이 약 50초 단축되고, 랭킹 속도는 20배가량 빨라진다는 결과가 보고돼 있습니다(Neon — Postgres FTS vs Elasticsearch vs pg_search).
-- 확장 설치
CREATE EXTENSION pg_search;
CREATE EXTENSION vector;
-- 상품 테이블 예시
CREATE TABLE products (
id SERIAL PRIMARY KEY,
name TEXT,
description TEXT,
category TEXT,
embedding vector(1536) -- OpenAI text-embedding-3-small 기준
);
-- BM25 인덱스 생성
-- key_field는 반드시 고유 식별자 컬럼으로 지정
CREATE INDEX products_search_idx ON products
USING bm25 (id, name, description, category)
WITH (key_field = 'id');
key_field는 테이블의 기본 키 역할을 하는 컬럼이어야 하고, BM25 인덱스에 포함할 텍스트 컬럼들을 명시적으로 나열해야 합니다. 처음 사용 시 놓치기 쉬운 부분입니다.
pgvector: 의미 기반 검색의 엔진
pgvector는 PostgreSQL에 고차원 벡터를 저장하고 유사도를 계산하는 기능을 추가합니다. 코사인 유사도(<=>), L2 거리(<->), 내적(<#>) 세 가지 연산자를 제공하며, 의미적으로 가까운 문서를 찾는 시맨틱 검색에 사용됩니다.
-- HNSW 인덱스 생성 (코사인 유사도 기준)
CREATE INDEX products_embedding_idx ON products
USING hnsw (embedding vector_cosine_ops);텍스트를 벡터로 변환하는 것은 임베딩 모델이 담당합니다. OpenAI의 text-embedding-3-small이 범용 영어 임베딩으로 많이 쓰이고, 한국어를 포함한 다국어 환경에서는 BAAI/BGE-M3(1,000개 이상 언어 지원)나 Cohere Embed v4가 좋은 선택입니다.
왜 둘 다 필요한가
예를 들어 "계약 해지 관련 조항"을 검색할 때, BM25는 "계약", "해지", "조항"이 정확히 들어간 문서를 상위에 올립니다. 하지만 "agreement termination clause"나 "계약 종료 규정"처럼 같은 뜻의 다른 표현을 놓칩니다. 반대로 벡터 검색은 의미가 비슷한 문서를 잘 찾지만, "SKU-12345" 같은 제품 코드나 "IMF"처럼 의미보다 정확한 매칭이 중요한 키워드에서 엉뚱한 결과를 내놓기도 합니다. 두 방식을 함께 쓰면 이 한계를 서로 보완할 수 있습니다.
RRF: 서로 다른 점수를 하나의 순위로
BM25 점수와 코사인 유사도는 스케일이 전혀 다릅니다. BM25는 양수이지만 상한이 없고, 코사인 유사도는 -1에서 1 사이입니다. 이 두 점수를 그대로 더하면 스케일이 큰 쪽이 항상 이깁니다.
**RRF(Reciprocal Rank Fusion)**는 점수가 아니라 순위를 사용해 이 문제를 피합니다. 각 문서의 RRF 점수는 1 / (k + rank)로 계산하고(k는 보통 60), 두 방식의 점수를 합산합니다. 1등 문서는 1/61 ≈ 0.0164, 2등은 1/62 ≈ 0.0161, 10등은 1/70 ≈ 0.0143처럼 순위만 반영하기 때문에 스케일 정규화가 필요 없습니다(ParadeDB — What is Reciprocal Rank Fusion?).
RRF 점수 = 1/(60 + BM25_순위) + 1/(60 + 벡터_순위)k=60이 마법 숫자처럼 보이지만, 이 값은 하위 순위 문서들의 기여도를 억제하는 역할을 합니다. k를 낮추면 상위 순위에 가중치가 집중되고, 높이면 하위 순위도 더 많이 반영됩니다.
실전 적용
하이브리드 검색 기본 패턴
전체 흐름부터 살펴보겠습니다.
CTE(Common Table Expression)와 FULL OUTER JOIN을 활용한 RRF 패턴이 핵심입니다.
-- 쿼리 임베딩은 애플리케이션 레이어에서 미리 생성해 파라미터로 전달
-- 함수명은 pg_search 0.15+ 기준. 신버전은 paradedb.score(id) 형태를 사용하므로
-- 실사용 버전의 공식 문서를 반드시 확인하세요.
-- https://docs.paradedb.com/documentation/guides/hybrid
WITH bm25_results AS (
SELECT
id,
ROW_NUMBER() OVER (ORDER BY paradedb.score_bm25(id) DESC) AS bm25_rank
FROM products
WHERE description @@@ 'wireless headphones'
ORDER BY paradedb.score_bm25(id) DESC
LIMIT 50
),
vector_results AS (
SELECT
id,
ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS vec_rank
FROM products
ORDER BY embedding <=> $1
LIMIT 50
)
SELECT
COALESCE(b.id, v.id) AS id,
COALESCE(1.0 / (60 + b.bm25_rank), 0) +
COALESCE(1.0 / (60 + v.vec_rank), 0) AS rrf_score
FROM bm25_results b
FULL OUTER JOIN vector_results v ON b.id = v.id
ORDER BY rrf_score DESC
LIMIT 10;$1 자리에 쿼리 임베딩 벡터가 들어갑니다. FULL OUTER JOIN을 쓰는 이유는 한쪽 검색에는 등장했지만 다른 쪽에는 없는 문서도 결과에 포함시키기 위해서입니다. COALESCE(..., 0)은 해당 결과에 없는 문서의 점수를 0으로 처리합니다. bm25_results CTE의 ORDER BY가 빠지면 LIMIT 50이 임의의 50행을 뽑아 조용히 잘못된 후보군으로 RRF가 계산되므로 반드시 명시해야 합니다.
이커머스 상품 검색
"파란색 러닝화"를 검색할 때, BM25는 제품명이나 카테고리에 "러닝화"가 정확히 들어간 상품을 잡고, 벡터 검색은 "조깅화", "스포츠 운동화" 같은 표현을 쓴 상품도 함께 올려줍니다. 여기에 PostgreSQL의 일반 WHERE 절을 자연스럽게 결합할 수 있다는 것이 큰 장점입니다.
-- 재고가 있고 5만 원 이하인 상품만 하이브리드 검색
WITH bm25_results AS (
SELECT
id,
ROW_NUMBER() OVER (ORDER BY paradedb.score_bm25(id) DESC) AS bm25_rank
FROM products
WHERE description @@@ '러닝화'
AND stock > 0
AND price <= 50000
ORDER BY paradedb.score_bm25(id) DESC
LIMIT 50
),
vector_results AS (
SELECT
id,
ROW_NUMBER() OVER (ORDER BY embedding <=> $1) AS vec_rank
FROM products
WHERE stock > 0
AND price <= 50000
ORDER BY embedding <=> $1
LIMIT 50
)
SELECT
p.id,
p.name,
p.price,
ranked.rrf_score
FROM (
SELECT COALESCE(b.id, v.id) AS id,
COALESCE(1.0 / (60 + b.bm25_rank), 0) +
COALESCE(1.0 / (60 + v.vec_rank), 0) AS rrf_score
FROM bm25_results b
FULL OUTER JOIN vector_results v ON b.id = v.id
) ranked
JOIN products p ON p.id = ranked.id
ORDER BY ranked.rrf_score DESC
LIMIT 20;Elasticsearch였다면 별도 필터 쿼리 DSL을 써야 했겠지만, 여기서는 표준 WHERE 절로 해결됩니다. PostgreSQL의 Row-Level Security도 자동 적용되어 사용자별 접근 제어를 별도 레이어에서 구현할 필요가 없습니다.
RAG 기반 사내 문서 검색
LLM 애플리케이션에서 RAG 파이프라인을 구축할 때 하이브리드 검색이 특히 유용합니다. Pinecone이나 Weaviate 같은 전용 벡터 DB 없이도 PostgreSQL 하나로 처리할 수 있습니다.
import openai
import psycopg2
from pgvector.psycopg2 import register_vector
def hybrid_search(query: str, conn, top_k: int = 5) -> list[dict]:
# 연결 시 한 번만 등록하면 list/np.ndarray를 그대로 바인딩할 수 있습니다.
register_vector(conn)
# 1. 쿼리 임베딩 생성
response = openai.embeddings.create(
model="text-embedding-3-small",
input=query
)
query_embedding = response.data[0].embedding # list[float]
# 2. 하이브리드 검색 실행 (pg_search 0.15+ 기준)
sql = """
WITH bm25_results AS (
SELECT
id,
ROW_NUMBER() OVER (ORDER BY paradedb.score_bm25(id) DESC) AS bm25_rank
FROM documents
WHERE content @@@ %s
ORDER BY paradedb.score_bm25(id) DESC
LIMIT 50
),
vector_results AS (
SELECT
id,
ROW_NUMBER() OVER (ORDER BY embedding <=> %s) AS vec_rank
FROM documents
ORDER BY embedding <=> %s
LIMIT 50
)
SELECT
d.id,
d.title,
d.content,
ranked.rrf_score
FROM (
SELECT
COALESCE(b.id, v.id) AS id,
COALESCE(1.0 / (60 + b.bm25_rank), 0) +
COALESCE(1.0 / (60 + v.vec_rank), 0) AS rrf_score
FROM bm25_results b
FULL OUTER JOIN vector_results v ON b.id = v.id
) ranked
JOIN documents d ON d.id = ranked.id
ORDER BY ranked.rrf_score DESC
LIMIT %s;
"""
with conn.cursor() as cur:
cur.execute(sql, (query, query_embedding, query_embedding, top_k))
rows = cur.fetchall()
return [
{"id": r[0], "title": r[1], "content": r[2], "score": r[3]}
for r in rows
]BEIR 계열 벤치마크에서 하이브리드 + RRF 방식은 순수 벡터 대비 nDCG@10을 약 1525%p 향상시키는 것으로 보고됩니다(ParadeDB — Hybrid Search 가이드). 상위 20개 결과에 크로스 인코더 리랭커를 추가하면 37%p를 추가로 얻을 수 있다는 사례도 있습니다(DEV — Building Hybrid Search for RAG). 참고로 nDCG@10과 정밀도(precision@k)는 서로 다른 지표이며, 초록에서 소개한 "정밀도 62%→84%"(Instaclustr)도 nDCG와 직접 비교되는 값은 아닙니다.
다국어 콘텐츠 검색
한국어나 일본어를 다룰 때 주의할 부분이 있습니다. BM25는 공백 기반 토큰화를 전제하는데, 한국어는 띄어쓰기가 불규칙하고 형태소 분리가 필요합니다. "달리기화"와 "달리기 화"가 다른 토큰으로 처리되는 식입니다.
이런 경우 두 가지 전략을 병행하면 좋습니다.
- 사전 처리: kiwi나 mecab으로 형태소 분리 후 PostgreSQL에 저장
- 벡터 의존도 높이기: 순위 병합 시 벡터 점수에 더 높은 가중치 부여
-- 다국어 환경: 벡터 순위에 2배 가중치를 준 변형(weighted RRF)
-- 표준 RRF가 아닌 선형 결합 방식이라는 점에 유의합니다.
SELECT
COALESCE(b.id, v.id) AS id,
1.0 * COALESCE(1.0 / (60 + b.bm25_rank), 0) +
2.0 * COALESCE(1.0 / (60 + v.vec_rank), 0) AS score
FROM bm25_results b
FULL OUTER JOIN vector_results v ON b.id = v.id
ORDER BY score DESC
LIMIT 10;가중치를 부여한 순간부터 이 방식은 순수한 RRF가 아니라 weighted RRF 또는 linear combination에 가깝습니다. 이름이 다르다는 점을 팀 내에서 정확히 공유하는 편이 후속 튜닝에 유리합니다. BAAI/BGE-M3는 1,000개 이상 언어를 지원하는 오픈소스 임베딩 모델로, 영어 쿼리로 한국어 문서를 찾거나 그 반대 시나리오에서도 잘 작동합니다.
장단점 분석
장단점 비교
| 항목 | pg_search + pgvector | Elasticsearch |
|---|---|---|
| 인프라 복잡도 | PostgreSQL 하나 | ES 클러스터 + ETL 파이프라인 별도 |
| 실시간 동기화 | 쓰기 즉시 인덱스 반영 | ETL 지연 발생 |
| 행 수준 보안 | PostgreSQL RLS 자동 적용 | 별도 보안 레이어 구현 필요 |
| SQL 통합 | WHERE, JOIN, GROUP BY 그대로 사용 | 검색 DSL 별도 학습 필요 |
| 인프라 비용 | 60~90% 절감 사례 보고(SoftwareSeni) | 월 수천~수만 달러 |
| NLP 고도화 | 기본 수준 | 동의어 사전, 심층 형태소 분석 등 우위 |
| 극대 규모 | 대부분의 서비스 커버 가능 | 페타바이트급, 초당 수십만 문서 인덱싱 |
| 관리형 서비스 | Neon, ParadeDB Cloud (Supabase는 pg_search 공식 지원 여부 확인 필요) | AWS Elastic, Elastic Cloud |
| CJK 언어 처리 | 추가 전처리 필요 | 전용 플러그인 생태계 풍부 |
실무에서 자주 하는 실수들
1. BM25 CTE에서 ORDER BY를 빠뜨리기
LIMIT 50만 두고 ORDER BY가 없으면 임의의 50행이 반환되어, 겉으로는 쿼리가 잘 돌지만 실제로는 무작위 후보군으로 RRF가 계산됩니다. 오류가 나지 않고 조용히 잘못된 결과가 나오기 때문에 가장 잡기 힘든 함정입니다. 반드시 ORDER BY paradedb.score_bm25(id) DESC LIMIT N 형태로 작성해야 합니다.
2. 후보군 LIMIT을 너무 작게 잡기
BM25와 벡터 각각의 후보군을 너무 좁게 잡으면 FULL OUTER JOIN 후에 좋은 문서가 아예 들어오지 않습니다. 실험적으로 각 후보군을 50100개로 잡고 최종적으로 상위 1020개를 뽑는 것이 일반적입니다.
3. RRF k값을 무조건 60으로 고정하기
k=60은 많이 인용되는 기본값이지만, 도메인에 따라 최적값이 다릅니다. nDCG, MRR 등의 검색 품질 지표를 측정하면서 k값과 가중치를 조정하는 편이 좋습니다.
4. AWS RDS에서 pg_search 설치 시도하기
표준 AWS RDS는 커스텀 확장 설치를 허용하지 않습니다. pg_search를 사용하려면 Neon, ParadeDB Cloud 같은 서비스를 사용하거나 EC2/Aurora Machine Learning 인스턴스에 직접 설치해야 합니다. Supabase 지원 여부는 공식 문서를 통해 개별 확인이 필요합니다.
5. 한국어 BM25 인덱스를 아무 설정 없이 쓰기
공백만으로 토큰화되면 "검색엔진최적화"와 "검색 엔진 최적화"가 전혀 다른 토큰으로 인식됩니다. 한국어 컬럼은 사전에 형태소 분리를 적용하거나, 해당 필드에서는 벡터 검색 가중치를 높이는 전략이 현실적입니다.
6. 벡터 HNSW 인덱스 없이 운영하기
pgvector에서 인덱스 없이 벡터 검색을 하면 순차 스캔이 일어납니다. 데이터가 수만 건만 넘어도 쿼리가 눈에 띄게 느려지므로 반드시 HNSW 또는 IVFFlat 인덱스를 생성해야 합니다.
운영 체크리스트
- 벡터 컬럼에 HNSW 또는 IVFFlat 인덱스가 생성되어 있는가?
- 텍스트 컬럼에 pg_search BM25 인덱스가 생성되어 있는가? (기본
tsvector만 사용 중이면 랭킹 품질이 저하됩니다)
마치며
정리하면 이렇습니다.
- BM25는 키워드 정밀도, 벡터는 시맨틱 이해를 담당하며 서로의 한계를 보완합니다.
- RRF는 스케일이 다른 두 점수를 순위 기반으로 안전하게 합산합니다. 가중치를 부여하면 표준 RRF가 아닌 weighted RRF/선형 결합 방식임을 인지해야 합니다.
- pg_search + pgvector 조합으로 Elasticsearch 없이 PostgreSQL 단일 스택에서 하이브리드 검색이 가능합니다.
- 순수 벡터 대비 nDCG@10이 약 15
25%p 향상되며, 인프라 비용은 6090% 줄어든 사례들이 보고됩니다. (nDCG와 precision은 서로 다른 지표이므로 도입부의 "정밀도" 수치와 직접 비교하지 않는 편이 안전합니다.)
시작해본다면 다음 순서를 권합니다.
- Neon 또는 ParadeDB Cloud에서 pg_search와 pgvector가 활성화된 PostgreSQL 인스턴스를 하나 만들어봅니다. 설치 환경 세팅이 가장 번거로운 관문인데, 관리형 서비스에서는 확장이 이미 준비돼 있습니다.
- 기존 테이블에 BM25 인덱스와 HNSW 인덱스를 추가하고, 위 CTE + RRF 패턴을 작은 데이터셋으로 먼저 실행해봅니다. 결과를 보면서 k값과 가중치를 조정하다 보면 도메인에 맞는 설정을 찾을 수 있습니다.
- Elasticsearch를 운영 중이라면 같은 쿼리를 양쪽에서 실행해 결과 품질을 비교해봅니다. nDCG나 MRR 같은 지표를 측정하면 마이그레이션 여부를 데이터로 판단할 수 있습니다.
참고 자료
- Hybrid Search in PostgreSQL: The Missing Manual — ParadeDB
- ParadeDB 공식 문서 — Create Index (BM25)
- Hybrid Search — ParadeDB 가이드
- The pg_search extension — Neon Docs
- Comparing Native Postgres, ElasticSearch, and pg_search for Full-Text Search — Neon
- Elasticsearch's Hybrid Search, Now in Postgres (BM25 + Vector + RRF) — TigerData
- BM25 in PostgreSQL: Full-Text Search Without Elastic — TigerData
- It's 2026, Just Use Postgres — TigerData
- Hybrid search with PostgreSQL and pgvector — Jonathan Katz
- Building Hybrid Search for RAG: pgvector + RRF — DEV Community
- Hybrid search with Postgres Native BM25 and VectorChord — VectorChord Blog
- What is Reciprocal Rank Fusion? — ParadeDB
- pgvector Hybrid Search: Benefits, Use Cases, and Quick Tutorial — Instaclustr
- Replacing Elasticsearch with Postgres Using BM25 Hybrid Search and RRF — SoftwareSeni
- GitHub — paradedb/paradedb
- GitHub — pgvector/pgvector
- Postgres Vector Search Compared 2026: pgvector vs pgvectorscale vs ParadeDB vs Lantern