简介
官网地址:https://www.elastic.co/guide/cn/elasticsearch/guide/current/nested-objects.html
由于在 Elasticsearch 中单个文档的增删改都是原子性操作,那么将相关实体数据都存储在同一文档中也就理所当然。 比如说,我们可以将订单及其明细数据存储在一个文档中。又比如,我们可以将一篇博客文章的评论以一个 comments
数组的形式和博客文章放在一起:
PUT /my_index/blogpost/1
{
"title": "Nest eggs",
"body": "Making your money work...",
"tags": [ "cash", "shares" ],
"comments": [ # 如果我们依赖字段自动映射,那么 comments 字段会自动映射为 object 类型。
{
"name": "John Smith",
"comment": "Great article",
"age": 28,
"stars": 4,
"date": "2014-09-01"
},
{
"name": "Alice White",
"comment": "More like this please",
"age": 31,
"stars": 5,
"date": "2014-10-22"
}
]
}
由于所有的信息都在一个文档中,当我们查询时就没有必要去联合文章和评论文档,查询效率就很高。
但是当我们使用如下查询时,上面的文档也会被当做是符合条件的结果:
代码语言:javascript复制GET /_search
{
"query": {
"bool": {
"must": [
{ "match": { "name": "Alice" }},
{ "match": { "age": 28 }} # Alice实际是31岁,不是28!
]
}
}
}
正如我们在 对象数组 中讨论的一样,出现上面这种问题的原因是 JSON 格式的文档被处理成如下的扁平式键值对的结构。
代码语言:javascript复制{
"title": [ eggs, nest ],
"body": [ making, money, work, your ],
"tags": [ cash, shares ],
"comments.name": [ alice, john, smith, white ],
"comments.comment": [ article, great, like, more, please, this ],
"comments.age": [ 28, 31 ],
"comments.stars": [ 4, 5 ],
"comments.date": [ 2014-09-01, 2014-10-22 ]
}
Alice
和 31 、 John
和 2014-09-01
之间的相关性信息不再存在。虽然 object
类型 (参见 内部对象) 在存储 单一对象 时非常有用,但对于对象数组的搜索而言,毫无用处。
嵌套对象 就是来解决这个问题的。将 comments
字段类型设置为 nested
而不是 object
后,每一个嵌套对象都会被索引为一个 隐藏的独立文档 ,举例如下:
{ # 第一个 嵌套文档
"comments.name": [ john, smith ],
"comments.comment": [ article, great ],
"comments.age": [ 28 ],
"comments.stars": [ 4 ],
"comments.date": [ 2014-09-01 ]
}
{ # 第二个 嵌套文档
"comments.name": [ alice, white ],
"comments.comment": [ like, more, please, this ],
"comments.age": [ 31 ],
"comments.stars": [ 5 ],
"comments.date": [ 2014-10-22 ]
}
{ # 根文档 或者也可称为父文档
"title": [ eggs, nest ],
"body": [ making, money, work, your ],
"tags": [ cash, shares ]
}
在独立索引每一个嵌套对象后,对象中每个字段的相关性得以保留。我们查询时,也仅仅返回那些真正符合条件的文档。
不仅如此,由于嵌套文档直接存储在文档内部,查询时嵌套文档和根文档联合成本很低,速度和单独存储几乎一样。
嵌套文档是隐藏存储的,我们不能直接获取。如果要增删改一个嵌套对象,我们必须把整个文档重新索引才可以。值得注意的是,查询的时候返回的是整个文档,而不是嵌套文档本身。
嵌套对象映射
设置一个字段为 nested
很简单 — 你只需要将字段类型 object
替换为 nested
。
PUT /my_index
{
"mappings": {
"blogpost": {
"properties": {
"comments": {
"type": "nested", # nested 字段类型的设置参数与 object 相同。
"properties": {
"name": { "type": "string" },
"comment": { "type": "string" },
"age": { "type": "short" },
"stars": { "type": "short" },
"date": { "type": "date" }
}
}
}
}
}
}
这就是需要设置的一切。至此,所有 comments
对象会被索引在独立的嵌套文档中。可以查看 nested
类型参考文档 获取更多详细信息。
嵌套对象查询
由于嵌套对象 被索引在独立隐藏的文档中,我们无法直接查询它们。 相应地,我们必须使用 nested
查询 去获取它们。
GET /my_index/blogpost/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"title": "eggs" # title 子句是查询根文档的。
}
},
{
"nested": {
"path": "comments", # nested 子句作用于嵌套字段 comments 。在此查询中,既不能查询根文档字段,也不能查询其他嵌套文档。
"query": {
"bool": {
"must": [ # comments.name 和 comments.age 子句操作在同一个嵌套文档中。
{
"match": {
"comments.name": "john"
}
},
{
"match": {
"comments.age": 28
}
}
]
}
}
}
}
]
}}}
nested
字段可以包含其他的nested
字段。同样地,nested
查询也可以包含其他的nested
查询。而嵌套的层次会按照你所期待的被应用。
nested
查询肯定可以匹配到多个嵌套的文档。每一个匹配的嵌套文档都有自己的相关度得分,但是这众多的分数最终需要汇聚为可供根文档使用的一个分数。
默认情况下,根文档的分数是这些嵌套文档分数的平均值。可以通过设置 score_mode 参数来控制这个得分策略,相关策略有 avg
(平均值), max
(最大值), sum
(加和) 和 none
(直接返回 1.0
常数值分数)。
GET /my_index/blogpost/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"title": "eggs"
}
},
{
"nested": {
"path": "comments",
"score_mode": "max", # 返回最优匹配嵌套文档的 _score 给根文档使用。
"query": {
"bool": {
"must": [
{
"match": {
"comments.name": "john"
}
},
{
"match": {
"comments.age": 28
}
}
]
}
}
}
}
]
}
}
}
如果
nested
查询放在一个布尔查询的filter
子句中,其表现就像一个nested
查询,只是score_mode
参数不再生效。因为它被用于不打分的查询中 — 只是符合或不符合条件,不必打分 — 那么score_mode
就没有任何意义,因为根本就没有要打分的地方。
使用嵌套字段排序
尽管嵌套字段的值存储于独立的嵌套文档中,但依然有方法按照嵌套字段的值排序。 让我们添加另一个记录,以使得结果更有意思:
代码语言:javascript复制PUT /my_index/blogpost/2
{
"title": "Investment secrets",
"body": "What they don't tell you ...",
"tags": [ "shares", "equities" ],
"comments": [
{
"name": "Mary Brown",
"comment": "Lies, lies, lies",
"age": 42,
"stars": 1,
"date": "2014-10-18"
},
{
"name": "John Smith",
"comment": "You're making it up!",
"age": 28,
"stars": 2,
"date": "2014-10-16"
}
]
}
假如我们想要查询在10月份收到评论的博客文章,并且按照 stars
数的最小值来由小到大排序,那么查询语句如下:
GET /_search
{
"query": {
"nested": { # 此处的 nested 查询将结果限定为在10月份收到过评论的博客文章。
"path": "comments",
"filter": {
"range": {
"comments.date": {
"gte": "2014-10-01",
"lt": "2014-11-01"
}
}
}
}
},
"sort": {
"comments.stars": { # 结果按照匹配的评论中 comment.stars 字段的最小值 (min) 来由小到大 (asc) 排序。
"order": "asc", # 结果按照匹配的评论中 comment.stars 字段的最小值 (min) 来由小到大 (asc) 排序。
"mode": "min", # 结果按照匹配的评论中 comment.stars 字段的最小值 (min) 来由小到大 (asc) 排序。
"nested_path": "comments", # 排序子句中的 nested_path 和 nested_filter 和 query 子句中的 nested 查询相同,原因在下面有解释。
"nested_filter": {
"range": {
"comments.date": {
"gte": "2014-10-01",
"lt": "2014-11-01"
}
}
}
}
}
}
我们为什么要用 nested_path 和 nested_filter 重复查询条件呢?原因在于,排序发生在查询执行之后。 查询条件限定了在10月份收到评论的博客文档,但返回的是博客文档。如果我们不在排序子句中加入 nested_filter
, 那么我们对博客文档的排序将基于博客文档的所有评论,而不是仅仅在10月份接收到的评论。
嵌套聚合
在查询的时候,我们使用 nested
查询就可以获取嵌套对象的信息。同理, nested
聚合允许我们对嵌套对象里的字段进行聚合操作。
GET /my_index/blogpost/_search
{
"size" : 0,
"aggs": {
"comments": { # nested 聚合 “进入” 嵌套的 comments 对象。
"nested": {
"path": "comments"
},
"aggs": {
"by_month": {
"date_histogram": { # comment对象根据 comments.date 字段的月份值被分到不同的桶。
"field": "comments.date",
"interval": "month",
"format": "yyyy-MM"
},
"aggs": {
"avg_stars": { # 计算每个桶内star的平均数量。
"avg": {
"field": "comments.stars"
}
}
}
}
}
}
}
}
从下面的结果可以看出聚合是在嵌套文档层面进行的:
代码语言:javascript复制...
"aggregations": {
"comments": {
"doc_count": 4,
"by_month": {
"buckets": [
{
"key_as_string": "2014-09",
"key": 1409529600000,
"doc_count": 1, # 总共有4个 comments 对象 :1个对象在9月的桶里,3个对象在10月的桶里。
"avg_stars": {
"value": 4
}
},
{
"key_as_string": "2014-10",
"key": 1412121600000,
"doc_count": 3, # 总共有4个 comments 对象 :1个对象在9月的桶里,3个对象在10月的桶里。
"avg_stars": {
"value": 2.6666666666666665
}
}
]
}
}
}
...
逆向嵌套聚合
nested
聚合 只能对嵌套文档的字段进行操作。 根文档或者其他嵌套文档的字段对它是不可见的。 然而,通过 reverse_nested
聚合,我们可以 走出 嵌套层级,回到父级文档进行操作。
例如,我们要基于评论者的年龄找出评论者感兴趣 tags
的分布。 comment.age
是一个嵌套字段,但 tags
在根文档中:
GET /my_index/blogpost/_search
{
"size" : 0,
"aggs": {
"comments": {
"nested": { # nested 聚合进入 comments 对象。
"path": "comments"
},
"aggs": {
"age_group": {
"histogram": { # histogram 聚合基于 comments.age 做分组,每10年一个分组。
"field": "comments.age",
"interval": 10
},
"aggs": {
"blogposts": {
"reverse_nested": {}, # reverse_nested 聚合退回根文档。
"aggs": {
"tags": {
"terms": { # terms 聚合计算每个分组年龄段的评论者最常用的标签词。
"field": "tags"
}
}
}
}
}
}
}
}
}
}
简略结果如下所示:
代码语言:javascript复制..
"aggregations": {
"comments": {
"doc_count": 4, # 一共有4条评论。
"age_group": {
"buckets": [
{
"key": 20, # 在20岁到30岁之间总共有两条评论。
"doc_count": 2, # 在20岁到30岁之间总共有两条评论。
"blogposts": {
"doc_count": 2, # 这些评论包含在两篇博客文章中。
"tags": {
"doc_count_error_upper_bound": 0,
"buckets": [ # 在这些博客文章中最热门的标签是 shares、 cash、equities。
{ "key": "shares", "doc_count": 2 },
{ "key": "cash", "doc_count": 1 },
{ "key": "equities", "doc_count": 1 }
]
}
}
},
...
嵌套对象的使用时机
嵌套对象在只有一个主要实体时非常有用,这个主要实体包含有限个紧密关联但又不是很重要的实体,例如我们的blogpost
对象包含评论对象。 在基于评论的内容查找博客文章时,nested
查询有很大的用处,并且可以提供更快的查询效率。
嵌套模型的缺点如下:
- 当对嵌套文档做增加、修改或者删除时,整个文档都要重新被索引。嵌套文档越多,这带来的成本就越大。
- 查询结果返回的是整个文档,而不仅仅是匹配的嵌套文档。尽管目前有计划支持只返回根文档中最佳匹配的嵌套文档,但目前还不支持。
有时你需要在主文档和其关联实体之间做一个完整的隔离设计。这个隔离是由 父子关联 提供的。
实例分享
嵌套文档看似与文档内有一个集合字段类似,但是实则有很大区别,以上面图中嵌套文档为例,留言1,留言2,留言3虽然都在当前文章所在的文档内,但是在内部其实存储为4个独立文档,如下图所示。
同时,嵌套文档的字段类型需要设置为nested,设置成nested后的不能被直接查询,需要使用nested查询。
新建索引
代码语言:javascript复制{
"title": "这是一篇文章",
"body": "这是一篇文章,从哪里说起呢? ... ...",
"comments": [
{
"name": "张三",
"comment": "写的不错",
"age": 28,
"date": "2020-05-04"
},
{
"name": "李四",
"comment": "写的很好",
"age": 20,
"date": "2020-05-04"
},
{
"name": "王五",
"comment": "这是一篇非常棒的文章",
"age": 31,
"date": "2020-05-01"
}
]
}
创建索引名和type均为blog的索引,其中comments字段为嵌套文档类型,需要将type设置为nested,其余都是一些正常的字段
代码语言:javascript复制设置Setting:
PUT /test_book
{
"settings": {
"number_of_shards": 1,
"analysis": {
"analyzer": {
"index_ansj_analyzer": {
"type": "custom",
"tokenizer": "index_ansj",
"filter": [
"my_synonym",
"asciifolding"
]
},
"comma": {
"type": "pattern",
"pattern": ","
},
"shingle_analyzer": {
"type": "custom",
"tokenizer": "standard",
"filter": [
"lowercase",
"shingle_filter"
]
}
},
"filter": {
"my_synonym": {
"type": "synonym",
"synonyms_path": "analysis/synonym.txt"
},
"shingle_filter": {
"type": "shingle",
"min_shingle_size": 2,
"max_shingle_size": 2,
"output_unigrams": false
}
}
}
}
}
设置Mapping:
PUT /test_book/_mapping/_doc
{
"_doc":{
"properties":{
"comments":{
"type":"nested",
"properties":{
"date":{
"type":"date"
},
"name":{
"type":"text",
"fields":{
"keyword":{
"type":"keyword"
}
}
},
"comment":{
"type":"text",
"fields":{
"keyword":{
"type":"keyword"
}
}
},
"age":{
"type":"long"
}
}
},
"body":{
"type":"text",
"fields":{
"keyword":{
"type":"keyword"
}
}
},
"title":{
"type":"text",
"fields":{
"keyword":{
"type":"keyword"
}
}
}
}
}
}
索引数据
代码语言:javascript复制PUT test_book/_doc/1
{
"title":"这是一篇文章",
"body":"这是一篇文章,从哪里说起呢? ... ...",
"comments":[
{
"name":"张三",
"comment":"写的不错",
"age":28,
"date":"2020-05-04"
},
{
"name":"李四",
"comment":"写的很好",
"age":20,
"date":"2020-05-04"
},
{
"name":"王五",
"comment":"这是一篇非常棒的文章",
"age":31,
"date":"2020-05-01"
}
]
}
PUT test_book/_doc/2
{
"title": "这是一篇文章2",
"body": "这是一篇文章2,从哪里说起呢? ... ...",
"comments": [
{
"name": "张三",
"comment": "写的不错",
"age": 28,
"date": "2020-05-11"
},
{
"name": "李四",
"comment": "写的很好",
"age": 20,
"date": "2020-05-16"
},
{
"name": "王五",
"comment": "这是一篇非常棒的文章",
"age": 31,
"date": "2020-05-01"
}
]
}
PUT test_book/_doc/3
{
"title": "这是一篇文章3",
"body": "这是一篇文章3,从哪里说起呢? ... ...",
"comments": [
{
"name": "张三",
"comment": "写的不错",
"age": 28,
"date": "2020-05-03"
},
{
"name": "李四",
"comment": "写的很好",
"age": 20,
"date": "2020-05-20"
},
{
"name": "王五",
"comment": "这是一篇非常棒的文章",
"age": 31,
"date": "2020-05-01"
}
]
}
POST test_book/_search
{
"query": {
"match_all": {}
}
}
结果:
{
"took" : 1,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : 1,
"max_score" : 1.0,
"hits" : [
{
"_index" : "test_book",
"_type" : "_doc",
"_id" : "1",
"_score" : 1.0,
"_source" : {
"title" : "这是一篇文章",
"body" : "这是一篇文章,从哪里说起呢? ... ...",
"comments" : [
{
"name" : "张三",
"comment" : "写的不错",
"age" : 28,
"date" : "2020-05-04"
},
{
"name" : "李四",
"comment" : "写的很好",
"age" : 20,
"date" : "2020-05-04"
},
{
"name" : "王五",
"comment" : "这是一篇非常棒的文章",
"age" : 31,
"date" : "2020-05-01"
}
]
}
}
]
}
}
查询数据
代码语言:javascript复制直接查询根文档:
POST test_book/_search
{
"query": {
"match": {
"title.keyword": "这是一篇文章"
}
}
}
查询结果:
{
"took" : 3,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : 1,
"max_score" : 0.2876821,
"hits" : [
{
"_index" : "test_book",
"_type" : "_doc",
"_id" : "1",
"_score" : 0.2876821,
"_source" : {
"title" : "这是一篇文章",
"body" : "这是一篇文章,从哪里说起呢? ... ...",
"comments" : [
{
"name" : "张三",
"comment" : "写的不错",
"age" : 28,
"date" : "2020-05-04"
},
{
"name" : "李四",
"comment" : "写的很好",
"age" : 20,
"date" : "2020-05-04"
},
{
"name" : "王五",
"comment" : "这是一篇非常棒的文章",
"age" : 31,
"date" : "2020-05-01"
}
]
}
}
]
}
}
备注:要是想直接查询内部文档是查询不到的,需要加上nested即可
POST test_book/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"title": "文章"
}
},
{
"nested": {
"path": "comments",
"query": {
"bool": {
"must": [
{
"match": {
"comments.name": "张三"
}
}
]
}
}
}
}
]
}
}
}
查询结果:
{
"took" : 10,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : 1,
"max_score" : 2.5370226,
"hits" : [
{
"_index" : "test_book",
"_type" : "_doc",
"_id" : "1",
"_score" : 2.5370226,
"_source" : {
"title" : "这是一篇文章",
"body" : "这是一篇文章,从哪里说起呢? ... ...",
"comments" : [
{
"name" : "张三",
"comment" : "写的不错",
"age" : 28,
"date" : "2020-05-04"
},
{
"name" : "李四",
"comment" : "写的很好",
"age" : 20,
"date" : "2020-05-04"
},
{
"name" : "王五",
"comment" : "这是一篇非常棒的文章",
"age" : 31,
"date" : "2020-05-01"
}
]
}
}
]
}
}
备注:nested中查询的是嵌套文档的内容,语法与正常查询时一致。 nested 查询肯定可以匹配到多个嵌套的文档。每一个匹配的嵌套文档都有自己的相关度得分,但是这众多的分数最终需要汇聚为可供根文档使用的一个分数。 默认情况下,根文档的分数是这些嵌套文档分数的平均值。可以通过设置 score_mode 参数来控制这个得分策略,相关策略有 avg(平均值),max(最大值),sum(加和) 和 none(直接返回 1.0 常数值分数)。
排序
代码语言:javascript复制POST test_book/_search
{
"query": {
"bool": {
"must": [
{
"match": {
"title": "文章"
}
},
{
"nested": {
"path": "comments",
"query": {
"bool": {
"must": [
{
"match": {
"comments.name": "张三"
}
}
]
}
}
}
}
]
}
},
"sort": {
"comments.date": {
"order": "desc",
"mode": "max",
"nested_path": "comments",
"nested_filter": {
"bool": {
"must": [
{
"match": {
"comments.name": "张三"
}
}
]
}
}
}
}
}
备注:需要注意的是,在sort内,又添加了nested_filter来过滤一遍上面嵌套文档的查询条件,原因是这样的,在嵌套文档查询排序时是先按照条件进行查询,查询后再进行排序,那么可能由于数据的原因,导致排序的字段不是按照匹配上的数据进行排序
聚合
聚合的场景可能也比较常见,其实熟悉上面嵌套文档的使用的话,对聚合文档使用难度应该也不大。
代码语言:javascript复制PUT test_book/_doc/4
{
"title": "这是一篇文章4",
"body": "这是一篇文章4,从哪里说起呢? ... ...",
"comments": [
{
"name": "张三",
"comment": "写的不错",
"age": 28,
"date": "2020-03-03"
},
{
"name": "李四",
"comment": "写的很好",
"age": 20,
"date": "2020-04-20"
},
{
"name": "王五",
"comment": "这是一篇非常棒的文章",
"age": 31,
"date": "2020-06-01"
}
]
}
举例:需要查询每个月评论人数的平均数,查询语句如下:
代码语言:javascript复制POST test_book/_search
{
"size": 0,
"aggs": {
"comments": {
"nested": {
"path": "comments"
},
"aggs": {
"by_month": {
"date_histogram": {
"field": "comments.date",
"interval": "month",
"format": "yyyy-MM"
},
"aggs": {
"avg_stars": {
"avg": {
"field": "comments.age"
}
}
}
}
}
}
}
}
查询结果:
{
"took" : 16,
"timed_out" : false,
"_shards" : {
"total" : 1,
"successful" : 1,
"skipped" : 0,
"failed" : 0
},
"hits" : {
"total" : 4,
"max_score" : 0.0,
"hits" : [ ]
},
"aggregations" : {
"comments" : {
"doc_count" : 12,
"by_month" : {
"buckets" : [
{
"key_as_string" : "2020-03",
"key" : 1583020800000,
"doc_count" : 1,
"avg_stars" : {
"value" : 28.0
}
},
{
"key_as_string" : "2020-04",
"key" : 1585699200000,
"doc_count" : 1,
"avg_stars" : {
"value" : 20.0
}
},
{
"key_as_string" : "2020-05",
"key" : 1588291200000,
"doc_count" : 9,
"avg_stars" : {
"value" : 26.333333333333332
}
},
{
"key_as_string" : "2020-06",
"key" : 1590969600000,
"doc_count" : 1,
"avg_stars" : {
"value" : 31.0
}
}
]
}
}
}
}
备注:正如本文所说,嵌套文档中,所有内容都在同一个文档内,这就导致嵌套文档进行增加、修改或者删除时,整个文档都要重新被索引。嵌套文档越多,这带来的成本就越大。