7장. Query DSL — 검색을 말하는 언어
SQL이 "어떤 행이 조건에 맞는가"를 묻는다면, Query DSL은 "어떤 문서가 얼마나 잘 맞는가"까지 묻습니다. 점수, 컨텍스트, bool 조합 — 검색 특유의 정신 모델을 그림으로 익힙니다.
① Query context vs Filter context — 점수와 캐시의 갈림길 ② term vs match, 그리고 match 쿼리가 매칭되는 실제 과정 ③ bool 쿼리의 must/filter/should/must_not 조합 ④ BM25 점수와 깊은 페이지네이션(PIT + search_after)
7.1 Query Context vs Filter Context — 가장 근본적인 구분
같은 조건이라도 어느 컨텍스트에 넣느냐에 따라 동작이 완전히 달라집니다. 이 구분 하나가 ES 쿼리 운영의 80%를 좌우합니다.
| Query Context | Filter Context | |
|---|---|---|
| 점수 계산 | O — _score 산출 (BM25) | X — 매칭 여부만 (예/아니오) |
| 캐시 | 캐시 안 됨 (매번 계산) | node-level filter cache 적용 |
| 사용 위치 | query, bool.must, bool.should | bool.filter, bool.must_not, constant_score.filter |
| 용도 | "얼마나 잘 맞는가" | "조건에 맞는가" |
점수에 영향을 주지 않아야 하는 모든 조건은 filter에 넣어라. must에 term 조건을 쌓으면 점수도 왜곡되고 캐시 혜택도 못 받습니다.
7.2 term vs match — 그리고 match가 매칭되는 실제 과정
초보자가 가장 많이 헤매는 지점입니다. term은 분석하지 않고 입력 그대로 역색인을 찾습니다. text 필드의 역색인에는 이미 토큰화·소문자화된 값이 들어 있으므로, text 필드에 term을 쓰면 거의 항상 매칭에 실패합니다.
// ❌ name이 text 필드면 거의 매칭 안 됨 (역색인엔 "quick","brown","fox"가 있음)
{ "term": { "name": "Quick Brown Fox" } }
// ✅ 정확 일치가 목적이면 keyword 서브필드로
{ "term": { "name.keyword": "Quick Brown Fox" } }
// ✅ 전문 검색이 목적이면 match로 (검색어도 분석됨)
{ "match": { "name": "Quick Brown Fox" } }
그렇다면 match 쿼리는 내부에서 정확히 무슨 일을 할까요? 6장의 analyzer가 여기서 다시 등장합니다.
사용자가 match: "Quick Foxes" 쿼리를 보냅니다. 이 문자열은 아직 "사람의 언어"입니다.
검색어가 색인 때와 같은 analyzer를 통과해 [quick, foxes] 토큰이 됩니다. 색인과 검색이 같은 언어로 변환되는 순간 — 6장이 여기서 힘을 발휘합니다.
각 토큰으로 역색인의 posting list를 조회합니다. quick → [doc1, doc3], foxes → [doc1, doc2].
후보 문서마다 BM25 점수를 계산합니다. 두 토큰을 모두 가진 doc1이 가장 높은 점수를 받습니다. match의 기본 연산자는 OR이라 한 토큰만 맞아도 후보에 포함됩니다.
_score 내림차순으로 정렬된 결과가 반환됩니다. "모든 토큰 필수"로 바꾸려면 "operator": "AND"를 지정하세요.
자주 쓰는 leaf 쿼리를 정리하면:
| 분류 | 쿼리 | 특징 |
|---|---|---|
| Term-level (분석 X) | term / terms | 정확 일치 / OR 목록. keyword·숫자·bool 필드용 |
range | 범위 (gte, lte) | |
exists, prefix, wildcard | 존재/접두사/와일드카드 — wildcard·regexp는 느림 | |
| Full-text (분석 O) | match | 기본 OR. operator: AND 가능 |
match_phrase | 구문 매칭 (토큰 순서·인접성 요구) | |
multi_match | 여러 필드 동시 검색. fields: ["title^3", "body"]로 boost |
7.3 bool 쿼리 — 조건을 조합하는 4개의 방
실전 쿼리의 대부분은 bool로 조립됩니다. 4개의 절이 각각 다른 역할을 합니다.
| 절 | 점수 | 매칭 요건 | 용도 |
|---|---|---|---|
must | O | 모두 매칭 | 필수 조건 + 점수 계산 |
should | O | 기본 0개 이상 (minimum_should_match로 강제) | 선택 조건, 점수 가산 |
filter | X | 모두 매칭 | 필터 (점수 X, 캐시 O) |
must_not | X | 하나도 매칭 안 됨 | 제외 |
5개의 후보 문서가 bool 쿼리에 들어옵니다. 각 절이 순서대로 "관문" 역할을 한다고 상상해 보세요.
filter와 must_not이 먼저 예/아니오 판정을 합니다. 재고 없는 doc2, 단종된 doc4가 탈락 — 점수 계산 없이 비트셋으로 처리되고 캐시되므로 매우 쌉니다.
must는 필수 매칭 + 점수 계산. 이름에 "무선 노트북"이 매칭된 문서들이 BM25 점수를 받습니다.
should는 보너스 점수. 삼성 브랜드인 doc5가 가산점을 받아 역전합니다. should는 매칭 안 돼도 탈락시키지 않습니다(minimum_should_match로 강제하지 않는 한).
최종 순위는 점수 순: doc5 → doc1 → doc3. "필수는 must, 예/아니오는 filter, 가산점은 should, 제외는 must_not" — 이 배치가 bool 설계의 전부입니다.
GET /products/_search
{
"query": {
"bool": {
"must": [ { "match": { "name": "무선 노트북" } } ],
"should": [ { "match": { "tags": "프리미엄" } },
{ "match": { "brand": "삼성" } } ],
"filter": [ { "term": { "category": "laptop" } },
{ "range": { "price": { "lte": 2000000 } } },
{ "term": { "in_stock": true } } ],
"must_not": [ { "term": { "discontinued": true } } ],
"minimum_should_match": 1
}
}
}
그 밖의 컴파운드 쿼리: dis_max(여러 쿼리 중 최고 점수 채택), function_score(인기도 가산·최신성 감쇠 등 점수 커스터마이징), boosting(제외 대신 점수 감점), constant_score(filter를 점수 1.0으로 고정).
7.4 점수의 정체 — BM25
ES 5.x부터 기본 점수 알고리즘은 BM25(Okapi BM25)입니다. 직관만 잡으면 충분합니다.
- TF (term frequency) — 문서 안에 토큰이 많이 나올수록 점수↑. 단, 무한히 오르지 않고 포화됩니다 (
k1, 기본 1.2가 포화 정도 조절) - IDF (inverse document frequency) — 전체 문서에서 희귀한 토큰일수록 점수↑. "the"보다 "elasticsearch"가 강력
- 문서 길이 보정 — 평균보다 긴 문서는 점수를 깎아 공평하게 (
b, 기본 0.75가 강도 조절)
// 점수가 이상할 때 첫 번째 디버깅 도구: 점수 계산 내역을 트리로 출력
GET /products/_search
{
"explain": true,
"query": { "match": { "name": "노트북" } }
}
① 상위 결과가 짧은 스팸성 문서 — 길이 정규화가 짧은 문서를 선호하기 때문. function_score로 보정. ② 오타·고유명사가 IDF로 과대평가. ③ "filter에 넣었는데 점수가 변해요" — filter는 점수 0이 맞고, 함께 쓴 should가 점수에 들어간 경우가 대부분입니다.
7.5 페이지네이션 — from/size의 한계와 search_after
{ "from": 20, "size": 10 } 방식은 얕은 페이지에서는 문제없지만, from + size가 index.max_result_window(기본 10,000)를 넘으면 거부됩니다. 왜 이런 안전장치가 있을까요?
1,001번째 페이지 요청 from=10000, size=10이 coordinating node에 도착해 3개 샤드로 흩어집니다.
각 샤드는 자기 몫의 상위 문서만으로는 전역 순위를 알 수 없으므로 상위 10,010개 전부를 정렬해 coordinating node로 보냅니다.
coordinating node는 30,030개를 메모리에 올려 재정렬한 뒤 겨우 10개를 반환합니다. 깊이가 깊어질수록 메모리가 폭발 — 그래서 10,000 한계가 기본으로 걸려 있습니다.
search_after는 "직전 페이지 마지막 정렬키 이후부터 10개"만 요청하므로 각 샤드가 10개씩, 총 30개만 이동합니다. 페이지 깊이와 무관하게 비용이 일정합니다.
8.x 표준 패턴은 PIT(point-in-time) + search_after입니다. PIT는 검색 시작 시점의 인덱스 스냅샷을 고정해, 페이지를 넘기는 동안 문서가 추가·삭제되어도 결과가 일관되게 유지되도록 합니다. (scroll API는 deprecated 추세 — 새 코드에서는 쓰지 마세요.)
// 1) PIT 열기
POST /products/_pit?keep_alive=1m
// 2) 첫 페이지 — tiebreaker로 _shard_doc 권장(8.x)
POST /_search
{
"size": 100,
"pit": { "id": "46To...", "keep_alive": "1m" },
"sort": [ { "created_at": "desc" }, { "_shard_doc": "asc" } ]
}
// 응답 마지막 hit의 sort 값: [1714512345000, 42]
// 3) 다음 페이지
POST /_search
{
"size": 100,
"pit": { "id": "46To...", "keep_alive": "1m" },
"sort": [ { "created_at": "desc" }, { "_shard_doc": "asc" } ],
"search_after": [1714512345000, 42]
}
// 4) 끝나면 PIT 닫기
DELETE /_pit
{ "id": "46To..." }
7.6 운영 팁 모음
🚦 track_total_hits
ES는 기본적으로 총 일치 수를 10,000까지만 셉니다. 총 개수 표시가 필요 없으면 track_total_hits: false로 더 빠르게, 정확한 수가 꼭 필요할 때만 true로.
🛡️ 검색창엔 simple_query_string
query_string은 Lucene 문법 오류 시 쿼리 전체가 실패합니다. 사용자 입력에는 잘못된 문법을 조용히 무시하는 simple_query_string을 쓰세요.
🐢 wildcard·regexp는 비싸다
특히 leading wildcard(*foo)는 사실상 풀스캔. 자동완성이 목적이면 6장의 edge_ngram이나 completion suggester로.
🔬 profile API
"profile": true를 붙이면 샤드별 Lucene 단계별 소요 시간이 응답에 포함됩니다. "왜 느리지?"의 첫 답.
8.16 GA된 retriever 프레임워크로 BM25와 kNN(벡터 검색)을 RRF(Reciprocal Rank Fusion)로 결합하는 것이 hybrid search 표준입니다. 9.x의 semantic_text 필드는 임베딩 생성·검색까지 추상화해 줍니다. 원문 7.8절 참고.
✍️ 이해도 체크
name에 { "term": { "name": "Quick Brown Fox" } } 쿼리가 매칭되지 않는 이유는?이 장의 원문 문서: chapters/ch07_query_dsl.md — function_score 상세, multi_match type 옵션, highlight/suggester, retriever·RRF·semantic_text까지 담겨 있습니다.