Elasticsearch FILTERS 성능 가이드
이 글은 Elasticsearch 전체 기능을 백과사전처럼 훑지 않는다. 주제는 하나다.
FILTERS를 어디에, 어떤 순서로 적용해야 불필요한 계산을 줄이면서 검색 결과는 더 정확하게 만들 수 있을까?
처음에는 “검색창에 단어를 넣으면 무슨 일이 생기는가”부터 시작한다. 그다음 한국어 형태소 분석, mixed, 동의어, bool.filter를 거쳐 벡터 검색과 키워드 검색을 섞는 하이브리드 검색까지 단계적으로 들어간다. 생소한 용어는 처음 나오는 자리에서 바로 뜻을 설명한다.
예시는 Elasticsearch 9.x 최신 공식 문서를 기준으로 작성했다. 8.x에서도 핵심 개념은 같지만 semantic_text, Synonyms API, retriever 같은 기능은 사용 중인 버전의 지원 범위를 확인해야 한다.
먼저 1분 만에 이해하는 Elasticsearch 검색
Elasticsearch를 아주 큰 도서관이라고 생각해 보자. 사서가 책을 한 권씩 펼쳐 보며 검색어를 찾는다면 책이 늘수록 느려진다. Elasticsearch는 책을 넣을 때부터 어떤 단어가 어느 문서에 있는지 목록을 만들어 둔다. 이것이 역색인(inverted index)이다.
예를 들어 두 상품이 있다고 하자.
문서 1: 초경량 노트북 파우치
문서 2: 방수 랩톱 슬리브
텍스트를 분석한 뒤에는 개념적으로 다음과 같은 목록이 생긴다.
노트북 → 문서 1
파우치 → 문서 1
랩톱 → 문서 2
슬리브 → 문서 2
사용자가 노트북 파우치를 검색하면 Elasticsearch는 모든 문장을 처음부터 읽지 않고 이 목록에서 후보 문서를 찾는다. 동의어 규칙에 노트북 = 랩톱이 있다면 문서 2도 후보가 될 수 있다. 이후 판매 중, 4만 원 이하, 최근 90일 같은 조건으로 후보를 거르고, 남은 문서를 관련도 순으로 정렬한다.
그림 읽기 — 검색 요청은 coordinating node에서 shard로 분산되고, 각 shard의 filter·Lucene 결과가 global reduce 단계에서 합쳐진다. JSON에 적은 순서가 곧 내부 실행 순서라는 뜻은 아니다.
여기서 문서(document)는 Elasticsearch에 저장하는 JSON 한 건, 인덱스(index)는 문서들을 담는 논리적 공간, 매핑(mapping)은 각 필드가 글자인지 숫자인지 날짜인지 정하는 스키마(schema)다. 검색 성능은 저장 단계의 설계와 조회 단계의 쿼리가 함께 결정한다.
검색은 크게 네 가지 방식으로 생각하면 쉽다
| 원하는 결과 | 주로 쓰는 방식 | 쉬운 설명 |
|---|---|---|
| 상태·코드·가격이 정확히 맞는 문서 | term, range, bool.filter | 통과/탈락만 판단한다. |
| 검색어가 들어 있고 중요한 단어가 잘 맞는 문서 | match, multi_match | 단어를 분석한 뒤 BM25로 관련도를 계산한다. |
| 표현은 달라도 의미가 비슷한 문서 | semantic_text, knn | 문장을 숫자 벡터로 바꿔 가까운 의미를 찾는다. |
| 정확한 단어와 의미 유사성을 모두 살린 문서 | RRF 등 하이브리드 검색 | 키워드 결과와 벡터 결과의 순위를 합친다. |
BM25는 검색어가 문서에 얼마나 중요하게 등장하는지를 계산하는 대표적인 키워드 관련도 공식이다. 벡터(vector)는 문장의 의미를 여러 숫자로 표현한 값이고, 임베딩(embedding)은 텍스트나 이미지를 그 벡터로 바꾸는 과정 또는 결과다.
FILTERS는 이 네 방식 사이에서 두 가지 역할을 한다.
- 분석 단계에서는 검색 가능한 단어를 정리하거나 확장한다. 이를 token filter라고 한다.
- 조회 단계에서는 상태·가격·권한처럼 점수가 필요 없는 조건으로 문서를 거른다. 이를 query filter context라고 한다.
벡터 검색에서는 세 번째 역할도 생긴다. 비싼 유사도 계산을 하기 전 또는 하는 도중에 대상 문서 범위를 제한하는 vector pre-filter다.
분석 필터는 “무엇을 검색 가능한 단어로 만들 것인가”를 정하고, 쿼리 필터는 “후보 중 어떤 문서를 남길 것인가”를 정한다.
이 글을 읽는 데 꼭 필요한 용어만 먼저 보자
용어를 전부 외울 필요는 없다. 아래 세 묶음만 구분하면 뒤의 설정과 예시를 따라갈 수 있다. 나머지 용어는 처음 쓰는 자리에서 설명한다.
저장과 분석: 원문이 검색 가능한 단어가 되는 과정
| 용어 | 이 글에서의 뜻 | 예시 |
|---|---|---|
| document·field | document는 JSON 한 건, field는 그 안의 값이다. | 상품 한 건 안의 title, price, status |
| index·mapping | index는 문서 모음, mapping은 각 field의 타입과 검색 방식을 정하는 스키마다. | title: text, status: keyword |
| analyzer | char_filter → tokenizer → token filter를 묶은 텍스트 처리기다. | ko_index, ko_search |
| token·term | token은 분석 중 만들어진 검색 단위이고, 최종 문자열이 역색인에 term으로 기록된다. | 노트북 파우치 → 노트북, 파우치 |
| 역색인·posting | term에서 그 term을 가진 문서로 찾아가는 구조와 문서 ID·빈도·위치 정보다. | 노트북 → D1, D7 |
Nori·mixed | Nori는 한국어 tokenizer이고, mixed는 복합어 원형과 분해형을 함께 남기는 옵션이다. | 가곡역 → 가곡역, 가곡, 역 |
검색과 필터: 후보를 찾고 점수를 매기는 과정
| 용어 | 이 글에서의 뜻 | 예시 |
|---|---|---|
match·term | match는 검색어를 분석하고, term은 입력값을 분석하지 않고 정확한 term을 찾는다. | 본문은 match, 상태 코드는 term |
| query context | 얼마나 잘 맞는지 계산해 _score에 반영하는 문맥이다. | 제목의 BM25 관련도 |
| filter context | 통과·탈락만 판단하고 자체 점수는 만들지 않는 문맥이다. | 공개 상태, 가격, 권한 |
| token filter | tokenizer 뒤에서 token을 제거·변환·추가한다. 문서를 거르는 bool.filter와 다르다. | lowercase, synonym_graph |
| BM25 | query term과 역색인의 term이 만난 문서에 키워드 관련도 점수를 주는 방식이다. | TF·IDF·field length 반영 |
| precision·recall | precision은 결과가 얼마나 정확한지, recall은 찾아야 할 문서를 얼마나 놓치지 않았는지다. | 오타 확장은 recall을 높이고 precision을 낮출 수 있음 |
벡터와 하이브리드: 표현이 달라도 의미로 찾는 과정
| 용어 | 이 글에서의 뜻 | 예시 |
|---|---|---|
| embedding·vector | 문장이나 이미지를 의미를 담은 숫자 배열로 바꾼 결과다. | 문의 문장을 768개 숫자로 표현 |
| kNN·HNSW | 가까운 벡터 k개를 찾는 검색과 이를 빠르게 탐색하는 그래프 구조다. | 의미가 비슷한 상품 10개 검색 |
| pre-filter·post-filter | pre-filter는 벡터 후보를 찾는 과정 안에서, post-filter는 후보를 찾은 뒤 적용한다. | 공개 문서 안에서 kNN 검색 |
| hybrid search·RRF | 키워드 결과와 벡터 결과를 함께 쓰며, RRF는 원점수 대신 각 결과의 순위를 합친다. | 모델명은 BM25, 사용 목적은 벡터로 보완 |
자주 헷갈리는 차이는 네 줄이면 충분하다.
token filter는 단어를 바꾸고,bool.filter는 문서를 거른다.mixed는 Nori tokenizer 옵션,synonym_graph는 token filter다.text는 분석하는 본문 검색용,keyword는 정확 일치·필터·정렬·집계용이다.- 검색 analyzer는 지금 들어온 검색어를 바꾸고, index analyzer는 문서를 저장할 때 역색인을 만든다.
이 글에서 먼저 기억할 결론
bool.filter는 상태·가격·날짜·권한처럼 점수가 필요 없는 조건을 관련도 계산에서 분리한다.mixed는 속도 옵션이 아니라 한국어 복합어를 원형과 분해형으로 함께 찾기 위한 재현율 옵션이다.synonym_graph는노트북,랩톱,laptop처럼 표현 차이를 흡수하지만 쿼리를 더 복잡하게 만들 수 있다.- 벡터 검색은 표현이 달라도 의미가 비슷한 문서를 찾고, 하이브리드 검색은 키워드의 정확성과 벡터의 의미 이해를 결합한다.
- kNN 내부
filter는 조건을 만족하는 가까운 결과를 찾게 하지만, HNSW에서는 강한 필터가 항상 더 빠른 것은 아니다.
“FILTERS로 몇 퍼센트 빨라지는가?”에는 공통 숫자로 답할 수 없다. 효과는 필터 선택도, 반복률, shard·segment 구성, 동시 요청 수, 캐시 상태, 벡터 후보 수에 따라 달라진다. 따라서 이 글은 무슨 계산이 줄어드는지, 언제 역효과가 생기는지, 무엇을 측정해야 하는지까지 함께 설명한다.
0. 운영 변경은 “검색 시점만 바꾸는가”부터 판단한다
한국어 검색 설정을 운영에 적용할 때 가장 먼저 볼 것은 기존 문서가 어떤 term으로 색인되어 있는가다. 색인 analyzer가 만든 term과 검색 analyzer가 만든 token이 다르고 둘 사이에 교집합이 없으면, 문서는 존재해도 검색에서 누락될 수 있다.
특히 Nori 사용자 사전을 검색 analyzer에만 연결하면 새 검색어의 분해 방식만 달라지고, 이미 만들어진 역색인의 term은 그대로 남는다. 임시 실험은 가능하지만 기존 문서 전체에 같은 분해 규칙을 정확히 적용한 상태는 아니다. 운영에서 Nori 사용자 사전을 완전히 적용하려면 신규 인덱스의 색인 analyzer와 검색 analyzer에 의도한 사전을 연결하고, 기존 문서를 reindex하거나 원천 시스템에서 다시 적재해야 한다.
이 절에서 사용하는 운영 용어는 다음과 같다.
| 용어 | 뜻 |
|---|---|
| 물리 인덱스 | ko-products-v1처럼 실제 데이터를 저장하는 index다. |
| alias | 애플리케이션이 사용하는 고정된 논리 이름이다. 가리키는 물리 인덱스를 한 번에 바꿀 수 있다. |
| Nori 사용자 사전 | 기본 한국어 사전에 도메인 고유 명사와 원하는 복합어 분해 규칙을 추가하는 파일 또는 규칙 목록이다. |
| reindex | 기존 index의 _source를 읽어 다른 index에 다시 색인하는 작업이다. 새 analyzer와 mapping은 목적지 index에 미리 만들어야 한다. |
| 쓰기 동기화 | reindex가 진행되는 동안 발생한 신규·수정·삭제 데이터를 새 index에도 반영하는 절차다. |
| alias 전환 | 검증이 끝난 뒤 서비스 alias가 새 물리 index를 가리키도록 원자적으로 변경하는 작업이다. |
| rollback | 문제가 생겼을 때 alias를 이전 index로 되돌리는 복구 절차다. |
0.1 변경 유형별 첫 선택
| 하려는 작업 | 기존 인덱스에서 할 일 | 운영 권장 |
|---|---|---|
| Synonyms API·Kibana로 관리하는 동의어 규칙만 변경 | synonym set을 수정하면 연결된 search analyzer가 자동 reload된다. 이후 _analyze와 대표 검색어를 확인한다. | 기존 인덱스 사용 |
| 파일 기반 동의어·오타 규칙만 변경 | 모든 Elasticsearch node에 같은 파일 배포 → search analyzer reload → request cache 제거 | 기존 인덱스 사용 |
| 검색어를 Nori로만 분석해 시험 | query의 analyzer 지정, 별도 시험 index 또는 search_analyzer 변경으로 비교 | 임시 검증에만 사용 |
| Nori 사용자 사전을 검색어에만 시험 | 사용자 사전 tokenizer를 가진 search analyzer로 검색 token을 확인 | 임시 검증에만 사용 |
| Nori 사용자 사전을 기존 문서 전체에 적용 | 기존 문서의 term은 제자리에서 바뀌지 않는다. | 신규 인덱스 + reindex/재적재 |
| 기존 필드의 색인 analyzer 변경 | 기존 field의 analyzer는 mapping update로 변경할 수 없다. | 신규 인덱스 + reindex/재적재 |
기존 keyword에 새 .text multi-field 추가 | mapping 추가 후 기존 문서를 다시 처리해야 새 field가 채워진다. | 소규모는 update-by-query, 대규모·안전 우선은 reindex |
| data stream의 analyzer 변경 | index template을 바꾸고 rollover하면 새 backing index부터 적용된다. | 과거 데이터까지 바꾸려면 신규 data stream + reindex |
판단 기준: 검색 analyzer와 검색 시점 synonym만 바꾸는 작업은 기존 index를 유지할 수 있다. 기존 문서의 term, field 타입, index analyzer가 달라지는 작업은 새 index로 옮기는 것이 기본이다.
0.2 반드시 먼저 준비할 것
- 대상 이름이 alias인지 물리 index인지 확인하고, 변경·검증 API에는 의도한 물리 index명을 명시한다.
- Nori를 사용하는 모든 Elasticsearch node에 호환 버전의
analysis-noriplugin이 설치되어 있는지 확인한다. - 파일 방식이면 index를 생성하거나 열기 전에 모든 관련 node에 같은 UTF-8 파일을 배포하고 checksum을 맞춘다. shard가 지금 없는 node도 향후 shard 이동에 대비해 포함한다.
- 현재 settings, mapping, alias 대상, 문서 수, 대표 질의 결과를 저장하고 필요하면 snapshot을 만든다.
- 사전에만 존재하는 고유 단어, 기존 핵심 검색어, 0건 검색어를 대표 검증 문장으로 준비한다.
- 사용자 사전·색인 분석 변경이면 신규 물리 index명, 목적지 settings와 mapping, 재색인 중 쓰기 동기화 방법, alias 전환과 rollback 대상을 먼저 정한다.
- reindex는 목적지 index의 settings와 mapping을 복사해 주지 않으며, 원본 문서의
_source가 필요하다는 점을 확인한다. - 디스크 여유 공간, shard 수, replica, refresh 주기, reindex 부하 제한과 작업 시간을 정한다.
운영 상태를 기록하는 최소 명령은 다음과 같다.
GET /_alias/products
GET /ko-products-v1/_settings
GET /ko-products-v1/_mapping
GET /ko-products-v1/_count
GET /_cat/plugins?v
GET /_cat/allocation?v
0.3 기억할 핵심 규칙
- search analyzer 변경은 변경 이후 들어오는 검색어에 바로 영향을 주지만 기존 역색인의 term은 바꾸지 않는다.
- index analyzer·Nori 사용자 사전·
decompound_mode변경은 기존 문서에 소급되지 않는다. 전체 적용에는 reindex 또는 원천 재적재가 필요하다. - 색인 analyzer와 검색 analyzer가 반드시 완전히 같아야 하는 것은 아니다. 자동완성이나
mixed/discard조합처럼 의도적으로 다르게 만들 수 있지만, 대표 문장에서 두 결과 token이 실제로 만나는지 확인해야 한다. updateable: true인 synonym token filter는 search analyzer에서만 사용한다. Synonyms API 방식은 규칙 변경 시 연결 analyzer를 자동 갱신하고, 파일 방식은 node별 파일 동기화와_reload_search_analyzers호출이 필요하다.- analyzer의 token filter 배열은 작성한 순서대로 적용된다. 반대로
bool.filter배열의 JSON 순서가 Lucene 내부 실행 순서를 보장하지는 않는다. - analyzer reload 뒤 제거해야 하는 것은 오래된 검색 응답이 남을 수 있는 request cache다. query cache를 습관적으로 모두 비우는 절차로 혼동하지 않는다.
- alias 전환은 검증과 마지막 쓰기 동기화가 끝난 뒤 한 번의
_aliases요청으로 수행한다. 이전 index는 rollback 기간 동안 바로 삭제하지 않는다.
예시: 운영 중간에 사용자 사전을 넣으면 BM25는 무엇을 볼까
먼저 질문에 바로 답하면 다음과 같다.
기존 문서는 검색할 때 기존 analyzer로 다시 계산되지 않는다. 문서를 색인할 당시 analyzer가 만든 term이 Lucene 역색인에 남아 있고, 검색할 때는 사용자의 검색어만 현재 search analyzer를 거친다. BM25는 두 analyzer를 실행하는 기능이 아니라, 검색어에서 만들어진 query term과 역색인에 이미 저장된 document term이 일치한 뒤 그 문서들의 점수를 계산하는 방식이다.
아래는 동작을 이해하기 위한 단순화된 예다. 실제 Nori 출력은 Elasticsearch 버전, 사전 내용, decompound_mode, 앞뒤 token filter에 따라 달라질 수 있으므로 _analyze 결과를 기준으로 판단해야 한다.
상품 문서가 먼저 들어왔다고 하자.
문서 D1의 title: "초경량 노트북파우치"
기존 v1 index analyzer의 색인 결과
→ [초경량, 노트북, 파우치]
v1 역색인에 저장된 term
초경량 → D1
노트북 → D1
파우치 → D1
운영 중간에 다음 Nori 사용자 사전 규칙을 추가하고, 우선 검색 analyzer에만 연결했다고 가정한다.
노트북파우치 노트북 파우치
새 search analyzer가 decompound_mode: none처럼 복합어 원형만 만들면 검색어 결과가 다음처럼 될 수 있다.
검색어: "노트북파우치"
새 검색 token: [노트북파우치]
기존 D1의 색인 term: [초경량, 노트북, 파우치]
교집합: 없음
이 경우 match query가 찾는 term 노트북파우치가 v1 역색인에 없으므로 D1은 후보가 되지 못한다. 후보가 되지 않은 문서에는 BM25 점수도 계산되지 않는다. _source에 원문 노트북파우치가 보이더라도 Elasticsearch가 검색할 때 _source를 다시 Nori로 분석해 주지는 않는다.
반대로 search analyzer가 mixed 또는 discard를 사용해 분해형까지 만들면 다음과 같이 기존 term과 만날 수 있다.
검색어의 새 token: [노트북파우치, 노트북, 파우치] # mixed의 개념적 예
기존 D1의 term: [초경량, 노트북, 파우치]
공통 term: [노트북, 파우치]
이 경우 D1은 분해형 term을 통해 다시 후보가 될 수 있다. 다만 mixed는 위치 정보를 가진 token graph를 만들 수 있으므로 실제 match, match_phrase, operator: and의 Lucene query 구조와 점수는 _validate/query?rewrite=true와 _explain으로 확인해야 한다.
시간 순서로 보면 더 명확하다.
| 시점 | 문서·검색어에 적용되는 분석 | 실제 결과 |
|---|---|---|
| T0: D1 색인 | 기존 index analyzer | D1의 term이 역색인에 고정된다. |
| T1: search analyzer에만 새 사전 적용 | 기존 D1은 그대로, 새 검색어만 새 search analyzer 사용 | 새 query term과 기존 term이 만나지 않으면 D1이 누락된다. |
| T2: 같은 v1에 새 문서 색인 | field의 기존 index analyzer 사용 | search analyzer만 바꿨다면 신규 문서도 여전히 기존 index analyzer로 색인된다. |
| T3: 사전을 연결한 v2 생성 후 reindex | 모든 기존 문서가 v2의 새 index analyzer를 통과 | D1도 새 사전 기준 term으로 다시 만들어진다. |
| T4: alias를 v2로 전환 | v2의 index/search analyzer 조합 사용 | 검증한 token 체계로 검색과 BM25가 일관되게 동작한다. |
즉 search analyzer만 바꾼 상태에서는 새로 들어오는 문서까지 새 사용자 사전으로 색인되는 것이 아니다. 새 문서에 어떤 analyzer가 적용되는지는 field mapping의 analyzer가 결정한다. 기존 field의 index analyzer는 mapping update로 다른 analyzer로 바꿀 수 없으므로, 완전한 적용은 v2 index와 reindex가 기준이다.
BM25가 실제로 계산하는 순서
match 검색은 개념적으로 다음 순서로 진행된다.
- search analyzer가 사용자 검색어를 query term으로 만든다.
- Lucene이 각 query term의 posting list에서 일치하는 후보 문서를 찾는다.
- 일치한 문서에 대해 BM25가 term frequency, document frequency, field length 등을 사용해 점수를 만든다.
bool.filter가 있다면 query와 함께 후보 범위를 제한하지만 filter 자체는 BM25 점수를 더하지 않는다. 실제 내부 평가 순서는 Lucene이 최적화하므로 이 번호가 물리적 실행 순서를 뜻하지는 않는다.
여기서 주요 값은 다음과 같다.
| 값 | 쉬운 뜻 | 사전 변경의 영향 |
|---|---|---|
| TF·term frequency | 해당 term이 한 문서에 몇 번 있는가 | 분해 방식이 달라지면 문서 안 term 빈도가 달라질 수 있다. |
| DF·document frequency | 해당 term을 가진 문서가 전체에 몇 개인가 | 일부 문서만 새 term을 가지면 그 term이 매우 희귀한 것으로 계산될 수 있다. |
| IDF·inverse document frequency | 드문 term에 더 큰 가중치를 주는 값 | 새 복합어 term이 신규·재처리 문서에만 있으면 점수 편향이 생길 수 있다. |
| field length | 해당 field에 색인된 token 수 | mixed처럼 token이 늘어나는 방식은 길이 정규화에 영향을 줄 수 있다. 겹침 token 처리 여부도 관련된다. |
예를 들어 같은 물리 index 안에서 일부 문서만 새 analyzer로 다시 처리된 상태를 만들면 다음과 같은 문제가 생길 수 있다.
D1 오래된 문서: [노트북, 파우치]
D2 새 체계 문서: [노트북파우치, 노트북, 파우치]
query term: [노트북파우치]
노트북파우치는 D2에만 있으므로 D2만 검색되고, corpus 안에서 매우 드문 term이라 높은 IDF를 가질 수 있다. 내용이 비슷한 D1은 아예 후보에서 빠지거나 분해 term으로만 검색될 수 있다. 이처럼 부분 재처리는 recall뿐 아니라 ranking의 일관성도 흔들 수 있다. analyzer migration은 일부 문서만 고치는 방식보다 새 index 전체를 같은 설정으로 다시 만드는 편이 안전하다.
세 가지 검색 방식으로 결과를 비교해 보기
| 검색 token 전략 | 기존 v1 문서 | 사전 적용 후 reindex한 v2 문서 | 주의점 |
|---|---|---|---|
원형만 [노트북파우치] | 원형 term이 없으면 누락 | 원형 term이 있으면 일치 | 정확하지만 이전 term 체계와 호환되지 않을 수 있다. |
분해형만 [노트북, 파우치] | 대체로 일치 가능 | 분해형을 저장했다면 일치 | 복합어 자체의 구분력이 줄 수 있다. |
원형+분해형 mixed | 분해형 경로로 찾을 가능성 | 원형과 분해형 경로 모두 사용 가능 | token graph, phrase, 점수를 실제 쿼리로 검증해야 한다. |
두 index를 전환 전에 직접 비교하면 안전하다.
POST /ko-products-v1/_analyze
{
"analyzer": "ko_index",
"text": "초경량 노트북파우치",
"explain": true
}
POST /ko-products-v2/_analyze
{
"analyzer": "ko_index",
"text": "초경량 노트북파우치",
"explain": true
}
검색어도 v1과 v2에서 각각 확인한다.
POST /ko-products-v2/_analyze
{
"analyzer": "ko_search",
"text": "노트북파우치",
"explain": true
}
문서 ID를 알고 있다면 같은 query를 두 index의 _explain으로 실행해 어떤 term이 일치했고 BM25의 TF·IDF·field length가 점수에 어떻게 반영됐는지 비교한다.
GET /ko-products-v1/_explain/1
{
"query": {
"match": {
"title": "노트북파우치"
}
}
}
GET /ko-products-v2/_explain/1
{
"query": {
"match": {
"title": "노트북파우치"
}
}
}
정리하면 다음과 같다.
- 사전을 검색 쪽에만 추가: 기존 문서는 그대로이며 검색어 token만 달라진다. 빠른 시험에는 쓸 수 있지만 누락 여부를 확인해야 한다.
- 동의어를 search-time에 추가: query term을 기존 역색인의 term으로 확장하는 방식이므로 일반적으로 reindex 없이 운영할 수 있다.
- Nori 사용자 사전과 index analyzer를 완전히 적용: v2 index를 만들고 전체 문서를 다시 색인한다.
- BM25: 원문이나 analyzer 설정을 다시 계산하지 않고, 현재 query term과 이미 저장된 posting·통계로 점수를 만든다.
참고: 색인 시점과 검색 시점 분석, analyzer 결정 순서와 기존 index 변경, BM25 similarity, Ranking과 BM25
0.4 TXT 파일은 이름이 아니라 연결 위치로 구분한다
Elasticsearch에 typo.txt라는 전용 기능은 없다. 파일명은 typo.txt, misspellings.txt, commerce_terms.txt처럼 자유롭게 정할 수 있고, 어떤 분석 구성 요소의 어떤 *_path 설정에 연결했는지가 실제 기능을 결정한다. 따라서 txt 사전이라는 이름만 보고 한 파일에 동의어·오타·불용어·형태소 규칙을 섞으면 안 된다.
그림 읽기 — typo.txt는 고정 기능이 아니다. 문자열 자체를 바꾸려면 mapping char filter에, 분석된 단어나 구를 표준어로 보내려면 synonym_graph에 연결한다. stopwords, keepwords, keywords, stemmer_override는 모두 token filter 단계의 서로 다른 작업이다.
Elasticsearch에서 파일로 관리할 수 있는 대표 분석 리소스
| 파일명 예시 | 연결 설정 | 실행 단계 | 실제 역할 |
|---|---|---|---|
mapping.txt | mapping.mappings_path | char filter | tokenizer 전에 문자·기호·확정 문자열을 치환한다. |
userdict_ko.txt | nori_tokenizer.user_dictionary | tokenizer | 한국어 고유 명사를 추가하고 복합 명사의 분해 경계를 지정한다. |
synonyms.txt | synonym_graph.synonyms_path | token filter | 동의어·약어·별칭·다단어 표현을 확장하거나 표준 표현으로 모은다. |
typo.txt | 보통 synonym_graph.synonyms_path | token filter | 등록한 오타를 오타 => 표준어 단방향 규칙으로 교정한다. 전용 typo 기능은 아니다. |
stopwords.txt | stop.stopwords_path | token filter | 지정한 token을 제거한다. 의미를 뒤집는 부정어까지 지우지 않도록 주의한다. |
keepwords.txt | keep.keep_words_path | token filter | 지정 목록에 있는 token만 남긴다. 일반 검색보다 제한된 추출용 분석에 가깝다. |
keywords.txt | keyword_marker.keywords_path | token filter | 뒤에 오는 stemmer가 지정 단어를 변형하지 못하게 보호한다. |
stemmer_override.txt | stemmer_override.rules_path | token filter | running, runs => run처럼 특정 단어의 어간 결과를 직접 지정한다. |
한국어 상품·문서 검색에서 가장 먼저 검토할 것은 userdict_ko.txt, synonyms.txt, typo.txt, mapping.txt, stopwords.txt다. keywords.txt와 stemmer_override.txt는 영어처럼 stemming을 사용하는 분석기에서 더 자주 쓰인다. keepwords.txt는 대부분의 token을 버리므로 일반 본문 검색에 무심코 넣지 않는다.
A. Nori 사용자 사전: “어디서 단어를 나눌지” 정한다
Nori의 user_dictionary 파일은 기본 사전에 없는 사용자 정의 명사를 추가한다. 한 줄의 첫 항목은 필수인 사용자 정의 명사이고, 뒤 항목은 복합 명사의 원하는 분해형이다.
$ES_PATH_CONF/userdict_ko.txt 예시:
C쁠쁠
젠레스존제로
세종시 세종 시
노트북파우치 노트북 파우치
에어팟프로 에어팟 프로
앞의 두 줄은 단일 명사이고, 뒤의 세 줄은 첫 항목 뒤에 원하는 분해형을 적은 복합 명사다. Nori 사용자 사전은 동의어 파일과 문법이 다르므로 설명용 # 주석이나 => 규칙을 섞지 않고 공식 형식만 사용한다.
이 파일에서 노트북파우치 노트북 파우치는 “노트북파우치와 파우치는 같은 뜻”이라는 규칙이 아니다. 복합 명사 노트북파우치를 노트북과 파우치로 분해할 수 있게 하는 형태소 규칙이다. 실제로 원형과 분해형을 모두 남길지는 tokenizer의 decompound_mode: mixed가 결정한다.
다음처럼 색인용 tokenizer와 검색용 tokenizer 양쪽에 같은 파일을 명시할 수 있다.
"tokenizer": {
"ko_index_tokenizer": {
"type": "nori_tokenizer",
"decompound_mode": "mixed",
"user_dictionary": "userdict_ko.txt"
},
"ko_search_tokenizer": {
"type": "nori_tokenizer",
"decompound_mode": "discard",
"user_dictionary": "userdict_ko.txt"
}
}
파일 경로는 Elasticsearch config 디렉터리를 기준으로 한다. 작은 실험은 user_dictionary_rules 배열로 인라인 정의할 수도 있지만, 운영에서 여러 node가 같은 파일을 읽는다면 배포와 checksum 검증 절차를 둔다.
변경 영향: 검색 tokenizer에만 새 사전을 연결하면 새 검색어의 token만 바뀐다. 색인 tokenizer의 사전을 바꾸거나 기존 문서 전체에 같은 분해를 적용하려면 새 index를 만들고 reindex·재적재한다.
B. 동의어·오타 파일: “어떤 검색 표현을 어떤 term으로 보낼지” 정한다
검색 로그에서 반복되는 오타와 표기 차이는 synonym_graph의 단방향 규칙으로 통제할 수 있다. 이 파일은 형태소 사전이 아니라 검색어 확장·치환 규칙이다.
$ES_PATH_CONF/analysis/synonyms_ko.txt 예시:
# 쉼표: 서로 동등하게 확장하는 동의어
노트북, 랩톱, laptop
휴대폰 케이스, 스마트폰 케이스
# 화살표: 왼쪽 표현을 오른쪽 표준어로 보내는 단방향 규칙
에어팟푸로, 에어팟프로, 에어팟 프로 => 에어팟 프로
맥북에어, 맥북 에어, macbook air => 맥북 에어
갤럭시s25울트라, 갤럭시 s25 울트라 => 갤럭시 s25 울트라
- 쉼표만 사용한 줄은 기본적으로 각 표현을 서로 확장한다. recall은 늘지만 query clause도 늘 수 있다.
=>는 왼쪽의 오타·별칭을 오른쪽 표준 표현으로 모은다. 명확한 오타에는 이 방식이 예측하기 쉽다.- 주석은
#로 시작한다. 빈 줄도 허용된다. - 임의의 오타를 모두 넣지 말고 실제 0건 검색 로그와 업무 검수를 통과한 규칙만 넣는다.
애플 → 사과처럼 문맥에 따라 의미가 달라지는 표현을 전역 규칙으로 만들면 precision이 크게 떨어질 수 있다.
검색 analyzer에만 연결하는 설정은 다음과 같다.
"filter": {
"ko_search_synonyms": {
"type": "synonym_graph",
"synonyms_path": "analysis/synonyms_ko.txt",
"updateable": true
}
},
"analyzer": {
"ko_search": {
"type": "custom",
"tokenizer": "ko_search_tokenizer",
"filter": [
"nori_readingform",
"lowercase",
"ko_search_synonyms"
]
}
}
파일을 바꾼 뒤에는 모든 Elasticsearch data node에 같은 파일을 배포하고 다음 순서로 적용한다.
1. UTF-8 파일 배포
2. node별 checksum 비교
3. _reload_search_analyzers 호출
4. request cache 제거
5. _analyze와 대표 검색어 회귀 테스트
POST /ko-products-v1/_reload_search_analyzers
POST /ko-products-v1/_cache/clear?request=true
updateable: true인 synonym filter는 search analyzer에서만 사용한다. 이 방식은 기존 문서의 역색인을 건드리지 않으므로 보통 reindex가 필요 없다. 반대로 index-time synonym을 사용했다면 새 규칙을 과거 문서에 적용할 때 reindex가 필요하다.
C. 문자 치환 파일: “형태소 분석 전에 문자 모양을 맞춘다”
mapping char filter는 tokenizer보다 먼저 실행된다. 뜻을 추측하는 오타 교정기가 아니라, 같은 의미라고 확정할 수 있는 문자 표현을 기계적으로 통일하는 도구다.
$ES_PATH_CONF/analysis/char_mapping_ko.txt 예시:
A => A
B => B
C => C
- => -
"char_filter": {
"ko_char_mapping": {
"type": "mapping",
"mappings_path": "analysis/char_mapping_ko.txt"
}
},
"analyzer": {
"ko_index": {
"type": "custom",
"char_filter": ["ko_char_mapping"],
"tokenizer": "ko_index_tokenizer",
"filter": ["nori_readingform", "lowercase"]
},
"ko_search": {
"type": "custom",
"char_filter": ["ko_char_mapping"],
"tokenizer": "ko_search_tokenizer",
"filter": ["nori_readingform", "lowercase", "ko_search_synonyms"]
}
}
색인 analyzer와 검색 analyzer에 같은 문자 치환을 두면 A형과 A형 같은 입력이 같은 문자 상태에서 Nori로 들어간다. 다만 색인 쪽 치환 규칙을 바꿔도 기존 posting은 자동으로 바뀌지 않으므로 과거 문서까지 통일하려면 reindex가 필요하다.
D. 불용어와 유지 단어 파일: token을 제거하거나 필요한 것만 남긴다
stopwords.txt는 tokenizer가 만든 token 중 목록에 있는 단어를 제거한다. 한 줄에 한 단어를 두는 것이 기본이다.
$ES_PATH_CONF/analysis/stopwords_ko.txt 예시:
그리고
그러나
또한
"filter": {
"ko_stop": {
"type": "stop",
"stopwords_path": "analysis/stopwords_ko.txt"
}
}
불용어는 성능을 위해 많이 지우는 목록이 아니다. 불가, 미지급, 취소, 제외처럼 문장의 의미와 업무 상태를 바꾸는 단어를 제거하면 “환불 가능”과 “환불 불가”가 비슷한 검색어가 될 수 있다. 실제 query log와 실패 사례를 기준으로 최소화한다.
keepwords.txt는 반대로 목록에 있는 token만 남긴다.
상품명
모델명
제품코드
"filter": {
"field_name_only": {
"type": "keep",
"keep_words_path": "analysis/keepwords.txt"
}
}
이 필터는 분석 결과 대부분을 제거하므로 일반 검색 field보다 분류·추출·특수 보조 field에 제한적으로 사용한다.
E. 어간 제어 파일: stemming을 사용하는 언어에서 단어 변형을 통제한다
keyword_marker의 keywords_path는 지정 token에 keyword 표식을 붙여 이후 stemmer가 변형하지 못하게 한다. 반드시 stemmer보다 앞에 둔다.
elasticsearch
opensearch
running
"filter": {
"protected_terms": {
"type": "keyword_marker",
"keywords_path": "analysis/keywords.txt"
}
}
stemmer_override의 rules_path는 특정 단어의 어간 결과를 직접 지정한다. 이 필터도 stemmer보다 앞에 둔다.
running, runs => run
indices => index
"filter": {
"custom_stems": {
"type": "stemmer_override",
"rules_path": "analysis/stemmer_override.txt"
}
}
Nori는 한국어 형태소 분석을 별도로 수행하므로 이 두 파일이 한국어 검색의 기본 구성은 아니다. 다국어 index에서 영어 analyzer를 함께 운영할 때 구분해 두면 좋다.
F. typo.txt를 어디에 연결할지 결정하는 기준
| 해결하려는 문제 | 연결 방법 | 예시 | 주의점 |
|---|---|---|---|
| 단어나 구 단위의 확인된 오타 | synonym_graph.synonyms_path | 노트붇 => 노트북 | 검색 시점 단방향 규칙이 관리하기 쉽다. |
| 문자·기호 자체의 확정 치환 | mapping.mappings_path | & => & | tokenizer 전에 부분 문자열도 바꿀 수 있어 부작용을 검사한다. |
| 사전에 없는 오타도 유사한 단어로 찾기 | Query DSL의 fuzziness | elastcisearch와 가까운 term 탐색 | TXT 기능이 아니며 편집 거리 기반이라 한국어 품질은 별도 검증한다. |
검색 token을 표준어로 모으는 가장 일반적인 typo.txt 예시는 다음과 같다.
elastcisearch => elasticsearch
노트붇 => 노트북
에어팟푸로 => 에어팟 프로
"filter": {
"typo_corrections": {
"type": "synonym_graph",
"synonyms_path": "analysis/typo.txt",
"updateable": true
}
}
synonym_graph 방식은 분석된 token과 phrase를 다루므로 제품명·약어·띄어쓰기 변형을 통제하기 좋다. mapping 방식은 token 경계를 보기 전에 원문 문자열을 치환하므로 전각 문자, 기호, 고정 코드 표기처럼 문맥이 없는 교정에 적합하다. 등록하지 않은 오타를 자동으로 찾아 주는 기능은 아니며, 그런 요구는 fuzzy query나 별도 교정 모델을 함께 평가한다.
더 넓게 보면 TXT 외의 파일 리소스도 있다
Elasticsearch에는 제품 코드 분할 예외를 위한 word_delimiter_graph.protected_words_path, 독일어권 복합어 처리를 위한 word_list_path와 hyphenation_patterns_path, 사전 기반 stemming을 위한 Hunspell의 .dic·.aff 파일도 있다. 다만 이들은 한국어 Nori 검색의 기본 구성이 아니므로 이 글의 주 흐름에서는 제외한다.
TXT 규칙을 고르는 빠른 질문
| 질문 | 선택 |
|---|---|
| 기본 사전에 없는 제품명·전문 용어인가? | Nori 사용자 사전 |
| 복합어를 원하는 경계로 나누고 싶은가? | Nori 사용자 사전 + decompound_mode |
노트북과 랩톱처럼 뜻을 연결하려는가? | synonym 규칙 |
에어팟푸로처럼 확인된 오타를 표준어로 보내려는가? | 단방향 synonym 규칙 |
| 전각 영문·특수 기호처럼 문자 자체를 치환하려는가? | mapping char filter |
| 자주 나오지만 검색 의미가 없는 token을 제거하려는가? | stop filter + stopwords_path |
| 특정 목록의 token만 남기려는가? | keep filter + keep_words_path |
| 영어 고유어가 stemming으로 변형되는 것을 막으려는가? | keyword marker + keywords_path |
| 특정 영어 단어의 stem을 강제로 정하려는가? | stemmer override + rules_path |
| 띄어쓰기·오타가 상황에 따라 다르게 해석되는가? | 전역 TXT 규칙보다 검색 로그 기반 교정 서비스나 제한된 synonym부터 검토 |
한국어 검색의 핵심 파일을 함께 쓴다면 실행 순서는 다음과 같다.
원문/검색어
→ char_mapping_ko.txt로 문자 치환
→ userdict_ko.txt를 읽는 Nori tokenizer로 단어 분리
→ stopwords·keepwords 등 일반 token filter
→ 검색 시점에 synonyms_ko.txt·typo.txt로 동의어·오타 확장
→ 역색인 term 조회와 BM25 점수 계산
파일을 수정한 뒤 반영 방식은 모두 같지 않다
| 변경 대상 | 운영 반영 | 기존 문서 reindex |
|---|---|---|
search analyzer의 synonym·synonym_graph, updateable: true | 모든 data node에 같은 파일 배포 → _reload_search_analyzers → request cache 제거 | 보통 불필요 |
| 검색 시점의 stop·keep·mapping·Nori 사용자 사전 등 | synonym reload API의 범용 대상이 아니다. 배포 환경과 구성 요소에 맞는 analyzer 재로딩·index 교체 절차를 별도로 잡는다. | 검색어에만 적용하면 문서 reindex는 불필요하지만 기존 term과의 호환을 검증한다. |
| 색인 analyzer에서 사용하는 모든 사전·규칙 | 새 설정의 index를 만들고 문서를 다시 처리한다. | 과거 문서의 term을 바꾸려면 필요 |
_reload_search_analyzers는 모든 TXT 파일을 다시 읽는 범용 명령이 아니다. 공식적으로 갱신 가능한 대상은 search analyzer에서 사용하는 updateable: true의 synonym 계열 filter다. 다른 파일의 변경은 “파일만 바꾸면 즉시 반영된다”고 가정하지 않는다.
참고: Nori 사용자 사전 형식, 파일 기반 동의어와 규칙 형식, stopwords_path, keep_words_path, keywords_path, stemmer override, search analyzer reload, mapping char filter, fuzzy query
0.5 어느 순간에 무엇을 해야 하나
| 시점 | 해야 할 일 | 완료 기준 |
|---|---|---|
| 설계 전 | 변경이 search-time인지 index-time인지 분류하고 대표 질의와 기대 문서를 만든다. | 변경 유형과 성공 지표가 문서화됨 |
| index 생성 전 | plugin 설치, 사전·동의어 준비, settings·mapping·alias·쓰기 전략을 확정한다. | 모든 node의 버전과 파일 checksum이 일치함 |
| 개발·스테이징 | _analyze?explain=true로 색인 token과 검색 token을 비교하고 검색 품질을 회귀 테스트한다. | 필수 고유어와 기존 핵심 검색어가 모두 통과함 |
| 운영 중 변경 직전 | snapshot·설정 백업, 자원 확인, 목적지 index 생성, rollback 명령을 준비한다. | 새 index가 green이고 analyzer 검증이 끝남 |
| reindex 중 | 비동기 task, 실패, version conflict, CPU·heap·disk를 감시하고 신규 쓰기를 동기화한다. | 대량 복사 완료 + 변경분 반영 완료 |
| alias 전환 직전 | 문서 수·샘플 문서·token·대표 질의·p95를 이전 index와 비교한다. | 합의한 품질·성능 기준을 충족함 |
| 전환 직후 | 오류율, 0건 비율, 검색 순위, 쓰기 성공 여부를 관찰한다. | 관찰 시간 동안 회귀가 없음 |
| 안정화 후 | 이전 index와 임시 동기화를 정리한다. | rollback 보존 기간이 끝나고 승인됨 |
0.6 처음부터 새로 구축할 때의 순서
- 모든 node에
analysis-nori를 설치하고 Elasticsearch 버전과 맞춘다. - 사용자 사전 파일을 모든 node에 배포하거나
user_dictionary_rules를 확정한다. - search-time synonym을 쓴다면 index보다 먼저 synonym set을 생성한다.
ko_index,ko_search와 mapping을 가진 첫 물리 index를 만든다.- 사용자 사전 전용 단어로 두 analyzer를 각각
_analyze하고 token·position을 저장한다. - 문서를 적재한 뒤 기존 핵심 검색어, 신규 고유어, 0건 검색어를 검증한다.
- 애플리케이션에는 물리 index명이 아니라 alias를 연결한다.
이 순서를 지키면 첫 버전부터 다음 교체를 alias 기반으로 준비할 수 있다.
0.7 운영 중 동의어·오타 규칙만 바꿀 때
Synonyms API 또는 Kibana의 synonym set을 사용하면 규칙 변경 시 연결된 analyzer가 자동으로 갱신된다. 저장 직후 _analyze와 대표 검색 결과를 확인한다. 파일 기반이라면 다음 순서를 사용한다.
- 새 파일을 모든 Elasticsearch node에 배포한다.
- node별 checksum이 같은지 확인한다.
- synonym filter가
updateable: true이고 search analyzer에서만 쓰이는지 확인한다. - 대상 물리 index의 search analyzer를 reload한다.
- request cache를 제거한 뒤 대표 검색어를 다시 실행한다.
POST /ko-products-v1/_reload_search_analyzers
POST /ko-products-v1/_cache/clear?request=true
동의어 변경은 기존 문서의 term을 바꾸지 않으므로 보통 reindex가 필요 없다. 다만 index-time synonym을 사용했다면 과거 문서에 새 규칙을 소급하려면 reindex해야 한다.
0.8 운영 중 search analyzer만 시험할 때
가장 안전한 순서는 별도 시험 index → query별 analyzer 지정 → 필요한 경우에만 mapping 변경이다. 기존 field의 search_analyzer는 update mapping API로 변경할 수 있지만, index analyzer와 type도 함께 반복해서 적어야 한다.
PUT /ko-products-v1/_mapping
{
"properties": {
"title": {
"type": "text",
"analyzer": "ko_index",
"search_analyzer": "ko_search_trial"
}
}
}
ko_search_trial이 기존 index에 없는 custom analyzer라면 먼저 analyzer를 정의해야 한다. 일반 index는 close → settings 변경 → open 과정이 필요하며 이 시간에는 해당 index를 검색·색인할 수 없다. data stream의 현재 write index는 닫을 수 없으므로 template 변경과 rollover 또는 별도 시험 환경을 사용한다.
이 시험은 검색어 token만 바꾼다. 신규 사전 단어가 기존 역색인에 없는 term으로만 분석되면 결과가 줄 수 있으므로 다음 두 출력을 반드시 비교한다.
POST /ko-products-v1/_analyze
{
"analyzer": "ko_index",
"text": "도메인고유복합어",
"explain": true
}
POST /ko-products-v1/_analyze
{
"analyzer": "ko_search_trial",
"text": "도메인고유복합어",
"explain": true
}
0.9 Nori 사용자 사전·index analyzer를 기존 문서 전체에 적용할 때
기존 index를 제자리에서 완전히 바꾸지 말고 버전이 붙은 신규 index를 만든다. 다음은 products alias가 ko-products-v1을 가리키고 있고 ko-products-v2로 교체하는 예다.
ko-products-v2에 새 사용자 사전, analyzer, mapping, shard·replica 설정을 먼저 정의한다._analyze로 색인 analyzer와 검색 analyzer를 검증한다.- 재색인 중 쓰기를 처리할 방식을 선택한다.
- 짧게 쓰기를 멈추고 reindex한 뒤 전환한다.
- 애플리케이션이 v1과 v2에 dual write하되, 늦게 도착한 과거 문서가 v2의 최신 값을 덮지 않도록
op_type: create, 외부 version 또는 별도 변경 로그 정책을 함께 정한다. - DB·Kafka 같은 원천 변경 로그를 v2에 재생하고 마지막 차이를 맞춘다.
- 대규모 작업은 비동기로 reindex하고 task와 cluster 자원을 감시한다.
POST /_reindex?slices=auto&wait_for_completion=false
{
"source": {
"index": "ko-products-v1"
},
"dest": {
"index": "ko-products-v2"
}
}
반환된 task ID로 진행 상태와 failures, version_conflicts를 확인한다.
GET /_tasks/<task_id>
- 마지막 변경분을 동기화하고 v1·v2의 문서 수, 샘플
_source,_analyze, 대표 검색 결과와 성능을 비교한다. - 검증이 끝나면 하나의
_aliases요청에서 이전 index를 제거하고 새 index를 추가한다.
POST /_aliases
{
"actions": [
{ "remove": { "index": "ko-products-v1", "alias": "products" } },
{ "add": { "index": "ko-products-v2", "alias": "products", "is_write_index": true } }
]
}
- 전환 후 문제가 생기면 같은 방식으로 alias를 v1에 되돌린다. 단, 전환 뒤 v2에만 발생한 쓰기가 있다면 v1으로 먼저 되돌려 놓을 데이터가 있는지 확인해야 한다.
- rollback 보존 기간과 운영 승인이 끝난 뒤에만 이전 index를 정리한다.
주의:
_reindex는 목적지 index의 settings와 mapping을 자동 복사하지 않는다. 또한 작업 시작 뒤 발생한 쓰기와 삭제를 자동으로 계속 따라가지 않으므로, 운영 무중단 전환에는 별도의 쓰기 동기화가 필요하다. dual write와 reindex를 겹쳐 실행한다면 목적지 최신 문서의 덮어쓰기와 version conflict 처리 방식도 먼저 시험한다.
0.10 기존 field에 새 multi-field를 추가할 때
기존 keyword field 아래에 검색용 .text multi-field를 추가하는 mapping 변경은 가능하다. 그러나 변경 전에 저장된 문서는 새 sub-field의 역색인을 갖지 않는다.
PUT /ko-products-v1/_mapping
{
"properties": {
"product_name": {
"type": "keyword",
"fields": {
"text": {
"type": "text",
"analyzer": "ko_index",
"search_analyzer": "ko_search"
}
}
}
}
}
작은 index이고 in-place 부하와 rollback 제약을 감수할 수 있다면 _update_by_query로 기존 _source를 다시 색인해 sub-field를 채울 수 있다.
POST /ko-products-v1/_update_by_query?slices=auto&wait_for_completion=false&conflicts=proceed
{
"query": {
"match_all": {}
}
}
대규모 index, analyzer 변경 동반, 엄격한 rollback이 필요한 경우에는 v2 index를 만들고 reindex하는 편이 안전하다. 어느 방식을 쓰든 실행 전 디스크·CPU·쓰기 충돌을 확인하고, 완료 후 새 .text field의 검색 결과를 검증한다.
참고: Nori 사용자 사전, search analyzer 변경, analyzer 변경 제한, search analyzer reload, mapping과 multi-field 갱신, Reindex API, Aliases
1. FILTERS는 성능을 세 가지 방식으로 바꾼다
Elasticsearch는 Lucene 기반의 분산 검색·분석 엔진이다. 텍스트를 역색인으로 만들고, 쿼리 시 후보 문서를 모아 점수를 계산한 뒤 상위 결과를 돌려준다. FILTERS는 이 흐름의 서로 다른 비용을 줄인다.
첫째, 계산하지 않아도 되는 점수를 계산하지 않는다
status=published, tenant_id=42, price<=50000은 “얼마나 잘 맞는가”가 아니라 “통과하는가”만 중요하다. 이런 조건을 must의 query context가 아니라 bool.filter로 옮기면 _score 계산에서 빠진다. 필터 문맥은 숫자·날짜·boolean·keyword 같은 구조화 필드에 특히 잘 맞는다.
둘째, 반복되는 조건을 재사용할 가능성을 만든다
필터 문맥의 쿼리는 node query cache의 대상이 될 수 있다. 다만 “filter 배열에 넣으면 무조건 캐시된다”는 뜻은 아니다. Elasticsearch는 사용 이력을 보고 캐시 적격 여부를 판단한다. 현재 공식 문서 기준으로 query cache는 노드마다 하나이고 기본 상한은 heap의 10% 또는 최대 10,000개 쿼리이며, 충분히 큰 segment에서만 동작한다. term query 자체처럼 캐시 대상이 아닌 쿼리도 있다.
즉 캐시는 보너스이지 설계 근거의 전부가 아니다. filter의 첫 번째 이점은 score를 계산하지 않는다는 점이고, 캐시는 반복성과 segment 조건이 맞을 때 추가로 얻는 효과다.
셋째, 비싼 단계에 들어갈 후보를 통제한다
벡터 검색, reranking, script score 같은 단계는 후보가 많을수록 비싸다. 업무상 필수 조건을 먼저 적용하면 계산 범위를 줄일 수 있다. 하지만 approximate kNN의 HNSW 검색은 예외가 있다. 조건을 만족하는 벡터를 충분히 모으기 위해 그래프를 더 탐색할 수 있어, 강한 pre-filter가 오히려 느려질 수 있다. 그러므로 일반 검색의 “더 강한 필터 = 대체로 더 빠름”을 벡터 검색에 그대로 적용하면 안 된다.
참고: Query and filter context, Node query cache, Filtered approximate kNN
2. filter라는 이름으로 불리는 기능들을 구분하자
문서와 API에서 filter라는 단어를 만나면 먼저 무엇을 입력받고, 언제 실행되는가를 확인해야 한다.
| 종류 | 실행 시점 | 역할 |
|---|---|---|
char_filter | tokenizer 실행 전 | 원문 문자열을 치환하거나 정리한다. 아직 token은 만들어지지 않은 단계다. |
analysis token filter | tokenizer 실행 후 | 만들어진 token을 변환·제거·추가한다. analyzer의 filter 배열에 순서대로 쓴다. |
bool.filter·filter context | 일반 쿼리 실행 중 | 상태·가격·날짜·권한 조건으로 document를 통과 또는 탈락시킨다. 자체 점수는 계산하지 않는다. |
post_filter | query와 aggregation 실행 후 hit를 정리할 때 | aggregation 결과는 유지하면서 최종 hit만 좁힌다. facet 화면에서 자주 쓴다. |
kNN filter·pre-filter | 벡터 후보를 찾는 과정 | 조건을 만족하는 문서 범위 안에서 가까운 벡터를 찾는다. |
즉 이름은 비슷하지만 입력부터 다르다. char_filter의 입력은 문자열, token filter의 입력은 token 스트림, query filter의 입력은 document와 field 값이다. 전체 흐름을 그리면 다음과 같다.
flowchart TD
A[원문 또는 검색어] --> B[char filter: 문자 전처리]
B --> C[tokenizer: 토큰 분리]
C --> D[token filter 1: 정규화]
D --> E[token filter 2: 제거 또는 확장]
E --> F[색인 토큰 또는 검색 토큰]
F --> G[query context: 일치도와 점수]
H[구조화 조건] --> I[filter context: 통과 여부]
G --> J[최종 검색 결과]
I --> J
flowchart TD
A[원문 또는 검색어] --> B[char filter: 문자 전처리]
B --> C[tokenizer: 토큰 분리]
C --> D[token filter 1: 정규화]
D --> E[token filter 2: 제거 또는 확장]
E --> F[색인 토큰 또는 검색 토큰]
F --> G[query context: 일치도와 점수]
H[구조화 조건] --> I[filter context: 통과 여부]
G --> J[최종 검색 결과]
I --> J
분석기 내부에서는 배열에 적은 순서대로 token filter가 실행된다. 앞 필터의 출력이 다음 필터의 입력이므로 순서는 설정의 장식이 아니라 검색 동작 그 자체다.
3. 가장 먼저 할 최적화: scoring 조건과 filter 조건 분리
다음 쿼리는 동작할 수는 있지만, 모든 조건을 query context에 넣었다.
GET /ko-products-v1/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "노트북 파우치" } },
{ "term": { "status": "published" } },
{ "range": { "price": { "lte": 40000 } } },
{ "range": { "published_at": { "gte": "now-90d" } } }
]
}
}
}
status, price, published_at은 순위에 기여할 이유가 없다. 다음처럼 옮긴다.
GET /ko-products-v1/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "노트북 파우치" } }
],
"filter": [
{ "term": { "status": "published" } },
{ "range": { "price": { "lte": 40000 } } },
{ "range": { "published_at": { "gte": "now-90d/d" } } }
]
}
}
}
이 변경의 효과는 명확하다.
- BM25 점수는
title의 텍스트 일치도만 반영하므로 ranking의 의미가 깨끗해진다. - 구조화 조건은
_score계산에서 제외된다. - 반복되는 필터는 조건이 맞을 때 query cache의 도움을 받을 수 있다.
now-90d/d처럼 시간을 일 단위로 반올림하면 같은 날의 요청이 같은 범위를 재사용할 가능성이 커진다. 정확한 현재 시각이 필요하다면 반올림하면 안 된다.
두 가지 오해도 피해야 한다.
- JSON 배열의 앞쪽에 쓴 필터가 반드시 먼저 실행된다고 가정하지 않는다. Elasticsearch와 Lucene은 내부적으로 실행 계획을 최적화할 수 있다.
bool.filter로 옮겼다고 무조건 큰 폭으로 빨라지는 것은 아니다. 후보 문서가 매우 적거나 score 계산 비용이 작다면 차이가 작을 수 있다. 반대로 고빈도 요청, 넓은 후보 집합, 여러 구조화 조건이 겹치는 환경에서는 CPU와 tail latency 개선이 더 커질 수 있다.
참고: 검색 속도 튜닝과 날짜 반올림
4. Analyzer는 어떤 순서로 동작할까
Elasticsearch의 사용자 정의 분석기는 세 단계로 구성된다.
char_filter: 원문 문자열을 치환하거나 불필요한 문자를 정리한다.tokenizer: 문자열을 토큰으로 나눈다. 분석기마다 하나만 사용한다.filter: 토큰을 순서대로 변환·제거·추가한다.
예를 들어 "갤럭시S25 3.2천원"이 들어왔을 때 다음과 같은 설계를 할 수 있다.
입력
"갤럭시S25 3.2천원"
↓ nori_tokenizer
형태소와 숫자 관련 토큰
↓ nori_number
한국어식 숫자 표현을 아라비아 숫자로 정규화
↓ lowercase
영문 토큰이 있으면 소문자화
↓ synonym_graph (검색 시점)
등록한 동의어를 같은 위치 또는 토큰 그래프로 확장
어떤 토큰이 실제로 나오는지는 추측하지 말고 _analyze API로 확인해야 한다. 공식 문서도 분석 설정을 테스트할 때 이 API를 사용한다.
POST /_analyze
{
"tokenizer": "nori_tokenizer",
"text": "갤럭시S25 3.2천원",
"explain": true
}
참고: Analyzer의 구조, _analyze API
5. mixed는 filter가 아니라 Nori tokenizer의 옵션이다
한국어는 조사와 어미가 붙고 복합 명사가 많다. 기본 standard tokenizer만으로는 기대한 검색 품질을 만들기 어렵다. Nori는 Lucene의 한국어 형태소 분석 모듈을 Elasticsearch에서 사용할 수 있게 한 공식 플러그인이다.
Nori tokenizer의 decompound_mode는 복합어를 어떻게 처리할지 정한다.
| 모드 | 가곡역 예시 | 특징 |
|---|---|---|
none | 가곡역 | 원형만 유지한다. 정확한 복합어 검색에는 유리하지만 부분 개념 검색의 재현율이 낮을 수 있다. |
discard | 가곡, 역 | 복합어 원형을 버리고 분해된 토큰만 남긴다. |
mixed | 가곡역, 가곡, 역 | 원형과 분해형을 함께 보존한다. 재현율을 넓히기 좋지만 토큰 그래프와 구문 검색을 더 주의해야 한다. |
mixed가 흔히 출발점으로 선택되는 이유는 “스마트폰 케이스” 같은 텍스트에서 복합어 전체와 구성 단어를 함께 찾고 싶기 때문이다. 다만 토큰 수가 늘고, 원형과 분해형이 같은 위치에 쌓일 수 있다. match_phrase, 동의어, 하이라이트처럼 토큰 위치를 사용하는 기능은 반드시 실제 데이터로 검증해야 한다.
성능만 보면 mixed는 최적화 옵션이 아니다. discard보다 더 많은 토큰과 posting을 만들 수 있어 색인 CPU·index 크기·검색 시 확인할 term 수가 늘어날 수 있다. 대신 복합어 원형까지 찾는 recall을 얻는다. 따라서 전체 필드에 무조건 적용하기보다 다음처럼 선택한다.
- 복합 상품명·전문 용어가 중요하면
mixed후보 - 분해 검색만 충분하고 index 크기가 민감하면
discard후보 - 원형 일치가 중요하고 사용자 사전이 잘 관리되면
none후보 - 가장 중요한 필드는
mixed, 보조 필드는 더 단순한 analyzer를 쓰는 multi-field 설계도 후보
결정 기준은 토큰 수 자체가 아니라 index 크기·색인 처리량·p95 검색 시간·Recall@k의 동시 비교다.
POST /_analyze
{
"tokenizer": {
"type": "nori_tokenizer",
"decompound_mode": "mixed"
},
"text": "가곡역"
}
6. 한국어 검색에서 자주 쓰는 token filter
nori_part_of_speech: 필요 없는 품사를 제거한다
조사·어미·기호처럼 검색에 도움이 적은 품사를 제거할 수 있다. 단, 기본 제외 목록을 그대로 믿으면 안 된다. 기본 nori analyzer는 XPN인 접두사를 제거하므로 비급여가 급여로만 남을 수 있다. 의료·보험 검색이라면 의미가 정반대로 바뀔 수 있는 위험한 사례다.
대응 방법은 두 가지다.
비급여를 Nori 사용자 사전에 명사로 등록한다.- 커스텀
nori_part_of_speech에서XPN을 제외 대상에서 빼되, 노이즈 증가를 평가한다.
성능 관점에서는 고빈도이면서 검색 가치가 낮은 토큰을 줄이면 posting과 후보 확장을 줄이는 데 도움이 될 수 있다. 그러나 의미를 가진 토큰까지 제거하면 속도는 빨라져도 품질은 실패다. POS filter는 “많이 제거”가 아니라 “안전하게 제거”가 목표다.
nori_readingform: 한자를 한글 독음으로 바꾼다
鄕歌를 향가로 바꾼다. 한자와 한글 표기가 섞인 문서에서 유용하다.
POST /_analyze
{
"tokenizer": "nori_tokenizer",
"filter": ["nori_readingform"],
"text": "鄕歌"
}
예상 토큰은 향가다. 참고: nori_readingform
이 필터는 주로 표기 통일을 위한 품질 개선이다. 한자와 한글을 별도 동의어로 무분별하게 확장하는 대신 하나의 표현으로 정규화하면 term 종류를 줄이는 효과도 기대할 수 있다.
nori_number: 한국어 숫자 표현을 정규화한다
3.2천을 3200으로 정규화할 수 있다. 가격·수량·기간 표현이 중요한 검색에 유용하다. 이 필터는 구두점 토큰을 활용하므로 Nori tokenizer의 discard_punctuation을 false로 설정해야 한다.
POST /_analyze
{
"tokenizer": {
"type": "nori_tokenizer",
"discard_punctuation": false
},
"filter": ["nori_number"],
"text": "3.2천"
}
예상 토큰은 3200이다. 참고: nori_number
숫자 표기를 한 형태로 모으면 같은 값을 여러 문자열 term으로 다루는 일을 줄일 수 있다. 다만 숫자 범위 검색과 정렬이 핵심이라면 분석된 text 토큰에만 의존하지 말고 숫자 전용 필드를 함께 저장하는 편이 낫다.
synonym_graph: 검색어를 동의어로 확장한다
노트북, 랩톱, laptop처럼 같은 의도로 쓰는 표현을 연결한다. 여러 단어로 된 동의어의 위치 정보까지 다루므로 일반적으로 검색 시점 분석기에서 쓰는 것이 권장된다.
PUT /_synonyms/ko-commerce
{
"synonyms_set": [
{
"id": "laptop",
"synonyms": "노트북, 랩톱, laptop"
},
{
"id": "phone-case",
"synonyms": "휴대폰 케이스, 스마트폰 케이스"
},
{
"id": "typo-airpod",
"synonyms": "에어팟프로, 에어팟 프로 => 에어팟 프로"
}
]
}
- 쉼표 형식은 서로 동등한 표현을 만든다.
=>는 왼쪽을 오른쪽으로 보내는 단방향 규칙이다.- API로 관리하는 synonym set은 검색 시점에 사용한다.
- 검색 시점 동의어는 규칙을 바꿔도 재색인하지 않아도 되는 장점이 있다.
성능 측면에서 search-time synonym은 색인 폭증과 재색인 비용을 피하는 대신, 요청마다 쿼리 term과 clause를 늘릴 수 있다. 동의어 집합을 크게 만드는 것보다 실제 0건 검색과 사용자 표현 차이를 근거로 좁게 운영하고, 의미가 한쪽으로만 성립하면 =>로 확장을 제한한다. 변경 전후에는 _validate/query?rewrite=true와 대표 질의의 _rank_eval을 함께 비교한다.
참고: 동의어 구성 절차, synonym_graph
7. mixed와 synonym을 함께 쓰는 기준 설정
다음 예시는 완성된 정답이라기보다 안전하게 실험을 시작할 수 있는 기준점이다.
새 index를 처음 만들 때는 아래 설정을 그대로 검증할 수 있다. 이미 운영 중인 index에 사용자 사전이나 index analyzer까지 적용하려면 앞의 0.9절 신규 index·reindex·alias 전환 절차를 따른다.
- 색인 시점:
mixed로 복합어 원형과 분해형을 함께 저장한다. - 검색 시점:
discard로 검색어를 분해한 뒤 동의어를 적용한다. - synonym은 검색 분석기의 마지막 쪽에 둔다.
- exact filter용 값은 별도
keyword필드에 둔다.
그림 읽기 — 색인 시점에는 mixed로 복합어 원형과 분해형을 저장하고, 검색 시점에는 synonym_graph로 표현 차이를 확장해 역색인의 term과 만난다.
Nori 플러그인은 self-managed 환경이라면 모든 노드에 설치하고 노드를 재시작해야 한다. 버전은 Elasticsearch와 맞춰야 한다.
bin/elasticsearch-plugin install analysis-nori
PUT /ko-products-v1
{
"settings": {
"analysis": {
"tokenizer": {
"ko_index_tokenizer": {
"type": "nori_tokenizer",
"decompound_mode": "mixed"
},
"ko_search_tokenizer": {
"type": "nori_tokenizer",
"decompound_mode": "discard"
}
},
"filter": {
"ko_synonyms": {
"type": "synonym_graph",
"synonyms_set": "ko-commerce",
"updateable": true
}
},
"analyzer": {
"ko_index": {
"type": "custom",
"tokenizer": "ko_index_tokenizer",
"filter": [
"nori_readingform",
"lowercase"
]
},
"ko_search": {
"type": "custom",
"tokenizer": "ko_search_tokenizer",
"filter": [
"nori_readingform",
"lowercase",
"ko_synonyms"
]
}
}
}
},
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "ko_index",
"search_analyzer": "ko_search",
"fields": {
"raw": { "type": "keyword" }
}
},
"description": {
"type": "text",
"analyzer": "ko_index",
"search_analyzer": "ko_search"
},
"status": { "type": "keyword" },
"category": { "type": "keyword" },
"price": { "type": "integer" },
"published_at": { "type": "date" }
}
}
}
synonym_graph 앞의 tokenizer와 filter는 synonym 규칙 자체를 읽을 때도 영향을 준다. ngram, word_delimiter_graph, multiplexer처럼 같은 위치에 여러 토큰을 만들 수 있는 필터를 synonym 앞에 무심코 추가하면 규칙 로딩 오류나 예상 밖의 그래프가 생길 수 있다. 필터 순서를 바꿨다면 반드시 _analyze?explain=true로 다시 확인해야 한다.
8. 문서를 넣고 분석 흐름부터 확인하기
POST /_bulk?refresh=true
{"index":{"_index":"ko-products-v1","_id":"1"}}
{"title":"초경량 노트북 파우치","description":"출퇴근용 13인치 파우치","status":"published","category":"accessory","price":29000,"published_at":"2026-10-01"}
{"index":{"_index":"ko-products-v1","_id":"2"}}
{"title":"가벼운 랩톱 슬리브","description":"방수 원단 노트북 보호 케이스","status":"published","category":"accessory","price":39000,"published_at":"2026-10-10"}
{"index":{"_index":"ko-products-v1","_id":"3"}}
{"title":"업무용 데스크톱 본체","description":"사무실용 컴퓨터","status":"draft","category":"computer","price":890000,"published_at":"2026-09-20"}
색인 분석기와 검색 분석기의 출력을 각각 본다.
POST /ko-products-v1/_analyze
{
"analyzer": "ko_index",
"text": "초경량 노트북 파우치",
"explain": true
}
POST /ko-products-v1/_analyze
{
"analyzer": "ko_search",
"text": "랩톱",
"explain": true
}
여기서 확인할 것은 “내가 예상한 단어가 보이는가”뿐만이 아니다.
- 원형과 분해형의
position이 어떻게 잡혔는가 - 동의어가 어느 위치에 추가됐는가
- 필요한 접두사나 품사가 사라지지 않았는가
- 색인 토큰과 검색 토큰 사이에 실제로 교집합이 있는가
9. 검색 조건을 한 단계씩 추가해 보기
검색 장애를 줄이는 가장 좋은 습관은 처음부터 큰 쿼리를 한 번에 만들지 않는 것이다. 후보 검색부터 확인하고 조건을 하나씩 붙인다.
1단계: 텍스트만 검색한다
GET /ko-products-v1/_search
{
"track_total_hits": true,
"query": {
"match": {
"title": "노트북"
}
}
}
match는 검색어를 해당 필드의 search analyzer로 분석한다. 동의어 설정이 정상이라면 랩톱 계열 표현도 후보가 될 수 있다.
2단계: 게시 상태를 제한한다
GET /ko-products-v1/_search
{
"track_total_hits": true,
"query": {
"bool": {
"must": [
{ "match": { "title": "노트북" } }
],
"filter": [
{ "term": { "status": "published" } }
]
}
}
}
must의 match는 관련도 점수에 참여한다. filter의 term은 문서가 published인지 예/아니오만 판단하며 점수에는 참여하지 않는다.
3단계: 가격과 날짜 조건을 추가한다
GET /ko-products-v1/_search
{
"track_total_hits": true,
"query": {
"bool": {
"must": [
{
"multi_match": {
"query": "노트북 파우치",
"fields": ["title^2", "description"]
}
}
],
"filter": [
{ "term": { "status": "published", "_name": "published_only" } },
{ "term": { "category": "accessory", "_name": "accessory_only" } },
{ "range": { "price": { "lte": 40000, "_name": "price_limit" } } },
{ "range": { "published_at": { "gte": "now-90d/d", "_name": "recent_only" } } }
]
}
}
}
조건을 하나 추가할 때마다 hits.total.value가 어떻게 줄어드는지 기록하면 어떤 조건이 결과를 0건으로 만드는지 바로 찾을 수 있다. _name을 붙여 두면 응답의 matched_queries도 진단에 도움이 된다.
10. match와 term을 잘못 고르면 왜 0건이 될까
다음 쿼리는 흔한 실수다.
{
"term": {
"title": "초경량 노트북 파우치"
}
}
title은 text 필드이고, 색인할 때 이미 여러 토큰으로 분해됐다. term은 입력값을 분석하지 않고 정확히 같은 토큰을 찾는다. 따라서 문장 전체가 하나의 토큰으로 저장되지 않았다면 결과가 나오지 않는다.
- 문장·본문 같은
text필드: 보통match,multi_match,match_phrase - 상태·코드·카테고리 같은
keyword필드: 보통term,terms - 전체 제목의 정확 일치:
title.raw같은keyword멀티 필드
GET /ko-products-v1/_search
{
"query": {
"term": {
"title.raw": "초경량 노트북 파우치"
}
}
}
참고: term query는 text 필드에 사용하지 말라는 공식 안내, Query DSL 검색·필터 튜토리얼
11. token filter 순서가 결과를 바꾸는 사례
사례 A: 정규화 뒤에 synonym을 둔다
"filter": [
"nori_readingform",
"lowercase",
"ko_synonyms"
]
이 순서에서는 검색어와 synonym 규칙이 한글 독음·소문자 기준으로 맞춰진 다음 동의어가 적용된다. 반대로 synonym 뒤에 정규화 필터를 두면 규칙이 기대한 형태로 매칭되지 않을 수 있다.
사례 B: stop filter와 synonym의 앞뒤가 다르다
stop filter를 synonym 앞에 두면 synonym 규칙을 읽는 과정에서도 불용어가 제거된다. 뒤에 두면 동의어 확장 결과가 다시 제거될 수 있다. 어느 쪽이 맞는지는 규칙에 따라 다르지만, 순서를 바꾼 뒤 규칙이 조용히 달라질 수 있다는 점이 핵심이다.
사례 C: autocomplete용 n-gram은 색인 쪽에 둔다
edge_ngram은 보통 자동완성용 토큰을 색인할 때 사용하고, 검색 시점에는 사용자가 입력한 문자열을 일반 분석기로 처리한다. 양쪽 모두 n-gram을 적용하면 토큰이 과도하게 늘고 엉뚱한 결과가 나올 수 있다.
"title_autocomplete": {
"type": "text",
"analyzer": "autocomplete_index",
"search_analyzer": "ko_search"
}
12. query filter는 언제 어떻게 쓰나
bool.filter는 구조화된 데이터의 필수 조건에 잘 맞는다.
keyword: 상태, 카테고리, 사용자 등급, 테넌트 ID- 숫자: 가격, 재고, 점수 하한선
- 날짜: 게시 기간, 이벤트 시간
- boolean: 공개 여부, 삭제 여부
- geo: 서비스 가능 거리나 지역
필터 문맥에서는 관련도 점수를 계산하지 않고 자주 쓰는 필터는 캐시 후보가 될 수 있다. 반면 must와 should는 “얼마나 잘 맞는가”를 계산한다.
"bool": {
"must": [
{ "match": { "title": "검색어" } }
],
"should": [
{ "match": { "description": { "query": "검색어", "boost": 0.3 } } }
],
"filter": [
{ "term": { "status": "published" } }
],
"must_not": [
{ "term": { "blocked": true } }
]
}
post_filter는 조금 다르다. 검색 hit만 나중에 줄이고 aggregation에는 영향을 주지 않는다. 쇼핑몰에서 전체 브랜드·색상 facet 집계는 유지하면서 현재 선택한 색상의 상품만 보여 줄 때 유용하다.
13. 벡터 검색: 단어가 아니라 의미가 가까운 문서를 찾는다
키워드 검색은 환불, 교환, 반품처럼 실제 단어가 잘 맞을 때 강하다. 하지만 사용자가 “마음에 안 들면 돌려보낼 수 있나요?”라고 물었고 문서에는 “단순 변심 반품 정책”이라고만 쓰여 있다면 공통 단어가 적어 놓칠 수 있다.
벡터 검색은 문장이나 이미지를 임베딩이라는 숫자 배열로 바꾸고, 그 공간에서 가까운 항목을 찾는다. 의미가 비슷한 문장은 표현이 달라도 가까운 벡터가 되도록 학습된다.
- 유사도(similarity): 두 벡터가 얼마나 가까운지 나타내는 값이다. cosine, dot product, L2 같은 방식이 있다.
- kNN(k-nearest neighbors): 검색 벡터와 가장 가까운
k개를 찾는 방법이다. - HNSW: 모든 벡터를 전부 비교하지 않고 그래프를 따라 가까운 후보를 빠르게 찾는 대표적인 근사 검색 구조다.
- 근사 검색(approximate search): 속도를 위해 모든 후보를 완전 탐색하지 않는다. 아주 빠르지만 이론적으로 가장 가까운 문서를 일부 놓칠 수 있다.
- 재현율(recall): 찾아야 할 문서 중 실제로 얼마나 찾았는지를 뜻한다.
가장 쉬운 시작: semantic_text에 match 사용하기
semantic_text는 임베딩 생성과 저장을 Elasticsearch가 관리하도록 만든 필드 타입이다. 사용할 inference endpoint를 연결해 두면 문서를 색인할 때 임베딩을 만들고, 검색할 때는 평소와 비슷하게 match를 사용할 수 있다.
PUT /help-center
{
"mappings": {
"properties": {
"title": { "type": "text" },
"content": {
"type": "semantic_text",
"inference_id": "my-embedding-endpoint"
},
"status": { "type": "keyword" },
"language": { "type": "keyword" },
"tenant_id": { "type": "keyword" }
}
}
}
GET /help-center/_search
{
"size": 10,
"query": {
"bool": {
"must": {
"match": {
"content": "마음에 안 들면 물건을 돌려보낼 수 있나요?"
}
},
"filter": [
{ "term": { "status": "published" } },
{ "term": { "language": "ko" } },
{ "term": { "tenant_id": "shop-42" } }
]
}
}
}
현재 공식 문서 기준으로 dense embedding을 사용하는 semantic_text의 match는 부모 bool의 must, filter, must_not 조건을 벡터 검색의 pre-filter로 자동 적용한다. 즉 shop-42의 공개된 한국어 문서 안에서 의미가 가까운 10개를 찾는다.
직접 제어하는 방법: dense_vector와 knn
외부 모델에서 임베딩을 만들거나 차원 수·유사도·벡터 저장 방식을 직접 통제하려면 dense_vector 필드를 사용한다. 문서 벡터와 검색 벡터는 반드시 같은 모델과 같은 차원으로 만들어야 한다.
GET /products-vector/_search
{
"size": 10,
"query": {
"knn": {
"field": "description_vector",
"query_vector_builder": {
"text_embedding": {
"model_id": "my-embedding-model",
"model_text": "출장할 때 가볍게 들고 다닐 노트북 보호 가방"
}
},
"k": 10,
"num_candidates": 100,
"filter": [
{ "term": { "status": "published" } },
{ "term": { "category": "accessory" } },
{ "range": { "price": { "lte": 50000 } } }
]
}
}
}
k는 최종적으로 원하는 가까운 결과 수다. num_candidates는 shard마다 일단 모아 볼 후보 수다. 후보 수를 늘리면 보통 재현율은 좋아지지만 지연 시간과 CPU 비용이 늘어난다. 무조건 크게 두지 말고 대표 질의의 Recall@k와 p95 latency를 같이 비교해야 한다.
벡터 filter는 위치가 결과 개수를 바꾼다
- pre-filter: 조건을 만족하는 문서 집합 안에서 가까운 벡터를 찾는다. 요청한
k개를 채우는 데 유리하다. - post-filter: 가까운
k개를 먼저 찾고 나중에 조건을 적용한다. 충분한 문서가 있어도 최종 결과가k개보다 적을 수 있다.
knn 내부의 filter는 pre-filter다. 반면 knn 바깥 Query DSL 트리에 둔 다른 조건은 직접 knn을 사용할 때 post-filter로 작동할 수 있다. semantic_text + match의 자동 pre-filter와 직접 knn의 규칙이 다르므로 혼동하면 안 된다.
pre-filter가 언제나 더 빠른 것도 아니다. 필터를 통과하는 문서가 드물면 HNSW 그래프에서 충분한 후보를 찾기 위해 더 많은 노드를 방문할 수 있다. Elasticsearch/Lucene은 조건에 따라 brute-force 탐색으로 전환할 수도 있다. 따라서 벡터 필터는 먼저 결과 범위와 권한을 보장하는 장치로 보고, 속도 효과는 별도로 측정한다.
참고: Vector search 개념, semantic_text의 자동 pre-filter, kNN query, Filtered approximate kNN
14. 하이브리드 검색: 키워드의 정확성과 벡터의 의미를 섞는다
벡터 검색만 쓰면 의미는 비슷하지만 제품 코드, 고유명사, 숫자가 정확히 맞지 않는 결과가 위로 올라올 수 있다. 반대로 키워드 검색만 쓰면 표현이 다른 문서를 놓친다. 하이브리드 검색은 두 결과 목록을 함께 사용한다.
예를 들어 사용자가 맥북 에어 13인치 출장용 파우치를 검색한다고 하자.
- 키워드 검색은
맥북 에어,13인치가 정확히 들어간 상품에 강하다. - 벡터 검색은
가벼운 노트북 슬리브,휴대용 보호 케이스처럼 의미가 비슷한 상품을 보완한다. - filter는
판매 중,5만 원 이하,한국 배송 가능을 두 검색에 똑같이 적용한다.
그림 읽기 — 키워드와 벡터 검색은 후보를 따로 만들지만 동일한 업무 filter를 적용하며, RRF는 서로 다른 점수를 직접 더하지 않고 두 결과의 순위를 합친다.
RRF(Reciprocal Rank Fusion)는 여러 검색 결과의 원점수를 직접 더하지 않고 각 결과에서의 순위를 기준으로 합치는 방식이다. BM25 점수와 벡터 유사도는 단위와 분포가 다르므로 단순 합산보다 안전한 출발점이 된다.
GET /ko-products-v2/_search
{
"size": 10,
"retriever": {
"rrf": {
"retrievers": [
{
"standard": {
"query": {
"multi_match": {
"query": "맥북 에어 13인치 출장용 파우치",
"fields": ["title^3", "description"]
}
}
}
},
{
"knn": {
"field": "description_vector",
"query_vector_builder": {
"text_embedding": {
"model_id": "my-embedding-model",
"model_text": "맥북 에어 13인치 출장용 파우치"
}
},
"k": 50,
"num_candidates": 200
}
}
],
"filter": [
{ "term": { "status": "published" } },
{ "term": { "ships_to": "KR" } },
{ "range": { "price": { "lte": 50000 } } }
],
"rank_window_size": 100,
"rank_constant": 60
}
}
}
여기서 retriever는 “후보 문서를 어떤 방식으로 가져오고, 여러 후보 목록을 어떻게 결합할지” 표현하는 검색 구성 요소다. standard retriever는 일반 Query DSL을 실행하고, knn retriever는 벡터 후보를 찾고, 상위 rrf retriever가 두 순위를 합친다. RRF의 filter는 모든 하위 retriever에 공통 적용되므로 키워드 검색과 벡터 검색이 같은 업무 범위를 보게 된다.
하이브리드 검색을 튜닝할 때는 다음 순서가 안전하다.
- 키워드 검색과 벡터 검색을 각각 따로 평가한다.
- 두 검색에 같은 보안·테넌트·상태 filter가 적용되는지 확인한다.
- RRF로 결합한 뒤
rank_window_size를 바꿔 품질과 비용을 비교한다. - 필요할 때만 가중치, linear retriever, reranker를 검토한다.
reranking은 처음 모은 후보 일부를 더 비싼 모델이나 규칙으로 다시 정렬하는 단계다. 모든 문서에 적용하면 비싸므로 보통 1차 검색으로 후보를 줄인 뒤 상위 N개에만 적용한다.
15. 상황별로 어떤 검색을 쓰면 될까
사례 1: 상품 상태·가격·날짜를 정확히 제한한다
status, category, price, published_at처럼 정답이 명확한 값은 keyword, 숫자, 날짜 타입으로 저장하고 bool.filter에 둔다. 관련도 점수를 만들 필요가 없다.
사례 2: 한국어 상품명에서 복합어와 동의어를 찾는다
노트북파우치의 원형과 노트북, 파우치를 함께 살리고 싶다면 Nori mixed를 검토한다. 노트북, 랩톱, laptop은 search-time synonym_graph로 연결한다. 두 기능 모두 recall을 늘리므로 정확도가 떨어지지 않는지 대표 질의로 확인한다.
사례 3: 오타나 띄어쓰기 차이를 처리한다
영문 제품 코드나 짧은 영문 단어는 fuzzy가 도움이 될 수 있다. 한국어는 형태소와 자모 특성 때문에 편집 거리만으로 기대한 오타 교정이 되지 않을 수 있다. 실제 0건 로그에서 자주 나오는 오타는 동의어·사용자 사전·별도 교정 사전으로 통제하는 편이 예측 가능하다.
사례 4: 검색창 자동완성을 만든다
edge_ngram을 색인 분석기에만 적용한 별도 필드를 만든다. 사용자의 검색어에는 일반 search analyzer를 사용한다. 자동완성 필드와 본 검색 필드를 분리하면 토큰 폭증이 본문 검색에 번지는 것을 막을 수 있다.
사례 5: 쇼핑몰 facet 집계는 유지하고 상품만 좁힌다
색상 검정을 골랐을 때 상품 hit는 검정만 보여 주되 브랜드·색상 전체 집계를 유지하려면 post_filter가 맞다. 집계 자체도 검정 상품만 대상으로 해야 한다면 bool.filter를 사용한다.
사례 6: 고객센터 질문을 자연어 의미로 찾는다
“결제 취소는 언제 돈이 들어오나요?”처럼 문서 표현과 다른 질문은 semantic_text 또는 dense_vector + knn이 잘 맞는다. language, service, published를 pre-filter로 묶어 잘못된 서비스 문서가 섞이지 않게 한다.
사례 7: 상품 검색에서 정확성과 발견성을 같이 얻는다
제품명·모델명은 BM25 키워드 검색으로, 사용 목적·스타일·상황은 벡터 검색으로 찾고 RRF로 결합한다. 재고, 판매 지역, 가격은 공통 filter로 둔다. 이것이 전형적인 전자상거래 하이브리드 검색이다.
사례 8: RAG 문서 검색에 권한과 최신성을 적용한다
RAG(Retrieval-Augmented Generation)는 답변 전에 관련 문서를 검색해 언어 모델에 근거로 제공하는 방식이다. 의미 검색만 잘돼도 권한 없는 문서가 섞이면 실패다. tenant_id, acl, status, valid_from, valid_to를 pre-filter로 먼저 보장하고, 그 안에서 벡터 또는 하이브리드 검색을 수행한다.
"filter": [
{ "term": { "tenant_id": "company-a" } },
{ "terms": { "acl": ["all", "team-search"] } },
{ "term": { "status": "approved" } },
{ "range": { "valid_from": { "lte": "now" } } },
{ "range": { "valid_to": { "gt": "now" } } }
]
사례 9: 현재 보고 있는 상품과 비슷한 상품을 추천한다
현재 상품의 벡터를 lookup query vector builder로 읽어 이웃 상품을 찾을 수 있다. 자기 자신은 must_not ids filter로 제외하고, 품절·비공개 상품도 함께 제외한다.
사례 10: 옵션 배열에서 같은 옵션 행을 정확히 찾는다
[{색상: 검정, 재고: 0}, {색상: 흰색, 재고: 10}]을 일반 object 배열로 저장하면 검정과 재고 10이 잘못 한 조합처럼 매칭될 수 있다. 같은 배열 원소 안에서 조건이 성립해야 하면 nested mapping과 nested query를 사용한다.
빠른 선택표
| 사용자의 의도 | 시작할 검색 | 함께 둘 filter |
|---|---|---|
| 정확한 코드·상태 조회 | term | 테넌트, 권한, 삭제 여부 |
| 일반 한국어 문서 검색 | Nori + match | 상태, 날짜, 문서 종류 |
| 여러 필드 상품 검색 | multi_match | 카테고리, 가격, 재고 |
| 문장 의미 검색 | semantic_text 또는 knn | 언어, 서비스, 공개 상태 |
| 키워드+의미 검색 | RRF 하이브리드 | 모든 retriever의 공통 업무 조건 |
| 자동완성 | edge_ngram 전용 필드 | 노출 가능 상태 |
| facet UI | aggregation + post_filter | 화면 요구에 따라 분리 |
| RAG 근거 검색 | 벡터 또는 하이브리드 | ACL, 테넌트, 유효 기간 |
16. 조회가 안 될 때 실무에서 쓰는 진단 순서
검색 결과가 0건이면 동의어부터 의심하고 싶지만, 실제 원인은 인덱스·매핑·분석기·쿼리·필터 중 어디에나 있을 수 있다. 아래 순서대로 범위를 줄이면 빠르다.
1단계: 인덱스와 데이터가 실제로 있는가
HEAD /ko-products-v1
GET /ko-products-v1/_count
GET /ko-products-v1/_search?size=1
Kibana Discover라면 data view와 시간 범위도 함께 확인한다.
2단계: 필드명과 mapping이 맞는가
GET /ko-products-v1/_mapping/field/title
GET /ko-products-v1/_mapping/field/status
GET /ko-products-v1/_field_caps?fields=title,status,price,published_at
text라고 생각한 필드가 keyword이거나, 반대로 filter하려는 필드가 text인 경우가 많다. alias가 여러 인덱스를 가리키고 있다면 같은 필드의 타입 충돌도 확인한다.
3단계: 색인 분석 결과를 본다
POST /ko-products-v1/_analyze
{
"analyzer": "ko_index",
"text": "검색되지 않는 실제 문서 제목",
"explain": true
}
문서가 이미 색인된 뒤 index analyzer를 바꿨다면 기존 문서의 토큰은 자동으로 바뀌지 않는다. 새 인덱스를 만들고 reindex해야 한다.
4단계: 검색어 분석 결과를 본다
POST /ko-products-v1/_analyze
{
"analyzer": "ko_search",
"text": "사용자가 입력한 검색어",
"explain": true
}
색인 토큰과 검색 토큰에 교집합이 없다면 쿼리를 아무리 조정해도 찾기 어렵다. 동의어가 기대대로 추가됐는지, mixed의 위치 정보가 어떻게 생겼는지, stop/POS filter가 중요한 토큰을 지우지 않았는지 본다.
5단계: 모든 query filter를 빼고 텍스트 검색만 실행한다
텍스트만 검색하면 나오는데 필터를 붙이면 사라지는 경우, 필터를 하나씩 추가한다.
match만 실행
→ status 추가
→ category 추가
→ 날짜 추가
→ 가격 추가
→ 권한/tenant 추가
각 단계의 hits.total.value를 비교하면 범인이 되는 조건을 찾을 수 있다. 날짜는 타임존, 경계값의 gte/gt, lte/lt, now 해석을 특히 확인한다.
6단계: term과 match 사용 위치를 확인한다
term을 분석된text필드에 쓰지 않았는가match를 exact 조건에 쓰면서 원치 않는 분석이 발생하지 않았는가.keyword또는 직접 만든.raw멀티 필드가 실제 mapping에 존재하는가- 대소문자와 공백까지 정확히 같은가
7단계: 특정 문서가 왜 탈락했는지 _explain으로 본다
문서 ID를 알고 있다면 가장 직접적인 방법이다.
GET /ko-products-v1/_explain/1
{
"query": {
"bool": {
"must": [
{ "match": { "title": "노트북" } }
],
"filter": [
{ "term": { "status": "published" } }
]
}
}
}
matched: false 아래의 설명을 따라가면 텍스트가 안 맞은 것인지, 필터에서 탈락한 것인지 확인할 수 있다.
8단계: 쿼리 구조와 성능은 validate·profile로 본다
GET /ko-products-v1/_validate/query?rewrite=true
{
"query": {
"match": { "title": "노트북" }
}
}
_validate/query?rewrite=true는 쿼리가 어떤 Lucene 쿼리로 바뀌는지 확인하는 데 도움이 된다. 결과는 맞지만 느리다면 profile: true나 Kibana Search Profiler로 병목을 찾는다. Profile API는 비용이 있으므로 운영 트래픽의 기본 옵션으로 켜 두는 기능은 아니다.
9단계: 벡터 검색이라면 임베딩과 필터를 분리해 본다
벡터 검색이 0건이거나 엉뚱하다면 다음 순서로 확인한다.
- 문서 벡터와 검색 벡터가 같은 모델·같은 차원으로 만들어졌는가
- inference endpoint가 정상이고 색인 문서에 임베딩이 생성됐는가
knn을 filter 없이 실행하면 결과가 나오는가- filter를 하나씩 넣을 때 어느 조건에서 0건이 되는가
similarity하한을 너무 높게 두지 않았는가k와num_candidates가 지나치게 작지 않은가
filter를 뺐을 때만 결과가 나온다면 의미 검색 문제가 아니라 메타데이터 값이나 pre-filter 범위 문제일 가능성이 높다.
10단계: 하이브리드 검색은 두 검색을 따로 실행한다
RRF 결과가 이상하면 키워드 retriever와 kNN retriever를 각각 단독 실행해 상위 결과를 비교한다. 한쪽 검색이 이미 나쁘면 결합 방식만 조정해 해결하기 어렵다. 두 결과가 모두 괜찮은데 결합 후 순위가 이상하면 공통 filter, rank_window_size, 각 retriever의 후보 수를 확인한다.
17. 운영에서 자주 만나는 함정
“동의어를 추가했는데 기존 검색이 망가졌다”
동의어는 단순 사전이 아니라 쿼리를 확장하는 규칙이다. 너무 넓은 양방향 규칙은 정밀도를 떨어뜨린다. 예를 들어 AI를 모든 의미의 “인공지능”과 무조건 양방향으로 묶으면 제품명이나 다른 약어까지 섞일 수 있다. 의미가 한쪽으로만 성립한다면 =>를 사용한다.
“synonym set을 만들기 전에 인덱스부터 만들었다”
존재하지 않는 synonyms_set을 참조해 인덱스를 만들면 인덱스가 정상 동작하지 않는 상태가 될 수 있다. synonym set을 먼저 만들고 인덱스를 만든다.
“필터를 추가했는데 결과가 너무 줄었다”
bool.filter 배열의 각 항목은 기본적으로 AND다. 카테고리 A 또는 B를 원했다면 두 개의 term filter가 아니라 terms 또는 내부 bool.should를 사용해야 한다.
"filter": [
{
"terms": {
"category": ["accessory", "computer"]
}
}
]
“배열 안 객체의 서로 다른 값이 한 객체처럼 매칭된다”
일반 object 배열은 내부적으로 평탄화된다. 같은 배열 원소 안에서 조건이 함께 성립해야 한다면 mapping을 nested로 만들고 nested query를 사용한다.
18. 추천하는 적용 순서
처음부터 Nori, 동의어, 자동완성, 벡터 검색을 모두 넣지 않는다. 다음 순서가 문제를 분리하기 쉽다.
아래는 검색 기능을 설계하는 순서다. 운영 중인 index를 실제로 교체하는 배포 순서는 앞의 0장 운영 변경 절차와 함께 사용한다.
- 실제 검색 시나리오 30~100개와 기대 문서를 먼저 만든다.
- mapping에서
text와keyword를 분리한다. - Nori 기본 분석 결과를
_analyze로 확인한다. none·discard·mixed를 실제 복합어로 비교한다.- 사용자 사전으로 도메인 용어의 분해를 통제한다.
- 필요한 POS·한자·숫자 token filter만 하나씩 추가한다.
- 동의어는 검색 시점에 넣고 단방향·양방향을 구분한다.
match검색이 안정된 뒤bool.filter를 하나씩 추가한다.- 자동완성은 별도 필드와 별도 analyzer로 분리한다.
- 마지막에 하이브리드·벡터 검색을 붙이고 같은 필터 범위를 유지한다.
각 단계에서 최소한 다음 세 가지를 저장해 두면 회귀 테스트가 된다.
_analyze의 색인 토큰_analyze의 검색 토큰- 대표 검색어별 상위 결과와 순위
19. 그래서 성능이 얼마나 좋아졌는지는 이렇게 측정한다
필터 최적화의 효과를 단일 요청의 took 한 번으로 판단하면 안 된다. JVM warm-up, filesystem cache, query cache, shard 배치, 동시 요청에 따라 값이 크게 흔들린다. 동일한 데이터·하드웨어·부하에서 한 변수만 바꿔 비교한다.
비교할 실험군
| 실험 | 바꾸는 것 | 확인할 질문 |
|---|---|---|
| A | 구조화 조건을 모두 must에 둠 | 기준 latency와 CPU는 얼마인가 |
| B | 구조화 조건을 bool.filter로 이동 | score 계산 분리 효과가 있는가 |
| C | 반복 날짜 범위를 업무 허용 범위에서 반올림 | cache hit와 p95가 개선되는가 |
| D | Nori discard와 mixed 비교 | recall 이득이 색인 크기·latency 비용보다 큰가 |
| E | synonym 없음/있음 비교 | 0건 검색과 품질이 얼마나 개선되고 query 비용은 얼마나 늘었는가 |
| F | kNN post-filter와 pre-filter 비교 | k개 결과 보장, recall, latency가 어떻게 달라지는가 |
| G | 키워드·벡터 단독과 RRF 비교 | 하이브리드가 품질을 높이는 만큼 latency 비용이 타당한가 |
성능 지표
- 응답 시간: 평균보다
p50,p95,p99 - 처리량: 초당 요청 수와 동시 사용자 수
- 자원: data node의 CPU, heap, GC, disk I/O
- cache: query cache의 hit, miss, eviction
- 검색 범위: shard 수, segment 수, 후보 문서 수
- 색인 비용: 초당 색인량, index 크기, refresh 영향
query cache 통계는 다음처럼 확인할 수 있다.
GET /_nodes/stats/indices/query_cache
품질 지표
속도만 빨라지고 필요한 문서를 놓치면 개선이 아니다.
- 대표 검색어의 0건 비율
- Precision@k, Recall@k
- MRR 또는 NDCG@k
- 검색어별 기대 문서의 순위
mixed, POS filter, synonym 변경 전후의 회귀 사례
Elasticsearch의 _rank_eval API로 사람이 평가한 대표 질의 집합의 precision, MRR, DCG 계열 지표를 계산할 수 있다.
POST /ko-products-v1/_rank_eval
{
"metric": {
"reciprocal_rank": {
"k": 10
}
},
"requests": [
{
"id": "laptop-pouch",
"request": {
"query": {
"match": { "title": "노트북 파우치" }
}
},
"ratings": [
{ "_index": "ko-products-v1", "_id": "1", "rating": 1 }
]
}
]
}
profile: true는 어느 query component가 상대적으로 비싼지 찾는 도구다. 계측 오버헤드가 크고 네트워크·큐·coordinating node의 merge 시간 등을 모두 포함하지 않으므로 실제 latency 수치로 사용하지 않는다. 반복 가능한 부하 시험은 운영 클러스터가 아닌 동일 조건의 테스트 환경에서 ES Rally 같은 벤치마크 도구로 수행하는 편이 안전하다.
참고: Profile API의 오버헤드와 한계, Ranking evaluation, 검색 성능 진단과 ES Rally
마무리
Elasticsearch에서 FILTERS로 얻는 가장 확실한 성능 개선은 점수가 필요 없는 조건을 bool.filter로 분리해 계산을 없애는 것이다. 반복성이 높은 조건은 cache의 도움까지 받을 수 있다. 한국어 token filter는 주로 품질을 개선하지만, 불필요한 토큰을 줄이면 색인과 검색 비용에도 영향을 준다. 반대로 mixed와 synonym은 recall을 높이는 대가로 토큰·쿼리 복잡도를 늘릴 수 있다. 벡터 검색의 pre-filter는 결과 범위를 보장하지만 HNSW에서는 항상 더 빠르지는 않다.
결국 FILTERS 최적화는 “filter를 많이 넣는 기술”이 아니다. scoring이 필요한 조건, 통과 여부만 필요한 조건, 토큰 품질을 바꾸는 규칙을 분리하고 각각의 비용과 품질을 함께 측정하는 기술이다.
가장 실용적인 습관은 단순하다.
설정을 믿지 말고 토큰을 보고, 큰 쿼리를 믿지 말고 조건을 하나씩 추가하자.
참고 자료
- Elastic Docs: Text analysis components
- Elastic Docs: Korean Nori analysis plugin
- Elastic Docs: nori_tokenizer
- Elastic Docs: Configure synonyms
- Elastic Docs: Boolean query
- Elastic Docs: Vector search
- Elastic Docs: kNN search
- Elastic Docs: Hybrid search
- Elastic Docs: RRF retriever
- Elastic Docs: Troubleshoot searches
'관심있는 주제 > 탐구 노트' 카테고리의 다른 글
| Codex 쓰다가 PC가 멈췄다: 범인은 2,172개의 Git 프로세스 (1) | 2026.10.20 |
|---|---|
| AI 에이전트에게 신분증과 격리실이 필요한 이유: MCP 보안 오픈소스 5종 지도 (0) | 2026.10.19 |
| Jev에서 Microsoft Decision-1까지: 한 달 만에 시작된 판단 모델 경쟁 (0) | 2026.10.12 |
| MinIO 오픈소스 EOL 이후, 개발자들은 어떻게 움직였나 (0) | 2026.10.09 |
| EmbeddingGemma 2와 LAYA의 Retrieve–Rerank 구조를 직접 실험해봤다 (0) | 2026.10.07 |