← 回文章列表

2022年3月18日

Elasticsearch 入門:從 RDBMS 概念對照到 Query DSL 與 Aggregation 實戰

ElasticsearchKibanaNoSQL資料庫

這篇是從實際專案(房仲平台核心系統重構、 加盟房仲平台 等)用 Elasticsearch 支 撐物件搜尋的經驗整理出來的入門教學,不求涵蓋所有官方文件的細節,而是把剛接觸 Elasticsearch 時最容易卡住的幾個概念,用關聯式資料庫(RDBMS)的角度對照講清 楚。完整範例查詢與 docker-compose 環境放在 HappyElasticSearch。

先建立心智模型:Elasticsearch 對應 RDBMS 的什麼

RDBMSElasticsearch
TableIndex
RowDocument
ColumnField
SchemaMapping
SQLDSL(Query DSL,用 JSON 表示查詢)

Mapping:定義欄位型別

Mapping 定義 Document 裡每個欄位的型別,等同於在 RDBMS 定義 Table 的欄位型 別。

Dynamic Mapping(動態映射)

Elasticsearch 預設是動態映射:只要新增 Document 進 Index,就會依資料值自動 推斷欄位型別,不需要事先定義。

要注意的地方:字串欄位經過 Dynamic Mapping 後,預設會同時產生 text 與 keyword 兩個子欄位——text 用於全文檢索(會被斷詞),keyword 用於精確比 對與排序、聚合(不斷詞)。這代表同一個字串值會被存兩份。如果某個欄位你確 定只會拿來做精確比對(例如關鍵字標籤),讓它預設同時保留 text 反而浪費儲存 空間,應該用 Dynamic Mapping Template 或 Explicit Mapping 把它限定成只有 keyword。

Explicit Mapping(明確映射)

想完全自己控制型別時,可以在建立 Index 時就宣告 Mapping:

JSON
PUT /indexName
{
  "mappings": {
    "properties": {
      "name":     { "type": "text" },
      "account":  { "type": "text" },
      "password": { "type": "keyword" }
    }
  }
}

也可以先建 Index 再補上映射;但既有欄位的型別一旦寫入資料就無法直接改,只能 重建 Index 或用 Reindex API 遷移。

查詢結果的欄位怎麼看(Response 結構)

一次查詢的回應大致長這樣,理解這幾個欄位對讀查詢結果很關鍵:

  • hits.total.value:符合查詢條件的資料總數
  • hits.total.relation:eq 表示 total 是精確值(沒有被截斷估算)
  • hits.max_score:整次查詢中最高的匹配分數
  • hits.hits[]._index:資料所在的 Index
  • hits.hits[]._id:Document 的 ID
  • hits.hits[]._score:這筆資料的匹配分數,分數越高代表跟查詢條件越相關
  • hits.hits[]._source:實際的資料內容

Query DSL:兩種查詢情境

Elasticsearch 的查詢分成兩種 Context,效能特性完全不同:

  • Query Context:會計算匹配分數(_score),分數越高排序越前面;子查詢 結果與分數會被快取
  • Filter Context:只回答「符合或不符合」,不計算分數,處理速度更 快,也不快取有相關性算分的查詢

⚠️ 效能建議:不需要排序相關性的條件(例如「狀態是否為上架中」「日期是否在 某個範圍」)優先用 filter 而不是 must;也盡量避免用 Script、 Wildcard,這兩種查詢方式很容易吃滿效能。

Boolean Query(複合查詢)

如同 RDBMS 的多條件組合查詢:

JSON
GET /kibana_sample_data_ecommerce/_search
{
  "query": {
    "bool": {
      "must": [
        { "term": { "order_date": { "value": "2022-02-03" } } },
        { "term": { "day_of_week": { "value": "Thursday" } } }
      ],
      "should": [
        { "term": { "customer_id": { "value": 26 } } },
        { "term": { "customer_id": { "value": 27 } } }
      ]
    }
  }
}

對應的 SQL 概念大致是:

SQL
SELECT * FROM TABLE
WHERE order_date = '2022-02-03'
  AND day_of_week = 'Thursday'
  AND (customer_id = 26 OR customer_id = 27)

四種子句的角色:

子句作用對應邏輯
must必須符合,會計算分數相當於 AND
filter必須符合,不計算分數,速度較快相當於 AND,建議優先使用
should符合會加分,非必要條件相當於 OR
must_not必須不符合相當於 NOT

