搜索結果頁上那排「劇情 3 / 科幻 1 / 紀錄片 1」的篩選數字,還有「按年份分佈」的柱狀圖,靠的都是聚合(aggregation)。搜索解決「哪些文檔命中」,聚合解決「命中的文檔按某個字段怎麼分組、每組多少、每組裡的數值怎麼分佈」。這篇不重新建索引,直接拿從零跑通 OpenSearch 本地搜索服務裡的 movies 索引和 5 條數據,六步從最簡單的 terms 桶走到帶計數的分面面板。
文中沿用第一篇的固定值:端口 9200、用戶 admin、示例密碼 Devfix@2026、索引 movies、OpenSearch 鏡像 latest(3.9.0)。如果你還沒跑通第一篇,先回去把容器起起來、數據導進去,再回來。

前置條件
| 項 | 要求 | 說明 |
|---|---|---|
| 已完成 | 從零跑通 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 在這個集合上按字段切桶,桶裡的指標再對每條文檔的字段值做計算。

| 概念 | 是什麼 | 類比 |
|---|---|---|
| 聚合(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——後者會把字段值全加載進堆記憶體。
加了篩選條件後分面計數只剩一個
現象:分面面板點選「scifi」後,請求裡的 hits 只剩 1 條,同時 by_genre 的桶也只剩 scifi: 1,其他類型的計數消失。
原因:篩選條件寫進了 query,而 aggs 的作用域就是 query 命中的文檔集,聚合只能在這個子集裡數數。把同一個條件挪到 post_filter,聚合在過濾之前計算,分面就能保留全集計數。
terms 桶總是隻有 10 個
現象:字段有十幾個取值,terms 聚合只返回 10 個桶,剩下的類型在篩選面板上不見了,接口沒有報錯。
原因:terms 的 size 預設 10,被丟掉的桶的文檔數彙總在 sum_other_doc_count。多分片下每個分片只上報 size * 1.5 + 10 個候選,計數還可能有誤差,看 doc_count_error_upper_bound。
