在實際搜尋系統中,分頁是必須的。Elasticsearch 預設提供 from + size,但這種方式在深度分頁時會變得非常慢。我們今天的目標是:
理解 from + size 的限制,改用 search_after 作為分頁方案,在 Go 端實作一個支援分頁的查詢
from + size假設我們要查詢第 3 頁,每頁 10 筆:
curl -X POST "http://localhost:9200/books/_search?pretty" \
  -H 'Content-Type: application/json' \
  -d '{
    "from": 20,
    "size": 10,
    "query": {
      "match": { "title": "golang" }
    }
  }'
這會跳過前 20 筆,再取後 10 筆。
當分頁很深(例如跳過 10 萬筆)時,ES 仍需掃過前面所有資料,效能急速下降。
search_after 概念search_after 不是用「第幾頁」,而是用「上一次最後一筆的排序值」來接續查詢。這種方式是 cursor-based pagination,效能遠比 from + size 好。
用法:
sort
search_after
先查詢第一頁,每頁 2 筆:
curl -X POST "http://localhost:9200/books/_search?pretty" \
  -H 'Content-Type: application/json' \
  -d '{
    "size": 2,
    "sort": [
      { "published_at": "asc" },
      { "_id": "asc" }
    ],
    "query": {
      "match_all": {}
    }
  }'
回傳結果裡,每筆文件會帶有 sort 欄位,例如:
"sort": ["2025-01-01T12:00:00Z", "1"]
下一頁查詢就帶上它:
curl -X POST "http://localhost:9200/books/_search?pretty" \
  -H 'Content-Type: application/json' \
  -d '{
    "size": 2,
    "search_after": ["2025-01-01T12:00:00Z", "1"],
    "sort": [
      { "published_at": "asc" },
      { "_id": "asc" }
    ],
    "query": {
      "match_all": {}
    }
  }'
在 ESSearchService 新增一個分頁版查詢:
func (es *ESSearchService) SearchPaged(ctx context.Context, query string, after []any, size int) ([]SearchResult, []any, error) {
    body := fmt.Sprintf(`{
      "size": %d,
      "sort": [
        { "published_at": "asc" },
        { "_id": "asc" }
      ],
      "query": {
        "match": { "title": "%s" }
      }`, size, query)
    if after != nil {
        sa, _ := json.Marshal(after)
        body = body + fmt.Sprintf(`, "search_after": %s`, sa)
    }
    body = body + "}"
    results, sorts, err := es.searchRawWithSort(ctx, body)
    return results, sorts, err
}
其中 sorts 會存回最後一筆文件的 sort 值,供下一頁使用。
今天我們完成了:
from + size 的限制search_after 做 cursor-based paginationSearchPaged,支援分頁查詢這樣,我們的 /search API 已經可以處理大規模資料集的分頁,而不怕深度分頁效能崩壞。