Skip to content
Published on

Elasticsearch와 OpenSearch, Lucene의 내부 — Inverted Index, BM25, Sharding, Vector Search, Hybrid RAG까지 (2025)

Authors

"Search is not a feature. It's a philosophy of how humans interact with information." — Doug Cutting (creator of Lucene, 1999)

Google이 없던 시절을 기억하는가? 1999년 Doug Cutting이 자바로 Lucene을 만들었을 때, 그는 "누구나 자기 데이터에 Google급 검색을 달 수 있어야 한다"고 믿었다. 26년이 지난 지금, Elasticsearch, OpenSearch, Solr는 모두 Lucene 위에 서 있다. 로그 분석, 상품 검색, 자동완성, 그리고 2024년부터는 RAG(Retrieval-Augmented Generation)의 핵심 인프라까지.

그러나 "Elasticsearch 쓴다"와 "Lucene을 이해한다"는 하늘과 땅 차이다. 이 글은 검색의 본질부터 2025년 하이브리드 검색까지, 한 번에 꿰뚫는 지도다.


1. 왜 관계형 DB의 LIKE '%keyword%'는 안 되는가

선형 스캔의 벽

SELECT * FROM articles WHERE content LIKE '%postgresql%';
  • 인덱스 사용 불가 (prefix %)
  • 모든 row의 text 필드를 풀 스캔
  • 1억 문서 → 수십 분

Postgres의 GIN도 일부 해결하지만:

  • 토큰화/언어 분석 기능 제한
  • 관련성 점수(scoring) 매기기 어려움
  • 자동완성, 오타 교정, 동의어 생태계 약함
  • 분산 검색 지원 약함

검색 전용 엔진이 필요한 이유다.


2. Inverted Index — 검색의 수학적 심장

기본 아이디어

Document 1: "The quick brown fox"
Document 2: "The lazy brown dog"
Document 3: "Foxes and dogs"

이를 단어 → 문서 리스트로 뒤집으면:

brown  → [1, 2]
dog    → [2]
dogs   → [3]
fox    → [1]
foxes  → [3]
lazy   → [2]
quick  → [1]
the    → [1, 2]

질의 "brown dog"는:

  • brown → [1,2]
  • dog → [2]
  • AND 연산(교집합) → [2]

이것이 Inverted Index(역인덱스)의 본질이다. 수십억 문서에서도 O(logN)O(\log N)에 가까운 속도.

Lucene의 구현 — 왼쪽에서 오른쪽으로

Lucene이 디스크에 저장하는 단위:

  1. Term Dictionary — 모든 term의 정렬된 사전 (FST, Finite State Transducer)
  2. Postings List — 각 term이 나타난 문서 리스트 + 빈도/위치
  3. Stored Fields — 원본 문서 (압축)
  4. Doc Values — 컬럼나(columnar) 저장, 집계/정렬용
  5. Norms — 필드 길이 정규화 값

FST — 접두사 공유로 메모리 절약

"fox, foxes, foxy" 같은 term들의 공통 접두사를 유한 상태 전이기로 압축. 수천만 term의 사전을 수 MB로 저장. 자동완성의 핵심 구조이기도 하다.


3. Segment — Lucene의 불변성 원칙

불변(Immutable) Segment

Lucene에서 한 번 쓴 파일은 변경되지 않는다. 이것이 Lucene을 빠르고 안전하게 만든다.

  • Append 전용 — 새 문서는 새 segment 생성
  • Delete는 삭제 마커(tombstone)만 남김
  • Update는 delete + insert
  • 여러 작은 segment들이 병합(merge)되어 큰 segment 됨

Segment 구조

한 segment는 여러 파일로 구성:

_0.cfs     — composite file (여러 파일을 하나로)
_0.cfe     — 진입점
_0.si      — segment info
_0.fdt/fdx — field data
_0.tim/tip — term dictionary
_0.doc/pos — postings
_0.dvm/dvd — doc values
_0.liv     — live docs (삭제 비트맵)

Merge 정책

  • 작은 segment 많으면 검색 느림 (각각 순회)
  • 큰 segment 만들면 merge 비용 큼 (I/O 폭발)
  • 기본은 TieredMergePolicy — 크기별로 계층화해 merge

Refresh / Flush / Commit의 차이

