StackTraces
OpenSearch從零跑通

從零跑通 OpenSearch 聚合與分面:6 步,外加 3 個容易卡住的地方

@debug_master2026-10-27

搜索結果頁上那排「劇情 3 / 科幻 1 / 紀錄片 1」的篩選數字,還有「按年份分佈」的柱狀圖,靠的都是聚合(aggregation)。搜索解決「哪些文檔命中」,聚合解決「命中的文檔按某個字段怎麼分組、每組多少、每組裡的數值怎麼分佈」。這篇不重新建索引,直接拿從零跑通 OpenSearch 本地搜索服務裡的 movies 索引和 5 條數據,六步從最簡單的 terms 桶走到帶計數的分面面板。

文中沿用第一篇的固定值:端口 9200、用戶 admin、示例密碼 Devfix@2026、索引 movies、OpenSearch 鏡像 latest(3.9.0)。如果你還沒跑通第一篇,先回去把容器起起來、數據導進去,再回來。

從零跑通 OpenSearch 聚合與分面 6 步流程封面:terms 分桶 → range 分區間 → stats 指標 → 子聚合 → 篩選與分面 → bucket_sort

前置條件

項 要求 說明
已完成 從零跑通 OpenSearch 本地搜索服務 本文複用它的容器、密碼、movies 索引和 5 條電影數據
數據 movies 索引裡有 5 條文檔 第一篇最後刪了 1 條的話只有 4 條,重新跑一次 bulk 即可
curl 任意版本 全文用 curl 調 REST API
記憶負擔 無 聚合命令都是同一條 _search,只換 aggs 裡的內容

先自檢兩件事:文檔數對不對、genre 是不是 keyword。

curl -sk -u admin:Devfix@2026 "https://localhost:9200/movies/_count?pretty"
curl -sk -u admin:Devfix@2026 "https://localhost:9200/movies/_mapping?pretty"

count 應該是 5,mapping 裡 genre 的類型是 keyword。如果你在第一篇第六步刪過文檔,count 會是 4——把當時那個 movies.ndjson 再 bulk 一次就恢復成 5。genre 不是 keyword 的話,第一步就會撞上後面 FAQ 第 1 項那個報錯。

第一步:先看「桶」——terms 聚合

聚合請求和搜索請求是同一個接口,區別在於多了一個 aggs 字段。先數一下每個類型有幾部電影:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "size": 0,
    "aggs": { "by_genre": { "terms": { "field": "genre" } } }
  }'
{
  "hits": {
    "total": { "value": 5, "relation": "eq" },
    "max_score": null,
    "hits": []
  },
  "aggregations": {
    "by_genre": {
      "doc_count_error_upper_bound": 0,
      "sum_other_doc_count": 0,
      "buckets": [
        { "key": "drama", "doc_count": 3 },
        { "key": "scifi", "doc_count": 1 },
        { "key": "documentary", "doc_count": 1 }
      ]
    }
  }
}

怎麼讀這份響應:

  • size: 0 表示不要命中列表,只看聚合。不加這一項,hits 會塞 10 條文檔進來,白算一遍。
  • aggregations.by_genre.buckets 就是「桶」:每個 key 是一個類型,doc_count 是這個類型下的文檔數。by_genre 是我們給這個聚合起的名字,響應裡原樣還回來。
  • 桶的預設順序是 doc_count 降序。scifi 和 documentary 都是 1,誰先誰後不保證——想要穩定順序,加 "order": { "_key": "asc" }。

這一步的驗證標準:三個桶的計數是 3、1、1,加起來等於 hits.total.value。對不上說明數據不是 5 條,回前置條件重導。

這裡有個必須提前說清的邊界:terms 的 field 只能是 keyword、數字、ip、布爾或日期字段。genre 能聚合,是因為第一篇建索引時把它映射成了 keyword;換成 title 這種 text 字段會直接 400,原因和修法見下面 FAQ 第 1 項。

第二步:數值用 range 分桶

