“知了”开发日志——基于ES嵌套文档实现的支持包含附件及评论的全文检索

与专用的关系型数据库存储有所不同,Elasticsearch 并没有对处理实体之间的关系给出直接的方法。在知识管理应用之前的版本开发中,附件与评论的存储都需要要以类似于「数组」「列表」的方式存储下来,所以早些版本采用了直接将列表字符串存储入ES的一个字段中,但很显然这并不利于我们的检索。好在,ES给出了我们新的数据建模方式——嵌套文档(Nested)和父子文档(Join),本次文档将着重介绍目前知识管理应用采用的数据建模方式——嵌套文档(Nested)。

img

1. 为什么使用嵌套文档(Nested)

数据建模的一致性

由于在 Elasticsearch 中单个文档的增删改都是原子性操作,那么将相关实体数据都存储在同一文档中也就理所当然,在前一版本使用父子文档(Join)时,每一个附件都会生成一个新的子文档,这在本质上与父文档是具有同等的数据地位的,这造成了在Elasticsearch中父文档与子文档杂糅,十分混乱,不够优雅!

img

查询效率的提升

嵌套文档(Nested)先天的查询效率要高于父子文档(Join)。嵌套对象通过冗余数据来提高查询性能,适用于读多写少的场景。父子文档类似关系型数据库中的关联关系,适用于写多的场景,减少了文档修改的范围。很显然对于评论和附件都是读多写少的场景,因此选择嵌套索引对于性能来说非常划算!

该场景下父子文档(Join)增删改查相当复杂

举一个例子,当Permissioned字段(即资源是否公开字段)被用户修改,我们需要既需要修改父文档的公开状态也需要修改子文档,而使用嵌套文档所有嵌套对象都是自动处理的,因为他们本来就属于同一个文档。

2. 嵌套文档(Nested)的实现原理

事实上Elasticsearch没有内部对象的概念。因此,就比如说在我们的知识管理应用中,一个文档有作者、创建时间、标题、正文、附件等属性,附件这个属性是一个对象,它又包含了附件的名字、内容、解析状态等等,ES并不能理解这样的嵌套对象,它将对象层次结构扁平化为字段名称和值的简单列表。比如说像这样的嵌套对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
{
"user_id": 144,
"id": 17760,
"text": "<p><span style=\"font-size: 18pt;\">附件解析太牛啦!!</span></p>",
"comment": [
{
"resourceId": 17760,
"user_id": 144,
"comment": "好资源!!!",
"time": 1648291958000
},
{
"resourceId": 17760,
"user_id": 144,
"comment": "赞!!",
"time": 1648292134000
}]
}

ES会这样存储起来:

1
2
3
4
5
6
7
"user_id": 144, 
"id": 17760,
"text": "<p><span style="font-size: 18pt;">附件解析太牛啦!!</span></p>",
"comment.resourceId": [17760,17760]
"comment.user_id": [144,144]
"comment.comment": ["好资源!!!","赞!!"]
"comment.time": [1648291958000,1648292134000]

comment.resourceIdcomment.user_id等字段被扁平化为了多值字段

3. “知了”中数据建模的具体实现

在之后重建ES索引时可采用如下建模方法:

