Elasticsearch로 RAG 시스템을 만들다 보면 이런 상황이 생겨요.
"의미 기반 벡터 검색도 하고 싶고, 정확한 키워드 검색도 하고 싶은데 어떻게 같이 써?"
그리고 한국어 데이터를 다루면 또 이런 문제가 생겨요.
"형태소 분석 없이 BM25 하면 '검색엔진'으로 검색할 때 '검색'만 들어간 문서가 안 나오네."
이번 글에서는 Dense Vector KNN으로 의미 검색을 하고, Nori 형태소 분석기 기반 BM25로 키워드 검색을 하고, 두 개를 하이브리드로 결합하는 방법을 처음부터 끝까지 정리해 드릴게요.
전체 구조 먼저
사용자 쿼리
│
├─ 임베딩 변환 → Dense Vector KNN (의미 기반 검색)
└─ 텍스트 그대로 → BM25 + Nori (키워드 기반 검색)
│
└─ 두 점수 결합 (RRF 또는 가중 합산) → 최종 결과
벡터 검색은 "강아지"를 검색할 때 "반려견", "puppy" 같은 의미적으로 유사한 문서를 찾아줘요. BM25는 정확한 키워드가 포함된 문서를 찾아줍니다. 두 개를 함께 쓰면 의미도 키워드도 놓치지 않는 검색이 돼요.
1단계: Nori 플러그인 설치
Nori는 Elasticsearch 공식 한국어 형태소 분석기예요. 기본 설치에는 포함되어 있지 않아서 따로 설치해야 해요.
# Elasticsearch 컨테이너 안에서 실행
bin/elasticsearch-plugin install analysis-nori
# Docker 사용 시
docker exec -it elasticsearch \
bin/elasticsearch-plugin install analysis-nori
# 설치 후 재시작 필요
docker restart elasticsearch
Docker Compose로 처음부터 설치하려면 이렇게 해요.
# docker-compose.yml
services:
elasticsearch:
image: elasticsearch:9.1.10
environment:
- discovery.type=single-node
- xpack.security.enabled=false
ports:
- "9200:9200"
command: >
bash -c "
bin/elasticsearch-plugin install analysis-nori &&
bin/elasticsearch
"
2단계: 인덱스 설계
인덱스를 만들 때 세 가지를 정의해야 해요. Nori 분석기 설정, 텍스트 필드 매핑, 벡터 필드 매핑이에요.
from elasticsearch import Elasticsearch
es = Elasticsearch("http://localhost:9200")
index_settings = {
"settings": {
"analysis": {
"analyzer": {
"korean_analyzer": {
"type": "custom",
"tokenizer": "nori_tokenizer",
"filter": [
"nori_part_of_speech", # 불필요한 품사 제거
"lowercase", # 소문자 변환
"nori_readingform" # 한자 → 한글 변환
]
}
},
"tokenizer": {
"nori_tokenizer": {
"type": "nori_tokenizer",
"decompound_mode": "mixed", # 복합어 처리 방식
# none: 분해 안 함
# discard: 복합어만 남김 (검색엔진 → 검색, 엔진 제거)
# mixed: 원형 + 분해 모두 보존 (권장)
}
},
"filter": {
"nori_part_of_speech": {
"type": "nori_part_of_speech",
"stoptags": [
"E", # 어미
"IC", # 감탄사
"J", # 조사
"MAG", # 일반 부사
"MM", # 관형사
"SP", # 공백
"SSC", # 닫는 괄호
"SSO", # 여는 괄호
"SC", # 구분자
"SE", # 줄임표
"XPN", # 접두사
"XSA", # 형용사 파생 접미사
"XSN", # 명사 파생 접미사
"XSV", # 동사 파생 접미사
"UNA", # 알 수 없음
"VSV" # 불규칙 동사
]
}
}
}
},
"mappings": {
"properties": {
# 텍스트 필드 — BM25 + Nori 적용
"title": {
"type": "text",
"analyzer": "korean_analyzer",
"search_analyzer": "korean_analyzer"
},
"content": {
"type": "text",
"analyzer": "korean_analyzer",
"search_analyzer": "korean_analyzer"
},
# 벡터 필드 — KNN 검색용
"embedding": {
"type": "dense_vector",
"dims": 768, # 임베딩 모델 차원에 맞게
"index": True, # KNN 인덱스 활성화
"similarity": "cosine" # cosine | dot_product | l2_norm
},
# 메타데이터 필드 — 필터링용
"category": {
"type": "keyword" # 정확한 값 매칭용
},
"author": {
"type": "keyword"
},
"created_at": {
"type": "date"
},
"view_count": {
"type": "integer"
}
}
}
}
es.indices.create(index="documents", body=index_settings)
decompound_mode: mixed가 중요해요. "검색엔진"을 인덱싱하면 "검색엔진", "검색", "엔진" 세 가지를 모두 저장해요. 덕분에 "검색"으로 검색해도 "검색엔진"이 담긴 문서가 나와요.
3단계: 문서 인덱싱
from sentence_transformers import SentenceTransformer
model = SentenceTransformer("snunlp/KR-SBERT-V40K-klueNLI-augSTS")
def index_document(doc: dict):
# 텍스트를 벡터로 변환
text = f"{doc['title']} {doc['content']}"
embedding = model.encode(text).tolist()
es.index(
index="documents",
body={
"title": doc["title"],
"content": doc["content"],
"embedding": embedding,
"category": doc.get("category", "일반"),
"author": doc.get("author", ""),
"created_at": doc.get("created_at"),
"view_count": doc.get("view_count", 0)
}
)
# 예시 문서
docs = [
{
"title": "Elasticsearch 검색엔진 튜닝 가이드",
"content": "BM25 알고리즘과 벡터 검색을 결합하면 검색 품질이 올라갑니다.",
"category": "기술",
"author": "김개발",
"view_count": 1500
},
{
"title": "한국어 형태소 분석기 Nori 사용법",
"content": "Nori 플러그인으로 한국어 텍스트를 정확하게 분석할 수 있습니다.",
"category": "기술",
"author": "이검색",
"view_count": 800
}
]
for doc in docs:
index_document(doc)
4단계: 검색 쿼리
벡터 검색만 (KNN)
def vector_search(query: str, top_k: int = 10):
query_vector = model.encode(query).tolist()
results = es.search(
index="documents",
body={
"knn": {
"field": "embedding",
"query_vector": query_vector,
"k": top_k,
"num_candidates": top_k * 10 # 후보군 크기, 클수록 정확하지만 느림
}
}
)
return results["hits"]["hits"]
BM25 + Nori 검색만
def keyword_search(query: str, top_k: int = 10):
results = es.search(
index="documents",
body={
"query": {
"multi_match": {
"query": query,
"fields": ["title^2", "content"], # 제목에 2배 가중치
"analyzer": "korean_analyzer"
}
},
"size": top_k
}
)
return results["hits"]["hits"]
하이브리드 검색 — KNN + BM25 결합
def hybrid_search(
query: str,
top_k: int = 10,
category: str = None, # 메타데이터 필터 (선택)
min_view_count: int = None
):
query_vector = model.encode(query).tolist()
# 메타데이터 필터 구성
filters = []
if category:
filters.append({"term": {"category": category}})
if min_view_count:
filters.append({"range": {"view_count": {"gte": min_view_count}}})
# KNN 쿼리
knn_query = {
"field": "embedding",
"query_vector": query_vector,
"k": top_k,
"num_candidates": top_k * 10,
"boost": 0.7 # 벡터 검색 가중치
}
if filters:
knn_query["filter"] = {"bool": {"must": filters}}
# BM25 쿼리
bm25_query = {
"multi_match": {
"query": query,
"fields": ["title^2", "content"],
"analyzer": "korean_analyzer",
"boost": 0.3 # 키워드 검색 가중치
}
}
if filters:
bm25_query = {
"bool": {
"must": bm25_query,
"filter": filters
}
}
results = es.search(
index="documents",
body={
"knn": knn_query,
"query": bm25_query,
"size": top_k
}
)
return results["hits"]["hits"]
boost 값으로 두 검색의 가중치를 조정할 수 있어요. 의미 검색이 더 중요하면 KNN boost를 높이고, 키워드 정확도가 중요하면 BM25 boost를 높여요.
5단계: RRF로 더 정교하게 결합하기
단순 boost 합산 대신 **RRF(Reciprocal Rank Fusion)**을 쓰면 두 검색 결과를 더 균형 있게 결합할 수 있어요. Elasticsearch 8.9+에서 지원해요.
def hybrid_search_rrf(query: str, top_k: int = 10):
query_vector = model.encode(query).tolist()
results = es.search(
index="documents",
body={
"retriever": {
"rrf": {
"retrievers": [
{
"knn": {
"field": "embedding",
"query_vector": query_vector,
"k": top_k,
"num_candidates": top_k * 10
}
},
{
"standard": {
"query": {
"multi_match": {
"query": query,
"fields": ["title^2", "content"],
"analyzer": "korean_analyzer"
}
}
}
}
],
"rank_window_size": top_k * 2,
"rank_constant": 60 # 기본값, 낮을수록 상위 랭크에 더 집중
}
},
"size": top_k
}
)
return results["hits"]["hits"]
RRF는 두 검색 결과의 순위만 보고 점수를 재계산해요. KNN 점수(코사인 유사도)와 BM25 점수의 단위가 다른 문제를 자연스럽게 해결해줘요.
6단계: 메타데이터 필터 + 하이브리드 검색 실전 예시
# 기술 카테고리 문서 중에서 "벡터 검색 방법" 검색
results = hybrid_search(
query="벡터 검색 방법",
top_k=5,
category="기술",
min_view_count=500
)
for hit in results:
print(f"제목: {hit['_source']['title']}")
print(f"점수: {hit['_score']:.4f}")
print(f"조회수: {hit['_source']['view_count']}")
print("---")
메타데이터 필터는 KNN과 BM25 양쪽에 동시에 적용돼요. "기술 카테고리이면서 조회수 500 이상인 문서 중에서 의미와 키워드가 모두 맞는 것"을 찾는 거예요.
Nori 분석기 동작 확인
인덱싱 전에 분석기가 어떻게 토큰을 쪼개는지 확인해볼 수 있어요.
result = es.indices.analyze(
index="documents",
body={
"analyzer": "korean_analyzer",
"text": "Elasticsearch 검색엔진 튜닝 가이드"
}
)
for token in result["tokens"]:
print(f"{token['token']} (위치: {token['position']})")
# 출력 예시:
# elasticsearch (위치: 0)
# 검색엔진 (위치: 1)
# 검색 (위치: 1)
# 엔진 (위치: 2)
# 튜닝 (위치: 3)
# 가이드 (위치: 4)
"검색엔진"이 "검색"과 "엔진"으로도 분해되는 걸 확인할 수 있어요. mixed 모드 덕분에 원형도 남고 분해된 것도 남아요.
성능 튜닝 포인트
num_candidates 조정
num_candidates는 KNN 검색 시 후보군 크기예요. 클수록 정확하지만 느려져요. 보통 k * 10을 기본값으로 시작하고 성능과 지연 시간을 보면서 조정해요.
벡터 필드 양자화
메모리 사용량을 줄이려면 양자화를 적용해요.
"embedding": {
"type": "dense_vector",
"dims": 768,
"index": True,
"similarity": "cosine",
"index_options": {
"type": "int8_hnsw" // float32 대신 int8 사용 → 메모리 4배 절감
}
}
한국어 임베딩 모델 선택
영어 임베딩 모델을 한국어 텍스트에 그대로 쓰면 성능이 떨어져요. 한국어에 맞는 모델을 쓰세요.
- snunlp/KR-SBERT-V40K-klueNLI-augSTS — 한국어 특화, 가볍고 빠름
- jhgan/ko-sroberta-multitask — 한국어 멀티태스크 모델
- Qwen3-Embedding — 다국어 지원, MTEB 최상위권
마무리
정리하면 이렇습니다.
Nori로 한국어 형태소를 분석하면 "검색엔진"으로 검색할 때 "검색"만 포함된 문서도 찾아줘요. Dense Vector KNN은 의미적으로 유사한 문서를 찾아줍니다. 두 개를 RRF로 결합하면 키워드도 의미도 놓치지 않는 하이브리드 검색이 완성돼요.
이미 Elasticsearch를 쓰고 있다면 별도 벡터 DB 없이 이 구조로 충분히 프로덕션 수준의 RAG를 만들 수 있어요. 😄
'DB' 카테고리의 다른 글
| Supabase 보안 대변화 완전 가이드 — 4월 28일부터 테이블 자동 노출 비활성화 (0) | 2026.05.06 |
|---|---|
| RAG 시스템에 맞는 벡터 DB는 뭔가 — ChromaDB vs Qdrant vs Pinecone vs Elasticsearch 완전 비교 (0) | 2026.03.25 |
| 검색 결과 순서가 바뀌는 원리 — TF-IDF, BM25, 쿼리 튜닝 한 번에 정리 (Elasticsearch) (0) | 2026.03.24 |