역 인덱스 심화와 분석기(Analyzer) 설계
역 인덱스 내부를 뜯어보자
챕터 1에서 역 인덱스의 기본 구조(토큰 → 문서 목록)를 살펴봤다. 이번에는 한 단계 더 들어가 Lucene이 실제로 어떤 자료구조로 이를 구현하는지, 그리고 검색 점수는 어떻게 계산되는지 알아본다.
Lucene의 역 인덱스는 단순한 HashMap이 아니다. 수십억 개의 유일 토큰을 메모리에 효율적으로 담기 위해 FST(Finite State Transducer) 기반의 Term Dictionary를 사용한다. FST는 공통 접두사를 공유하므로 일반 해시 테이블 대비 메모리를 크게 절약한다. Term Dictionary에서 특정 토큰을 찾으면, 해당 토큰이 등장하는 Posting List로 연결된다.
Posting List는 다음 정보를 포함한다.
| 필드 | 설명 | 용도 |
|---|---|---|
| Document ID | 해당 토큰이 있는 문서의 내부 ID | 기본 검색 |
| Term Frequency (TF) | 한 문서 안에서 토큰이 등장하는 횟수 | 점수 계산 |
| Positions | 문서 내에서 토큰이 나오는 위치 목록 | Phrase 검색 |
| Offsets | 원문에서의 문자 시작·끝 위치 | Highlighting |
Positions와 Offsets는 디스크 공간을 차지하므로, 집계 전용 필드처럼 점수나 구문 검색이 불필요한 경우 index_options: docs로 Document ID만 저장해 인덱스를 가볍게 할 수 있다.
BM25: 기본 스코어링 알고리즘
Elasticsearch 5.0부터 기본 점수 알고리즘이 클래식 TF-IDF에서 BM25로 바뀌었다. BM25(Best Match 25)는 두 가지 중요한 개선을 포함한다.
① TF 포화(Saturation): TF-IDF에서는 단어가 많이 등장할수록 점수가 선형으로 올라갔다. BM25는 파라미터 k1(기본 1.2)로 포화 곡선을 적용한다. 단어가 10번 나오는 것이 1번보다 높은 점수지만, 100번 나온다고 무한정 높아지지 않는다.
② 문서 길이 정규화: 같은 단어라도 긴 문서에 나오면 짧은 문서보다 덜 중요하다. 파라미터 b(기본 0.75)로 평균 문서 길이 대비 현재 문서 길이를 반영한다.
GET /my-index/_explain/1 -d '{"query":{"match":{"title":"elasticsearch"}}}'로 각 토큰의 TF, IDF, 최종 점수를 확인할 수 있다.
분석 파이프라인: 텍스트가 토큰이 되기까지
색인 시 텍스트는 반드시 분석(Analysis) 파이프라인을 거친다. 파이프라인은 세 단계다.
"<b>Quick Foxes</b>" →
Character Filter: 토큰화 전에 원문 문자 스트림을 조작한다.
html_strip: HTML 태그 제거mapping: 특정 문자를 다른 문자로 치환 (예:&→and)pattern_replace: 정규식으로 치환
Tokenizer: 문자 스트림을 토큰으로 쪼갠다. Analyzer당 단 하나만 지정 가능하다.
standard: 유니코드 알고리즘 기반 단어 분리. 대부분의 유럽어 기본값whitespace: 공백만으로 분리. 구두점을 토큰에 포함keyword: 전체 입력을 하나의 토큰으로. 정렬·집계 전용 필드에 사용ngram/edge_ngram: 문자 단위로 n-gram 생성. 자동완성에 사용
Token Filter: 토큰 스트림을 변환·추가·제거한다. 여러 개를 순서대로 적용한다.
lowercase: 소문자 변환 (대소문자 무관 검색의 기본)stop: 불용어(the, a, in …) 제거stemmer: 영어 형태소 분석 (running → run)synonym: 유의어 처리 (automobile → car)edge_ngram: 자동완성 n-gram 생성
내장 분석기
Elasticsearch는 자주 쓰이는 분석기를 기본으로 제공한다.
| 분석기 | 동작 방식 | 주요 사용처 |
|---|---|---|
standard | Unicode 단어 분리 + lowercase | 기본 전문 검색 |
whitespace | 공백 분리만 | 구두점 유지가 필요한 검색 |
keyword | 전체 입력 = 1개 토큰 | 정렬, 집계, exact match |
english | lowercase + stop + stemmer | 영어 전문 검색 |
simple | 비알파벳 기준 분리 + lowercase | 단순 검색 |
_analyze API로 실제 동작을 바로 확인할 수 있다.
POST /_analyze
{
"analyzer": "english",
"text": "Running foxes quickly"
}결과: ["run", "fox", "quickli"] — stemmer가 형태소를 어간으로 변환한다.
커스텀 분석기 설계
실무에서는 내장 분석기로는 부족해 커스텀 분석기를 만든다. 대표적인 두 패턴을 본다.
패턴 1: 자동완성 (edge_ngram)
사용자가 "ela"를 입력했을 때 "elasticsearch"를 찾으려면 edge_ngram이 필요하다. 핵심은 인덱스 타임과 서치 타임에 다른 분석기를 사용하는 것이다.
PUT /products
{
"settings": {
"analysis": {
"analyzer": {
"autocomplete": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase", "autocomplete_filter"]
},
"autocomplete_search": {
"type": "custom",
"tokenizer": "standard",
"filter": ["lowercase"]
}
},
"filter": {
"autocomplete_filter": {
"type": "edge_ngram",
"min_gram": 2,
"max_gram": 15
}
}
}
},
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "autocomplete",
"search_analyzer": "autocomplete_search"
}
}
}
}색인 시 autocomplete이 "elasticsearch" → ["el","ela","elas"…"elasticsearch"]를 만든다. 검색 시 autocomplete_search가 "elas"를 하나의 토큰으로 그대로 두어, edge_ngram 목록에서 일치하는 것을 찾는다. 서치 타임에도 edge_ngram을 쓰면 "elas"가 ["el","ela","elas"]로 쪼개져 과도하게 매칭된다.
패턴 2: 유의어(Synonym) 처리
"filter": {
"synonym_filter": {
"type": "synonym",
"synonyms": [
"automobile, car, vehicle",
"elasticsearch => es"
]
}
}=> 형식은 한 방향 교체(우변으로 교체), 쉼표 구분은 양방향 동의어로 처리된다.
한국어: nori 분석기
한국어는 교착어라 영어처럼 공백만으로 형태소를 분리할 수 없다. "검색엔진"이 "검색"과 "엔진"으로 분리되어야 하고, "검색하다"에서 동사 어간 "검색"이 추출되어야 한다.
analysis-nori 플러그인이 이를 처리한다. nori 분석기는 다음 구성을 포함한다.
nori_tokenizer: 세종 코퍼스 기반 형태소 분석.decompound_mode로 복합어 분해 방식 조정 (none/discard/mixed)nori_part_of_speech필터: 조사(JX), 어미(E), 접두사(XPN) 등 불필요한 품사 제거nori_readingform필터: 한자를 한글 독음으로 변환
POST /_analyze
{
"analyzer": "nori",
"text": "검색엔진 테스트"
}결과: ["검색", "엔진", "테스트"] — 복합어가 분해되고 조사가 제거된다.
Multi-field 매핑: 하나의 필드, 여러 분석기
실무에서는 같은 필드를 전문 검색용과 집계·정렬용으로 동시에 써야 할 때가 많다. Elasticsearch의 fields 매핑이 이를 해결한다.
"title": {
"type": "text",
"analyzer": "nori",
"fields": {
"keyword": { "type": "keyword" },
"suggest": { "type": "text", "analyzer": "autocomplete", "search_analyzer": "autocomplete_search" }
}
}하나의 원본 값이 세 가지 방식으로 색인된다. title은 형태소 검색, title.keyword는 집계·정렬, title.suggest는 자동완성에 각각 쓴다. 저장 공간이 늘어나는 트레이드오프가 있으므로 실제로 필요한 sub-field만 정의한다.
분석기 설계 체크리스트
| 요구사항 | 분석기 선택 |
|---|---|
| 영어 전문 검색 | english (stemmer + stop) |
| 한국어 전문 검색 | nori + nori_part_of_speech |
| 자동완성 | edge_ngram (색인) + standard (검색) |
| 유의어 처리 | 커스텀 + synonym filter |
| 정렬·집계 | keyword 타입 (분석 없음) |
| 정확히 일치 검색 | keyword 타입 |
| HTML 포함 텍스트 | html_strip char filter 추가 |
한 줄 정리
역 인덱스는 FST Term Dictionary + Posting List로 구성되며, BM25 스코어링으로 관련도를 계산한다. 분석기는 Character Filter → Tokenizer → Token Filter 파이프라인으로 텍스트를 토큰으로 변환하며, 한국어는 nori, 자동완성은 edge_ngram, 집계·정렬은 keyword 타입으로 요구사항에 맞게 설계한다.
References
- https://dev.to/iprithv/inverted-index-explained-how-elasticsearch-achieves-sub-millisecond-search-on-billions-of-documents-3la6
- https://medium.com/@imadsaddik/22-analyzers-in-elasticsearch-8916eced1773
- https://medium.com/elasticsearch/introduction-to-analysis-and-analyzers-in-elasticsearch-4cf24d49ddab
- https://www.elastic.co/docs/manage-data/data-store/text-analysis/anatomy-of-an-analyzer
- https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-nori-analyzer.html
- https://www.elastic.co/guide/en/elasticsearch/plugins/current/analysis-nori-tokenizer.html
- https://oneuptime.com/blog/post/2026-01-30-elasticsearch-custom-analyzers/view
- https://blog.mimacom.com/autocomplete-elasticsearch-part2/
- https://www.hellointerview.com/learn/system-design/deep-dives/elasticsearch