PUT http://192.168.36.136:9200/knowledge_ms_v3/

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
{
"settings":{
"number_of_shards":"3",
"number_of_replicas":"1",
"analysis":{
"analyzer":{
"douhao":{
"type":"pattern",
"pattern":","
}
}
}
},
"mappings":{
"resource":{
"properties":{
"@timestamp":{
"type":"date"
},
"@version":{
"type":"text",
"fields":{
"keyword":{
"type":"keyword",
"ignore_above":256
}
}
},
"attachment":{
"type":"nested",
"properties":{
"resourceId":{
"type":"keyword"
},
"attachmentId":{
"type":"keyword"
},
"createTime":{
"type":"keyword"
},
"name":{
"type":"keyword"
},
"url":{
"type":"keyword"
},
"attach_text":{
"type":"text"
},
"status":{
"type":"keyword"
}
}
},
"comment":{
"type":"nested",
"properties":{
"resourceId":{
"type":"keyword"
},
"user_id":{
"type":"keyword"
},
"comment":{
"type":"text"
},
"time":{
"type":"keyword"
},
"userName":{
"type":"keyword"
},
"picName":{
"type":"keyword"
}
}
},
"collection":{
"type":"keyword"
},
"create_time":{
"type":"date"
},
"crop_id":{
"type":"long"
},
"edit_time":{
"type":"keyword"
},
"group_id":{
"type":"text",
"fields":{
"keyword":{
"type":"keyword",
"ignore_above":256
}
},
"analyzer":"douhao"
},
"id":{
"type":"keyword"
},
"label_id":{
"type":"text",
"fields":{
"keyword":{
"type":"keyword",
"ignore_above":256
}
},
"analyzer":"douhao"
},
"opposition":{
"type":"keyword"
},
"pageview":{
"type":"keyword"
},
"permissionId":{
"type":"keyword"
},
"recognition":{
"type":"keyword"
},
"superior":{
"type":"keyword"
},
"text":{
"type":"text",
"term_vector":"with_positions_offsets",
"fields":{
"keyword":{
"type":"keyword",
"ignore_above":256
}
},
"analyzer":"ik_max_word",
"search_analyzer":"ik_smart"
},
"title":{
"type":"text",
"term_vector":"with_positions_offsets",
"fields":{
"keyword":{
"type":"keyword",
"ignore_above":256
}
},
"analyzer":"ik_max_word",
"search_analyzer":"ik_smart"
},
"type":{
"type":"text",
"fields":{
"keyword":{
"type":"keyword",
"ignore_above":256
}
}
},
"user_id":{
"type":"keyword"
}
}
}
}
}

4. 嵌套查询在Java中的支持

嵌套查询在知了中主要在public Page<ResourceES> searchResourceES()函数中实现。

首先我们构建一个BoolQueryBuilder

1
BoolQueryBuilder reBuilder = new BoolQueryBuilder();

然后我们使用嵌套查询对象NestedQueryBuilder分别构建附件与评论的嵌套查询:

1
2
NestedQueryBuilder nestedQuery = new NestedQueryBuilder("attachment", new MatchQueryBuilder("attachment.attach_text", query), ScoreMode.Total).boost(5.0f);
NestedQueryBuilder nestedQuery2 = new NestedQueryBuilder("comment", new MatchQueryBuilder("comment.comment", query), ScoreMode.Total).boost(10.0f);

然后我们要将两个嵌套查询对象与对标题与正文搜索的多字段查询合并到BoolQueryBuilder中:

1
reBuilder = reBuilder.should(multiMatchQuery(query, "title", "text")).boost(1.0f).should(nestedQuery).should(nestedQuery2);

最后我们对BoolQueryBuilder对象进行一些约束和排序,放入函数中进行查询,返回到ES对象中:

1
resourcePage = elasticsearchTemplate.queryForPage(searchQuery, ResourceES.class);

特别要注意的是,我们需要将Java对象中的附件与索引的属性进行修改,即要将附件与评论的数据类型由先前的String改为Object,否则无法将ES返回的结果映射到我们的对象上。

5. 使用HTTP进行查询设计

虽然ES为我们设计了非常方便好用的JavaAPI,但是我们仍然可以根据自己的需要进行查询的设计。

上述JavaAPI对应的HTTP方法:

POST http://192.168.36.136:9200/knowledge_ms_v3/resource/_search

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
{
"query":{
"bool":{
"should":[
{
"match":{
"title":"query"
}
},
{
"match":{
"text":"query"
}
},
{
"nested":{
"path":"attachment",
"query":{
"bool":{
"must":[
{
"match":{
"attachment.attach_text":"query"
}
}
]
}
}
}
},
{
"nested":{
"path":"comment",
"query":{
"bool":{
"must":[
{
"match":{
"comment.comment":"query"
}
}
]
}
}
}
}
]
}
}
}
------ 本文结束,感谢观看! ------
 wechat
扫一扫,访问本站