Elasticsearch는 어떻게 텍스트를 이해하는가
분석 파이프라인의 3단계 구조부터 Nori 형태소 분석, 동의어·n-gram 커스텀 설계, 매핑 폭발 방지, Analyzer 불일치 디버깅까지, 검색 품질의 뿌리를 추적한다.
- 01 Elasticsearch는 어떻게 분산 검색을 완성하는가
- 02 Elasticsearch는 왜 역색인인가
- 03 Elasticsearch는 어떻게 텍스트를 이해하는가
- 04 Elasticsearch는 왜 같은 조건도 다르게 처리하는가
- 05 Elasticsearch 집계는 왜 느리고, 왜 틀릴 수 있는가
- 06 Elasticsearch 운영의 모든 결정은 하나의 균형에서 나온다
- 07 Spring + Elasticsearch, 어디서 무엇이 깨지는가
“분명히 저장했는데 검색이 안 된다.” Elasticsearch를 쓰다 보면 한 번쯤 마주치는 상황이다. 인덱스를 지우고 다시 만들어도 같은 현상이 반복된다면, 문제는 데이터가 아니라 텍스트를 토큰으로 변환하는 파이프라인에 있다. ES가 텍스트를 어떻게 이해하는지 모르면, 검색 버그의 원인을 영원히 찾지 못한다.
파이프라인의 3단계
ES가 문서를 인덱싱할 때 텍스트는 세 단계를 거쳐 역색인(inverted index)에 저장된다.
입력: "<p>Hello, World! It's a beautiful day.</p>"
[Character Filter]
html_strip → "Hello, World! It's a beautiful day."
[Tokenizer]
standard → ["Hello", "World", "It's", "a", "beautiful", "day"]
[Token Filter]
lowercase → ["hello", "world", "it's", "a", "beautiful", "day"]
stop → ["hello", "world", "beautiful", "day"]
역색인 저장: hello, world, beautiful, day
순서는 고정이다. Character Filter가 원본 문자열을 정제하고, Tokenizer가 토큰 목록으로 쪼개고, Token Filter가 각 토큰을 변환·추가·제거한다. Tokenizer는 정확히 하나만 지정할 수 있다. Token Filter는 여러 개를 순서대로 적용할 수 있으며, 그 순서가 결과를 바꾼다.
stop 필터를 lowercase 앞에 두면 대문자 “The”가 불용어 목록의 소문자 “the”와 일치하지 않아 제거되지 않는다. 권장 순서는 lowercase → stop → stemmer다.
한국어: Nori가 필요한 이유
Standard Analyzer는 Unicode Text Segmentation(UAX #29) 기준으로 단어를 분리한다. 영문에서는 잘 동작하지만 한국어에서는 공백이 없으면 전체가 하나의 토큰이 된다.
Standard Analyzer:
"전자제품을 검색합니다"
→ ["전자제품을", "검색합니다"]
검색: "전자" → 역색인에 없음 → 실패
Nori는 세종 말뭉치 기반 형태소 분석기다. “전자제품”을 형태소 단위로 분해하고 조사·어미를 제거한다.
Nori (decompound_mode: mixed):
"전자제품을 검색합니다"
→ ["전자제품", "전자", "제품", "검색"]
decompound_mode의 선택이 검색 품질을 결정한다. discard는 복합어 원형을 버리고 분해 결과만 저장해 역색인을 작게 유지한다. mixed는 복합어와 분해 결과를 모두 저장해 어느 방향으로 검색해도 매칭된다. 역색인 크기가 늘어나는 대신 recall이 높아지는 트레이드오프다.
영문이 섞인 텍스트에서는 Nori가 영문 단어를 그대로 통과시키므로, lowercase Token Filter를 별도로 추가해야 한다.
동의어·Stemmer·n-gram의 설계
검색 품질을 높이는 세 가지 도구는 각각 비용이 다르다.
동의어는 인덱스 시점보다 검색 시점에 적용하는 것이 권장된다. 인덱스 시점 동의어는 모든 동의어를 역색인에 저장해 크기를 늘리고, 동의어 추가 시마다 전체 재인덱싱이 필요하다. 검색 시점 동의어(synonym_graph + updateable: true)는 역색인을 건드리지 않고 _reload_search_analyzers API 한 번으로 즉시 반영된다.
Stemmer는 “running”, “runs”, “ran”을 모두 “run”으로 통합해 recall을 높인다. 단, Porter Stemmer에서 “university”와 “universe”가 같은 어간 “univers”로 처리되는 것처럼 과한 어간 추출이 precision을 낮출 수 있다. Stemmer 적용 필드와 미적용 필드를 멀티필드로 병행하고, 정확 매칭 필드에 더 높은 가중치를 부여하는 방식으로 균형을 잡는다.
n-gram은 부분 검색을 가능하게 하지만 역색인 크기를 수십 배 늘린다. 자동완성에는 단어 앞부분만 생성하는 edge_ngram을 쓰고, 인덱스 시점과 검색 시점 Analyzer를 의도적으로 다르게 설정해야 한다.
인덱스: edge_ngram → ["el", "ela", "elas", "elast", ...]
검색: keyword → ["elas"]
검색 시점에도 edge_ngram을 쓰면 "el"로 시작하는
모든 단어가 반환되어 자동완성의 의미가 없어진다.
매핑: 스키마를 설계하지 않으면 생기는 일
매핑은 ES에서 MySQL의 스키마에 해당한다. 설계 없이 dynamic: true(기본값)로 두면 새 필드가 들어올 때마다 자동으로 매핑이 추가된다. 로그처럼 문서마다 키가 다른 데이터를 인덱싱하면 수천 개의 필드가 클러스터 상태에 쌓이고, Master 노드 부하가 늘어 클러스터 전체가 불안정해진다. 이것이 Mapping Explosion이다.
text와 keyword의 선택도 중요하다.
text: Analyzer를 적용해 토큰으로 분리. 전문 검색에 적합. 집계·정렬 불가.keyword: 분석 없이 원본 그대로 저장. 집계·정렬·정확 일치에 적합.- 둘 다 필요하면
text+keyword멀티필드 패턴을 사용한다.
dynamic: strict는 정의되지 않은 필드를 오류로 거부해 Mapping Explosion을 원천 차단한다. 대신 모든 필드를 사전에 정의해야 하는 부담이 생긴다. 키가 동적으로 변하는 JSON은 flattened 타입으로 단일 역색인으로 묶어 필드 수 폭증을 막을 수 있다.
이미 운영 중인 인덱스에서 필드 타입이나 Analyzer를 바꾸려면 반드시 재인덱싱이 필요하다. 새 매핑으로 새 인덱스를 만들고 Reindex API로 데이터를 복사한 뒤, 별칭(alias)을 원자적으로 전환하면 다운타임 없이 마이그레이션할 수 있다.
Analyzer 불일치 디버깅
“검색이 안 된다”는 신고에서 가장 먼저 확인해야 할 것은 인덱스 시점 토큰과 검색 시점 토큰이 교차하는가다.
# 인덱스 시점 토큰 확인
GET /myindex/_analyze
{ "field": "title", "text": "최신 스마트폰 추천" }
# 검색 시점 토큰 확인
GET /myindex/_analyze
{ "analyzer": "my_search_anal", "text": "핸드폰" }
두 결과의 교집합이 비어 있으면 검색이 실패한다. _explain API는 실제 탐색에 사용된 토큰과 점수 계산 과정을 보여줘 원인을 정확히 특정할 수 있다.
Nori와 동의어를 함께 쓸 때 순서 실수가 잦다. 동의어 필터를 Nori 뒤에 두면 “스마트폰”이 이미 “스마트” + “폰”으로 분해된 뒤 동의어를 찾으므로 매핑이 실패한다. 검색 시점에서는 synonym_graph → nori_part_of_speech → lowercase 순서로 동의어를 먼저 확장하고 그 결과를 형태소 분석해야 한다.
정리
- 분석 파이프라인은 Character Filter → Tokenizer → Token Filter 순서로 고정이다. Token Filter 순서는
lowercase → stop → stemmer가 기본이다. - 한국어 서비스에 Standard Analyzer를 쓰면 복합어 검색이 불가능하다. Nori +
nori_part_of_speech+lowercase조합이 최소 구성이다. - 동의어는 검색 시점, n-gram은 별도 서브필드, Stemmer는 멀티필드 병행이 각각의 권장 패턴이다.
dynamic: strict와 명시적 매핑으로 Mapping Explosion을 차단하고, 타입 변경은 재인덱싱 + 별칭 전환으로 무중단 처리한다.- 검색 불일치는
_analyze로 양쪽 토큰을 확인하고,_explain으로 실제 탐색 경로를 추적하면 대부분 원인을 찾을 수 있다.
다음 글에서는 ES가 BM25 점수를 계산하는 방식과, 쿼리 DSL이 내부적으로 Lucene 쿼리로 변환되는 과정을 추적한다.