genre 是離散值,切桶很自然。year 是數字,如果直接 terms,一個年份一個桶,1939、2013、2015……切得比電影還碎,沒意義。數字字段該用 range,自己定區間:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "size": 0,
    "aggs": {
      "by_period": {
        "range": {
          "field": "year",
          "ranges": [
            { "from": 1930, "to": 2000 },
            { "from": 2000, "to": 2020 },
            { "from": 2020, "to": 2030 }
          ]
        }
      }
    }
  }'
{
  "aggregations": {
    "by_period": {
      "buckets": [
        { "key": "1930.0-2000.0", "from": 1930.0, "to": 2000.0, "doc_count": 1 },
        { "key": "2000.0-2020.0", "from": 2000.0, "to": 2020.0, "doc_count": 3 },
        { "key": "2020.0-2030.0", "from": 2020.0, "to": 2030.0, "doc_count": 1 }
      ]
    }
  }
}

對一下數據:1939 落在第一段,2013、2015、2018 落在第二段,2021 落在第三段,1 + 3 + 1 = 5。

這裡有個容易記反的規則:區間是左閉右開,from 包含、to 不包含。所以 2000 年的電影會進 2000.0-2020.0,不會進 1930.0-2000.0。官方文檔的原話是「響應包含 from 的值、不包含 to 的值」。

驗證標準:三個桶加起來還是 5,且 2020 這個邊界值如果存在,一定在 2020.0-2030.0 裡。

第三步:每個桶算指標——stats

前面兩步數的都是「有幾條」。想知道「這些電影最早哪年、最晚哪年、平均哪年」,用指標聚合。stats 一次給全五個數:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "size": 0,
    "aggs": { "year_stats": { "stats": { "field": "year" } } }
  }'
{
  "aggregations": {
    "year_stats": {
      "count": 5,
      "min": 1939.0,
      "max": 2021.0,
      "avg": 2001.2,
      "sum": 10006.0
    }
  }
}

算一遍對得上:1939 + 2013 + 2021 + 2018 + 2015 = 10006,除以 5 是 2001.2。count 是參與計算的文檔數,不是字段的取值個數。

驗證標準:sum 除以 count 等於 avg,min/max 落在你的數據範圍內。這個自洽關係可以拿來檢查任何一份 stats 響應。

指標聚合和桶聚合是兩類東西:terms、range 負責「怎麼分組」,avg、sum、stats 負責「每組算個什麼數」。下面把它們合起來用。

第四步:桶裡套桶(子聚合)

結果頁要的往往是「每個類型各有幾部,平均年份是多少」——一個桶聚合,每個桶裡再掛一個指標聚合:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "size": 0,
    "aggs": {
      "by_genre": {
        "terms": { "field": "genre" },
        "aggs": { "year_stats": { "stats": { "field": "year" } } }
      }
    }
  }'
{
  "aggregations": {
    "by_genre": {
      "buckets": [
        {
          "key": "drama",
          "doc_count": 3,
          "year_stats": { "count": 3, "min": 1939.0, "max": 2015.0, "avg": 1989.0, "sum": 5967.0 }
        },
        {
          "key": "scifi",
          "doc_count": 1,
          "year_stats": { "count": 1, "min": 2021.0, "max": 2021.0, "avg": 2021.0, "sum": 2021.0 }
        },
        {
          "key": "documentary",
          "doc_count": 1,
          "year_stats": { "count": 1, "min": 2018.0, "max": 2018.0, "avg": 2018.0, "sum": 2018.0 }
        }
      ]
    }
  }
}

寫法上只有一個變化:把子聚合放進 aggs 字段,和父聚合的 terms 平級。drama 的 year_stats 只統計那 3 部 drama,不會串到別的桶裡。核對一下:drama 三部是 1939、2013、2015,和是 5967,平均 1989。

驗證標準:每個桶的 doc_count 等於該桶 year_stats.count。不等說明數據裡有年份缺失的文檔,stats 只統計有值的文檔。

這一步之後,一塊分面面板需要的數據已經齊了。接下來是它最容易翻車的地方:用戶點了篩選項之後,這些數字會變成什麼樣。

第五步:篩選與分面並存——query 與 post_filter

