DB Study

7장. Query DSL — 검색을 말하는 언어

SQL이 "어떤 행이 조건에 맞는가"를 묻는다면, Query DSL은 "어떤 문서가 얼마나 잘 맞는가"까지 묻습니다. 점수, 컨텍스트, bool 조합 — 검색 특유의 정신 모델을 그림으로 익힙니다.

중급 ⏱ 약 30분 🎬 애니메이션 3개 선수 지식: 6장 Analyzer
🧭
이 장에서 배우는 것

① 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 ContextFilter Context
점수 계산O — _score 산출 (BM25)X — 매칭 여부만 (예/아니오)
캐시캐시 안 됨 (매번 계산)node-level filter cache 적용
사용 위치query, bool.must, bool.shouldbool.filter, bool.must_not, constant_score.filter
용도"얼마나 잘 맞는가""조건에 맞는가"
💡
운영 원칙 한 줄

점수에 영향을 주지 않아야 하는 모든 조건은 filter에 넣어라. mustterm 조건을 쌓으면 점수도 왜곡되고 캐시 혜택도 못 받습니다.

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 📖 역색인 brown → [doc1, doc3] quick → [doc1, doc3] foxes → [doc1, doc2] 토큰별 posting list 조회 🧮 BM25 스코어링 doc1: quick+foxes 둘 다 → _score 3.9 doc3: quick만 → _score 1.7 doc2: foxes만 → _score 1.4 결과: doc1 → doc3 → doc2 _score 내림차순 정렬 (기본 OR — 토큰 1개만 맞아도 후보)

자주 쓰는 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개의 절이 각각 다른 역할을 합니다.

점수매칭 요건용도
mustO모두 매칭필수 조건 + 점수 계산
shouldO기본 0개 이상 (minimum_should_match로 강제)선택 조건, 점수 가산
filterX모두 매칭필터 (점수 X, 캐시 O)
must_notX하나도 매칭 안 됨제외
후보 문서 (노트북 검색) doc1 재고O doc2 재고X doc3 재고O doc4 단종품 doc5 재고O 삼성 filter: in_stock=true · price≤200만 점수 X · 비트셋 캐시 O must_not: discontinued=true 매칭되면 제외 · 점수 X doc2(재고X) 탈락 doc4(단종) 탈락 must: match(name, "무선 노트북") 필수 매칭 + BM25 점수 doc1 _score 2.1 · doc3 _score 1.6 · doc5 _score 1.9 should: brand=삼성 · tags=프리미엄 맞으면 점수 가산 (없어도 통과) doc5: 삼성 매칭 → _score 1.9 + 0.8 = 2.7 ⤴ 최종: doc5(2.7) → doc1(2.1) → doc3(1.6)
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)입니다. 직관만 잡으면 충분합니다.

// 점수가 이상할 때 첫 번째 디버깅 도구: 점수 계산 내역을 트리로 출력
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 + sizeindex.max_result_window(기본 10,000)를 넘으면 거부됩니다. 왜 이런 안전장치가 있을까요?

from=10000, size=10 shard A shard B shard C 상위 10,010개 정렬 상위 10,010개 정렬 상위 10,010개 정렬 coordinating node 병합 + 재정렬 30,030개 로드 → 10개만 반환, 30,020개는 버려짐 💸 정렬키 이후 10개만 정렬키 이후 10개만 정렬키 이후 10개만 ✅ search_after: 샤드당 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 단계별 소요 시간이 응답에 포함됩니다. "왜 느리지?"의 첫 답.

🧪
더 나아가기: Hybrid Search (8.x/9.x)

8.16 GA된 retriever 프레임워크로 BM25와 kNN(벡터 검색)을 RRF(Reciprocal Rank Fusion)로 결합하는 것이 hybrid search 표준입니다. 9.x의 semantic_text 필드는 임베딩 생성·검색까지 추상화해 줍니다. 원문 7.8절 참고.

✍️ 이해도 체크

bool 쿼리에서 "카테고리가 laptop이고 재고가 있는" 조건처럼 점수에 영향을 줄 필요가 없는 조건은 어디에 넣는 것이 가장 좋은가?
✅ filter context는 점수를 계산하지 않고(관련도 왜곡 없음) 결과 비트셋이 node-level 캐시에 저장되어 반복 쿼리가 매우 빨라집니다. must에 넣으면 매번 점수를 계산하고 캐시도 받지 못합니다. "점수에 무관한 조건은 filter로"가 운영 제1원칙입니다.
text 필드 name{ "term": { "name": "Quick Brown Fox" } } 쿼리가 매칭되지 않는 이유는?
✅ term 쿼리는 분석을 거치지 않으므로 "Quick Brown Fox"라는 통짜 문자열을 역색인에서 찾습니다. 하지만 색인 시점에 analyzer가 이미 [quick, brown, fox]로 쪼개 두었기 때문에 매칭될 키가 없습니다. 정확 일치는 name.keyword에 term, 전문 검색은 match를 쓰세요.
📚
원문으로 더 깊이

이 장의 원문 문서: chapters/ch07_query_dsl.md — function_score 상세, multi_match type 옵션, highlight/suggester, retriever·RRF·semantic_text까지 담겨 있습니다.