BM25 검색 완벽 가이드: PostgreSQL vs MongoDB vs Elasticsearch - k82022603/k82022603.github.io GitHub Wiki
BM25 검색 완벽 가이드: PostgreSQL vs MongoDB vs Elasticsearch #
서론: BM25가 검색의 표준이 된 이유
BM25(Best Matching 25)는 1990년대 Stephen Robertson과 그의 연구팀이 개발한 검색 랭킹 알고리즘으로, 현재 Google, Elasticsearch, MongoDB를 포함한 대부분의 주요 검색 엔진에서 표준 알고리즘으로 사용되고 있다. TF-IDF의 단순한 개선이 아니라, 확률론적 정보 검색 이론에 기반한 정교한 랭킹 모델이다.
BM25가 TF-IDF를 대체한 이유
전통적인 TF-IDF는 치명적인 문제를 가지고 있었다:
1. 용어 빈도의 무한 증가 문제
- TF-IDF에서 단어가 100번 등장하면 10번 등장할 때보다 10배의 점수를 받음
- 실제로는 단어가 3번 등장하든 100번 등장하든 관련성에 큰 차이가 없음
- 키워드 스터핑(keyword stuffing)에 취약
2. 문서 길이 정규화 부재
- 긴 문서가 단순히 단어를 더 많이 포함한다는 이유로 불공정하게 높은 점수를 받음
- 짧고 관련성 높은 문서가 긴 문서에 밀려남
BM25는 이 두 문제를 수학적으로 우아하게 해결한다:
BM25 공식:
score(D, Q) = Σ IDF(qi) × [f(qi, D) × (k1 + 1)] / [f(qi, D) + k1 × (1 - b + b × |D| / avgdl)]
여기서:
IDF(qi): 역문서 빈도 (희귀한 단어일수록 높은 가중치)f(qi, D): 문서 D에서 단어 qi의 빈도k1: 용어 빈도 포화 파라미터 (기본값 1.2)b: 문서 길이 정규화 파라미터 (기본값 0.75)|D|: 문서 D의 길이avgdl: 컬렉션의 평균 문서 길이
핵심 개선사항:
-
용어 빈도 포화(Term Frequency Saturation): k1 파라미터가 로그 곡선처럼 작동하여, 단어가 추가로 등장할 때마다 점수 증가폭이 점점 감소한다. 단어가 2번 등장하면 1번일 때보다 큰 폭으로 증가하지만, 10번에서 11번으로 증가할 때는 거의 영향이 없다.
-
문서 길이 정규화(Length Normalization): b 파라미터가 문서 길이를 평균과 비교하여, 평균보다 긴 문서는 페널티를, 짧은 문서는 보너스를 받는다. 같은 단어가 1번 등장해도 짧은 문서가 더 높은 점수를 받는다.
PostgreSQL BM25: 확장을 통한 구현
PostgreSQL은 전통적으로 기본 전문 검색(Full-Text Search)에서 단순한 ts_rank() 함수만 제공했다. BM25를 사용하려면 확장을 설치해야 한다.
1. ParadeDB의 pg_search (Tantivy 기반)
ParadeDB는 Rust로 작성된 Tantivy 검색 엔진을 PostgreSQL 확장으로 통합했다.
설치 (Docker 사용 권장):
# ParadeDB 컨테이너 실행
docker run --name paradedb \
-e POSTGRES_PASSWORD=password \
-p 5432:5432 \
-d paradedb/paradedb:latest
# 또는 기존 PostgreSQL에 설치 (Ubuntu/Debian)
curl -fsSL https://downloads.paradedb.com/paradedb/repo/deb/paradedb.gpg | sudo gpg --dearmor -o /usr/share/keyrings/paradedb.gpg
echo "deb [signed-by=/usr/share/keyrings/paradedb.gpg] https://downloads.paradedb.com/paradedb/repo/deb stable main" | sudo tee /etc/apt/sources.list.d/paradedb.list
sudo apt update && sudo apt install postgresql-16-paradedb
사용 예제:
-- 확장 활성화
CREATE EXTENSION pg_search;
-- 테스트 데이터 생성
CREATE TABLE products (
product_id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
description TEXT,
category TEXT,
price NUMERIC(10, 2),
rating NUMERIC(3, 2)
);
INSERT INTO products (name, description, category, price, rating) VALUES
('Wireless Bluetooth Headphones', 'Premium noise-cancelling headphones with 30-hour battery life', 'Electronics', 299.99, 4.5),
('Gaming Laptop', 'High-performance laptop with RTX 4080 GPU and 32GB RAM', 'Electronics', 2499.99, 4.8),
('Organic Coffee Beans', 'Fair-trade Colombian coffee beans, medium roast', 'Food', 24.99, 4.3),
('Running Shoes', 'Lightweight running shoes with responsive cushioning', 'Sports', 129.99, 4.6);
-- BM25 인덱스 생성
CALL paradedb.create_bm25(
index_name => 'products_search_idx',
table_name => 'products',
key_field => 'product_id',
text_fields => paradedb.field('name', tokenizer => paradedb.tokenizer('en_stem')) ||
paradedb.field('description', tokenizer => paradedb.tokenizer('en_stem')),
numeric_fields => paradedb.field('price') ||
paradedb.field('rating'),
categorical_fields => paradedb.field('category')
);
-- BM25 검색 실행
SELECT
product_id,
name,
description,
paradedb.score(product_id) AS bm25_score
FROM products
WHERE products @@@ 'description:headphones OR name:wireless'
ORDER BY bm25_score DESC
LIMIT 5;
-- 결과:
-- product_id | name | bm25_score
-- -----------+-------------------------------+------------
-- 1 | Wireless Bluetooth Headphones | 8.234
-- 2 | Gaming Laptop | 0.0
-- 퍼지 검색 (오타 허용)
SELECT name, paradedb.score(product_id) AS score
FROM products
WHERE products @@@ 'description:headphons~2' -- 2글자까지 오타 허용
ORDER BY score DESC;
-- 부스팅 (필드 가중치)
SELECT name, paradedb.score(product_id) AS score
FROM products
WHERE products @@@ 'name:laptop^3 OR description:laptop' -- name 필드에 3배 가중치
ORDER BY score DESC;
-- 복합 쿼리 (범위 + 텍스트)
SELECT name, price, paradedb.score(product_id) AS score
FROM products
WHERE products @@@ 'description:gaming AND price:[1000 TO 3000] AND rating:[4 TO 5]'
ORDER BY score DESC;
-- 하이라이팅 (매칭된 부분 강조)
SELECT
name,
paradedb.highlight(product_id, 'description') AS highlighted_description,
paradedb.score(product_id) AS score
FROM products
WHERE products @@@ 'description:noise-cancelling'
ORDER BY score DESC;
-- 집계 (faceting)
SELECT
category,
COUNT(*) as count
FROM products
WHERE products @@@ 'description:coffee OR description:laptop'
GROUP BY category;
pg_search의 장점:
- Elasticsearch와 유사한 Query DSL 문법
- Rust/Tantivy 기반으로 매우 빠른 성능
- 퍼지 매칭, 구문 검색, 하이라이팅 등 고급 기능 지원
- ParadeDB가 적극 개발 중 (2024-2025년 급속 성장)
pg_search의 단점:
- 상대적으로 새로운 프로젝트 (성숙도 문제)
- 관리형 PostgreSQL 서비스(AWS RDS, Google Cloud SQL)에서 사용 불가능
- Docker 또는 자체 호스팅 필요
2. Tiger Data의 pg_textsearch
2024년 12월에 오픈소스로 공개된 PostgreSQL 네이티브에 가까운 BM25 구현.
설치:
# 소스 컴파일 필요 (현재 바이너리 배포 없음)
git clone https://github.com/tigerdata/pg_textsearch.git
cd pg_textsearch
make
sudo make install
사용 예제:
-- 확장 활성화
CREATE EXTENSION pg_textsearch CASCADE;
-- BM25 인덱스 생성
CREATE INDEX idx_products_description ON products
USING bm25(description)
WITH (text_config='english');
-- BM25 검색 (<@> 연산자 사용)
-- 주의: 점수가 낮을수록 더 관련성이 높음 (거리 개념)
SELECT
product_id,
name,
description <@> 'wireless headphones' AS bm25_score
FROM products
ORDER BY bm25_score ASC -- 오름차순!
LIMIT 5;
-- 다중 필드 검색
CREATE INDEX idx_products_combined ON products
USING bm25((name || ' ' || description))
WITH (text_config='english');
SELECT
name,
(name || ' ' || description) <@> 'gaming laptop' AS score
FROM products
ORDER BY score ASC
LIMIT 5;
-- 필터와 결합
SELECT
name,
price,
description <@> 'coffee beans' AS score
FROM products
WHERE category = 'Food'
AND price < 50
ORDER BY score ASC;
-- 병렬 인덱스 빌드 (대용량 데이터)
SET max_parallel_maintenance_workers = 4;
CREATE INDEX idx_large_table ON large_products
USING bm25(description)
WITH (text_config='english');
pg_textsearch의 장점:
- PostgreSQL 네이티브 스타일 문법 (
<@>연산자) - PostgreSQL 라이선스 (완전 오픈소스)
- pgvector/pgvectorscale과 자연스러운 통합 (하이브리드 검색)
- 병렬 인덱스 빌드 지원
pg_textsearch의 단점:
- 매우 새로운 프로젝트 (2024년 12월 공개)
- 바이너리 배포 없음 (소스 컴파일 필요)
- 관리형 서비스 지원 없음
3. PostgreSQL 하이브리드 검색 (BM25 + Vector)
BM25(키워드)와 pgvector(의미론적)를 결합한 하이브리드 검색:
-- Vector 확장 설치
CREATE EXTENSION vector;
-- 임베딩 컬럼 추가
ALTER TABLE products ADD COLUMN embedding vector(1536);
-- 임베딩 생성 (Python/OpenAI API)
UPDATE products SET embedding = generate_embedding(description);
-- generate_embedding()은 별도 구현 필요 (OpenAI API 등)
-- Vector 인덱스 생성
CREATE INDEX idx_products_embedding ON products
USING hnsw (embedding vector_cosine_ops);
-- 하이브리드 검색 (Reciprocal Rank Fusion)
WITH bm25_results AS (
SELECT
product_id,
name,
description <@> 'noise cancelling headphones' AS bm25_score,
ROW_NUMBER() OVER (ORDER BY description <@> 'noise cancelling headphones') AS bm25_rank
FROM products
ORDER BY bm25_score ASC
LIMIT 20
),
vector_results AS (
SELECT
product_id,
name,
1 - (embedding <=> '[임베딩 벡터]'::vector) AS vector_score,
ROW_NUMBER() OVER (ORDER BY embedding <=> '[임베딩 벡터]'::vector) AS vector_rank
FROM products
ORDER BY embedding <=> '[임베딩 벡터]'::vector
LIMIT 20
)
SELECT
COALESCE(b.product_id, v.product_id) AS product_id,
COALESCE(b.name, v.name) AS name,
-- Reciprocal Rank Fusion 점수 계산
COALESCE(1.0 / (60 + b.bm25_rank), 0.0) +
COALESCE(1.0 / (60 + v.vector_rank), 0.0) AS rrf_score
FROM bm25_results b
FULL OUTER JOIN vector_results v ON b.product_id = v.product_id
ORDER BY rrf_score DESC
LIMIT 10;
MongoDB Atlas Search: 완전 관리형 BM25
MongoDB Atlas Search는 Lucene을 기반으로 하는 완전 관리형 검색 솔루션이다. 별도의 검색 엔진 없이 MongoDB 내에서 BM25 검색을 사용할 수 있다.
아키텍처
MongoDB Atlas Search는 mongot라는 별도 프로세스가 검색 인덱스를 관리한다:
- mongod: MongoDB 데이터베이스 프로세스
- mongot: Lucene 기반 검색 프로세스
- 두 프로세스가 긴밀하게 통합되어 동작
기본 사용법
// 1. Atlas Search 인덱스 생성 (Atlas UI 또는 CLI)
// products 컬렉션에 대한 검색 인덱스 정의
{
"mappings": {
"dynamic": true, // 모든 필드 자동 인덱싱
"fields": {
"name": {
"type": "string",
"analyzer": "lucene.standard"
},
"description": {
"type": "string",
"analyzer": "lucene.english" // 영어 형태소 분석
},
"category": {
"type": "string",
"analyzer": "lucene.keyword" // 정확한 매칭
},
"price": {
"type": "number"
},
"rating": {
"type": "number"
}
}
}
}
// 2. 샘플 데이터 삽입
db.products.insertMany([
{
name: "Wireless Bluetooth Headphones",
description: "Premium noise-cancelling headphones with 30-hour battery life",
category: "Electronics",
price: 299.99,
rating: 4.5
},
{
name: "Gaming Laptop",
description: "High-performance laptop with RTX 4080 GPU and 32GB RAM",
category: "Electronics",
price: 2499.99,
rating: 4.8
},
{
name: "Organic Coffee Beans",
description: "Fair-trade Colombian coffee beans, medium roast",
category: "Food",
price: 24.99,
rating: 4.3
}
]);
// 3. 기본 BM25 텍스트 검색
db.products.aggregate([
{
$search: {
index: "default",
text: {
query: "wireless headphones",
path: ["name", "description"]
}
}
},
{
$project: {
name: 1,
description: 1,
score: { $meta: "searchScore" }
}
},
{ $limit: 10 }
]);
// 4. 필드별 부스팅
db.products.aggregate([
{
$search: {
index: "default",
compound: {
should: [
{
text: {
query: "laptop",
path: "name",
score: { boost: { value: 3 } } // name 필드에 3배 가중치
}
},
{
text: {
query: "laptop",
path: "description",
score: { boost: { value: 1 } }
}
}
]
}
}
},
{
$project: {
name: 1,
score: { $meta: "searchScore" }
}
}
]);
// 5. 복합 쿼리 (텍스트 + 범위 필터)
db.products.aggregate([
{
$search: {
index: "default",
compound: {
must: [
{
text: {
query: "gaming",
path: "description"
}
}
],
filter: [
{
range: {
path: "price",
gte: 1000,
lte: 3000
}
},
{
range: {
path: "rating",
gte: 4.0
}
}
]
}
}
},
{
$project: {
name: 1,
price: 1,
rating: 1,
score: { $meta: "searchScore" }
}
}
]);
// 6. 퍼지 검색 (오타 허용)
db.products.aggregate([
{
$search: {
index: "default",
text: {
query: "headphons", // 오타
path: "description",
fuzzy: {
maxEdits: 2, // 최대 2글자 차이 허용
prefixLength: 1
}
}
}
},
{
$project: {
name: 1,
score: { $meta: "searchScore" }
}
}
]);
// 7. 하이라이팅
db.products.aggregate([
{
$search: {
index: "default",
text: {
query: "noise-cancelling",
path: "description"
},
highlight: {
path: "description"
}
}
},
{
$project: {
name: 1,
highlights: { $meta: "searchHighlights" },
score: { $meta: "searchScore" }
}
}
]);
// 8. 집계 (Faceting)
db.products.aggregate([
{
$searchMeta: {
index: "default",
facet: {
operator: {
text: {
query: "electronics",
path: "description"
}
},
facets: {
categoriesFacet: {
type: "string",
path: "category"
},
priceRanges: {
type: "number",
path: "price",
boundaries: [0, 100, 500, 1000, 5000]
}
}
}
}
}
]);
MongoDB BM25 스코어 상세 분석
// 스코어 계산 과정을 상세하게 확인
db.products.aggregate([
{
$search: {
index: "default",
text: {
query: "wireless headphones",
path: "description"
}
}
},
{
$project: {
name: 1,
description: 1,
score: { $meta: "searchScore" },
scoreDetails: { $meta: "searchScoreDetails" } // 스코어 계산 상세
}
},
{ $limit: 1 }
]);
// 결과 예시:
{
"_id": ObjectId("..."),
"name": "Wireless Bluetooth Headphones",
"description": "Premium noise-cancelling headphones...",
"score": 6.011,
"scoreDetails": {
"value": 6.011,
"description": "sum of:",
"details": [
{
"value": 6.011,
"description": "weight(description:wireless) [BM25Similarity]",
"details": [
{
"value": 13.083,
"description": "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5))",
"details": [
{ "value": 1000, "description": "N, total documents" },
{ "value": 50, "description": "n, documents with term" }
]
},
{
"value": 0.459,
"description": "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl))",
"details": [
{ "value": 3, "description": "freq, occurrences of term" },
{ "value": 1.2, "description": "k1, term saturation parameter" },
{ "value": 0.75, "description": "b, length normalization" },
{ "value": 500, "description": "dl, length of field" },
{ "value": 450, "description": "avgdl, average field length" }
]
}
]
}
]
}
}
MongoDB 하이브리드 검색 (BM25 + Vector)
MongoDB는 2025년 9월 $rankFusion과 $scoreFusion을 도입하여 하이브리드 검색을 네이티브로 지원한다.
// 1. Vector Search 인덱스 생성 (별도)
{
"type": "vectorSearch",
"fields": [
{
"type": "vector",
"path": "embedding",
"numDimensions": 1536,
"similarity": "cosine"
}
]
}
// 2. $rankFusion을 사용한 하이브리드 검색
db.products.aggregate([
{
$rankFusion: {
input: {
pipelines: {
// BM25 텍스트 검색
textSearch: [
{
$search: {
index: "default",
text: {
query: "wireless headphones",
path: ["name", "description"]
}
}
},
{ $limit: 20 }
],
// Vector 의미론적 검색
vectorSearch: [
{
$vectorSearch: {
index: "vector_index",
queryVector: [0.12, 0.34, ...], // 임베딩 벡터
path: "embedding",
numCandidates: 100,
limit: 20
}
}
]
}
}
}
},
{ $limit: 10 }
]);
// 3. $scoreFusion으로 가중 평균 (더 세밀한 제어)
db.products.aggregate([
{
$scoreFusion: {
input: {
pipelines: {
textSearch: [
{
$search: {
index: "default",
text: {
query: "wireless headphones",
path: "description"
}
}
},
{ $limit: 20 },
{ $set: { textScore: { $meta: "searchScore" } } }
],
vectorSearch: [
{
$vectorSearch: {
index: "vector_index",
queryVector: [0.12, 0.34, ...],
path: "embedding",
numCandidates: 100,
limit: 20
}
},
{ $set: { vectorScore: { $meta: "vectorSearchScore" } } }
]
}
},
// 텍스트 70%, 벡터 30% 가중치
weights: {
textSearch: 0.7,
vectorSearch: 0.3
}
}
},
{ $limit: 10 }
]);
MongoDB Atlas Search 고급 기능
1. 동적 부스팅 (Embedded Scoring Pattern)
문서 내에 부스팅 로직을 직접 포함시켜 실시간으로 랭킹 조정:
// 제품에 부스팅 점수 추가
db.products.updateOne(
{ name: "Wireless Bluetooth Headphones" },
{
$set: {
boost_terms: [
{ term: "premium", boost: 10 },
{ term: "wireless", boost: 5 },
{ term: "noise-cancelling", boost: 8 }
]
}
}
);
// 검색 인덱스에 boost_terms 필드 추가
{
"mappings": {
"fields": {
"boost_terms": {
"type": "embeddedDocuments",
"fields": {
"term": { "type": "string" },
"boost": { "type": "number" }
}
}
}
}
}
// 부스팅을 활용한 검색
db.products.aggregate([
{
$search: {
index: "default",
embeddedDocument: {
path: "boost_terms",
operator: {
compound: {
should: [
{
text: {
query: "premium wireless",
path: "boost_terms.term",
score: {
boost: {
path: "boost_terms.boost"
}
}
}
}
]
}
}
}
}
}
]);
2. 대체 스코어링 알고리즘
BM25 외에도 두 가지 알고리즘 제공 (2025년 신규):
// stableTfl: 용어 길이 기반 희귀도 (일관된 페이지네이션)
db.products.aggregate([
{
$search: {
index: "default",
text: {
query: "laptop",
path: "description",
score: {
function: {
score: "stableTfl"
}
}
}
}
}
]);
// boolean: 용어 존재 여부만 카운트 (엔티티 매칭)
db.products.aggregate([
{
$search: {
index: "default",
text: {
query: "wireless bluetooth headphones",
path: "description",
score: {
function: {
score: "boolean" // 3개 용어 중 매칭 개수만
}
}
}
}
}
]);
Elasticsearch: BM25의 원조 구현
Elasticsearch는 Lucene 기반으로 BM25를 구현한 최초의 대중적 검색 엔진이다. 2016년 Elasticsearch 5.0부터 BM25가 기본 알고리즘이 되었다.
기본 설정과 사용법
// 1. 인덱스 생성
PUT /products
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 1,
"index": {
"similarity": {
"default": {
"type": "BM25"
}
}
}
},
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "standard"
},
"description": {
"type": "text",
"analyzer": "english"
},
"category": {
"type": "keyword"
},
"price": {
"type": "float"
},
"rating": {
"type": "float"
}
}
}
}
// 2. 문서 색인
POST /products/_bulk
{"index": {"_id": 1}}
{"name": "Wireless Bluetooth Headphones", "description": "Premium noise-cancelling headphones with 30-hour battery life", "category": "Electronics", "price": 299.99, "rating": 4.5}
{"index": {"_id": 2}}
{"name": "Gaming Laptop", "description": "High-performance laptop with RTX 4080 GPU and 32GB RAM", "category": "Electronics", "price": 2499.99, "rating": 4.8}
{"index": {"_id": 3}}
{"name": "Organic Coffee Beans", "description": "Fair-trade Colombian coffee beans, medium roast", "category": "Food", "price": 24.99, "rating": 4.3}
// 3. 기본 BM25 검색
GET /products/_search
{
"query": {
"multi_match": {
"query": "wireless headphones",
"fields": ["name^3", "description"] // name 필드에 3배 가중치
}
}
}
// 4. Bool 쿼리 (복합 조건)
GET /products/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"description": "gaming"
}
}
],
"filter": [
{
"range": {
"price": {
"gte": 1000,
"lte": 3000
}
}
},
{
"range": {
"rating": {
"gte": 4.0
}
}
}
]
}
}
}
// 5. 퍼지 검색
GET /products/_search
{
"query": {
"match": {
"description": {
"query": "headphons",
"fuzziness": "AUTO" // 자동으로 편집 거리 결정
}
}
}
}
// 6. 하이라이팅
GET /products/_search
{
"query": {
"match": {
"description": "noise-cancelling"
}
},
"highlight": {
"fields": {
"description": {
"fragment_size": 150,
"number_of_fragments": 3
}
}
}
}
// 7. 집계 (Aggregations)
GET /products/_search
{
"size": 0,
"query": {
"match": {
"description": "electronics"
}
},
"aggs": {
"categories": {
"terms": {
"field": "category"
}
},
"price_ranges": {
"range": {
"field": "price",
"ranges": [
{ "to": 100 },
{ "from": 100, "to": 500 },
{ "from": 500, "to": 1000 },
{ "from": 1000 }
]
}
}
}
}
BM25 파라미터 커스터마이징
Elasticsearch는 k1과 b 파라미터를 세밀하게 조정할 수 있다.
// 커스텀 BM25 설정
PUT /custom_products
{
"settings": {
"index": {
"similarity": {
"custom_bm25": {
"type": "BM25",
"k1": 1.5, // 기본값 1.2보다 높음 (용어 빈도 영향 증가)
"b": 0.7 // 기본값 0.75보다 낮음 (길이 정규화 약화)
}
}
}
},
"mappings": {
"properties": {
"title": {
"type": "text",
"similarity": "custom_bm25" // 커스텀 유사도 적용
},
"content": {
"type": "text",
"similarity": "custom_bm25"
}
}
}
}
// k1과 b 파라미터 의미:
// k1 (기본값 1.2):
// - 낮을수록 (예: 0.5): 용어 빈도 포화가 빠르게 일어남 (중복 단어 영향 감소)
// - 높을수록 (예: 3.0): 용어가 많이 등장할수록 점수가 계속 증가
// - 0에 가까우면: 거의 Boolean 검색 (용어 존재 여부만)
//
// b (기본값 0.75):
// - 0에 가까우면: 문서 길이 무시 (긴 문서 불이익 없음)
// - 1에 가까우면: 문서 길이를 강하게 반영 (짧은 문서 선호)
파라미터 튜닝 가이드라인:
| 상황 | k1 조정 | b 조정 |
|---|---|---|
| 짧은 문서 많음 (트윗, 제목) | 낮게 (0.8-1.0) | 낮게 (0.5-0.6) |
| 긴 문서 많음 (논문, 기사) | 높게 (1.5-2.0) | 높게 (0.8-0.9) |
| 정확한 용어 매칭 중요 | 낮게 (0.5-1.0) | 중간 (0.75) |
| 용어 빈도 중요 | 높게 (2.0-3.0) | 중간 (0.75) |
| 문서 길이 균일 | 중간 (1.2) | 낮게 (0.3-0.5) |
Explain API로 스코어 분석
GET /products/_explain/1
{
"query": {
"multi_match": {
"query": "wireless headphones",
"fields": ["name", "description"]
}
}
}
// 결과 (간략화):
{
"value": 6.234,
"description": "sum of:",
"details": [
{
"value": 2.145,
"description": "weight(description:wireless in 0) [PerFieldSimilarity], result of:",
"details": [
{
"value": 2.145,
"description": "score(freq=1.0), computed as boost * idf * tf from:",
"details": [
{
"value": 3.967,
"description": "idf, computed as log(1 + (N - n + 0.5) / (n + 0.5)) from:",
"details": [
{ "value": 3, "description": "n, number of documents containing term" },
{ "value": 100, "description": "N, total number of documents with field" }
]
},
{
"value": 0.541,
"description": "tf, computed as freq / (freq + k1 * (1 - b + b * dl / avgdl)) from:",
"details": [
{ "value": 1.0, "description": "freq, occurrences of term within document" },
{ "value": 1.2, "description": "k1, term saturation parameter" },
{ "value": 0.75, "description": "b, length normalization parameter" },
{ "value": 45.0, "description": "dl, length of field" },
{ "value": 50.0, "description": "avgdl, average length of field" }
]
}
]
}
]
},
{
"value": 4.089,
"description": "weight(description:headphones in 0)...",
"details": [ /* 유사한 구조 */ ]
}
]
}
Function Score로 비즈니스 로직 통합
BM25 점수에 비즈니스 메트릭(가격, 재고, 인기도 등)을 곱셈으로 결합:
GET /products/_search
{
"query": {
"function_score": {
"query": {
"multi_match": {
"query": "laptop",
"fields": ["name", "description"]
}
},
"functions": [
{
// 평점에 따른 부스팅
"filter": { "range": { "rating": { "gte": 4.5 } } },
"weight": 1.5
},
{
// 가격 역수 (저렴할수록 높은 점수)
"script_score": {
"script": {
"source": "1 / (doc['price'].value / 100)"
}
}
},
{
// 필드 값 기반 부스팅
"field_value_factor": {
"field": "rating",
"factor": 1.2,
"modifier": "sqrt",
"missing": 1
}
}
],
"score_mode": "multiply", // 모든 함수를 곱셈
"boost_mode": "multiply" // BM25 점수와도 곱셈
}
}
}
Elasticsearch 샤딩과 BM25
Elasticsearch의 분산 아키텍처는 BM25 점수에 영향을 줄 수 있다:
// 문제: 샤드별로 IDF가 다르게 계산됨
PUT /products
{
"settings": {
"number_of_shards": 3 // 3개 샤드로 분산
}
}
// 샤드 0: "laptop" 용어가 10개 문서 중 2개에 등장 -> IDF 높음
// 샤드 1: "laptop" 용어가 10개 문서 중 8개에 등장 -> IDF 낮음
// 샤드 2: "laptop" 용어가 10개 문서 중 5개에 등장 -> IDF 중간
// => 같은 단어인데 샤드에 따라 다른 점수!
// 해결책 1: DFS Query Then Fetch (전체 통계 수집)
GET /products/_search?search_type=dfs_query_then_fetch
{
"query": {
"match": { "description": "laptop" }
}
}
// - 모든 샤드에서 통계를 먼저 수집한 후 검색
// - 정확한 점수, 하지만 추가 네트워크 왕복 필요
// - 대부분의 경우 기본 설정으로 충분 (데이터가 많으면 통계 수렴)
// 해결책 2: 샤드 수 줄이기
PUT /products_single_shard
{
"settings": {
"number_of_shards": 1 // 단일 샤드
}
}
// - 가장 정확한 점수
// - 확장성 제한 (단일 노드)
3대 플랫폼 종합 비교
기능 비교표
| 기능 | PostgreSQL (pg_search) | PostgreSQL (pg_textsearch) | MongoDB Atlas Search | Elasticsearch |
|---|---|---|---|---|
| 설치 복잡도 | 중간 (Docker 권장) | 높음 (소스 컴파일) | 없음 (관리형) | 중간 (Docker/클라우드) |
| BM25 구현 | Tantivy | 자체 구현 | Lucene | Lucene |
| k1, b 조정 | ❌ | ❌ | ❌ | ✅ |
| 퍼지 매칭 | ✅ (~2) | ❌ | ✅ (maxEdits) | ✅ (AUTO) |
| 구문 검색 | ✅ | ✅ | ✅ | ✅ |
| 하이라이팅 | ✅ | ❌ | ✅ | ✅ |
| 집계/Faceting | ✅ | ❌ | ✅ | ✅ |
| 하이브리드 검색 | ✅ (pgvector) | ✅ (pgvector) | ✅ (네이티브) | ❌ (별도 구현) |
| 실시간 업데이트 | ✅ | ✅ | ✅ (~1초) | ✅ (~1초) |
| ACID 트랜잭션 | ✅ | ✅ | ❌ | ❌ |
| 확장성 | 수직 (단일 서버) | 수직 (단일 서버) | 수평 (샤딩) | 수평 (샤딩) |
| 관리형 서비스 | ❌ | ❌ | ✅ (Atlas) | ✅ (Elastic Cloud) |
| 가격 | 무료 (오픈소스) | 무료 (오픈소스) | Atlas 요금 | Elastic Cloud 요금 |
성능 비교 (추정치, 100만 문서 기준)
| 플랫폼 | 인덱싱 속도 | 검색 지연시간 | 메모리 사용량 |
|---|---|---|---|
| PostgreSQL (pg_search) | ~50초 (전체) | 10-50ms | 2-4GB |
| PostgreSQL (pg_textsearch) | ~60초 (전체) | 10-50ms | 2-3GB |
| MongoDB Atlas Search | ~2분 (백그라운드) | 20-100ms | 3-6GB |
| Elasticsearch | ~1분 (병렬) | 5-50ms | 4-8GB |
주의: 실제 성능은 하드웨어, 문서 크기, 쿼리 복잡도에 따라 크게 달라집니다.
아키텍처 선택 가이드
PostgreSQL을 선택하세요:
- 데이터가 이미 PostgreSQL에 있고, 별도 시스템을 추가하고 싶지 않을 때
- 강력한 ACID 트랜잭션이 필요할 때
- 데이터 볼륨이 수백만~수천만 건일 때
- 검색과 트랜잭션 로직이 긴밀하게 결합되어 있을 때
- 인프라를 단순하게 유지하고 싶을 때
- 하이브리드 검색(키워드+벡터)을 단일 DB에서 구현하고 싶을 때
MongoDB Atlas Search를 선택하세요:
- 데이터가 이미 MongoDB에 있고, 완전 관리형 솔루션을 원할 때
- JSON 문서 구조와 검색을 자연스럽게 통합하고 싶을 때
- 검색 인프라 관리 부담을 최소화하고 싶을 때
- 하이브리드 검색을 네이티브로 지원받고 싶을 때 ($rankFusion/$scoreFusion)
- 샤딩을 통한 수평 확장이 필요할 때
- 동적 스키마의 이점을 살리고 싶을 때
Elasticsearch를 선택하세요:
- 수억~수십억 건의 문서를 검색해야 할 때
- 복잡한 집계와 분석이 핵심 요구사항일 때
- 로그 분석, 모니터링, BI가 주 목적일 때
- BM25 파라미터(k1, b)를 세밀하게 튜닝해야 할 때
- Kibana 생태계가 필요할 때
- 이미 Elasticsearch를 성공적으로 운영 중일 때
- 검색 전용 시스템으로 최고 성능이 필요할 때
실무 의사결정 프레임워크
START
|
├─ 데이터가 어디 있는가?
| ├─ PostgreSQL → pg_search 또는 pg_textsearch 시작
| ├─ MongoDB → Atlas Search 시작
| └─ 없음/신규 → 아래 계속
|
├─ 데이터 볼륨은?
| ├─ <1천만 건 → PostgreSQL 충분
| ├─ 1천만~1억 건 → PostgreSQL 또는 MongoDB
| └─ >1억 건 → Elasticsearch 고려
|
├─ 트랜잭션이 중요한가?
| ├─ Yes (금융, 전자상거래) → PostgreSQL
| └─ No (로그, 분석) → MongoDB/Elasticsearch
|
├─ 팀의 전문성은?
| ├─ PostgreSQL DBA → PostgreSQL
| ├─ MongoDB 개발자 → MongoDB
| └─ Elasticsearch 엔지니어 → Elasticsearch
|
├─ 인프라 관리 역량은?
| ├─ 최소화 원함 → MongoDB Atlas (관리형)
| ├─ 자체 운영 가능 → PostgreSQL/Elasticsearch
| └─ 클라우드 선호 → Elastic Cloud 또는 Atlas
|
└─ 하이브리드 검색 필요?
├─ Yes, 네이티브 → MongoDB ($rankFusion)
├─ Yes, 커스텀 OK → PostgreSQL (pgvector)
└─ No → 위 조건만 고려
마이그레이션 전략
Elasticsearch → PostgreSQL
-- 1. PostgreSQL 스키마 생성
CREATE TABLE articles (
id SERIAL PRIMARY KEY,
title TEXT NOT NULL,
content TEXT NOT NULL,
author TEXT,
published_at TIMESTAMP,
tags TEXT[],
metadata JSONB
);
-- 2. pg_search 인덱스
CREATE EXTENSION pg_search;
CALL paradedb.create_bm25(
index_name => 'articles_idx',
table_name => 'articles',
key_field => 'id',
text_fields => paradedb.field('title', tokenizer => paradedb.tokenizer('en_stem')) ||
paradedb.field('content', tokenizer => paradedb.tokenizer('en_stem'))
);
-- 3. Elasticsearch 데이터 추출 및 마이그레이션
-- Python 스크립트 예제
from elasticsearch import Elasticsearch
from elasticsearch.helpers import scan
import psycopg2
es = Elasticsearch(['http://localhost:9200'])
pg_conn = psycopg2.connect("dbname=mydb user=postgres")
pg_cur = pg_conn.cursor()
# Elasticsearch에서 스크롤
for doc in scan(es, index="articles", query={"query": {"match_all": {}}}):
pg_cur.execute("""
INSERT INTO articles (title, content, author, published_at, tags, metadata)
VALUES (%s, %s, %s, %s, %s, %s)
""", (
doc['_source']['title'],
doc['_source']['content'],
doc['_source']['author'],
doc['_source']['published_at'],
doc['_source']['tags'],
json.dumps(doc['_source'].get('metadata', {}))
))
pg_conn.commit()
MongoDB → Elasticsearch
// 1. Elasticsearch 매핑 생성
PUT /products
{
"mappings": {
"properties": {
"name": { "type": "text" },
"description": { "type": "text" },
"category": { "type": "keyword" },
"price": { "type": "float" },
"attributes": { "type": "object", "enabled": false } // JSONB 데이터
}
}
}
// 2. MongoDB Change Streams로 실시간 동기화
const { MongoClient } = require('mongodb');
const { Client } = require('@elastic/elasticsearch');
const mongoClient = new MongoClient('mongodb://localhost:27017');
const esClient = new Client({ node: 'http://localhost:9200' });
async function syncToElasticsearch() {
await mongoClient.connect();
const db = mongoClient.db('mydb');
const collection = db.collection('products');
// Change Stream 시작
const changeStream = collection.watch();
changeStream.on('change', async (change) => {
if (change.operationType === 'insert' || change.operationType === 'update') {
const doc = change.fullDocument;
await esClient.index({
index: 'products',
id: doc._id.toString(),
document: {
name: doc.name,
description: doc.description,
category: doc.category,
price: doc.price,
attributes: doc.attributes
}
});
} else if (change.operationType === 'delete') {
await esClient.delete({
index: 'products',
id: change.documentKey._id.toString()
});
}
});
}
syncToElasticsearch();
실전 튜닝 가이드
PostgreSQL BM25 최적화
-- 1. 인덱스 통계 확인
SELECT
schemaname,
tablename,
indexname,
idx_scan as scans,
idx_tup_read as tuples_read,
idx_tup_fetch as tuples_fetched,
pg_size_pretty(pg_relation_size(indexrelid)) as size
FROM pg_stat_user_indexes
WHERE schemaname = 'public'
ORDER BY idx_scan DESC;
-- 2. 쿼리 성능 분석
EXPLAIN (ANALYZE, BUFFERS)
SELECT name, paradedb.score(product_id)
FROM products
WHERE products @@@ 'description:laptop AND price:[1000 TO 3000]'
ORDER BY paradedb.score(product_id) DESC
LIMIT 10;
-- 3. VACUUM 및 ANALYZE
VACUUM ANALYZE products;
-- 4. 파티셔닝 (시계열 데이터)
CREATE TABLE articles_2025_q1 PARTITION OF articles
FOR VALUES FROM ('2025-01-01') TO ('2025-04-01');
CREATE TABLE articles_2025_q2 PARTITION OF articles
FOR VALUES FROM ('2025-04-01') TO ('2025-07-01');
-- 파티션별 BM25 인덱스
CALL paradedb.create_bm25(
index_name => 'articles_2025_q1_idx',
table_name => 'articles_2025_q1',
key_field => 'id',
text_fields => paradedb.field('content')
);
MongoDB Atlas Search 최적화
// 1. 인덱스 분석
db.products.aggregate([
{ $indexStats: {} }
]);
// 2. 쿼리 프로파일링
db.setProfilingLevel(2); // 모든 쿼리 프로파일링
// 검색 수행
db.products.aggregate([
{
$search: {
index: "default",
text: {
query: "laptop",
path: "description"
}
}
}
]);
// 프로파일 확인
db.system.profile.find().sort({ ts: -1 }).limit(1).pretty();
// 3. 선택적 필드 인덱싱 (인덱스 크기 축소)
{
"mappings": {
"dynamic": false, // 자동 인덱싱 비활성화
"fields": {
"name": { "type": "string" },
"description": { "type": "string" }
// price, rating 등은 인덱싱하지 않음
}
}
}
// 4. 전용 Search Nodes (격리된 리소스)
// Atlas UI에서 설정:
// - Cluster Tier: M30 이상
// - Analytics Nodes: 검색 전용 노드 추가
// - 쿼리가 자동으로 Search Nodes로 라우팅됨
Elasticsearch 최적화
// 1. 샤드 크기 최적화
// 권장: 샤드당 20-50GB
PUT /products
{
"settings": {
"number_of_shards": 3, // 150GB 데이터 -> 3샤드
"number_of_replicas": 1
}
}
// 2. 리프레시 간격 조정
PUT /products/_settings
{
"index": {
"refresh_interval": "30s" // 기본 1s -> 30s (색인 속도 향상)
}
}
// 3. 병합 정책 조정
PUT /products/_settings
{
"index": {
"merge": {
"policy": {
"max_merged_segment": "5gb",
"segments_per_tier": 10
}
}
}
}
// 4. 필드 데이터 캐시 모니터링
GET /_nodes/stats/indices/fielddata?human
// 5. 인덱스 라이프사이클 관리 (ILM)
PUT /_ilm/policy/products_policy
{
"policy": {
"phases": {
"hot": {
"actions": {
"rollover": {
"max_size": "50GB",
"max_age": "7d"
}
}
},
"warm": {
"min_age": "30d",
"actions": {
"shrink": {
"number_of_shards": 1
},
"forcemerge": {
"max_num_segments": 1
}
}
},
"delete": {
"min_age": "90d",
"actions": {
"delete": {}
}
}
}
}
}
결론: 올바른 도구 선택하기
BM25는 검색의 표준이지만, 구현 플랫폼에 따라 특성이 크게 다르다. PostgreSQL은 단순성과 ACID 보장으로, MongoDB는 유연성과 관리 편의성으로, Elasticsearch는 확장성과 성능으로 각각 차별화된다.
핵심 선택 기준:
- 데이터 위치: 이미 사용 중인 데이터베이스에 검색 기능을 추가하는 것이 가장 단순하다.
- 데이터 볼륨: 수천만 건 이하면 PostgreSQL, 수억 건 이상이면 Elasticsearch를 고려한다.
- 트랜잭션: ACID가 중요하면 PostgreSQL이 유일한 선택지다.
- 팀 역량: 기존 팀의 전문성을 활용하는 것이 장기적으로 유리하다.
- 복잡도 허용: 단순성을 원하면 PostgreSQL, 최고 성능을 원하면 Elasticsearch다.
대부분의 애플리케이션은 PostgreSQL의 BM25로 충분하다. 복잡성을 추가하기 전에 먼저 시도해보라. 정말 Elasticsearch가 필요한 시점은 생각보다 늦게 온다.
작성 일자: 2026-02-01