Skip to content

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

한국어
0%
정확도 0%
💡 왼쪽 원문을 읽으면서 오른쪽에 따라 써보세요. Tab 키로 힌트를 받을 수 있습니다.
원문 렌더가 준비되기 전까지 텍스트 가이드로 표시합니다.

> "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(\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초마다 |

| Flush | segment를 디스크로 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의 한계

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

- `tf`: term의 문서 내 빈도

- `df`: term이 나타난 문서 수

- 문제: 긴 문서일수록 tf가 커져 점수 부풀림

BM25 공식

$$\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)** — 같은 단어가 많이 나와도 점수 증가 감소 ($k_1 \approx 1.2$)

- **길이 정규화** — 문서 길이를 평균과 비교해 페널티/보너스 ($b \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` | 여러 필드 동시 검색 |

| `bool` | AND/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 시대의 정답

BM25 vs Vector Search

| 측면 | BM25 | Vector |

|---|---|---|

| 정확 일치 (상품 코드) | 강함 | 약함 |

| 의미 유사성 | 약함 | 강함 |

| 희귀어 (전문용어) | 강함 | 약함 |

| 오타 | 약함 | 중간 |

| 다국어 | 약함 | 강함 |

**둘 다** 필요하다.

RRF — Reciprocal Rank Fusion

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

$$\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 8 | Elastic License 2 / SSPL | Elastic |

| Elasticsearch 8.14+ | **AGPL 추가** (2024.8) | Elastic (회복 시도) |

| OpenSearch 2.x | Apache 2.0 | AWS → 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)

현재 단락 (1/318)

Google이 없던 시절을 기억하는가? 1999년 Doug Cutting이 자바로 **Lucene**을 만들었을 때, 그는 "누구나 자기 데이터에 Google급 검색을 달 수 있어...

작성 글자: 0원문 글자: 9,749작성 단락: 0/318