용어시점
Refresh메모리 buffer를 검색 가능한 segment로 만듦기본 1초마다
Flushsegment를 디스크로 fsync자동 (메모리 임계치)
Commit트랜잭션 로그 포함 완전 영속덜 자주

**"Near Real-Time Search"**의 비밀: refresh는 1초, flush는 나중에. 그래서 "1초 지연"이 Elasticsearch의 트레이드마크.

Translog — 내구성을 지키는 로그

  • 모든 쓰기는 먼저 translog에 기록
  • 노드 재시작 시 translog 재생
  • index.translog.durability:
    • request — 매 요청 fsync (느림, 손실 0)
    • async — 주기 fsync (기본, 5초)

4. BM25 — TF-IDF를 대체한 점수

TF-IDF의 한계

tf-idf(t,d)=tf(t,d)×logNdf(t)\text{tf-idf}(t, d) = \text{tf}(t, d) \times \log\frac{N}{\text{df}(t)}

  • tf: term의 문서 내 빈도
  • df: term이 나타난 문서 수
  • 문제: 긴 문서일수록 tf가 커져 점수 부풀림

BM25 공식

score(d,q)=tqIDF(t)f(t,d)(k1+1)f(t,d)+k1(1b+bdavgdl)\text{score}(d, q) = \sum_{t \in q} \text{IDF}(t) \cdot \frac{f(t, d) \cdot (k_1 + 1)}{f(t, d) + k_1 \cdot (1 - b + b \cdot \frac{|d|}{\text{avgdl}})}

핵심 변경:

  • 포화(saturation) — 같은 단어가 많이 나와도 점수 증가 감소 (k11.2k_1 \approx 1.2)
  • 길이 정규화 — 문서 길이를 평균과 비교해 페널티/보너스 (b0.75b \approx 0.75)

BM25가 기본값인 이유

  • 20년 이상 경험적으로 우수
  • 파라미터 두 개로 튜닝 용이
  • Lucene이 2016년부터 기본값

파라미터 튜닝

  • k_1: 1.2 → 높이면 빈도 중요
  • b: 0.75 → 낮추면 길이 무관 (짧은 필드에 유리)

제품명/쿼리 로그 분석은 b=0.3 정도로 낮추는 게 흔한 패턴.


5. 분석기 (Analyzer) — 토큰화의 예술

3단계 파이프라인

  1. Character Filter — HTML 제거, 문자 교체
  2. Tokenizer — 문장을 단어로 자름
  3. Token Filter — 소문자화, 어간 추출, 동의어, 불용어

한국어의 지옥

영어: 공백 기준 토큰화 쉬움.

한국어: 교착어 + 어미 변화 + 조사.

  • "검색했다", "검색한다", "검색은", "검색을"
  • 모두 "검색"으로 매칭되어야
  • nori 분석기 (한국어), kuromoji (일본어), ik (중국어)

예: nori 분석

POST _analyze
{
  "analyzer": "nori",
  "text": "Elasticsearch는 검색엔진입니다"
}

-> "elasticsearch", "는", "검색", "엔진", "입니다"

, 입니다 같은 조사/어미 제거:

"filter": ["nori_part_of_speech"]

동의어 확장

"shoe, sneaker, runner" 
-> 질의 "shoe" 시 자동으로 sneaker/runner 문서도 매칭

검색 품질의 절반은 동의어 사전에 달려 있다. 구축은 지루하지만 투자 대비 효과 최고.


6. Shard & Replica — 분산의 기본

Primary Shard

  • 인덱스는 여러 primary shard로 분할
  • 문서 doc_id를 해싱해 shard 결정: shard = hash(_routing) % number_of_primary_shards
  • _routing 기본은 _id

Replica Shard

  • Primary의 복제본
  • 검색 성능 확장 + 장애 대비
  • number_of_replicas = 1이면 각 primary마다 복제 1개

한계와 원칙

  • primary 수는 index 생성 후 변경 불가 (routing 깨짐)
  • 해결: reindex하거나 alias + 새 인덱스
  • shard 크기 경험칙: 각 shard 10-50GB
  • 과도한 shard → cluster state 폭발, overhead

Cluster State & Split Brain

  • Master 노드가 cluster 메타데이터 관리
  • 여러 node가 동시에 master 되면 split brain
  • discovery.zen.minimum_master_nodes = (N/2) + 1 (7.x 이후 자동)
  • 2020년 7.0에서 합의 알고리즘 완전 재작성 (Raft-like)

