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

2026년 4월 30일
조회수 2
코멘트0

목차

"검색은 Elasticsearch"라는 공식이 더 이상 자명하지 않은 시대입니다. 작은~중간 규모 서비스에서 ES는 너무 무겁고, 의미 검색이 표준이 되면서 키워드만 다루던 도구로는 한계가 명확합니다. 그렇다고 벡터 DB만 쓰자니 사용자가 정확한 단어를 입력했을 때 놓치는 경우가 생깁니다. 이번 글에서는 한국어 위키피디아 1만 건을 같은 임베딩(multilingual-e5-small, 384차원)으로 색인해 Meilisearch · Qdrant · Postgres+pgvector+FTS 하이브리드 세 조합을 동일 조건에서 실측 비교했습니다. 모든 수치는 본문 코드를 그대로 돌려 얻은 결과입니다.

왜 단일 모드로는 안 되는가

검색 시스템을 처음 설계하면 보통 두 길 중 하나를 고릅니다. (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:pg17

10초 정도 기다려 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를 측정했습니다.

스택 / 모드p50p95p99평균최대
Meilisearch (키워드) 10.28 ms16.24 ms18.93 ms9.84 ms18.93 ms
Qdrant (벡터) 3.71 ms5.02 ms21.40 ms4.17 ms21.40 ms
pgvector (벡터)1.33 ms2.58 ms3.09 ms1.51 ms3.09 ms
Postgres FTS (키워드)0.30 ms0.85 ms1.19 ms0.36 ms1.19 ms
pgvector + FTS 하이브리드 RRF 2.39 ms3.63 ms17.10 ms2.81 ms17.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 하이브리드 RRF0.960두 모드의 약점이 서로 메워짐 — 단일 모드 대비 +12%p

벤치마크 결과 — 메모리·디스크

스택RAM (idle)디스크인덱싱 속도
Meilisearch v1.11723 MB213 MB1,119 docs/sec
Qdrant v1.12.4315 MB51 MB3,522 docs/sec
Postgres 17 + pgvector133 MB88 MB1,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가 필요 없는 경우가 더 많습니다.

참조 링크

조회 통계 (최근 30일)
PV 2UV 2
이 글이 도움이 되셨나요? 의견을 들려주세요!
지금까지 0명이 의견을 남겼어요
아직 댓글이 없어요. 첫 댓글을 남겨보세요!