Elasticsearch 쓰기 전에 — Meilisearch vs Qdrant vs pgvector+FTS 하이브리드 1만건 실측

목차
- 왜 단일 모드로는 안 되는가
- Docker 한 줄 설치
- 임베딩과 데이터 준비
- 3 스택 색인 코드
- 쿼리 패턴 — 그리고 RRF 하이브리드
- 벤치마크 결과 — 속도
- 벤치마크 결과 — 정확도(recall@10)
- 벤치마크 결과 — 메모리·디스크
- 실측 기반 의사결정 가이드
- 운영에서 빠뜨리면 후회하는 것들
- 마치며
- 참조 링크
"검색은 Elasticsearch"라는 공식이 더 이상 자명하지 않은 시대입니다. 작은~중간 규모 서비스에서 ES는 너무 무겁고, 의미 검색이 표준이 되면서 키워드만 다루던 도구로는 한계가 명확합니다. 그렇다고 벡터 DB만 쓰자니 사용자가 정확한 단어를 입력했을 때 놓치는 경우가 생깁니다. 이번 글에서는 한국어 위키피디아 1만 건을 같은 임베딩(multilingual-e5-small, 384차원)으로 색인해 Meilisearch · Qdrant · Postgres+pgvector+FTS 하이브리드 세 조합을 동일 조건에서 실측 비교했습니다. 모든 수치는 본문 코드를 그대로 돌려 얻은 결과입니다.
- 왜 단일 키워드/단일 벡터로는 부족하고 하이브리드가 답인지
- Meilisearch · Qdrant · Postgres+pgvector+FTS 한 줄 설치 (Docker)
- Python으로 1만 건 임베딩 → 3 스택 동시 색인하는 코드
- 50개 쿼리에 대한 p50/p95/p99 latency, recall@10, 메모리, 디스크 실측
- RRF(Reciprocal Rank Fusion)로 키워드+벡터를 한 SQL에 합치는 패턴
- 실측에 기반한 의사결정 가이드
- Apple M1, 16GB RAM, macOS Sequoia 15
- Docker 28.5.1 (각 스택 단일 컨테이너, --rm)
- Meilisearch v1.11 · Qdrant v1.12.4 · pgvector/pgvector:pg17
- 임베딩:
intfloat/multilingual-e5-small(384-dim, normalize) - 데이터: 한국어 위키피디아 abstract 1만 건 (
wikimedia/wikipedia 20231101.ko) - 쿼리: 무작위 50건(제목 정확 25 + 본문 리드 의미 25)
왜 단일 모드로는 안 되는가
검색 시스템을 처음 설계하면 보통 두 길 중 하나를 고릅니다. (1) 키워드 검색(BM25/FTS) — 정확한 단어가 들어오면 잘 잡지만 동의어·패러프레이즈에 약함. (2) 벡터 검색 — 의미가 비슷하면 잘 찾지만 짧고 정확한 키워드 쿼리("아이폰 17 Pro")에는 의외로 약함. 실제 사용자 쿼리는 두 종류가 섞여서 들어옵니다. 둘 다 잡으려면 두 결과를 합치는 하이브리드 구조가 필요합니다. RRF(Reciprocal Rank Fusion)가 그 표준 합산 방법으로, 두 랭크 리스트를 1/(60+rank)로 가중합하는 단순한 공식입니다.
이번 비교의 세 가지 조합은 각각 다음 시나리오를 대표합니다.
- Meilisearch — 키워드 전문 검색기. ES 대안의 1순위로 거론되며, 한국어 CJK 토크나이저 내장.
- Qdrant — 의미 검색 전문 벡터 DB. 자체 1.10+에서 sparse+dense 하이브리드도 지원하지만 본 글에서는 dense vector 단일 모드로 비교.
- Postgres + pgvector + FTS — Postgres 하나에서 키워드(FTS)·의미(pgvector)·하이브리드(RRF)를 모두 처리. 새 인프라 없이 기존 DB에 확장.
Docker 한 줄 설치
세 스택 모두 컨테이너 한 개로 끝납니다. 호스트 환경을 더럽히지 않으면서 정확히 같은 버전으로 비교할 수 있다는 게 Docker 비교의 가장 큰 이점입니다.
# Meilisearch
docker run -d --rm --name vb_meili -p 7700:7700 \
getmeili/meilisearch:v1.11 \
meilisearch --master-key=demoMasterKey
# Qdrant
docker run -d --rm --name vb_qdrant -p 6333:6333 -p 6334:6334 \
qdrant/qdrant:v1.12.4
# Postgres 17 + pgvector
docker run -d --rm --name vb_pg -p 5444:5432 \
-e POSTGRES_PASSWORD=bench -e POSTGRES_DB=bench \
pgvector/pgvector:pg1710초 정도 기다려 readiness check(/health, /healthz, pg_isready)가 모두 통과하면 준비 끝입니다. 컨테이너 이미지는 합쳐 1.17GB(pg 646MB · qdrant 293MB · meili 234MB)였습니다.
임베딩과 데이터 준비
한국어 abstract 1만 건을 multilingual-e5-small로 임베딩했습니다. e5 계열은 입력에 passage: / query: 접두사를 요구하는 점만 주의하면 됩니다. Apple M1 MPS 가속으로 1만 건 240초(약 41건/초)에 끝났습니다.
from sentence_transformers import SentenceTransformer
import numpy as np
model = SentenceTransformer("intfloat/multilingual-e5-small", device="mps")
# 색인용 임베딩
texts = [f"passage: {d['title']}. {d['text']}" for d in docs] # 1만개
emb = model.encode(texts, batch_size=64, normalize_embeddings=True,
convert_to_numpy=True)
# emb.shape == (10000, 384)
# 쿼리용 임베딩
q_emb = model.encode([f"query: {q}" for q in queries], normalize_embeddings=True)3 스택 색인 코드
각 스택의 클라이언트 라이브러리는 모두 pip install 한 번이면 됩니다 (meilisearch, qdrant-client, psycopg[binary]).
Meilisearch — 키워드 색인
import meilisearch
ms = meilisearch.Client("http://localhost:7700", "demoMasterKey")
ms.create_index("docs", {"primaryKey": "id"})
idx = ms.index("docs")
# 1000개씩 배치로 — 비동기 인덱싱이라 끝날 때까지 대기 필요
for i in range(0, len(docs), 1000):
idx.add_documents(docs[i:i+1000])
while idx.get_stats().is_indexing:
time.sleep(0.5)
# 실측: 8.94초 (1,119 docs/sec)Qdrant — 벡터 색인
from qdrant_client import QdrantClient
from qdrant_client.http import models as qm
qc = QdrantClient(url="http://localhost:6333")
qc.create_collection("docs",
vectors_config=qm.VectorParams(size=384, distance=qm.Distance.COSINE))
for i in range(0, len(docs), 500):
points = [
qm.PointStruct(id=d["id"], vector=emb[d["id"]].tolist(),
payload={"title": d["title"], "text": d["text"]})
for d in docs[i:i+500]
]
qc.upsert("docs", points=points)
# 실측: 2.84초 (3,522 docs/sec) — 색인은 가장 빠름Postgres + pgvector + FTS — 색인 + 인덱스 생성
import psycopg
conn = psycopg.connect(host="localhost", port=5444,
user="postgres", password="bench", dbname="bench",
autocommit=True)
cur = conn.cursor()
cur.execute("CREATE EXTENSION IF NOT EXISTS vector")
cur.execute("""
CREATE TABLE docs (
id INT PRIMARY KEY,
title TEXT NOT NULL,
body TEXT NOT NULL,
embedding VECTOR(384) NOT NULL,
tsv TSVECTOR GENERATED ALWAYS AS
(to_tsvector('simple', title || ' ' || body)) STORED
)""")
cur.executemany(
"INSERT INTO docs (id, title, body, embedding) VALUES (%s,%s,%s,%s)",
[(d["id"], d["title"], d["text"], emb[d["id"]].tolist()) for d in docs]
)
# 인덱스: HNSW(벡터) + GIN(FTS)
cur.execute("CREATE INDEX docs_emb_idx ON docs USING hnsw (embedding vector_cosine_ops)")
cur.execute("CREATE INDEX docs_tsv_idx ON docs USING gin (tsv)")
# 실측: 6.79초 (1,473 docs/sec)쿼리 패턴 — 그리고 RRF 하이브리드
핵심은 마지막 패턴입니다. Postgres에서 키워드 검색 결과와 벡터 검색 결과를 한 SQL에 합쳐서 RRF 점수로 정렬하는 쿼리입니다. 별도의 검색 게이트웨이가 필요 없고, 트랜잭션 안에서 끝납니다.
-- 하이브리드: pgvector 의미 검색 + Postgres FTS 키워드 검색을 RRF로 융합
WITH semantic AS (
SELECT id,
ROW_NUMBER() OVER (ORDER BY embedding <=> %s::vector) AS rnk
FROM docs
ORDER BY embedding <=> %s::vector
LIMIT 30
),
keyword AS (
SELECT id,
ROW_NUMBER() OVER (ORDER BY ts_rank(tsv, plainto_tsquery('simple', %s)) DESC) AS rnk
FROM docs
WHERE tsv @@ plainto_tsquery('simple', %s)
ORDER BY ts_rank(tsv, plainto_tsquery('simple', %s)) DESC
LIMIT 30
)
SELECT id, SUM(1.0/(60+rnk)) AS score FROM (
SELECT id, rnk FROM semantic
UNION ALL
SELECT id, rnk FROM keyword
) AS u
GROUP BY id ORDER BY score DESC LIMIT 10;분자의 1.0과 분모의 60은 RRF 표준 상수입니다. 두 랭크에서 모두 상위에 올라온 문서가 가산점을 받아 최종 정렬됩니다.
벤치마크 결과 — 속도
50개 쿼리를 5개 모드에 동일하게 던져 latency를 측정했습니다.
| 스택 / 모드 | p50 | p95 | p99 | 평균 | 최대 |
|---|---|---|---|---|---|
| Meilisearch (키워드) | 10.28 ms | 16.24 ms | 18.93 ms | 9.84 ms | 18.93 ms |
| Qdrant (벡터) | 3.71 ms | 5.02 ms | 21.40 ms | 4.17 ms | 21.40 ms |
| pgvector (벡터) | 1.33 ms | 2.58 ms | 3.09 ms | 1.51 ms | 3.09 ms |
| Postgres FTS (키워드) | 0.30 ms | 0.85 ms | 1.19 ms | 0.36 ms | 1.19 ms |
| pgvector + FTS 하이브리드 RRF | 2.39 ms | 3.63 ms | 17.10 ms | 2.81 ms | 17.10 ms |
속도만 보면 압도적으로 Postgres 진영이 빠릅니다. p50 기준 pgvector 1.33ms, FTS 0.30ms는 같은 머신·같은 데이터에서 측정한 값으로, 단일 컨테이너의 네트워크 오버헤드가 거의 없는 인-프로세스 호출에 가깝기 때문입니다. Qdrant도 충분히 빠르지만(3.71ms) localhost gRPC 호출 비용이 추가됩니다. Meilisearch는 키워드 토큰화 비용이 더 들어 10ms 수준입니다.
벤치마크 결과 — 정확도(recall@10)
각 쿼리의 정답 문서가 상위 10개 안에 들어오는지를 측정했습니다 (50개 쿼리 평균).
| 스택 / 모드 | recall@10 | 특징 |
|---|---|---|
| Meilisearch (키워드) | 1.000 | 제목·리드 단어를 그대로 색인하므로 정확매칭에 강함 |
| Qdrant (벡터) | 0.860 | 의미는 잡지만 짧은 정확매칭 14% 놓침 |
| pgvector (벡터) | 0.860 | 같은 임베딩이라 Qdrant와 동률 |
| Postgres FTS (키워드) | 0.740 | 한국어 형태소 분석 없이 simple 토크나이저라 26% 놓침 |
| pgvector + FTS 하이브리드 RRF | 0.960 | 두 모드의 약점이 서로 메워짐 — 단일 모드 대비 +12%p |
벤치마크 결과 — 메모리·디스크
| 스택 | RAM (idle) | 디스크 | 인덱싱 속도 |
|---|---|---|---|
| Meilisearch v1.11 | 723 MB | 213 MB | 1,119 docs/sec |
| Qdrant v1.12.4 | 315 MB | 51 MB | 3,522 docs/sec |
| Postgres 17 + pgvector | 133 MB | 88 MB | 1,473 docs/sec |
Postgres가 메모리를 가장 적게 쓰고(133MB), Qdrant 디스크가 가장 작으며(51MB, 색인이 매우 컴팩트), 인덱싱은 Qdrant가 가장 빠릅니다(3,522 docs/sec). Meilisearch는 inverted index와 prefix tree 등을 메모리에 펼쳐두기 때문에 RAM이 가장 큽니다.
실측 기반 의사결정 가이드
표에서 어느 한 칸도 압승은 아닙니다. 상황별로 정리하면 이렇습니다.
- 이미 Postgres가 운영 중이고, 검색이 핵심 기능이 아니라면 →
pgvector + FTS + RRF하나로 끝. 새 인프라 0개, 메모리 가장 작음, 정확도도 하이브리드 0.96로 최상위. 1순위. - 전용 검색 UX가 핵심이고 typo·자동완성이 중요 → Meilisearch. 인스턴트 서치 SDK가 잘 갖춰져 있고 한국어 토크나이저 내장. 단 의미 검색은 별도 도구로 보강 필요.
- 벡터가 메인이고 메타데이터 필터링이 복잡 → Qdrant. payload 인덱스, 사분면 검색, 다국어 collection 등 기능이 풍부하고 색인이 가장 빠름. 1.10+에서 sparse 벡터로 자체 하이브리드도 가능.
- RAG 파이프라인의 retrieval 레이어 →
pgvector + FTS 하이브리드가 정확도/지연 양쪽에서 가장 균형이 좋음. 운영 측면에서 단일 DB로 끝난다는 점이 결정적. - 10만 건 이상으로 커지고 멀티 노드가 필요 → 그제서야 Elasticsearch/OpenSearch나 분산 Qdrant 클러스터 검토. 그 전까진 위 세 조합이면 충분.
운영에서 빠뜨리면 후회하는 것들
- 한국어 형태소 분석 — Postgres FTS에서
'simple'토크나이저는 공백 분리만 합니다. 진짜 운영하려면pg_bigm확장이나 mecab 기반 토크나이저를 추가해야 합니다. - HNSW 인덱스 파라미터 — 본 벤치는 기본값(
m=16, ef_construction=64). 정확도를 더 끌어올리려면ef_search를 검색 시점에 64~256으로 키우면 됩니다. - 인덱싱 배치 크기 — pgvector는
executemany단건 INSERT보다COPY가 5~10배 빠릅니다. 100만 건 이상이면 반드시 COPY로. - RRF 상수 60 — 표준값이지만 도메인 따라 30~80 사이를 시도해 볼 가치가 있습니다.
- 임베딩 모델 차원 — e5-small은 384, e5-base는 768, OpenAI text-embedding-3-large는 3072. 차원이 커지면 정확도는 오르지만 인덱스 크기와 latency도 비례해서 늘어납니다.
- 벡터 차원 변경 비용 — 모델을 바꾸면 전체 재색인이 필요합니다. 한 번 시작하면 6개월~1년은 못 바꾼다고 보고 신중히 고르세요.
마치며
"Elasticsearch 쓰기 전에 무엇을 시도해볼까"라는 질문의 답은 데이터 규모와 검색 성격에 따라 다릅니다. 1만~10만 건 규모, 키워드+의미를 함께 다뤄야 하는 대부분의 한국어 서비스에서는 Postgres + pgvector + FTS의 RRF 하이브리드 한 줄 SQL이 정확도(0.96)·지연(2.4ms)·메모리(133MB)·운영 복잡도(컨테이너 1개) 네 가지 모두에서 가장 균형이 좋다는 것이 이번 실측의 결론입니다. 새 검색 인프라를 도입하기 전에, 30분만 투자해 본인 데이터로 같은 벤치마크를 돌려보시기 바랍니다 — 의외로 ES가 필요 없는 경우가 더 많습니다.
참조 링크
- pgvector — Postgres용 벡터 확장HNSW·IVFFlat 인덱스, cosine/L2/inner-product distance
- Qdrant 공식 문서payload 필터, sparse+dense 하이브리드, multi-vector
- Meilisearch 공식 문서인스턴트 서치, typo tolerance, 다국어 토크나이저
- multilingual-e5-small (Hugging Face)384-dim, 100여개 언어 지원, 한국어 우수
- Reciprocal Rank Fusion 표준 설명 (MS Learn)하이브리드 검색의 이론적 배경과 상수 60의 유래