假設用戶點了「只看 scifi」。最直覺的寫法是把條件寫進 query:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "size": 0,
    "query": { "term": { "genre": "scifi" } },
    "aggs": { "by_genre": { "terms": { "field": "genre" } } }
  }'
{
  "hits": { "total": { "value": 1, "relation": "eq" } },
  "aggregations": {
    "by_genre": {
      "buckets": [ { "key": "scifi", "doc_count": 1 } ]
    }
  }
}

列表只剩 1 條沒問題,但分面也只剩 scifi: 1——另外兩個類型的計數全沒了。用戶想切回 drama 時,界面上已經沒有這個選項。

這就是 query 和 post_filter 的分界線:query 在聚合之前執行,決定聚合能看到哪些文檔;post_filter 在聚合之後執行,只裁命中列表。把同一個條件挪到 post_filter:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "size": 0,
    "post_filter": { "term": { "genre": "scifi" } },
    "aggs": { "by_genre": { "terms": { "field": "genre" } } }
  }'
{
  "hits": { "total": { "value": 1, "relation": "eq" } },
  "aggregations": {
    "by_genre": {
      "buckets": [
        { "key": "drama", "doc_count": 3 },
        { "key": "scifi", "doc_count": 1 },
        { "key": "documentary", "doc_count": 1 }
      ]
    }
  }
}

hits.total.value 還是 1,但分面保留了全集:drama 3、documentary 1 都還在,用戶可以隨時切過去。這就是篩選面板該有的樣子。

官方 faceted search 教程把這個行為叫「保持篩選期間的分面選項」,用的就是 post_filter,原話是它在聚合算完之後過濾搜索結果,因此分面反映的是未過濾的數據集。

那什麼時候該用 query?關鍵詞搜索就是典型:用戶輸入「wind」,這個條件本來就該縮小統計範圍,聚合跟著收窄是正確行為。判斷標準很簡單——這個條件是不是用戶正在輸入或選擇的搜索意圖?是,寫 query;它只是結果頁上追加的一層篩選?寫 post_filter。

還有第三種需求:列表要裁,但某個維度的統計要跟著篩選走(比如「選了 scifi 之後,各年份分佈變成什麼樣」)。這時用 filter 聚合,它生成一個獨立的桶,桶內子聚合只在 matching 的文檔上算,不會影響旁邊的其他分面:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "size": 0,
    "aggs": {
      "all_genres": { "terms": { "field": "genre" } },
      "scifi_years": {
        "filter": { "term": { "genre": "scifi" } },
        "aggs": { "by_year": { "terms": { "field": "year" } } }
      }
    }
  }'

驗證標準:post_filter+aggs 與 query+aggs 的 hits.total.value 相同,但前者桶數是 3、後者是 1。兩個數字對上了,說明你抓住了這條分界線。

第六步:bucket_sort 排序與截斷

分面計數對了,還剩兩個問題:桶按什麼順序展示、最多展示幾個。terms 的預設順序是文檔數降序,前端如果想按「平均年份最新」排,就得用 bucket_sort:

curl -sk -u admin:Devfix@2026 -X GET "https://localhost:9200/movies/_search?pretty" \
  -H "Content-Type: application/json" \
  -d '{
    "size": 0,
    "aggs": {
      "by_genre": {
        "terms": { "field": "genre" },
        "aggs": {
          "avg_year": { "avg": { "field": "year" } },
          "top_genres": {
            "bucket_sort": {
              "sort": [ { "avg_year": { "order": "desc" } } ],
              "size": 2
            }
          }
        }
      }
    }
  }'
{
  "aggregations": {
    "by_genre": {
      "buckets": [
        { "key": "scifi", "doc_count": 1, "avg_year": { "value": 2021.0 } },
        { "key": "documentary", "doc_count": 1, "avg_year": { "value": 2018.0 } }
      ]
    }
  }
}

三個桶按平均年份從新到舊排是 scifi(2021)、documentary(2018)、drama(1989),size: 2 把第三個截掉,只留前兩個。

bucket_sort 是父級管道聚合,它排的是父聚合產出的桶,不改變桶裡的文檔數。sort、from、size 三個參數至少給一個,只給 size 就是純截斷:想展示前 3 個熱門類型,就寫 { "bucket_sort": { "size": 3 } },桶的順序沿用 terms 的預設排序。

驗證標準:桶的數量等於 size,順序與 avg_year 的降序一致。

