OpenSearch에서 검색 쿼리의 생애
Source: Dev.to
OpenSearch에서 검색 쿼리가 살아나는 과정
검색 엔진을 사용할 때, “검색어를 입력하면 결과가 바로 나온다”는 느낌을 받지만, 그 뒤에는 수많은 단계가 존재합니다. 이번 포스트에서는 OpenSearch가 들어온 검색 쿼리를 어떻게 처리하고, 최종 결과를 반환하기까지 어떤 과정을 거치는지 단계별로 살펴보겠습니다.
1️⃣ 요청 수신 (Request Reception)
클라이언트가 GET /my-index/_search 와 같은 HTTP 요청을 보내면, OpenSearch는 먼저 REST 레이어에서 이 요청을 받아 파싱합니다. 여기서는 헤더, 파라미터, 바디(JSON) 등을 검증하고, 인증/인가 절차를 수행합니다.
GET /my-index/_search
{
"query": {
"match": {
"title": "open source search"
}
}
}
2️⃣ 쿼리 DSL 파싱 (Query DSL Parsing)
전송된 JSON 바디는 쿼리 DSL(Domain Specific Language) 파서에 의해 내부 객체 모델로 변환됩니다. 이 단계에서 OpenSearch는 다음을 수행합니다.
- JSON 구조 검증
- 지원되는 쿼리 타입(
match,term,bool등) 확인 - 각 필드와 연산자에 대한 매핑 수행
파싱이 성공하면, SearchRequest 객체가 생성됩니다.
3️⃣ 분석기 적용 (Analysis)
검색어가 텍스트 필드에 매핑될 경우, 분석기(Analyzer) 가 적용됩니다. 기본적으로 다음과 같은 파이프라인이 실행됩니다.
- Tokenizer – 문자열을 토큰(단어)으로 분리
- Lowercase Filter – 토큰을 소문자로 변환
- Stopword Filter – 불용어 제거
- Stemmer – 어간 추출 (예:
searching→search)
{
"analyzer": "standard"
}
분석 결과는 TokenStream 형태로 저장되며, 이후 검색 단계에서 사용됩니다.
4️⃣ 쿼리 재작성 (Query Rewrite)
일부 쿼리는 실행 전에 재작성 과정을 거칩니다. 예를 들어 wildcard 혹은 prefix 쿼리는 내부적으로 Automaton 으로 변환되어 효율적인 매칭이 가능하도록 최적화됩니다.
{
"wildcard": {
"title": "op*"
}
}
재작성 단계에서는 캐시 가능한 서브쿼리를 미리 계산해 두어, 동일한 쿼리 재사용 시 성능을 향상시킵니다.
5️⃣ 검색 실행 (Search Execution)
5.1 샤드 라우팅 (Shard Routing)
OpenSearch 클러스터는 인덱스를 여러 프라이머리 샤드와 레플리카 샤드로 분산합니다. SearchRequest는 해당 인덱스의 샤드 매핑 정보를 기반으로 샤드 라우팅을 수행하고, 각 샤드에 검색 작업을 전파합니다.
5.2 인덱스 조회 (Index Lookup)
각 샤드에서는 역인덱스(inverted index)를 사용해 토큰 → 문서 ID 매핑을 빠르게 찾습니다. 이 단계에서 필터링(예: range, exists)이 먼저 적용되어 후보 문서 집합을 축소합니다.
5.3 스코어 계산 (Scoring)
match 쿼리와 같은 텍스트 기반 쿼리는 BM25 혹은 TF‑IDF와 같은 스코어링 알고리즘을 적용합니다. 스코어는 다음과 같이 계산됩니다.
score = (tf * idf) * boost
tf: Term Frequency (문서 내 토큰 빈도)idf: Inverse Document Frequency (전체 컬렉션 대비 희소성)boost: 사용자 정의 가중치
5.4 정렬 및 페이징 (Sorting & Pagination)
스코어가 계산된 뒤, OpenSearch는 정렬(스코어 내림차순, 혹은 지정된 필드)과 페이징(from, size)을 적용합니다. 이때 search_after 혹은 scroll API를 사용하면 대용량 결과를 효율적으로 페이지네이션할 수 있습니다.
6️⃣ 결과 집계 (Aggregations)
검색과 동시에 집계(aggregation) 를 요청한 경우, 각 샤드는 로컬 집계 결과를 계산하고, 최종 노드에서 합산합니다. 대표적인 집계 종류는 다음과 같습니다.
terms: 특정 필드의 값별 카운트date_histogram: 날짜 기반 구간별 집계avg,sum,min,max: 수치형 필드 통계
{
"aggs": {
"popular_tags": {
"terms": {
"field": "tags.keyword"
}
}
}
}
7️⃣ 응답 직렬화 (Response Serialization)
모든 샤드에서 수집된 결과와 집계 데이터는 SearchResponse 객체에 합쳐집니다. 최종 단계에서는 이를 JSON 형태로 직렬화해 클라이언트에 반환합니다.
{
"took": 12,
"timed_out": false,
"_shards": {
"total": 5,
"successful": 5,
"skipped": 0,
"failed": 0
},
"hits": {
"total": {
"value": 42,
"relation": "eq"
},
"max_score": 1.732,
"hits": [
{
"_index": "my-index",
"_id": "1",
"_score": 1.732,
"_source": {
"title": "OpenSearch: The Open Source Search Engine"
}
}
// ...
]
},
"aggregations": {
"popular_tags": {
"buckets": [
{ "key": "search", "doc_count": 15 },
{ "key": "open-source", "doc_count": 9 }
]
}
}
}
8️⃣ 캐시와 최적화 (Caching & Optimizations)
- 쿼리 캐시: 동일한 쿼리와 동일한 파라미터가 반복될 경우, 결과를 메모리 캐시에 저장해 재사용합니다.
- 필터 캐시:
bool쿼리의filter절은 별도로 캐시되어, 필터링 비용을 크게 줄입니다. - 리프레시 정책: 인덱스가 새 문서를 받아들일 때, segment merge와 refresh가 발생해 검색 가능 상태가 업데이트됩니다.
📌 정리
OpenSearch에서 검색 쿼리가 살아나는 과정은 크게 요청 수신 → DSL 파싱 → 분석 → 재작성 → 샤드 라우팅 → 인덱스 조회 → 스코어링 → 정렬/페이징 → 집계 → 응답 직렬화 로 이루어집니다. 각 단계마다 다양한 최적화와 캐시 메커니즘이 작동해, 대규모 데이터에서도 빠른 검색 경험을 제공합니다.
다음 번에 검색 쿼리를 튜닝하거나 새로운 분석기를 도입할 때, 위 흐름을 떠올리면 어느 부분을 개선해야 할지 한눈에 파악할 수 있을 것입니다. 🚀
개요
검색 요청은 일반적으로 다음과 같습니다:
GET /my-index/_search
Content-Type: application/json
{
"query": { "match": { "title": "opensearch" } },
"size": 10,
"from": 0,
"_source": true
}
클라이언트는 curl부터 Python SDK까지 무엇이든 될 수 있으며, 전송 형식은 항상 HTTP를 통한 JSON입니다.
HTTP 요청 및 코디네이팅 노드
- HTTP 서버 – OpenSearch는 요청을 파싱하는 경량 HTTP 서버를 실행합니다.
- 코디네이팅 노드 – 요청을 받은 노드가 코디네이팅 노드가 됩니다. 클러스터 내의 어떤 노드든 코디네이터 역할을 할 수 있으며, 데이터 저장이 필요하지 않습니다.
샤딩 및 라우팅
- 데이터는 샤드에 저장됩니다 – 클러스터에 분산된 Lucene 인덱스입니다.
- 각 문서는 라우팅 값(기본값: 문서
_id)에 따라 프라이머리 샤드에 할당됩니다. 라우팅 공식은 다음과 같습니다:
hash(routing) % number_of_primary_shards
- 코디네이팅 노드는 이 해시 함수를 실행하여 어떤 프라이머리 샤드가 쿼리를 담당할지 결정합니다.
- 단일 인덱스에 대한 간단한 용어 쿼리의 경우, 코디네이터는 해당 인덱스의 모든 프라이머리 샤드에 연락해야 할 수 있습니다. 특정 라우팅 값을 제공하면 샤드 집합을 크게 줄이고 지연 시간을 개선할 수 있습니다.
Query Execution on Shards
Once the responsible shards are known, the coordinator forwards the query to the shard nodes (which may be the same physical node or a different one). Each shard executes the query locally against its Lucene segments.
Segment Search
- Lucene stores data in immutable segments. During the query phase, each segment is searched independently.
- OpenSearch can search segments in parallel within a shard – a feature called concurrent segment search (introduced in version 3.0). The engine automatically decides how many slices to create based on CPU cores and segment size.
Scoring (BM25)
- For each matching document, Lucene computes a relevance score using the BM25 algorithm.
- Key parameters: term frequency, inverse document frequency, and the length‑normalisation factor
b(default 0.75). - Each shard returns the top‑k (default 10) documents together with their scores.
페치 단계
쿼리 단계는 문서 ID와 점수만 반환합니다. 클라이언트가 _source 필드를 요청한 경우(대부분이 그렇듯), fetch phase 라는 두 번째 라운드가 실행됩니다:
- 코디네이팅 노드가 각 샤드에 선택된 문서들의 전체 소스를 요청합니다.
- 샤드는 Lucene의 저장된 필드에서 저장된
_source를 가져와 반환합니다.
fetch phase는 네트워크를 통해 더 큰 페이로드를 전송할 수 있기 때문에, OpenSearch는 가져오는 문서 수를 최소화하려고 합니다. 페이지네이션(from/size) 및 stored_fields 필터는 중요한 성능 조정 옵션입니다.
결과 병합
각 샤드에서 top‑k 결과를 받은 후, 조정 노드는:
- 병합하여 단일 순위 목록으로 만든다.
- 전역
size및from매개변수를 다시 적용한다. - 각 샤드가 반환한 BM25 점수를 기준으로 결합된 집합을 정렬한다.
- 쿼리에서 지정한 모든 사용자 정의 정렬 규칙을 적용한다.
최종 병합된 목록은 JSON 응답으로 포맷되어 클라이언트에 반환된다.
실시간에 가까운 인덱싱
- 새 문서는 먼저 인‑메모리 버퍼에 기록되고 내구성을 위해 translog에 추가됩니다.
- 매초(기본
index.refresh_interval), 버퍼가 새로운 Lucene 세그먼트로 플러시되어 방금 인덱싱된 문서를 검색 가능하게 합니다. - 이로 인해 인덱싱과 검색 결과에 표시되는 사이에 일반적으로 < 1초 지연이 발생합니다.
공통 문제 및 해결 방안
| 문제 | 발생 원인 | 해결 방안 |
|---|---|---|
| Slow query latency | Too many shards queried, high segment count | Use routing, configure index.routing_partition_size, force‑merge to reduce segments |
| High CPU usage | Concurrent segment search on large shards | Tune search.max_concurrent_shard_requests and search.max_concurrent_segments |
| Stale results | Refresh interval too large for real‑time needs | Reduce index.refresh_interval on hot indices |
| Large payloads | Fetching full _source for many docs | Use stored_fields or docvalue_fields, limit size |
결론
OpenSearch에서 검색 쿼리는 단순한 HTTP 호출 그 이상입니다. 라우팅, 병렬 샤드 실행, 스코어링, 선택적 페칭, 그리고 모든 것을 하나로 연결하는 최종 병합 단계가 포함됩니다. 각 단계에 대한 이해는 더 나은 스키마 설계, 성능 튜닝, 불필요한 샤드 스캔이나 과도한 리프레시 간격과 같은 일반적인 함정을 피하는 데 도움이 됩니다. 쿼리의 흐름을 시각화함으로써 지연 문제를 진단하고, 올바른 인덱싱 전략을 선택하며, OpenSearch의 강력한 플러그인 및 분석 생태계를 최대한 활용할 수 있는 자신감을 얻을 수 있습니다.