Elasticsearch 입문 22편 RAG·Hybrid Search. Chunking·Embedding·Hybrid·RRF·Reranking·ELSER.
이 글은 Elasticsearch 입문에서 운영까지 시리즈 38편 중 22편이에요. 21편(Vector Search) 이 dense_vector 필드 + kNN 쿼리 같은 벡터 검색 자체 를 다뤘다면, 이번 편은 그 벡터 검색을 실제 RAG 시스템 으로 묶어 내는 한 단계 위 이야기예요. 임베딩 → 검색 → LLM 응답 흐름을 처음부터 끝까지 한 호흡에.
이 글은 Elasticsearch 8.x 공식 docs 의 Search · ELSER · Inference 섹션을 참고해 한국어 학습 노트로 풀어쓴 자료예요.
RAG 는 *읽기만* 으로는 머리에 잘 안 박혀요. 작은 위키 100건이라도 직접 임베딩해서 BM25 + kNN 하이브리드로 한 번 돌려 보면 본문 흐름이 또렷해져요.
왜 22편이 RAG·Hybrid Search 인가
21편의 자리에서 한 발만 더 나가 보면 — 임베딩 벡터를 잘 저장하고 kNN 으로 가까운 문서 10개를 찾는다 까지는 됐어요. 그런데 실무에서 그것만으로 "좋은 RAG" 가 안 돼요. 왜냐하면 벡터만으로 잡히지 않는 키워드 자리 가 있고, kNN 상위 10개가 정말 답에 맞는지 한 번 더 거르는 자리도 필요하기 때문이에요.
이번 편이 다루는 게 그 두 자리. Hybrid Search (BM25 텍스트 + kNN 벡터 결합) 가 벡터만으로 못 잡는 키워드 매칭 자리를 잡아 주고, Reranking 이 retrieval 상위 N 을 LLM 호출 직전에 한 번 더 정렬 해 줘요. 그 위에 Relevance Tuning (boost·function_score·LTR) 과 ELSER (Elastic 의 sparse 임베딩) 까지 다루면, RAG 시스템 한 채 가 한 편 안에 들어와요.
RAG 파이프라인 — 1·2·3단계 흐름
RAG 는 약자 그대로 Retrieval-Augmented Generation, 검색으로 보강한 LLM 응답 이에요. LLM 이 모든 지식을 가진 게 아니라 최신 사내 문서·매뉴얼·위키 같은 외부 자료 를 검색해서 답하게 하는 패턴. 흐름은 세 단계.
1단계 — 인덱싱 (offline)
원본 문서 → chunking (적당한 크기로 자르기) → embedding (각 청크를 벡터로 변환) → Elasticsearch 저장 (텍스트 + 벡터 같이). 이 단계는 사용자 질의가 오기 전에 배치성 으로 한 번 돌아가요.
원본 PDF (100p) → chunk (500자 x 200개)
→ embed(chunk) → vector[1024]
→ ES bulk index
{ text: "...", vector: [...] }
2단계 — Retrieval (online, 빠름)
사용자 질의가 들어오면 — 질의를 같은 임베딩 모델 로 벡터로 변환하고, ES 에 BM25 + kNN 하이브리드 로 던져서 상위 N 개 청크를 가져와요. 여기서 응답 속도가 가장 중요. 수십 ms 안에 끝나야 사용자 경험이 살아요.
3단계 — Generation (online, 느림)
retrieval 결과 N 개를 프롬프트의 context 로 박고, LLM (GPT·Claude·Gemini) 에 질의 + context 를 묶어 보내요. LLM 이 그 context 안에서만 답하도록 system prompt 로 가둬요. 이 단계가 수 초 단위라 시스템 전체 응답 시간의 대부분.
질의 → embed(질의) → ES hybrid search (BM25+kNN, RRF)
→ top 20 청크
→ reranker → top 5 청크
→ LLM(질의 + top 5 context)
→ 사용자 응답
세 단계가 깔끔하게 나뉘어 있어서 — 인덱싱은 배치, retrieval 은 ES, generation 은 LLM 으로 책임이 명확히 갈라져요. RAG 시스템을 짤 때 가장 먼저 그릴 그림이에요.
Chunking 전략 — 한국어 함정 포함
원본 문서를 그대로 임베딩하지 않고 적당히 자르는 이유 두 가지 — 임베딩 모델의 입력 길이 한도 (보통 512~8192 토큰) 와 retrieval 정밀도 (긴 글 1개보다 짧은 글 10개가 더 잘 매칭). 자르는 방식이 여러 가지예요.
전략 (1) — 고정 길이 chunking
가장 단순. "500자 단위로 자른다" 같은 룰. 빠르고 구현이 쉽지만, 문장 중간이 잘려서 retrieval 정확도가 떨어져요. 프로토타입용.
전략 (2) — 문장·문단 단위 chunking
문장 종결부호(.·?·!) 나 빈 줄로 자른 뒤, 합쳐서 대략 N자에 도달할 때까지 묶어요. 문맥이 잘 안 잘려서 retrieval 품질이 올라가요. 실무 1순위 권장.
전략 (3) — overlap 추가
청크 간에 50~100자 overlap 을 줘요. 청크 경계에 걸친 문장이 두 청크 모두에 들어가서 retrieval 누락이 줄어요. 일반 권장.
한국어 chunking 함정
영어는 띄어쓰기·문장부호가 또렷해서 자르기 쉬워요. 한국어는 — 종결어미(-요·-다·-습니다) 변형이 많고, 한 문장이 수십 어절 까지 길어지는 일이 잦아요. 그래서 "500자 고정" 같은 단순 룰이 한국어에서 영어보다 더 잘 깨져요.
권장 — kss (Korean Sentence Splitter) 나 konlpy 의 문장 분리 함수를 써서 문장 단위로 끊고, N자 도달까지 모으기 패턴. 그리고 표·코드 블록 은 의미 단위로 한 덩어리에 묶어요 — 자르면 의미가 깨져요.
Embedding 모델 — 옵션 4가지
청크를 벡터로 바꾸는 모델 선택이 RAG 전체 품질의 절반 을 좌우해요. 옵션이 크게 네 갈래.
(1) OpenAI text-embedding-3
산업 표준 — text-embedding-3-small (1536차원, $0.02/1M tokens) 과 text-embedding-3-large (3072차원, $0.13/1M) 두 가지. 한국어 포함 100+ 언어 OK. 가장 안정.
(2) Cohere embed-v3
다국어·한국어 품질 우수. embed-multilingual-v3.0 이 1024차원. MMR (Maximal Marginal Relevance) 같은 retrieval 기능과 잘 어울려요.
(3) BGE·E5 (오픈소스)
BAAI/bge-m3 가 한국어 포함 100+ 언어를 8192 토큰까지 처리하는 오픈소스 SOTA 급. 자체 GPU 서버에서 돌리면 OpenAI 대비 비용이 0 에 수렴.
(4) 한국어 특화 — KoSimCSE·KoBERT
KoSimCSE-roberta 같은 한국어 전용 모델이 한국어만 보면 다국어 모델보다 미세하게 우위. 단, 영어 섞인 사내 문서 에선 다국어 모델이 더 안정.
Inference Endpoint vs 외부 호출
Elasticsearch 8.11+ 의 inference endpoint 가 모델 호출 자체를 ES 안으로 끌어와요. 청크 색인 시 텍스트만 넣으면 ES 가 OpenAI·Cohere·자체 모델 API 를 부르고 벡터를 자동 저장. 외부 호출 코드를 짤 필요가 없어요. 운영 권장 패턴.
PUT _inference/text_embedding/openai-emb
{
"service": "openai",
"service_settings": {
"api_key": "sk-...",
"model_id": "text-embedding-3-small"
}
}
이렇게 등록해 두면, ingest pipeline 에서 inference processor 로 자동 벡터화 — 24편(Ingest Pipeline) 에서 깊이.
Hybrid Retrieval — BM25 + kNN + RRF
벡터 검색만으로 완벽한 RAG 가 안 되는 이유 — 고유명사·약어·숫자 같은 문자 매칭이 결정적인 자리 를 벡터가 못 잡아요. 예를 들어 "GPT-4 Turbo" 라는 정확한 모델명을 찾을 때, 벡터는 "GPT-4o"·"GPT-4" 같은 유사 모델도 같이 끌고 와요. 반면 BM25 (전통 풀텍스트 점수) 는 정확한 토큰 매칭 에 강해요.
그래서 둘을 묶은 Hybrid Search 가 표준. ES 8.x 가 Reciprocal Rank Fusion (RRF) 알고리즘으로 이 결합을 native 지원해요.
RRF (Reciprocal Rank Fusion) 알고리즘
여러 retrieval 결과를 점수가 아닌 순위 로 결합해요. 각 결과의 i번째 문서에 1/(k + rank_i) 점수를 주고 합산. k 는 일반적으로 60.
문서 A: BM25 rank 1, kNN rank 5
→ 1/(60+1) + 1/(60+5) = 0.0164 + 0.0154 = 0.0318
문서 B: BM25 rank 3, kNN rank 1
→ 1/(60+3) + 1/(60+1) = 0.0159 + 0.0164 = 0.0323
문서 B 가 RRF 점수 높음 → 1위
점수 정규화 가 필요 없는 게 RRF 의 장점. BM25 점수가 0~수십이고 kNN cosine 점수가 0~1 이라 그대로 더하면 한쪽이 묻혀요. 순위만 보면 그 문제가 사라져요.
ES 8.x Hybrid Search 예시
GET /docs/_search
{
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"match": { "text": "Elasticsearch RAG" }
}
}
},
{
"knn": {
"field": "vector",
"query_vector_builder": {
"text_embedding": {
"model_id": "openai-emb",
"model_text": "Elasticsearch RAG"
}
},
"k": 50,
"num_candidates": 100
}
}
],
"rank_window_size": 50,
"rank_constant": 60
}
},
"size": 10
}
retriever API 가 8.8 부터 들어온 표준. rank_constant 가 위 식의 k. 가중치를 조정하고 싶으면 별도 weight 파라미터 가 아니라 각 retriever 내부의 boost 로 조절.
Reranking — Cross-encoder 2차 정렬
Hybrid retrieval 로 상위 50개를 가져왔다고 해도, 정말 답에 맞는 5개 가 그 50개 안에서 1~5위에 있다는 보장이 없어요. 그래서 LLM 호출 직전에 한 번 더 정렬하는 단계가 reranking.
bi-encoder vs cross-encoder
임베딩 모델은 bi-encoder — 질의와 문서를 따로 벡터로 만든 뒤 cosine 으로 비교. 빠르지만 정밀도 한계가 있어요.
reranker 는 cross-encoder — 질의와 문서를 함께 모델에 넣고 직접 관련도 점수 를 뽑아요. 정밀도가 훨씬 높지만 느려요 (질의·문서 쌍마다 모델 1회 호출).
그래서 패턴이 — bi-encoder 로 50개 retrieve → cross-encoder 로 top 5 rerank. 빠른 retrieve + 정확한 rerank 의 조합.
옵션 — Cohere·BGE·Voyage
- Cohere Rerank v3 —
rerank-multilingual-v3.0가 한국어·100+ 언어. 산업 표준. - BGE Reranker —
BAAI/bge-reranker-v2-m3오픈소스. 자체 GPU 서버. - Voyage rerank-2 — 한국어 품질 우수, 토큰당 가격 저렴.
ES 8.x Reranking 통합
ES 가 reranker 도 inference endpoint 로 묶었어요. retriever 안에 text_similarity_reranker 를 박아 두면 retrieval 결과를 ES 가 자동으로 rerank.
GET /docs/_search
{
"retriever": {
"text_similarity_reranker": {
"retriever": {
"rrf": { ... }
},
"field": "text",
"rank_window_size": 50,
"inference_id": "cohere-rerank",
"inference_text": "Elasticsearch RAG 어떻게 짜요"
}
}
}
LLM 호출 직전 단계를 ES 한 쿼리 안에서 끝낼 수 있어요 — 별도 reranker 서버를 띄울 필요 X.
Relevance Tuning — boost·function_score·LTR
검색 결과가 "기술적으로는 맞는데 사용자 의도엔 부족" 한 자리를 잡는 단계가 Relevance Tuning. RAG 도 결국 검색이라 이 단계가 통째로 적용돼요.
boost — 필드 가중치
title 매치가 body 매치보다 더 중요하면 boost 로 가중. Hybrid retrieval 의 BM25 쪽 에서 가장 흔히 써요.
"query": {
"multi_match": {
"query": "RAG",
"fields": ["title^3", "body"]
}
}
function_score — 점수 가공
최신 문서를 우대 하거나 클릭률 높은 문서를 우대 하는 같은 자리. 점수 함수를 조합해서 retrieval 점수 × 비즈니스 신호 로 합쳐요.
"query": {
"function_score": {
"query": { ... },
"functions": [
{ "gauss": { "published_at": { "scale": "30d" } } },
{ "field_value_factor": { "field": "ctr", "modifier": "log1p" } }
],
"score_mode": "sum",
"boost_mode": "multiply"
}
}
LTR (Learning to Rank) — ES 8.12+ native
수동 boost 튜닝의 한계 를 넘으려면 클릭 로그 + 라벨 데이터 로 ranking 모델을 학습 시켜요. XGBoost 같은 모델이 N개 feature (BM25 score · kNN score · CTR · 최신성 · 인기도) 를 받아 최종 순위 를 예측. ES 8.12+ 가 learning_to_rank rescorer 로 native 지원.
평가 메트릭 4가지
튜닝의 효과 를 숫자로 확인하는 메트릭 — recall·precision·MRR·NDCG.
- Recall@k = top k 안에 정답 문서 비율. RAG 는 recall 이 결정적.
- Precision@k = top k 중 관련 있는 문서 비율. UI 에 검색 결과 보여 줄 때.
- MRR (Mean Reciprocal Rank) = 첫 정답의 역순위 평균. "정답이 얼마나 빨리 나오나".
- NDCG (Normalized DCG) = 순위별 가중치까지 반영한 정밀 메트릭. 평가 표준.
평가 데이터는 질의 100~500개 + 각 질의에 대한 정답 문서 목록 으로 시작. 클릭 로그 → 자동 라벨 도 일반 패턴.
ELSER (8.11+) — Elastic 자체 sparse 임베딩
dense vector (1024차원 같은 모든 차원이 값을 가진 vector) 와 다른, sparse vector 패러다임을 Elastic 이 자체로 들고 나왔어요 — ELSER (Elastic Learned Sparse Encoder).
sparse vector 의 특징
문서를 수만 개 토큰의 score 맵 으로 표현해요. {"검색": 1.8, "RAG": 2.1, "벡터": 1.5, ...} 같은 형태. 대부분의 토큰은 0 이고, 문서에 의미 있는 수십~수백 토큰만 값을 가져요.
dense vs sparse — 비교
| Dense (text-embedding-3 등) | Sparse (ELSER) | |
|---|---|---|
| 차원 | 384~3072 고정 | 30,000+ (sparse) |
| 모델 학습 데이터 | 필요 | 불필요 (즉시 사용) |
| 검색 방식 | kNN (cosine) | 전통 inverted index |
| 해석 가능성 | 낮음 (벡터가 의미 X) | 높음 (어느 토큰이 매칭) |
| 한국어 품질 | 모델 따라 다름 | 영어 위주 (한국어 약함) |
ELSER 의 가장 큰 매력 — 모델 학습 데이터가 불필요 해서, "검색 품질 끌어올리고 싶은데 라벨 데이터가 없다" 하는 회사가 깔자마자 풀텍스트보다 나은 결과를 보장.
ELSER 사용 예시
PUT _inference/sparse_embedding/elser-v2
{
"service": "elser",
"service_settings": { "num_allocations": 1, "num_threads": 1 }
}
POST docs/_doc/1
{ "text": "Elasticsearch RAG 어떻게 짜요" }
# 이 후 ingest pipeline 에서 ELSER 가 자동으로
# text_expansion 필드를 채워 넣음
GET docs/_search
{
"query": {
"text_expansion": {
"ml.tokens": {
"model_id": "elser-v2",
"model_text": "Elasticsearch RAG"
}
}
}
}
한국어가 약하다는 한계가 또렷해서 — 한국어 RAG 는 Cohere·BGE 다국어 dense embedding 이 1순위, 영어 RAG 는 ELSER + dense hybrid 가 표준.
자주 만나는 사고 7가지
사고 1 — chunk 너무 큼
원인 — 한 청크가 2,000자가 넘으면 retrieval 정밀도 가 떨어져요. 임베딩 한 벡터가 너무 많은 주제 를 한 번에 표현해서 어느 질의에도 미적지근하게 매칭.
해결 — 300~800자 가 한국어 권장 범위. 한 청크가 한 주제 만 다루도록 자르고, overlap 100자 추가.
사고 2 — 임베딩 모델 불일치
원인 — 인덱싱은 OpenAI text-embedding-3-small (1536차원) 으로 했는데 retrieval 은 Cohere embed-v3 (1024차원) 으로 하면 차원도 안 맞고, 차원이 같아도 벡터 공간이 달라서 검색이 망가져요.
해결 — 문서 임베딩과 질의 임베딩은 반드시 같은 모델. 모델 바꾸려면 전체 재색인 필요. mapping dense_vector.dims 에 차원 박아 두면 불일치 시 에러로 잡힘.
사고 3 — RRF 가중치 미튜닝
원인 — RRF rank_constant 기본 60 그대로 쓰면 BM25 와 kNN 영향력이 거의 동등해서, 키워드가 결정적인 도메인 (법률·의료) 에서 BM25 가 묻혀요.
해결 — 고유명사·약어 비중이 높으면 BM25 retriever 의 boost 를 높이거나, 별도 BM25-only 결과를 위로 박는 linear retriever 도 ES 8.13+ 에서 가능.
사고 4 — 평가 데이터 없음
원인 — "체감으로 좋아진 것 같다" 만 보고 튜닝 결정을 내려서, 다음 사용자 사고가 났을 때 어디가 망가졌는지 검증이 안 돼요.
해결 — 100~500개 평가 질의 + 정답 셋 을 일찍 만들어요. ES _rank_eval API 가 NDCG·MRR 자동 계산. 매 튜닝 전후로 돌려서 수치 비교 가 표준.
사고 5 — context window 초과
원인 — retrieval top 10 청크를 그대로 LLM 에 넣었더니 총 30,000 토큰 이 돼서 모델 context window (예 — 8K) 를 넘어 API 에러 발생.
해결 — top N 을 청크 길이까지 고려해 동적으로 줄이고, reranker 로 상위 5개로 압축. LLM 의 long context 모델 (Claude 200K · GPT-4 Turbo 128K) 을 쓰는 것도 옵션이지만 비용·지연시간 증가.
사고 6 — 한국어 형태소 분석 누락
원인 — 한국어 텍스트를 standard analyzer 로 색인하면 BM25 쪽이 어절 단위로만 잡혀서 Hybrid 의 BM25 contribution 이 거의 0. RRF 결과가 순수 kNN 결과 와 같아짐.
해결 — nori_tokenizer + 사용자 사전. 11편(Korean Analyzer) 에서 깊이.
사고 7 — RAG 응답에 출처 없음
원인 — LLM 이 retrieval context 만 보고 답하라고 했는데, 어느 청크에서 가져왔는지 표시 안 하면 사용자가 신뢰 검증 불가 하고 hallucination 책임 추적 불가.
해결 — system prompt 에 "답 끝에 출처 청크 ID 를 [1][2] 형태로 인용하라" 박고, 응답 후처리에서 청크 ID → 원본 문서 URL 매핑. 신뢰성과 운영 모두 결정적.
운영 권장 패턴 4가지
운영 시작 전 RAG 의 평가 셋 100~500개 부터 만들어요. 평가 셋 없이 시작하면 튜닝 효과 검증 불가 + 회귀 사고 추적 불가 가 같이 와요.
Hybrid retrieval + Reranker 를 처음부터 표준으로 깔아요. kNN 만 으로 시작하면 키워드 매칭이 결정적인 사고 가 났을 때 대규모 재설계 비용이 커요. ES 8.x retriever API + text_similarity_reranker 는 한 쿼리 안에서 두 단계가 끝나는 게 큰 매력.
Inference endpoint 로 임베딩·reranker 호출을 ES 안에 묶어요. 외부 호출 코드를 앱에 두면 재시도·rate limit · 에러 핸들링 이 앱마다 따로 짜야 해요. ES inference endpoint 가 그 자리를 통째로 흡수.
평가 자동화 를 CI 에 박아 둡니다. mapping·analyzer·embedding 모델·reranker 모델·boost 값 중 하나가 바뀔 때마다 평가 셋이 자동으로 돌고 NDCG 변동 이 PR 코멘트로 박혀요. 튜닝 회귀 사고 의 90% 가 여기서 잡혀요.
시험 직전 한 번 더 — 압축 노트
- RAG = Retrieval-Augmented Generation. 인덱싱(offline) → retrieval → generation(LLM) 3단계.
- Chunking — 300~800자 + overlap 100자. 한국어는 kss 같은 문장 분리기 권장.
- Embedding — OpenAI text-embedding-3 · Cohere embed-v3 · BGE-m3 · KoSimCSE. 인덱싱·질의 동일 모델 필수.
- Hybrid Search = BM25 + kNN 결합. RRF (Reciprocal Rank Fusion) 이 표준 알고리즘 (
1/(k+rank)합산, k=60). - ES 8.x retriever API —
rrfretriever 가 BM25 + kNN 결합을 한 쿼리로. - Reranking — cross-encoder 로 상위 N 을 2차 정렬. Cohere Rerank · BGE Reranker · Voyage.
- Inference endpoint — 임베딩·reranker 호출을 ES 안으로 흡수. ingest pipeline 과 묶임.
- Relevance Tuning — boost · function_score · LTR (ES 8.12+ native).
- 평가 메트릭 — Recall@k · Precision@k · MRR · NDCG. ES
_rank_evalAPI. - ELSER — Elastic 자체 sparse embedding. 학습 데이터 불필요, 영어 우수 한국어 약함.
- 7대 사고: chunk 너무 큼 · 모델 불일치 · RRF 미튜닝 · 평가 데이터 없음 · context window 초과 · 한국어 분석기 누락 · 출처 표시 없음.
시리즈 다른 편
- 이전 글 = 21편 Vector Search — dense_vector · kNN · HNSW
- 다음 글 = 23편 Bulk API — 대량 색인 · 1만 건 색인 패턴
- 11편 = Korean Analyzer — Nori · mecab-ko · 사용자 사전
- 12편 = Search API — Query DSL 전수
- 14편 = Full-text Query — match · match_phrase · multi_match
- 18편 = Aggregation Pipeline — moving_avg · cumulative_sum · derivative
- 24편 = Ingest Pipeline — inference processor 포함
- 32편 = Spring Data Elasticsearch — Repository · Template · POJO
- 38편 = 시리즈 마무리 — 결정 트리 · 체크리스트 · 자격증
한 줄 정리 — RAG 는 인덱싱·retrieval·generation 3단계로 나뉘고, ES 8.x 가 Hybrid (BM25+kNN+RRF) + Reranker + Inference endpoint 를 한 쿼리 안에 묶어 줘요. 평가 셋·출처 표시·임베딩 모델 일치 — 세 가지를 처음부터 박아 두면 튜닝 회귀 사고 의 90% 가 사라져요.