到這裡六步走完:能按字段切桶、按區間切桶、算指標、嵌套、在篩選時保住分面計數、控制桶的順序和條數。結果頁需要的聚合能力基本齊了。

核心概念

把六步壓成一張圖,就是聚合的三段式:一條 query 先圈出文檔集,aggs 在這個集合上按字段切桶,桶裡的指標再對每條文檔的字段值做計算。

OpenSearch 聚合全景圖:query 圈出文檔集 → terms/range 切桶 → stats/avg 算指標 → 組裝響應;post_filter 在聚合之後、返回 hits 之前過濾

概念 是什麼 類比
聚合(aggregation) 對命中文檔做分組統計的請求單元,寫在 aggs 裡 像 SQL 的 GROUP BY 加聚合函數
桶(bucket) 按字段值或區間分出來的一組文檔,帶 doc_count 像分組後的每一組
指標(metric) 對桶內文檔的某個數值字段做計算,如 avg、sum、stats 像 AVG()、SUM()
子聚合 掛在桶下面的聚合,只在該桶的文檔上計算 像先分組再對每組算一次
分面(facet) 結果頁上「類型 3 / 科幻 1」這類帶計數的篩選項,本質是桶聚合 像電商篩選欄
作用域 aggs 能看到的文檔集,由 query 決定;post_filter 不參與這個過程 像統計口徑
管道聚合 對已有桶再做一層處理,如 bucket_sort 排序和截斷 像對分組結果再排序取前 N

常見問題

對 title 做聚合報 fielddata 錯誤

現象:把第一步的 field 從 genre 換成 title,請求返回 400 illegal_argument_exception,reason 裡寫著 Text fields are not optimised for operations that require per-document field data like aggregations and sorting。

原因:title 是 text 字段,寫入時被分析器切成詞條,預設不建 doc values,聚合讀不到「每篇文檔的字段原值」;genre 是 keyword,天然支持聚合。想讓標題也能分面,得給它加 .keyword 子字段並重建索引,或者臨時開 fielddata——後者會把字段值全加載進堆記憶體。

相關文章:OpenSearch 對 text 字段聚合報 fielddata 錯誤

加了篩選條件後分面計數只剩一個

現象:分面面板點選「scifi」後,請求裡的 hits 只剩 1 條,同時 by_genre 的桶也只剩 scifi: 1,其他類型的計數消失。

原因:篩選條件寫進了 query,而 aggs 的作用域就是 query 命中的文檔集,聚合只能在這個子集裡數數。把同一個條件挪到 post_filter,聚合在過濾之前計算,分面就能保留全集計數。

相關文章:OpenSearch 篩選後分面計數只剩一個

terms 桶總是隻有 10 個

現象:字段有十幾個取值,terms 聚合只返回 10 個桶,剩下的類型在篩選面板上不見了,接口沒有報錯。

原因:terms 的 size 預設 10,被丟掉的桶的文檔數彙總在 sum_other_doc_count。多分片下每個分片只上報 size * 1.5 + 10 個候選,計數還可能有誤差,看 doc_count_error_upper_bound。

相關文章:OpenSearch terms 聚合只返回 10 個桶

下一步

給中文數據做聚合

聚合依賴字段類型和分詞結果。中文場景下 title 要被 IK 切成詞,genre 這類分面字段仍要留在 keyword 上——兩套映射怎麼並存,看中文搜索那一篇。

從零跑通 OpenSearch 中文搜索

對照閱讀:Meilisearch 的分面搜索

同一個「帶計數的篩選面板」,Meilisearch 用 filterableAttributes 和 facetDistribution 一次返回,不用自己拼 aggs。輕引擎和重引擎在這一層的差別很直觀。

從零跑通 Meilisearch 分面搜索

回到第一篇補齊 CRUD

本篇只用了第一篇留下的索引和數據,沒有重講建索引、bulk 導入和更新刪除。哪一步的記憶模糊了,回第一篇對應章節補。

從零跑通 OpenSearch 本地搜索服務

卡在哪一步了?

從零跑通的過程中撞到報錯?把命令和輸出記下來,幫助下一個剛起步的人。

貢獻一條解法