Elasticsearch 입문 19편 Search Features. highlight·sort·search_after·scroll·PIT. Deep pagination 회피.
이 글은 Elasticsearch 입문에서 운영까지 시리즈 38편 중 19편이에요. 12~15편에서 쿼리로 맞는 문서를 찾는 법 을, 16~18편에서 Aggregations 로 숫자를 뽑는 법 을 봤다면, 이번 편은 그렇게 찾은 결과를 사용자에게 어떻게 보여 줄지 에 대한 자리예요. Search Features — 검색 결과 자체보다 주변 의 부가 기능. highlight·sort·pagination 의 세 갈래가 본업이고, 그 뒤로 deep pagination 을 어떻게 피하느냐가 운영의 절반을 차지합니다.
이 글은 Elasticsearch 8.x 공식 docs 의 Search results · Paginate search results · Highlighting · Sort search results 챕터를 한국어 학습 노트로 풀어쓴 자료예요.
Kibana Dev Tools 콘솔 또는 curl 로 본문 예제를 한 번씩 직접 던져 보면 머리에 훨씬 잘 박혀요.
검색 결과를 사용자에게 보여 주는 자리
15편까지의 쿼리들은 "어떤 문서가 어떤 점수로 맞느냐" 까지만 책임을 져요. 그런데 실제 사용자 화면을 만들려면 그 위에 세 가지가 더 필요해요. 어디가 매칭됐는지 표시 해 주는 highlight, 어떤 순서로 보여 줄지 정하는 sort, 몇 개씩 끊어 줄지 결정하는 pagination. 이 세 가지가 본문의 절반이고, 나머지 절반은 큰 페이지를 어떻게 안전하게 끊느냐 하는 search_after·scroll·PIT 이야기.
이 자리를 잘못 짜면 검색 자체는 잘 도는데 사용자는 느려서 떠나는 사고가 나요. 1편의 7대 사고 중 Deep Pagination 폭망 이 가장 빈도 높게 일어나는 게 바로 여기. 본문 전체를 한 마디로 요약하면 — from + size 는 첫 페이지 몇 장에서만, 그 이상은 search_after + PIT 로 가라.
Highlight — 매칭 단어 강조
Highlight 는 검색어가 문서 어디서 매칭됐는지 를 보여 주는 기능이에요. 사용자는 "왜 이 결과가 떴는지" 한눈에 보고, 결과 신뢰가 올라가요. 구글이 검색 결과 페이지에서 키워드를 굵게 표시해 주는 게 같은 메커니즘.
기본 문법은 이렇게 단순.
GET /articles/_search
{
"query": {
"match": { "content": "Elasticsearch" }
},
"highlight": {
"fields": {
"content": {}
}
}
}
응답 안에 hits[].highlight.content 자리에 매칭된 문장 조각 이 <em>키워드</em> 로 감싸여 와요. 디폴트 태그가 <em> 인 이유는 시멘틱 강조 의 표준이라서 — 시각적으로 굵게 하든 색깔을 입히든 CSS 에 맡기는 방식이에요.
주요 옵션 — pre_tags · post_tags · fragment_size · number_of_fragments
태그를 바꾸고 싶으면 pre_tags 와 post_tags 를 박아요. e-commerce 처럼 브랜드 컬러 가 정해진 곳은 <mark class="hit"> 로 감싸 두고 CSS 한 번에 색을 잡는 패턴이 표준.
"highlight": {
"pre_tags": ["<mark class=\"hit\">"],
"post_tags": ["</mark>"],
"fields": {
"content": {
"fragment_size": 150,
"number_of_fragments": 3
}
}
}
fragment_size 는 조각 한 개의 글자 수 한도 예요. 디폴트 100 이고, 100~200 이 사용자 눈에 가장 편한 자리. number_of_fragments 는 몇 조각까지 돌려줄지 — 디폴트 5 이고, 검색 결과 한 행에 3 조각 정도가 일반.
number_of_fragments: 0 으로 박으면 문서 원문 전체 를 그대로 돌려주면서 매칭 위치만 태그로 감싸 줘요. 짧은 제목·요약 필드는 fragment 자르지 말고 전체 가 깔끔.
Highlighter 3종 — unified · plain · fvh
ES 는 강조 알고리즘을 세 가지 제공해요.
| Highlighter | 특징 | 언제 |
|---|---|---|
| unified (디폴트) | BM25 기반, 정확도 OK, 속도 빠름 | 대부분의 경우 |
| plain | 쿼리 그대로 토큰 매칭, 가볍지만 느림 | 작은 필드, 단순 매칭 |
| fvh (fast vector highlighter) | term_vector 필요, 큰 필드에 빠름 | 대용량 본문 (블로그 본문 등) |
fvh 를 쓰려면 mapping 에서 term_vector: with_positions_offsets 가 선언돼 있어야 해요. 이게 안 켜져 있으면 ES 가 fvh 못 쓴다 고 에러를 던지면서 unified 로 fallback 안 함 — 처음부터 mapping 단계에서 선언해 두는 게 안전.
"highlight": {
"fields": {
"content": {
"type": "fvh",
"fragment_size": 150
}
}
}
matched_fields — multi-field 위 강조
같은 텍스트가 분석기를 다르게 색인된 여러 multi-field 위에 있는 경우가 흔해요. 예 — content 는 nori 로, content.english 는 standard 로, content.exact 는 keyword 로 색인. 검색은 셋 다 묶어 multi_match 로 던지는데, 강조는 어느 필드 기준으로 할지 가 헷갈리는 자리.
matched_fields 옵션이 이 자리를 풀어 줘요. "여러 필드에서 매칭된 단어를 같이 모아 본 필드 위에 강조" 하는 방식. 단 fvh 와 같은 분석 단위 인 필드만 묶어야 해요.
"highlight": {
"fields": {
"content": {
"type": "fvh",
"matched_fields": ["content", "content.english"]
}
}
}
Sort 고급 — score 너머의 정렬
쿼리 결과는 디폴트로 _score 내림차순 으로 정렬돼요. 그런데 운영에서는 최신순·가격순·거리순·복합 기준 같은 다른 정렬이 더 자주 필요해요. ES 의 sort 옵션이 이걸 책임집니다.
가장 단순 — 필드 한 개
GET /products/_search
{
"query": { "match_all": {} },
"sort": [
{ "price": "asc" }
]
}
이렇게 박으면 _score 는 계산조차 안 해요. 풀텍스트 점수가 의미 없는 최신순 게시판 같은 자리에서는 _score 계산을 건너뛰는 게 성능에 큰 차이를 만들어요.
multi-field — 동률은 tiebreaker 로
같은 가격이 100개라면? sort 배열에 2차 기준 을 박아요.
"sort": [
{ "price": "asc" },
{ "created_at": "desc" },
{ "_id": "asc" }
]
마지막 _id 가 tiebreaker (동률 깨는 마지막 기준) 역할. search_after 를 쓰려면 반드시 unique 한 tiebreaker 가 sort 배열 끝에 들어가야 해요 (뒤에서 다시).
nested sort — 배열 내부 필드 기준
nested 타입 필드 안의 값으로 정렬할 때는 nested 옵션을 같이 박아요.
"sort": [
{
"reviews.score": {
"order": "desc",
"nested": {
"path": "reviews",
"filter": { "term": { "reviews.verified": true } }
}
}
}
]
리뷰 배열 중 verified=true 인 리뷰의 score 최댓값 으로 상품을 줄 세우는 식.
script_sort — 임의 수식
정렬 기준이 코드로 표현되는 수식 일 때 — 예: (가격 / 별점) 작은 순, (현재 시각 - 등록일) 가중치 — script_sort 가 답.
"sort": [
{
"_script": {
"type": "number",
"script": {
"source": "doc['price'].value / doc['rating'].value"
},
"order": "asc"
}
}
]
다만 script_sort 는 모든 문서마다 스크립트를 돌려야 해서 비싼 연산. 대량 데이터에서는 그 값을 색인 시점에 계산해서 별도 필드로 박아 두는 게 표준.
geo_distance sort — 거리순
지도 검색의 단골. 기준점에서 가까운 순 으로 줄 세워요.
"sort": [
{
"_geo_distance": {
"location": { "lat": 37.5665, "lon": 126.9780 },
"order": "asc",
"unit": "km"
}
}
]
응답의 sort 자리에 km 단위 거리 가 들어와요. "내 위치 기준 가까운 카페 10개" 같은 자리.
missing 처리 — null 어디로 보낼까
정렬 기준 필드가 비어 있는 문서 가 섞이면 어디로 보낼지가 모호해요. missing 옵션으로 명시.
"sort": [
{ "price": { "order": "asc", "missing": "_last" } }
]
_last 는 오름차순 정렬에서도 맨 뒤로, _first 는 맨 앞으로, 또는 임의의 값 을 박아 거기 끼워 넣을 수도 있어요. 디폴트는 _last. 이걸 안 박으면 내림차순 정렬 시 null 이 위로 올라와 사용자 눈에 거슬리는 사고가 흔해요.
Pagination 한계 — from + size 의 진실
가장 직관적인 페이징은 from + size 예요.
GET /articles/_search
{
"from": 20,
"size": 10,
"query": { "match_all": {} }
}
21번째부터 10개 를 가져오는 식. 사용자에게 친숙한 1·2·3·4 페이지 네비게이션 을 만들기 좋아요.
문제는 깊은 페이지 일 때예요. ES 는 분산 검색이라 모든 샤드에서 (from + size) 만큼 정렬한 다음 coordinator 가 머지해서 최종 (from + size) 중 상위 size 개 만 돌려줘요. from = 100,000 · size = 10 이라면? 샤드마다 100,010개를 정렬해서 보내야 하고, coordinator 는 (샤드 수 × 100,010) 개를 받아 다시 정렬. 수백 MB 메모리 + 수십 초 응답 폭증.
이 위험을 막으려고 ES 는 index.max_result_window 라는 안전 가드를 박아 둬요. 디폴트 10,000 — 즉 from + size > 10,000 요청은 그냥 에러 로 막혀요.
result_window is too large, from + size must be less than or equal to: [10000]
해결책은 두 갈래.
- 앱 정책에서 막기 — 10,000건 넘는 페이지는 그냥 검색을 더 좁히라고 UI 에서 유도. (e-commerce 표준)
- search_after · PIT · scroll 로 갈아타기 — 깊은 페이지·전수 추출이 필요한 자리. (뒷섹션)
max_result_window 자체를 50,000·100,000 으로 올리는 건 안티 패턴 이에요. 임시방편으로는 통할지 몰라도, 메모리 폭증·OOM 사고로 이어지는 길.
search_after — sort 값 기반 다음 페이지
search_after 는 "방금 받은 마지막 문서의 sort 값을 가지고 다음 페이지를 달라" 라고 묻는 방식이에요. 페이지 번호 대신 cursor 로 페이징한다고 보면 돼요. deep pagination 의 표준 해법.
핵심은 sort 배열의 마지막 값을 그대로 들고 가서 search_after 에 넣는다 는 점.
1차 요청 — 첫 페이지
GET /articles/_search
{
"size": 10,
"query": { "match_all": {} },
"sort": [
{ "created_at": "desc" },
{ "_id": "asc" }
]
}
응답의 마지막 hit 의 sort 자리에 [1715000000, "abc123"] 같은 배열이 들어와요.
2차 요청 — 다음 페이지
GET /articles/_search
{
"size": 10,
"query": { "match_all": {} },
"sort": [
{ "created_at": "desc" },
{ "_id": "asc" }
],
"search_after": [1715000000, "abc123"]
}
이걸 끝없이 반복하면서 cursor 만 밀어 줘요. 페이지 번호가 사라지는 대신 메모리·응답시간이 일정 (페이지가 깊어져도 일정 비용).
tiebreaker 가 반드시 unique 해야 한다
가장 흔한 사고가 tiebreaker 누락 또는 unique 하지 않음. created_at 만 sort 에 박으면, 같은 시각에 만든 문서 두 개가 cursor 로 구분이 안 돼서 페이지 사이에 중복 또는 누락 이 생겨요. 그래서 항상 마지막 sort 키로 _id 또는 unique 한 필드 를 박아야 해요. 이게 search_after 의 Rule 1.
무상태 — 서버에 컨텍스트 X
search_after 의 장점은 서버에 페이징 컨텍스트를 안 남긴다 는 점이에요. 한 사용자가 페이징하다 중간에 끊어도 서버 리소스 누수가 0. scroll API 의 가장 큰 단점이 세션 보존 메모리 였던 걸 search_after 가 깔끔하게 풀어 줘요.
snapshot — 일관성 보장은? → PIT 와 묶어 쓴다
search_after 자체는 그때그때의 인덱스 상태 를 봐요. 페이징 중간에 새 문서가 들어오면 결과가 흔들릴 수 있어요. 일관된 스냅숏 위에서 페이징하고 싶으면 PIT (Point In Time) 와 묶어 쓰는 게 8.x 표준 (다음 섹션).
Scroll API — 대량 데이터 추출용 (deprecated 인 추세)
Scroll 은 "수십만~수천만 건을 차례차례 다 끌어와야 하는" 자리에서 쓰던 옛 표준이에요. 데이터 마이그레이션·전수 백업·배치 처리 같은 자리.
흐름
1차 요청에 scroll 파라미터를 박아 컨텍스트 보존 시간 을 알려 줘요.
POST /articles/_search?scroll=1m
{
"size": 1000,
"query": { "match_all": {} }
}
응답에 _scroll_id 가 한 자리 따라와요. 다음 페이지부터는 쿼리 없이 _scroll_id 만 들고 가서 호출.
POST /_search/scroll
{
"scroll": "1m",
"scroll_id": "DXF1ZXJ5..."
}
scroll_id 가 서버에 보존된 인덱스 스냅숏의 cursor 역할. 끝까지 다 받았으면 반드시 DELETE /_search/scroll 로 컨텍스트를 해제 해야 해요. 안 해제하면 scroll context 가 서버 메모리에 쌓여서 사고로 이어져요.
일관성 보장
Scroll 의 장점은 시작 시점의 인덱스 스냅숏 위에서 페이징하므로 중간에 들어온 새 문서가 결과에 끼지 않는다 는 점. 진짜 전수 추출 에서는 이 보장이 중요.
Deprecated — 8.x 부터 PIT + search_after 로 옮기는 추세
작성 시점(2026-05) 기준 ES 8.x 공식 docs 는 Scroll 을 "deep pagination 또는 사용자 페이징에는 쓰지 말 것" 으로 권하고 있어요. transparent · 무상태 · 서버 리소스 적게 라는 세 축에서 PIT + search_after 가 우위. 새 프로젝트는 처음부터 PIT + search_after 로 짜는 게 표준.
다만 진짜 대량 전수 추출 (예: 1억 건 reindex 위한 read) 자리에서는 여전히 scroll 이 가장 단순 한 답이에요. 완전히 사라지지는 않을 것.
PIT (Point In Time) — 8.x 표준 페이징
PIT 는 "이 시점의 인덱스 스냅숏을 한 동안 잡고 있어 줘" 라고 명시적으로 요청하는 API 예요. 이름 그대로 시간상의 한 점 을 묶어 두는 핸들. 7.10+ 에서 도입돼 8.x 부터 scroll 대체 표준.
흐름 — 4단계
1. PIT 열기
POST /articles/_pit?keep_alive=5m
응답에 pit_id 가 와요. 이게 그 시점 스냅숏의 핸들.
2. 검색 — PIT + search_after 묶기
POST /_search
{
"size": 10,
"query": { "match_all": {} },
"pit": {
"id": "46ToAwMD...",
"keep_alive": "5m"
},
"sort": [
{ "created_at": "desc" },
{ "_shard_doc": "asc" }
]
}
주의 — PIT 검색은 인덱스 이름을 URL 에 넣지 않아요. POST /_search 만 쓰고 인덱스는 pit.id 안에 이미 박혀 있어요.
특수 tiebreaker 로 _shard_doc 을 쓰는 게 PIT 의 권장 패턴이에요. _id 보다 가볍고 unique 함이 보장.
3. 다음 페이지 — search_after 로 cursor 전진
응답 마지막 hit 의 sort 값을 가지고 search_after 에 넣어 다음 요청.
4. PIT 닫기
DELETE /_pit
{ "id": "46ToAwMD..." }
다 끝났으면 반드시 닫아 줘요. scroll context 와 마찬가지로 서버 메모리 누수 가 됩니다.
PIT vs Scroll — 한 표
| PIT + search_after | Scroll | |
|---|---|---|
| 일관성 | 보장 (스냅숏) | 보장 (스냅숏) |
| 서버 메모리 | 적음 | 많음 (scroll context) |
| 무상태 페이징 | OK | NG (scroll_id 필수) |
| 깊은 페이지 | OK | OK |
| 동시 사용자 | 잘 견딤 | 부담 큼 |
| 8.x 권장 | YES | 점진적 deprecated |
| 진짜 전수 추출 | 가능 | 가장 단순 |
새 프로젝트는 거의 모든 자리에서 PIT + search_after 가 답.
Search Templates / mustache — 쿼리 템플릿화
검색 쿼리가 복잡하고 자주 바뀌는 자리에서는, 코드에 JSON 을 박아 두는 대신 템플릿을 ES 에 등록 해 두고 파라미터만 던지는 방식이 깔끔해요. Search Templates 가 이걸 풀어요. 템플릿 엔진은 Mustache 문법.
템플릿 등록
PUT _scripts/article-search
{
"script": {
"lang": "mustache",
"source": {
"query": {
"multi_match": {
"query": "{{q}}",
"fields": ["title^3", "content"]
}
},
"size": "{{size}}",
"from": "{{from}}"
}
}
}
호출
POST /articles/_search/template
{
"id": "article-search",
"params": {
"q": "Elasticsearch",
"size": 10,
"from": 0
}
}
장점 셋. (1) 앱 코드에서 쿼리 빌드 코드가 사라져 깔끔. (2) 쿼리 튜닝이 재배포 없이 가능. (3) 운영팀과 개발팀의 책임 분리. 단점은 디버깅이 멀어진다 는 점 — 검색 품질 사고가 났을 때 템플릿 안 mustache 까지 추적해야 해서 진입장벽이 한 번 더 있어요.
작은 규모는 그냥 코드에서 JSON 빌드 가 답. 검색이 서비스의 본업 인 자리부터 템플릿이 진가.
자주 만나는 사고 6가지
사고 1 — from = 100,000 폭망
원인 — 사용자 페이지네이션 UI 에 1·2·3·... 무한 페이지를 그려 두고 사용자가 마지막 페이지로 점프. from + size 가 10,000 을 넘으면서 result_window 에러로 검색이 죽거나, 어쩌다 한도가 늘려져 있으면 응답이 수십 초.
해결 — UI 자체를 "검색 결과가 10,000건 넘어요. 더 좁혀 보세요" 안내로 막거나, 무한 스크롤 + search_after 로 바꾸는 게 표준. 1편의 7대 사고 중 가장 자주 만나는 자리.
사고 2 — scroll context 미해제
원인 — scroll API 를 DELETE 안 하고 종료. 페이징 시작만 했다가 사용자가 나가거나 에러로 끊겼는데, scroll context 가 keep_alive 끝까지 서버 메모리에 남아요. 동시 사용자 100명에서 수 GB 메모리 누수 사고.
해결 — try/finally 패턴으로 반드시 DELETE 호출. 그리고 keep_alive 를 짧게 (1m~5m) 잡아 사고 시 자동 회수. 가능하면 처음부터 PIT + search_after 로.
사고 3 — search_after tiebreaker 누락
원인 — sort: [{ created_at: desc }] 하나만 박고 search_after 를 돌림. 같은 created_at 인 문서가 cursor 로 구분이 안 돼서 페이지 사이에 중복 또는 누락. 사용자는 "방금 본 글이 다시 보여요" 라고 신고.
해결 — sort 배열 맨 뒤에 unique tiebreaker 박기 — _id, _shard_doc (PIT 권장), 또는 unique 필드 (예: order_no). 검증은 연속 페이지 두 개의 마지막 / 첫 hit 가 다른지 직접 확인.
사고 4 — highlight fragment 폭증
원인 — 큰 본문 필드(예: 블로그 본문 5MB) 에 highlight 를 default 옵션으로 박음. ES 가 fragment 5개 × 100 자 외에 내부적으로 전체 텍스트를 분석 해서 응답 시간이 1초 → 10초.
해결 — number_of_fragments 명시(보통 1~3), fragment_size 명시(150~200), 큰 본문은 fvh highlighter + term_vector: with_positions_offsets 색인. 그리고 highlight 대상은 검색 첫 페이지에만 박고, 깊은 페이지는 끄는 게 안전.
사고 5 — sort missing 처리 누락
원인 — price 가 null 인 문서가 섞인 채 sort: { price: desc } 만 박음. ES 는 디폴트 missing: _last 라 내림차순에서도 null 을 맨 뒤 로 보내는데, 이게 사용자에게 부자연스러운 결과 일 수도 있어요. 또는 디폴트가 바뀌어도 모름.
해결 — 항상 missing 명시. missing: _last (눈에서 멀리), missing: _first (눈에 띄게), 또는 임의의 sentinel 값 (예: 0 또는 999999).
사고 6 — PIT 미해제
원인 — PIT 를 열고 페이징하다 DELETE /_pit 를 호출 안 함. scroll context 와 같은 메커니즘의 메모리 누수. PIT 의 keep_alive 가 만료되면 자동 해제되지만, 수만 개의 PIT 가 쌓이는 시간 동안 노드 메모리 폭증.
해결 — try/finally 로 닫기. keep_alive 를 짧게 (1m~5m) 잡고 매 요청에 연장 해 가는 패턴. PIT 가 서버 자원을 잡는다 는 사실을 코드 옆에 주석으로 박아 두는 게 도움.
운영 권장 패턴 5가지
(1) 페이지 정책을 처음부터 명시. "검색 결과 첫 100건만 페이징 허용, 그 이상은 검색 다시 좁히도록 UI 유도" 가 e-commerce·뉴스 사이트 표준. 무한 페이지 네비게이션 은 deep pagination 사고 1번지.
(2) 무한 스크롤 = search_after + PIT. 모바일 UX 의 무한 스크롤은 거의 search_after + PIT 가 답. 첫 로드에 PIT 를 열고, 사용자가 스크롤할 때마다 search_after 로 cursor 전진. 페이지 떠날 때 PIT 닫기.
(3) 전수 추출 = scroll 또는 PIT, 무조건 try/finally. Reindex·백업·analytics 추출 같은 자리는 scroll 이 가장 단순. try/finally 로 컨텍스트 해제 가 절대 규칙.
(4) sort tiebreaker 는 강제. search_after 가 들어가는 모든 검색은 sort 마지막에 unique tiebreaker 가 의무. 팀 내에 코드 리뷰 체크리스트 로 박아 두면 사고 방지.
(5) highlight 는 첫 페이지에만. highlight 비용이 fragment 개수 × 필드 크기 에 비례. 첫 페이지에는 사용자 확인용으로 켜 두고, 두 번째 페이지 이후는 끄거나 number_of_fragments: 1 로 최소. 검색 응답 시간이 절반으로 줄어들기도.
시험 직전 한 번 더 — 압축 노트
- Search Features = highlight·sort·pagination 의 세 갈래 + deep pagination 대응.
- Highlight =
<em>디폴트 태그,pre_tags · post_tags커스텀,fragment_size 150~200 · number_of_fragments 1~3. - Highlighter 3종 — unified(디폴트) · plain(작은 필드) · fvh(큰 필드, term_vector 필요).
- Sort =
_score디폴트, sort 박으면 점수 계산 X. multi-field, nested, script_sort, geo_distance,missing처리. - from + size = 디폴트 페이징,
max_result_window 10,000안전 가드. 늘리지 말고 다른 길. - search_after = sort 값 cursor, 무상태, 깊은 페이지 OK. tiebreaker unique 필수.
- scroll = 옛 표준, 서버 메모리 컨텍스트, 반드시 DELETE. 8.x 에서 점진적 deprecated.
- PIT (Point In Time) = 8.x 표준 스냅숏 핸들.
keep_alive짧게, 반드시 close. tiebreaker =_shard_doc권장. - PIT + search_after = 8.x 사용자 페이징 표준 답.
- scroll = 대량 전수 추출 (reindex·백업) 자리에선 여전히 단순한 답.
- Search Templates = mustache 템플릿, 재배포 없이 쿼리 튜닝.
- 7대 사고 —
from=100000폭망 · scroll context 미해제 · search_after tiebreaker 누락 · highlight fragment 폭증 · sort missing 누락 · PIT 미해제. - 운영 5규칙 — 페이지 정책 명시 · 무한 스크롤 = search_after+PIT · 전수 추출 try/finally · tiebreaker 강제 · highlight 첫 페이지에만.
시리즈 다른 편
- 이전 글 = 18편 Aggregations Pipeline — moving_avg·derivative·cumulative_sum
- 다음 글 = 20편 Suggesters — completion·term·phrase 자동완성·오타 교정
- 12편 = Search API Basic —
_search엔드포인트 기본 문법 - 15편 = Compound Queries — bool·boosting·function_score
- 21편 = Vector Search — dense_vector·kNN
- 27편 = Shard Allocation — search_after 가 샤드 위에서 동작하는 방식
- 32편 = Spring Data Elasticsearch — Java 에서 search_after·PIT 호출
- 38편 = 시리즈 마무리 — 결정 트리·체크리스트·자격증
한 줄 정리 — from + size 는 첫 페이지 몇 장에서만, 깊은 페이지는 PIT + search_after 가 8.x 표준 답. Highlight 는 첫 페이지에만 가볍게, sort 에는 unique tiebreaker 가 의무.