7. Query DSL — JSON의 미로

주요 쿼리 타입

쿼리용도
match분석기 거친 풀텍스트 매칭
term분석기 거치지 않는 정확 일치 (keyword 필드)
match_phrase순서 보존 구문
multi_match여러 필드 동시 검색
boolAND/OR/NOT 조합 (핵심)
range범위
function_score커스텀 점수
rank_feature부스팅 필드

bool 쿼리의 4절(clause)

{
  "bool": {
    "must": [...],      // AND, 점수 기여
    "should": [...],    // OR, 점수 기여
    "filter": [...],    // AND, 점수 기여 안함, 캐시됨
    "must_not": [...]   // NOT
  }
}

filter 절을 최대한 활용하라 — 캐시되고 빠르다. match로 점수를 매기고, 고정 조건은 filter로.

Term vs Match — 가장 흔한 실수

// text 필드 "User Name"이 저장되어 있음
{"term": {"name": "User Name"}}   // 매칭 안 됨! (소문자화되었음)
{"term": {"name": "user name"}}   // 이것도 안 됨 (공백 토큰화 됨)
{"match": {"name": "User Name"}}  // OK

text 필드에는 match, keyword 필드에는 term.

집계(Aggregation) — 분석 DB로서의 ES

{
  "aggs": {
    "by_category": {
      "terms": {"field": "category"},
      "aggs": {
        "avg_price": {"avg": {"field": "price"}}
      }
    }
  }
}

SQL의 GROUP BY에 해당. 로그 분석, 대시보드 구축에 필수.


8. Vector Search — 2022년 이후의 혁명

왜 벡터인가

  • BM25는 단어 매칭 기반 — "강아지"와 "개"를 다르게 봄
  • 임베딩은 의미 기반 — 유사 의미 자동 연결

ES/OpenSearch의 kNN

Elasticsearch 8.0(2022)부터 native kNN. Lucene 9.0의 HNSW 구현 활용.

// 인덱스 매핑
{
  "mappings": {
    "properties": {
      "title_vector": {
        "type": "dense_vector",
        "dims": 768,
        "index": true,
        "similarity": "cosine"
      }
    }
  }
}

// 질의
{
  "knn": {
    "field": "title_vector",
    "query_vector": [0.1, 0.2, ...],
    "k": 10,
    "num_candidates": 100
  }
}

HNSW 파라미터

  • m: 각 노드의 이웃 수 (보통 16)
  • ef_construction: 인덱싱 시 탐색 폭 (100-200)
  • ef_search: 질의 시 탐색 폭 (recall↑ vs 속도↓)

저장 효율 — Quantization

  • int8 quantization — float32 → int8, 4배 절약, 정확도 1% 손실
  • BBQ (Better Binary Quantization) — 2024년 말 Lucene 10에 도입, 32배 절약
  • 2025년 RAG에서 사실상 표준

9. Hybrid Search — RAG 시대의 정답

측면BM25Vector
정확 일치 (상품 코드)강함약함
의미 유사성약함강함
희귀어 (전문용어)강함약함
오타약함중간
다국어약함강함

둘 다 필요하다.

RRF — Reciprocal Rank Fusion

두 랭킹의 결과를 순위 기반으로 결합:

RRF(d)=i1k+ranki(d)\text{RRF}(d) = \sum_i \frac{1}{k + \text{rank}_i(d)}

  • 각 검색 결과의 순위만 써서 결합
  • 스케일 차이 무관 (BM25 점수와 cosine이 달라도 OK)
  • k = 60이 경험적으로 가장 좋음

ES의 rank: rrf

{
  "retriever": {
    "rrf": {
      "retrievers": [
        {"standard": {"query": {"match": {"content": "검색어"}}}},
        {"knn": {"field": "vec", "query_vector": [...], "k": 50}}
      ],
      "rank_window_size": 50,
      "rank_constant": 60
    }
  }
}

Cross-Encoder Reranker

  • BM25/kNN로 상위 100개 후보 가져옴
  • Cross-Encoder 모델(BERT 기반)로 재정렬
  • 2024년부터 ES/OpenSearch 네이티브 지원 (Cohere, E5, BGE)