全文檢索(Full Text Queries)

match:標準的全文查詢,支援 operator(AND/OR)與 fuzziness (模糊比對,容忍拼字誤差):

JSON
GET /kibana_sample_data_ecommerce/_search
{
  "query": {
    "match": {
      "customer_id": {
        "query": 27,
        "operator": "and",
        "fuzziness": "AUTO"
      }
    }
  }
}

multi_match:同一個查詢值同時比對多個欄位,很適合「搜尋框」這種不知道 使用者要查哪個欄位的場景。

match_all:查詢全部資料,務必搭配 size/from 分頁使用,避免一次把 整個 Index 撈出來。

欄位等級查詢(Term-level Queries)

term / terms:精確比對單一或多個值(不做全文分詞),適合查 keyword、數字、日期這類欄位。

range:範圍查詢:

JSON
GET /kibana_sample_data_ecommerce/_search
{
  "query": {
    "range": {
      "order_date": { "gte": "2022-01-01", "lte": "2022-01-31" }
    }
  }
}

對應 SQL:WHERE order_date BETWEEN '2022-01-01' AND '2022-01-31'。

只要欄位,不要全部資料

用 _source 的 includes/excludes 控制回傳欄位,減少不必要的網路傳輸:

JSON
GET /kibana_sample_data_ecommerce/_search
{
  "size": 20,
  "from": 0,
  "query": {},
  "_source": {
    "excludes": ["order_id", "order_date"],
    "includes": ["*"]
  }
}

Aggregation:ES 版的 GROUP BY 與統計函式

Aggregation 提供類似 RDBMS GROUP BY、COUNT、AVG 的能力,分成三種:

Bucket Aggregation:分桶(相當於 GROUP BY)

依欄位值把 Document 分到不同的桶:

JSON
GET /IndexName/_search
{
  "size": 0,
  "aggs": {
    "day": {
      "terms": { "field": "day_of_week", "size": 10 }
    }
  }
}

回傳結果會依 day_of_week 分組,每組附上該值出現的次數(doc_count),例如 "key": "Thursday", "doc_count": 775。這裡的 "size": 0 代表不要回傳原 始查詢命中的資料,只要聚合結果——查詢結果本身用不到時,這樣可以省掉大量不 必要的資料傳輸。

Metric Aggregation:數學運算

min/max/avg/sum/cardinality,或是一次拿到全部統計值的 stats:

JSON
GET /IndexName/_search
{
  "size": 0,
  "aggs": {
    "productsBaseAvg": { "avg": { "field": "products.base_price" } },
    "productsBaseMax": { "max": { "field": "products.base_price" } },
    "productsBaseMin": { "min": { "field": "products.base_price" } },
    "productsBaseStats": { "stats": { "field": "products.base_price" } }
  }
}

stats 一次回傳 count/min/max/avg/sum,不用分開寫四個聚合。

Pipeline Aggregation:對聚合結果再聚合(巢狀分組)

在一個 Bucket Aggregation 裡面再放一個 Bucket Aggregation,達到多層分組, 相當於 RDBMS 多欄位的 GROUP BY:

JSON
GET /IndexName/_search
{
  "size": 0,
  "aggs": {
    "county": {
      "terms": { "field": "county.keyword" },
      "aggs": {
        "city": { "terms": { "field": "district.keyword" } }
      }
    }
  }
}

結果會先依 county(縣市)分組,每個縣市底下再依 district(行政區)分 組——像是「台北市 → 大安區、內湖區」「新北市 → 板橋區、新莊區」這樣的階層統 計。

本機環境

Shell
docker-compose up

會啟動對應版本的 Elasticsearch 與 Kibana,直接在 Kibana 的 Dev Tools 貼上面 的查詢範例就能跑。

延伸:在 ASP.NET Core 中使用

倉庫裡也附了一個分層(Application/Service/Repository)的 ASP.NET Core 範 例專案,示範怎麼把上面的查詢邏輯包裝進一般後端服務的架構裡,而不是散落在各處 直接寫死 DSL 字串。

總結

  • 把 Mapping/Document/Index 對應回 Schema/Row/Table,能少走很多彎路
  • 字串欄位預設會存兩份(text + keyword),確定只需要精確比對時就該用 Explicit Mapping 限定成 keyword
  • 不需要算分數的條件優先用 filter,效能比 must 好
  • 只要聚合結果、不需要原始資料時,"size": 0 能省下大量不必要的傳輸