10. 2021년 라이선스 전쟁 — OpenSearch 탄생

배경

  • AWS가 Elasticsearch를 managed service(Elasticsearch Service)로 판매
  • Elastic은 "AWS가 업스트림에 기여 없이 이익만 챙긴다"며 분노
  • 2021년 1월, Elasticsearch 7.11을 SSPL/Elastic License 이중 라이선스로 전환
  • AWS는 즉시 포크: OpenSearch

결과

  • Elastic: 라이선스 덕에 AWS와 경쟁 가능, 매출 방어
  • AWS: OpenSearch를 Linux Foundation 재단으로 이관(2024년 9월)
  • 커뮤니티: 분열

2024-2025 상황

제품라이선스주도
Elasticsearch 8Elastic License 2 / SSPLElastic
Elasticsearch 8.14+AGPL 추가 (2024.8)Elastic (회복 시도)
OpenSearch 2.xApache 2.0AWS → Linux Foundation

선택 가이드

  • 관리형 AWS 중심이면 → OpenSearch
  • 최신 ML/벡터/ESQL 기능 필요 → Elasticsearch
  • 오픈소스 순수주의 → OpenSearch
  • Elastic Agent/Fleet 생태 → Elasticsearch

11. 운영의 지옥 — 흔한 장애 패턴

JVM Heap 관리

  • 기본: 32GB 넘지 말 것 (Compressed OOPs 한계)
  • Heap의 50% 넘으면 경고, 75% 넘으면 위험
  • Old Gen GC가 검색을 멈춤 (stop-the-world)

Circuit Breaker

circuit_breaking_exception: Data too large
  • 한 질의가 heap의 일정 비율 넘으면 거절
  • 기본 60-70%
  • 거절된 질의는 다시 시도하지 말 것 — 더 악화

Hot Shard

  • 특정 shard에 질의 집중
  • 원인: routing 키 편향
  • 해결: _routing 조정, shard 수 증가

Shard 수 폭발

  • 노드당 1000+ shard면 cluster state 과부하
  • 인덱스 라이프사이클(ILM): rollover, shrink, delete

Snapshot & Restore

  • S3/GCS/Azure Blob 리포지토리
  • 증분 백업
  • 대형 클러스터에서 restore는 수 시간 — 주의

12. Ingest 파이프라인 — 데이터 투입의 예술

Logstash — 전통적 ETL

  • Input → Filter → Output 구조
  • Grok, Mutate, GeoIP, User Agent 등 풍부한 필터
  • JVM 기반, 무거움

Beats — 경량 에이전트

  • Filebeat (로그), Metricbeat (지표), Packetbeat (네트워크)
  • Go 기반, 가벼움
  • 직접 ES로 또는 Logstash 경유

Elastic Agent + Fleet (2021+)

  • 하나의 에이전트로 모든 데이터 수집
  • 중앙 UI(Fleet)에서 정책 관리

OpenTelemetry 통합

  • 2024년부터 OTel → ES가 1급 지원
  • OTel Collector가 Logstash 대체 가능

Ingest Node Pipeline

ES 자체에 간단한 파이프라인 정의:

{
  "processors": [
    {"set": {"field": "indexed_at", "value": "{{_ingest.timestamp}}"}},
    {"grok": {"field": "message", "patterns": ["%{COMBINEDAPACHELOG}"]}}
  ]
}

13. ES|QL — SQL의 귀환 (2024)

Elastic이 오랜 숙원 SQL-like 질의 언어를 내놓았다.

FROM logs-*
| WHERE status >= 500
| STATS count = COUNT(*) BY host
| SORT count DESC
| LIMIT 10
  • 파이프 기반 (Splunk SPL, Kusto KQL 영감)
  • JSON Query DSL의 복잡도 제거
  • 분석 쿼리에 압도적으로 편함

OpenSearch도 2024년 **PPL (Piped Processing Language)**로 유사 기능 추가.


14. 검색 품질 평가

nDCG (Normalized Discounted Cumulative Gain)

  • 상위 결과의 관련성을 로그 가중치로 합산
  • 0-1, 1이 완벽
  • 검색 팀의 주요 KPI

Precision / Recall

  • Precision: 가져온 것 중 정답 비율
  • Recall: 정답 중 가져온 것 비율
  • 트레이드오프 존재

클릭 로그로 학습 (Learning to Rank)

  • 사용자 클릭 → 관련성 레이블
  • LambdaMART 모델 학습
  • Elasticsearch LTR 플러그인

A/B Test

  • 실험군/대조군에 다른 랭킹
  • CTR, 전환율, 체류시간 측정
  • 오프라인 지표(nDCG) ≠ 온라인 성공 — 항상 A/B로 검증

15. 안티패턴 TOP 10

  1. 기본 1-shard/1-replica로 수십 TB 인덱스 — shard 당 10-50GB 유지
  2. _id를 수동 지정해 해싱 불균형 — routing 편향
  3. 너무 많은 인덱스/shard — cluster state 폭발
  4. JVM heap 64GB 설정 — Compressed OOPs 한계 (32GB 이하)
  5. deep pagination (from=10000) — scroll/search_after 사용
  6. 분석된 text 필드에 term 쿼리 — keyword 사용 또는 match
  7. 매 질의마다 클러스터 헬스 체크 — 과부하
  8. 빈번한 대량 삭제 — segment 폭발, _forcemerge 필요
  9. 백업 없음 — snapshot 필수
  10. 벡터만 쓰고 BM25 버림 — Hybrid가 거의 항상 이김

16. Elasticsearch/OpenSearch 현명하게 쓰기 체크리스트

  • 용도 구분 — 로그/검색/벡터/분석이 같은 클러스터에 있는가 (분리 고려)
  • shard 크기 10-50GB 유지
  • ILM policy 설정 — rollover, hot/warm/cold tier
  • snapshot 정기 실행 — S3/GCS
  • heap 32GB 이하, off-heap은 file cache로
  • circuit breaker 알람 설정
  • 분석기 선택 — 한국어는 nori, 문제별 커스텀
  • 동의어 사전 구축 및 관리
  • Hybrid Search 검토 — BM25 + vector + RRF
  • 검색 품질 모니터링 — CTR, nDCG, 0-result rate
  • Query DSL bool filter 활용 — 캐시되는 고정 조건
  • ES|QL/PPL 검토 — 분석 쿼리를 더 읽기 쉽게

마치며 — Lucene 위에 선 거인들

Elasticsearch, OpenSearch, Solr — 이름은 다르지만 모두 Lucene이라는 거인의 어깨 위에 서 있다. 그리고 그 Lucene은 1999년, Doug Cutting이 "검색을 민주화하자"고 만든 단일 자바 라이브러리에서 시작했다.

2025년 검색은:

  • 로그를 찾고 (OpenTelemetry → ES)
  • 제품을 찾고 (e-commerce 검색)
  • 콘텐츠를 추천하고 (Learning to Rank)
  • LLM의 컨텍스트가 되고 (RAG)
  • 자동완성과 오타 교정을 제공한다

모든 곳에 있지만, 모두가 제대로 이해하는 것은 아니다. Inverted Index와 Segment, BM25와 벡터 검색, Shard와 Routing — 이 용어들이 "아하!" 하고 연결되는 순간, 당신은 검색을 사용하는 사람에서 설계하는 사람이 된다.


다음 글 예고 — 분산 시스템의 합의 — Paxos, Raft, ZAB 완전 분해

ES의 cluster state 관리, etcd, ZooKeeper, PostgreSQL Patroni HA — 모두 합의 알고리즘 위에 서 있다. 다음 글에서는:

  • 분산 합의의 문제 정의 — FLP 불가능성 정리의 충격
  • Paxos — Lamport의 "나는 Multi-Paxos를 구현해보지 않았으나..."
  • Raft — "Paxos보다 이해하기 쉽게" 만든 혁명
  • ZAB — ZooKeeper Atomic Broadcast
  • Viewstamped Replication — 덜 유명하지만 우아한 설계
  • etcd vs ZooKeeper vs Consul — 언제 무엇을 쓰나
  • Raft의 함정 — Leader election flapping, split vote
  • PBFT와 BFT 계열 — 비잔틴 오류 감내 (블록체인의 기반)
  • Kafka KRaft의 내부 — 왜 ZooKeeper를 버렸나
  • CRDTs — 합의 없이도 수렴하는 마법

분산 시스템의 진짜 심장을 여는 여정.


"A good search engine doesn't just find what you typed. It finds what you meant." — Peter Norvig (Google Research Director)