安装

docker安装 es

拉取镜像

1
docker pull elasticsearch:7.3.0

准备好数据挂载文件夹,比如 E:\docker\elasticsearch,先启动一个容器

1
docker run -d --name es7 -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" -e ES_JAVA_OPTS="-Xms1024m -Xmx1024m" elasticsearch:7.3.0

复制需要挂载的文件夹

1
2
3
docker cp es7:/usr/share/elasticsearch/config E:\docker\elasticsearch
docker cp es7:/usr/share/elasticsearch/data E:\docker\elasticsearch
docker cp es7:/usr/share/elasticsearch/plugins E:\docker\elasticsearch

修改 /config 文件夹下的 elasticsearch.yml 文件如下,cluster.name 可自己指定

1
2
3
4
5
6
cluster.name: "elasticsearch-cluster"
network.host: 0.0.0.0
# 跨域相关
http.cors.allow-origin: "*"
http.cors.enabled: true
http.cors.allow-headers: Authorization,X-Requested-With,Content-Length,Content-Type

删除刚刚创建的容器

1
docker rm -f es7

新建容器,使用挂载的文件夹

1
docker run -d --name es7 -p 9200:9200 -p 9300:9300 -e "discovery.type=single-node" -e ES_JAVA_OPTS="-Xms1024m -Xmx1024m" -v E:\docker\elasticsearch\config:/usr/share/elasticsearch/config -v E:\docker\elasticsearch\data:/usr/share/elasticsearch/data -v E:\docker\elasticsearch\plugins:/usr/share/elasticsearch/plugins -v E:\docker\elasticsearch\logs:/usr/share/elasticsearch/logs elasticsearch:7.3.0

浏览器访问 http://localhost:9200/ 如下:

可视化工具 Kibana 安装

拉取镜像

1
docker pull kibana:7.3.0

准备好挂载的文件夹,先运行一个容器

1
docker run -d --name kibana -p 5601:5601 kibana:7.3.0

复制配置到挂载的文件夹

1
docker cp kibana:/usr/share/kibana/config E:\docker\kibana

编辑配置文件 kibana.yml 如下

1
2
3
4
5
server.name: kibana
server.host: "0"
elasticsearch.hosts: [ "http://172.17.0.1:9200" ] # 修改为宿主机 IP 地址
xpack.monitoring.ui.container.elasticsearch.enabled: true
i18n.locale: "zh-CN" # 设置使用中文

如何确定本机 IP 地址:windows 系统终端执行 ipconfig 命令,复制 IPV4 地址即可

删除刚刚运行的容器,并重新使用挂载的文件夹数据运行一个新的容器

1
docker rm -f kibana
1
docker run -d --name kibana -p 5601:5601 -v E:\docker\kibana\config:/usr/share/kibana/config kibana:7.3.0

浏览器访问 http://localhost:5601 即可访问控制台

Kibana 内置的 Dev Tools 提供了一个与 Elasticsearch 交互的界面,主要特点:

  • 类似命令行的输入界面:直接输入 Elasticsearch 的 RESTful API 调用,查询数据、创建索引、更新设置等都可以。
  • 历史记录:保存之前的查询,方便回顾或重用。
  • 语法高亮和自动补全:JSON 语法高亮,API 方法和参数有补全提示。
  • 响应格式化:返回的 JSON 自动格式化,便于阅读。

Elasticsearch 基本请求格式

Elasticsearch 提供 REST API,常见请求格式如下:

1
2
3
4
GET /索引名/_search
POST /索引名/_doc
PUT /索引名/_doc/文档ID
DELETE /索引名/_doc/文档ID

Elasticsearch 基础语法学习

1. 创建索引

创建一个名为 users 的索引。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
PUT /users
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0
},
"mappings": {
"properties": {
"name": {
"type": "text"
},
"age": {
"type": "integer"
},
"email": {
"type": "keyword"
}
}
}
}

2. 新增文档

指定 ID 新增

1
2
3
4
5
6
PUT /users/_doc/1
{
"name": "张三",
"age": 20,
"email": "zhangsan@example.com"
}

自动生成 ID 新增

1
2
3
4
5
6
POST /users/_doc
{
"name": "李四",
"age": 25,
"email": "lisi@example.com"
}

3. 根据 ID 查询文档

1
GET /users/_doc/1

4. 搜索文档

查询所有文档

1
2
3
4
5
6
GET /users/_search
{
"query": {
"match_all": {}
}
}

条件搜索

查询 name 包含 张 的文档:

1
2
3
4
5
6
7
8
GET /users/_search
{
"query": {
"match": {
"name": "张"
}
}
}

精确查询

查询 email 等于指定值:

1
2
3
4
5
6
7
8
GET /users/_search
{
"query": {
"term": {
"email": "zhangsan@example.com"
}
}
}

范围查询

查询年龄大于等于 20 的文档:

1
2
3
4
5
6
7
8
9
10
GET /users/_search
{
"query": {
"range": {
"age": {
"gte": 20
}
}
}
}

分页查询

1
2
3
4
5
6
7
8
GET /users/_search
{
"query": {
"match_all": {}
},
"from": 0,
"size": 10
}

参数含义:

  • from:从第几条开始
  • size:每页返回多少条

5. 更新文档

局部更新

只更新某个字段:

1
2
3
4
5
6
POST /users/_update/1
{
"doc": {
"age": 21
}
}

增加字段

1
2
3
4
5
6
POST /users/_update/1
{
"doc": {
"city": "北京"
}
}

6. 删除文档

删除 ID 为 1 的文档:

1
DELETE /users/_doc/1

7. 删除索引

删除整个 users 索引:

1
DELETE /users

8. 查看索引是否存在

1
HEAD /users

如果存在,返回状态码:

1
200

如果不存在,返回:

1
404

9. 查看索引信息

1
GET /users

查看索引 mapping:

1
GET /users/_mapping

查看索引 settings:

1
GET /users/_settings

10. 最小可用 CRUD 总结

增

1
2
3
4
5
6
PUT /users/_doc/1
{
"name": "张三",
"age": 20,
"email": "zhangsan@example.com"
}

查

1
GET /users/_doc/1

改

1
2
3
4
5
6
POST /users/_update/1
{
"doc": {
"age": 21
}
}

删

1
DELETE /users/_doc/1

记住这个格式就行:

1
2
3
4
5
6
PUT    /索引/_doc/ID       新增或覆盖文档
GET /索引/_doc/ID 查询单个文档
POST /索引/_update/ID 更新文档
DELETE /索引/_doc/ID 删除文档
GET /索引/_search 搜索文档
DELETE /索引 删除索引

倒排索引

认识

倒排索引是从关键词到文档的映射。它与传统的正排索引不同,正排索引存储的是文档及其内容,而倒排索引反其道而行之,存储的是每个关键词以及包含这些关键词的文档信息。

简单示例:

假设我们有以下文档,分别对应一条中文句子:

  • 文档 1: Elasticsearch 是分布式引擎
  • 文档 2: 分布式系统的核心是搜索
  • 文档 3: 搜索引擎的未来是智能化

倒排索引将这些文档转化为如下形式:

关键词 文档 ID
elasticsearch 1
分布式 1, 2
系统 2
核心 2
搜索 2, 3
引擎 1, 3
智能化 3

当用户搜索某个关键词,如“引擎” 时,Elasticsearch 可以通过倒排索引快速查找到关键词对应的文档 ID(文档 1 和文档 3),而无需扫描所有文档。

构建过程

1. 分词(Tokenization)

分词是将文本内容拆分为独立的单词或短语,例如:

1
2
3
原语句:Elasticsearch是分布式引擎

分词结果:[Elasticsearch, 是, 分布式, 引擎]

2. 归一化(Normalization)

归一化是对分词结果进行标准化处理,包括:

  • 转小写:将所有大写字母转为小写。例如 Elasticsearch → elasticsearch。
  • 去除停用词:删除一些对查询结果影响不大的词,如“的”“是”等。
  • 词干提取:将词汇归为基本形式,如 running → run。

3. 创建倒排索引

根据分词结果,为每个词项建立一个到文档的映射关系。最终生成的倒排索引可以看作如下结构,然后存储起来:

1
2
3
4
5
6
{
"elasticsearch": [1],
"分布式": [1, 2],
"引擎": [1, 3],
"搜索": [2, 3]
}

此时,倒排索引已经完成,后续的搜索操作会以此为基础。

ik 中文分词器插件

安装

打开终端进入 es7 容器的内部

1
docker exec -it es7 /bin/sh

执行安装命令

1
bin/elasticsearch-plugin install https://gt.infini.cloud/elasticsearch/analysis-ik/7.3.0

安装完成后,执行如下命令,退出容器:

1
exit

然后,执行 docker cp 命令,将已安装的 ik 插件相关文件,复制到宿主机中,防止丢失:

1
2
docker cp es7:/usr/share/elasticsearch/plugins/analysis-ik E:\docker\elasticsearch\plugins
docker cp es7:/usr/share/elasticsearch/config/analysis-ik E:\docker\elasticsearch\config

重启容器

1
docker restart es7

分词模式

智能分词:ik_smart

1
2
3
4
5
POST /_analyze
{
"analyzer": "ik_smart",
"text": "小米手机"
}

最细粒度分词:ik_max_word

1
2
3
4
5
POST /_analyze
{
"analyzer": "ik_max_word",
"text": "中华人民共和国"
}

进阶使用

索引

常用属性

顶层控制属性

dynamic

场景:你定义了 title 是 text,结果代码不小心多传了个 age 字段。

  • dynamic: true(默认):ES 默默帮你在映射里加上 age,类型可能错判成 long,导致后续范围查询异常。
  • dynamic: strict(推荐):直接拒绝写入,返回明确报错,倒逼你修正代码。

示例(严格模式下的报错):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
# 1. 创建索引,开启 strict
PUT /test_strict
{
"mappings": {
"dynamic": "strict",
"properties": { "title": { "type": "text" } }
}
}

# 2. 插入一条多带了字段的数据
POST /test_strict/_doc/1
{
"title": "Hello",
"unexpected_field": "oops" # 这个字段没在 mapping 里定义过
}

# 3. 返回结果:直接报错 400
# {
# "error": {
# "reason": "mapping set to strict, dynamic introduction of [unexpected_field] is not allowed"
# }
# }
fields + ignore_above

场景:title 既要全文搜索(用 text),又要按字母排序(必须用 keyword)。同时,为了防止有人存超长文本撑爆内存,用 ignore_above 切断。

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
# 1. 创建索引
PUT /test_fields
{
"mappings": {
"properties": {
"title": {
"type": "text",
"fields": { # 扩展子字段
"keyword": {
"type": "keyword",
"ignore_above": 10 # 超过10个字符的,不建立 keyword 索引
}
}
}
}
}
}

# 2. 插入两条数据
POST /test_fields/_doc/1
{ "title": "Short" } # 5个字符,正常

POST /test_fields/_doc/2
{ "title": "Very Very Long Title" } # 超过10个字符

# 3. 尝试按 title.keyword 排序
GET /test_fields/_search
{
"sort": [{ "title.keyword": "asc" }]
}

# 结果:文档2 的 title 超过 10 个字符,没有生成 title.keyword 的值,
# 排序时按缺失值处理(默认排在最后),文档本身仍会被返回。
copy_to

场景:用户想搜索 title 或 content,不想分两个字段查,希望一个 full 字段覆盖所有。用 copy_to 把多个字段的内容复制到一个隐藏字段里。

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
# 1. 创建索引
PUT /test_copy
{
"mappings": {
"properties": {
"title": {
"type": "text",
"copy_to": "full_text" # 复制一份给 full_text
},
"content": {
"type": "text",
"copy_to": "full_text" # 复制一份给 full_text
},
"full_text": { # 专门用来搜的组合字段
"type": "text"
}
}
}
}

# 2. 插入数据
POST /test_copy/_doc/1
{
"title": "Elasticsearch 教程",
"content": "本文讲解 mappings 核心属性"
}

# 3. 直接在 full_text 里搜,能命中 title 或 content 里的词
GET /test_copy/_search
{
"query": {
"match": { "full_text": "教程" } # 成功命中!
}
}
format

场景:你的数据来源复杂,有时候传 "2026-09-05",有时候传 "2026/09/05",默认格式是 strict_date_optional_time||epoch_millis,只认 ISO 风格(如 2026-09-05、2026-09-05T10:30:00Z)和毫秒时间戳,不认斜杠。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
# 1. 创建索引,指定多种兼容格式(用 || 分隔)
PUT /test_date
{
"mappings": {
"properties": {
"create_time": {
"type": "date",
"format": "yyyy-MM-dd||yyyy/MM/dd||yyyy-MM-dd HH:mm:ss"
}
}
}
}

# 2. 插入三种不同格式,全部成功!
POST /test_date/_doc/1
{ "create_time": "2026-09-05" }

POST /test_date/_doc/2
{ "create_time": "2026/09/05" }

POST /test_date/_doc/3
{ "create_time": "2026-09-05 18:30:00" }

# 3. 此时按日期范围查询,所有文档都能被正确筛选,不会因为格式报错。
null_value

场景:有些文档的 tags 字段没填,是 null。默认情况下,null 值不会建立倒排索引,你搜 tags: "NULL" 搜不到。如果你想统一把空值替换成占位符,就用 null_value。

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
# 1. 创建索引,将 null 替换成 "EMPTY"
PUT /test_null
{
"mappings": {
"properties": {
"tags": {
"type": "keyword",
"null_value": "EMPTY" # 存 null 时,实际索引里存的是 "EMPTY"
}
}
}
}

# 2. 插入一条 tags 为 null 的数据
POST /test_null/_doc/1
{ "tags": null }

# 3. 搜索 "EMPTY",能够搜到这条数据!
GET /test_null/_search
{
"query": {
"term": { "tags": "EMPTY" }
}
}
# 成功返回文档1
normalizer

场景:存邮箱或用户名,用户搜 "Admin" 想命中 "admin",但 keyword 是精确匹配,区分大小写。用 normalizer 在索引时统一转小写。

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
# 1. 先在 settings 里定义自定义 normalizer
PUT /test_normalizer
{
"settings": {
"analysis": {
"normalizer": {
"lowercase_normalizer": {
"type": "custom",
"filter": ["lowercase"] # 统一转小写
}
}
}
},
"mappings": {
"properties": {
"username": {
"type": "keyword",
"normalizer": "lowercase_normalizer"
}
}
}
}

# 2. 插入数据时存大写的 "Admin"
POST /test_normalizer/_doc/1
{ "username": "Admin" }

# 3. 实际 ES 倒排索引里存的是 "admin"(被转换了)
# 此时用小写去 term 精确查询,依然能命中
GET /test_normalizer/_search
{
"query": {
"term": { "username": "admin" } # 成功命中!
}
}

字段级属性

index:搜索开关

场景:有些字段(如内部 ID、base64 图片、加密数据)只需要随文档返回展示,永远不需要被搜索。关闭索引可以节省大量磁盘空间和写入 CPU。

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
# 1. 创建索引:secret_id 不建索引,name 正常建索引
PUT /test_index_attr
{
"mappings": {
"properties": {
"secret_id": {
"type": "keyword",
"index": false # 关闭索引,此字段不可被查询
},
"name": {
"type": "text" # 默认 index: true,可被搜索
}
}
}
}

# 2. 插入数据
POST /test_index_attr/_doc/1
{
"secret_id": "S999_TOP_SECRET",
"name": "Alice"
}

# 3. 尝试搜索 secret_id(会报错)
GET /test_index_attr/_search
{
"query": {
"term": { "secret_id": "S999_TOP_SECRET" }
}
}
# 报错:Cannot search on field [secret_id] since it is not indexed.

# 4. 搜索 name(正常返回)
GET /test_index_attr/_search
{
"query": {
"match": { "name": "Alice" }
}
}
# 成功命中
analyzer vs search_analyzer

场景:索引时希望尽可能多地切词(提高召回率),搜索时希望更符合用户意图(提高精准度)。比如中文搜索,索引用 ik_max_word(细粒度),搜索用 ik_smart(粗粒度)。

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
# 1. 创建索引(假设你已安装 IK 分词器;若未安装,用 standard 做对比演示原理)
PUT /test_analyzer
{
"mappings": {
"properties": {
"title": {
"type": "text",
"analyzer": "ik_max_word", # 索引时:尽可能多拆分(如"中华人民共和国"拆成"中华","华人","共和","国"...)
"search_analyzer": "ik_smart" # 搜索时:拆成最粗粒度(如"中华人民共和国"只拆成"中华人民共和国")
}
}
}
}

# 2. 插入数据
POST /test_analyzer/_doc/1
{ "title": "中华人民共和国国歌" }

# 3. 用户搜索"国歌"(ik_smart 会把"国歌"作为一个词),能命中
GET /test_analyzer/_search
{
"query": { "match": { "title": "国歌" } }
}
# 命中(因为索引时 ik_max_word 拆出了"国歌")

# 注意:如果不设置 search_analyzer,默认等于 analyzer。
# 如果搜索也用 ik_max_word,用户搜"国歌"时会被拆成"国"和"歌",会搜出更多无关结果,精准度下降。
doc_values

原理:text 类型默认 doc_values: false(因为分词后无法做精确排序/聚合),keyword 和数值类型默认 true。如果你错误地关闭了它,排序和聚合会直接报错。

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
# 1. 创建索引:score 关闭 doc_values
PUT /test_docvalues
{
"mappings": {
"properties": {
"score": {
"type": "integer",
"doc_values": false # 强制关闭列式存储
}
}
}
}

# 2. 插入数据
POST /test_docvalues/_doc/1
{ "score": 100 }
POST /test_docvalues/_doc/2
{ "score": 200 }

# 3. 尝试按 score 排序(会报错)
GET /test_docvalues/_search
{
"sort": [{ "score": "asc" }]
}
# 报错:Cannot sort on field [score] without doc_values

# 结论:除非你百分百确定这个字段永不排序/聚合,否则永远保持 doc_values: true(默认)
coerce

场景:你定义字段为 integer,但数据源传来 "10.0"(字符串带小数点)。默认 coerce: true 会自动转成整数 10,不会报错。如果你希望严格校验,就关掉它。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# 1. 创建索引,关闭 coerce(严格模式)
PUT /test_coerce
{
"mappings": {
"properties": {
"price": {
"type": "integer",
"coerce": false # 关闭自动类型转换
}
}
}
}

# 2. 插入带小数点的字符串(会报错)
POST /test_coerce/_doc/1
{ "price": "10.0" }
# 报错:failed to parse field [price] of type [integer]

# 对比:如果设为 coerce: true(默认),"10.0" 会被自动转为 10,正常写入。
# 生产建议:日志/爬虫数据来源混乱,保持 true;核心业务接口数据,设为 false 强制规范。
properties

场景:你的文档里有一个嵌套的 user 对象,包含 name 和 age。必须在 properties 里再定义一次内部字段,否则 ES 会动态映射,类型可能不准。

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
# 1. 创建索引,定义 object 内部结构
PUT /test_object
{
"mappings": {
"properties": {
"user": {
"type": "object", # 显式声明为对象
"properties": { # 关键:必须在这里定义内部字段
"name": { "type": "keyword" },
"age": { "type": "integer" }
}
}
}
}
}

# 2. 插入数据(标准 JSON 对象格式)
POST /test_object/_doc/1
{
"user": {
"name": "Charlie",
"age": 28
}
}

# 3. 查询时用「点号」访问内部字段
GET /test_object/_search
{
"query": {
"term": { "user.name": "Charlie" } # 点号访问,成功命中
}
}

# 如果不定义 properties,ES 也能动态生成,但 name 可能会被误判为 text(分词),导致 term 查询失效。
enabled

场景:你需要存储一个巨大的、结构不固定的 JSON(如第三方回调的原始报文),只是存着将来展示用,永远不需要在内部字段上做搜索或聚合。开启 enabled: false 可以节省大量索引开销。

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
# 1. 创建索引,metadata 字段只存不解析
PUT /test_enabled
{
"mappings": {
"properties": {
"metadata": {
"type": "object",
"enabled": false # 整个对象被当作一个“黑盒”字符串存储,不解析内部字段
}
}
}
}

# 2. 插入任意复杂 JSON(完全不报错)
POST /test_enabled/_doc/1
{
"metadata": {
"ip": "192.168.1.1",
"device": { "os": "iOS", "version": 17.5 },
"nested_any": { "deep": { "value": [1,2,3] } }
}
}

# 3. 尝试在 metadata 内部字段上搜索(会报错或查不到)
GET /test_enabled/_search
{
"query": {
"term": { "metadata.ip": "192.168.1.1" }
}
}
# 因为 enabled: false,内部字段未索引,搜索无效。

# 但取回文档时,metadata 完整保留(因为 _source 存储了原始 JSON)。

字段的数据类型

核心基础类型

1. text vs keyword
  • text:会分词,用于全文模糊搜索(如百度搜索文章内容)。默认不支持排序和聚合。
  • keyword:不分词,整个值作为一个整体,用于精确匹配、排序、聚合统计(如状态码、分类、邮箱)。

实战对比示例:

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
# 1. 创建索引,对比 text 和 keyword
PUT /test_strings
{
"mappings": {
"properties": {
"content": { "type": "text" }, // 全文搜索
"status": { "type": "keyword" } // 精确过滤
}
}
}

# 2. 插入一条数据
POST /test_strings/_doc/1
{
"content": "Hello World",
"status": "Published"
}

# 3. 用 match 搜 content(text 类型),能搜到,因为 "Hello World" 被拆成了 "Hello" 和 "World"
GET /test_strings/_search
{ "query": { "match": { "content": "Hello" } } } // 命中

# 4. 用 term 搜 status(keyword 类型),必须完全匹配 "Published"
GET /test_strings/_search
{ "query": { "term": { "status": "Published" } } } // 命中
GET /test_strings/_search
{ "query": { "term": { "status": "published" } } } // 查不到(大小写敏感)
2. long、integer、short、byte、double、float
  • 用途:存储数字,用于范围查询(range)、排序(sort)、数学聚合(sum、avg)。
  • 选型原则:尽量选最小的够用的类型,以节省磁盘。
    • 人的年龄:byte(-128~127)就够了。
    • 商品价格:用 float 或 scaled_float(推荐,精确控制小数点)。
    • 商品浏览量:用 integer(约 21 亿)通常够。
    • 订单金额(精确到分):建议用 long 存储“分”的整数值,避免浮点精度丢失。

示例:

1
2
3
4
5
6
7
8
9
10
PUT /test_numbers
{
"mappings": {
"properties": {
"age": { "type": "byte" }, // 小范围
"price": { "type": "scaled_float", "scaling_factor": 100 }, // 存 19.99 实际存 1999,精确
"views": { "type": "integer" } // 常规整数
}
}
}
3. date
  • 用途:存储时间,支持范围查询(最近7天)、排序(按发布时间)。
  • 关键配置:不指定 format 时默认是 strict_date_optional_time||epoch_millis,只认 ISO 风格(如 2026-09-05T10:30:00Z)和毫秒时间戳;其他格式(如 2026-09-05 18:30:00)需要自己指定 format。

实战示例(兼容多种格式):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
PUT /test_dates
{
"mappings": {
"properties": {
"create_time": {
"type": "date",
"format": "yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
}
}
}
}
# 以下三种格式都能正常写入:
POST /test_dates/_doc/1 { "create_time": "2026-09-05 18:30:00" }
POST /test_dates/_doc/2 { "create_time": "2026-09-05" }
POST /test_dates/_doc/3 { "create_time": "1693900000000" } // 毫秒时间戳
4. boolean
  • 用途:存真/假。
  • 注意:除了 JSON 的 true、false,ES 也接受字符串 "true"、"false"(以及表示 false 的空字符串 "");6.0 起不再接受 "on"、"off"、1、0 这类写法。
1
2
3
4
PUT /test_boolean
{ "mappings": { "properties": { "is_active": { "type": "boolean" } } } }
POST /test_boolean/_doc/1
{ "is_active": "true" } // 字符串也能被正确解析为 true

复杂结构类型

1. object
  • 用途:处理普通的 JSON 嵌套对象。
  • 写法:直接用 properties 定义内部字段。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
PUT /test_object
{
"mappings": {
"properties": {
"user": { // 默认就是 object 类型,可省略
"properties": {
"name": { "type": "keyword" },
"age": { "type": "integer" }
}
}
}
}
}
# 插入数据
POST /test_object/_doc/1
{ "user": { "name": "Alice", "age": 30 } }
# 查询时用点号访问
GET /test_object/_search
{ "query": { "term": { "user.name": "Alice" } } } // 命中
2. nested
  • 场景:当你的对象是数组,且需要独立维护数组内每个对象的查询条件时,必须用 nested。
  • 为什么必须用?如果用默认的 object 存数组,ES 会将其扁平化,丢失对象间的边界。

经典错误场景:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# 错误示范:用 object 存员工数组
PUT /test_wrong_object
{ "mappings": { "properties": { "employees": { "type": "object" } } } }

POST /test_wrong_object/_doc/1
{
"employees": [
{ "name": "John", "age": 25 },
{ "name": "Jane", "age": 30 }
]
}
# 此时你想查「name=John 且 age=30」的人,本应查不到,但 object 会扁平化成:
# employees.name = [John, Jane] AND employees.age = [25, 30]
# 结果导致 John(25) 和 Jane(30) 跨对象匹配,错误地命中了这条文档!

正确写法:

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
PUT /test_correct_nested
{
"mappings": {
"properties": {
"employees": {
"type": "nested", // 声明为 nested
"properties": {
"name": { "type": "keyword" },
"age": { "type": "integer" }
}
}
}
}
}
# 查询时,必须用 nested 查询包裹,保证条件只在同一个对象内生效
GET /test_correct_nested/_search
{
"query": {
"nested": {
"path": "employees",
"query": {
"bool": {
"must": [
{ "term": { "employees.name": "John" } },
{ "term": { "employees.age": 30 } }
]
}
}
}
}
}
# 结果:正确地返回 0 条(因为 John 只有 25 岁)

1. 创建索引

1.1 基础创建(使用默认配置)

最简单的创建方式,Elasticsearch 会应用默认设置(如1个主分片、1个副本分片)并启用动态映射。

1
PUT /my_first_index

1.2 指定分片与副本设置

在生产环境中,建议你根据数据量规划好主分片数,并设置副本以提高可用性。

1
2
3
4
5
6
7
PUT /my_blog
{
"settings": {
"number_of_shards": 3, # 主分片数,创建后不可修改!
"number_of_replicas": 2 # 副本数,可动态调整
}
}

1.3 定义字段映射 (Mappings)

映射定义了索引中字段的数据类型,搜索和聚合的行为都取决于它。下面的例子定义了一个包含 id(整数)、title(文本,支持全文搜索)和 publish_date(日期)的映射。

1
2
3
4
5
6
7
8
9
10
11
12
13
PUT /my_blog
{
"settings": {
"number_of_shards": 2
},
"mappings": {
"properties": {
"id": { "type": "integer" },
"title": { "type": "text" },
"publish_date": { "type": "date" }
}
}
}

注意:一旦创建,主分片数 (number_of_shards) 和已有字段的类型通常无法修改。如需更改,通常需要重建索引(Reindex)。

2. 查看索引信息

  • 查看单个索引的详细信息(包括设置和映射):
1
GET /my_blog
  • 查看集群中所有索引的概览:
1
GET /_cat/indices?v
  • 仅查看索引的设置或映射:
1
2
GET /my_blog/_settings
GET /my_blog/_mapping

3. 删除索引

此操作将永久删除整个索引及其包含的所有数据,操作前请务必确认。

1
DELETE /my_blog

你也可以使用通配符一次删除多个索引,例如 DELETE /index_*。

4. 更新索引设置

与映射不同,部分索引设置(如副本数)可以动态更新。

1
2
3
4
PUT /my_blog/_settings
{
"number_of_replicas": 1
}

查询

查询基础

Elasticsearch 所有查询都通过 _search 接口,并使用基于 JSON 的 Query DSL(领域特定语言)来构建,基本结构如下:

1
2
3
4
5
6
7
8
GET /your_index/_search
{
"query": {
"查询类型": {
// 查询的具体条件
}
}
}

注意:_search 是固定路径。

常用查询功能详解

Elasticsearch 的查询 DSL 主要分为叶子查询(在单个字段上查询)和复合查询(组合多个叶子查询)。

1. 全文检索

这类查询会对输入的搜索文本进行分词,然后用分词结果去匹配索引中的内容,并根据相关性(_score)排序。

  • match 查询:最常用的全文搜索,用于在一个指定字段中进行分词匹配。
1
2
3
4
5
6
7
8
GET /books/_search
{
"query": {
"match": {
"description": "分布式搜索引擎" // 会对 "分布式搜索引擎" 进行分词
}
}
}

示例场景:在图书描述中搜索包含“分布式”或“搜索引擎”的书籍。

  • multi_match 查询:match 的增强版,允许在多个字段中同时进行全文搜索。
1
2
3
4
5
6
7
8
9
GET /books/_search
{
"query": {
"multi_match": {
"query": "分布式搜索引擎",
"fields": ["title", "description"] // 同时在标题和描述中搜索
}
}
}
  • match_phrase 查询:要求分词后的所有词项必须全部出现在文档中,并且顺序一致、位置连续(允许通过 slop 参数调整间隔)。它把整个短语当作一个整体来查找,match 则会把词项拆开独立匹配。
1
2
3
4
5
6
7
8
GET /books/_search
{
"query": {
"match_phrase": {
"description": "分布式搜索引擎"
}
}
}

补充:

在 Elasticsearch 中,operator 主要用于 match 系列查询(如 match、multi_match),用来控制多个搜索词之间的逻辑关系,决定文档需要满足“任意一个”还是“全部”搜索词才算匹配。

它接受两个值,默认为 or:

  • or(默认值):只要文档包含任意一个搜索词,就算匹配。例如,搜索“quick brown fox”,只要文档包含quick、brown或fox中的任意一个词就会被召回。
  • and:文档必须同时包含所有搜索词,才算匹配。例如,搜索“quick brown fox”,文档必须同时包含quick、brown和fox这三个词才会被召回。

示例对比:
假设索引中有两篇文档:

  • 文档A: "I love cats"
  • 文档B: "I love dogs"

执行查询 "match": {"content": {"query": "cats dogs"}}:

operator 参数 逻辑 匹配结果
"or"(默认) cats OR dogs 文档A和文档B都会被匹配,因为每篇文档至少包含其中一个词。
"and" cats AND dogs 两篇文档都不会被匹配,因为没有文档同时包含cats和dogs。

2. 精确查询

这类查询不会对输入文本进行分词,而是将整个输入作为一个词条,与字段中的精确值进行匹配。通常用于 keyword、数值、日期等类型的字段。

  • term 查询:用于查找与指定值完全相等的文档。
1
2
3
4
5
6
7
8
GET /books/_search
{
"query": {
"term": {
"status": "published" // 精确匹配状态为 "published" 的文档
}
}
}

注意:不要用 term 查询 text 类型的字段,否则可能查不到结果。

  • terms 查询:term 的复数版本,用于匹配多个精确值中的任意一个。
1
2
3
4
5
6
7
8
GET /books/_search
{
"query": {
"terms": {
"category": ["技术", "科学"] // 查找分类是 "技术" 或 "科学" 的文档
}
}
}
  • range 查询:用于查找字段值在指定范围内的文档。
1
2
3
4
5
6
7
8
9
10
11
GET /books/_search
{
"query": {
"range": {
"price": {
"gte": 30, // 价格 >= 30
"lte": 80 // 价格 <= 80
}
}
}
}

同样适用于日期范围,如 publish_date 在 "2023-01-01" 之后。

3. 复合查询

用于组合多个查询条件,最常用的是 bool 查询,它包含四个子句:

  • must:条件必须满足,有算分贡献。
  • filter:条件必须满足,但不算分,用于过滤,性能更高。
  • should:条件满足任意一个即可,有算分贡献。
  • must_not:条件必须不满足,不算分。

示例:查找标题和描述都包含“Elasticsearch”,价格在 30 到 80 之间,且状态为“published”的书籍。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
GET /books/_search
{
"query": {
"bool": {
"must": [
{ "match": { "title": "Elasticsearch" } },
{ "match": { "description": "Elasticsearch" } }
],
"filter": [
{ "range": { "price": { "gte": 30, "lte": 80 } } },
{ "term": { "status": "published" } }
]
}
}
}

4. 聚合查询

聚合用于对数据分组统计,类似于 SQL 的 GROUP BY 和聚合函数。

1
2
3
4
5
6
7
8
9
10
11
GET /books/_search
{
"size": 0, // 不返回具体文档,只看聚合结果
"aggs": {
"category_stats": { // 自定义聚合名称
"terms": { // 按字段值分组
"field": "category.keyword" // 注意:text 类型需用 .keyword 子字段
}
}
}
}

这个查询会统计每个图书分类下的文档数量。

kNN 与 BM25 Rescore

版本说明:本节和后面“混合检索”一节的示例需要 Elasticsearch 8.x。dense_vector 的 index/similarity/index_options 和近似 kNN(当时是单独的 _knn_search 接口,技术预览)从 8.0 开始提供;在 _search 里直接写顶层 knn、以及 knn 与 query 组合的混合检索,要 8.4 及以上。前面安装部分用的 7.3.0 环境跑不了这些示例。

认识

1. kNN

在 kNN 向量检索出现之前,搜索主要依赖关键词匹配:你搜什么词,系统就找包含什么词的文档。这套机制在以下场景中不够用:

  • 语义鸿沟:用户搜“性价比高的手机”,系统只认识“性价比”“高”“手机”这几个词,无法理解“划算”“实惠”“值”等语义相近的表达。
  • 非文本数据:图片、视频、音频等无法用关键词描述的内容,传统全文检索无能为力。
  • 推荐系统:需要根据用户行为找到“相似”的商品或内容,关键词匹配无法表达“相似”这个概念。

kNN 检索的做法是:将文本、图片、用户行为等数据转化为向量(一串数字),用向量之间的“距离”来衡量相似度。你搜“好吃的餐厅”,系统会把这句话转成向量,然后找到向量最接近的那些文档,哪怕文档里没有“好吃”这个词,只要语义相近(如“美味”“口碑不错”),也能被检索出来。

Elasticsearch 从 8.0 版本开始原生支持近似 kNN 向量检索(8.0 时是 _knn_search 接口,8.4 起可以在 _search 里用顶层 knn)。

2. BM25 Rescore

BM25 是 Elasticsearch 默认的相关性评分算法。它根据词频(TF) 、逆文档频率(IDF) 和字段长度来计算文档与查询的相关性。

但 BM25 有一个局限:它只能基于关键词匹配来算分。在复杂场景下,比如:

  • 用户希望“苹果手机”这个短语整体匹配,而不是“苹果”和“手机”分开匹配;
  • 用户希望用更复杂的模型(如机器学习排序模型)来重新排序;
  • 第一轮检索结果不够精准,需要第二轮精细化调整。

如果对所有文档都执行这些复杂计算,性能会急剧下降。

Rescore(二次打分)的思路是:先用廉价的 BM25 快速召回 Top N 个候选文档,然后只对这 N 个文档执行第二轮更精准(但更昂贵)的评分,重新排序。这就像海选先用简单标准筛一遍,决赛再用严格标准精评,质量和成本都能兼顾。

适用场景

技术 适用场景 不适用场景
kNN 语义搜索、图片/视频相似检索、推荐系统、异常检测、RAG 知识库问答 纯关键词精确匹配、数据量极小(< 几千条)、对解释性要求极高的场景
BM25 Rescore 需要二次精排的复杂搜索、短语优先匹配、结合业务字段(如热度)重新排序、多轮渐进式排序 数据量极小(不需要二次优化)、对实时性要求极高且无法接受额外延迟、排序逻辑在第一轮已完全满足

kNN 和 BM25 Rescore 经常组合使用:先用 BM25 做关键词召回、kNN 做语义召回(混合搜索),再用 Rescore 对结果做二次精排,也就是常见的“召回层 + 精排层”搜索架构。

使用

dense_vector

dense_vector 是 Elasticsearch 中专门用来存储向量数据的字段类型。你在索引里定义一个 dense_vector 字段,然后把 ML 模型生成的向量(一串浮点数)存进去,kNN 检索就靠它。

用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
PUT my_index
{
"mappings": {
"properties": {
"title_vector": {
"type": "dense_vector",
"dims": 384,
"similarity": "cosine",
"index": true,
"index_options": {
"type": "hnsw",
"m": 16,
"ef_construction": 100
}
}
}
}
}

dims 是向量维度(必须与模型输出一致),similarity 是相似度算法(cosine/dot_product/l2_norm)。

近似 kNN vs 精确 kNN

近似 kNN 用 HNSW 图索引“大概”找到最近的向量,速度快但不保证 100% 精确;精确 kNN 逐个比较所有向量,保证精确但数据量大时极慢。

近似 kNN:

1
2
3
4
5
6
7
8
9
GET my_index/_search
{
"knn": {
"field": "title_vector",
"query_vector": [0.1, 0.2, 0.3, ...],
"k": 10,
"num_candidates": 100
}
}

精确 kNN:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
GET my_index/_search
{
"query": {
"script_score": {
"query": { "match_all": {} },
"script": {
"source": "cosineSimilarity(params.query_vector, 'title_vector') + 1.0",
"params": {
"query_vector": [0.1, 0.2, 0.3, ...]
}
}
}
}
}

精确 kNN 通过 script_score 实现,必须扫描所有匹配文档。

BM25(默认相关性评分算法)

BM25 是 Elasticsearch 默认的“算分公式”。它根据三个因素给文档打分:这个词在文档里出现了几次(词频 TF)、这个词在整个索引里稀不稀有(逆文档频率 IDF)、文档的字段是长是短(字段长度归一化)。

用法:

1
2
3
4
5
6
7
8
9
10
11
GET my_index/_search
{
"query": {
"match": {
"title": {
"query": "elasticsearch tutorial",
"operator": "or"
}
}
}
}

BM25 的参数可在索引设置中调整:

1
2
3
4
5
6
7
8
9
10
11
12
PUT my_index
{
"settings": {
"similarity": {
"my_bm25": {
"type": "BM25",
"k1": 1.2,
"b": 0.75
}
}
}
}

k1 控制词频饱和度(默认 1.2),b 控制字段长度归一化强度(默认 0.75)。

Rescore(二次打分)

Rescore 是在第一轮搜索结果返回后,只对排名靠前的少量文档“再算一次分”,用更复杂的逻辑重新排序。

用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
GET my_index/_search
{
"query": {
"match": {
"title": "elasticsearch tutorial"
}
},
"rescore": {
"window_size": 50,
"query": {
"rescore_query": {
"match_phrase": {
"title": {
"query": "elasticsearch tutorial",
"slop": 2
}
}
},
"query_weight": 0.7,
"rescore_query_weight": 1.2
}
}
}

window_size 控制参与重打分的文档数(默认 10),query_weight 和 rescore_query_weight 控制两轮分数的权重。

混合检索

完整的查询请求示例

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
{
"size": 10,
"knn": {
"field": "vector",
"query_vector": [0.12, -0.34, 0.56, ...],
"k": 300,
"num_candidates": 300,
"filter": {
"bool": {
"should": [
{ "term": { "userId": "42" } },
{ "term": { "public": true } },
{ "terms": { "orgTag": ["ENGINEERING", "TECH_DEPT", "COMPANY"] } }
]
}
}
},
"query": {
"bool": {
"must": [
{ "match": { "textContent": "如何配置Redis集群" } }
],
"filter": [
{
"bool": {
"should": [
{ "term": { "userId": "42" } },
{ "term": { "public": true } },
{
"bool": {
"should": [
{ "term": { "orgTag": "ENGINEERING" } },
{ "term": { "orgTag": "TECH_DEPT" } },
{ "term": { "orgTag": "COMPANY" } }
]
}
}
]
}
}
]
}
},
"rescore": {
"window_size": 300,
"query": {
"query_weight": 0.2,
"rescore_query_weight": 1.0,
"rescore_query": {
"match": {
"textContent": {
"query": "如何配置Redis集群",
"operator": "and"
}
}
}
}
}
}

注意:query_vector 里的 ... 是占位符,实际生产环境中是一长串几百位的浮点数(由 NLP 模型将“如何配置Redis集群”这句话转化而来)。

为什么需要“混合检索”?

假设你在公司内部知识库搜索“如何配置Redis集群”:

  1. 纯关键词检索(BM25):它会老老实实找包含“配置”、“Redis”、“集群”这些词的文档。但如果有人写的是“Redis 集群搭建步骤”(没有“配置”二字),它可能就搜不到了。
  2. 纯向量检索(kNN):它能读懂语义,知道“搭建”和“配置”是一回事。但它可能把权限搞混,把别人私密的“Redis故障演练”文档也推给你。

混合检索 = 左手抓关键词精确匹配,右手抓语义泛化理解,脚下还踩着“权限过滤器”防止越权,最后再用“精排算法”把最好的结果顶到最前面。

模块一:"size": 10(返回结果数)

这是整个查询的“出口阀门”。无论中间过程捞出来 300 条还是 1000 条,最后返回给前端或用户的,只有 10 条。

模块二:knn(语义检索)

1
2
3
4
5
6
"knn": {
"field": "vector", // ① 要在哪个字段里做向量搜索?
"query_vector": [0.12, ...], // ② 把“如何配置Redis集群”变成数学向量
"k": 300, // ③ 向量检索要拿回多少个“语义相似”的文档?
"num_candidates": 300 // ④ 每个分片内部先看多少个候选?
}
  • field:你在建索引时,一定有个字段叫 vector,类型是 dense_vector,里面存着每篇文档的向量值。
  • query_vector:这是通过机器学习模型实时推理出来的向量。
  • k:向量检索这一路,先猛捞 300 个语义最像的文档。
  • num_candidates:为了性能,每个分片会先挑出 300 个“看起来像的”,再汇总挑出全局最优的 300 个。

模块三:query.bool(关键词硬匹配 + 权限安检)

这是整个查询里最复杂的部分,由 must(必须匹配,算分)和 filter(必须匹配,不算分)组成。

① must 里的 match:全文检索(传统搜索引擎老本行)
1
{ "match": { "textContent": "如何配置Redis集群" } }
  • 这个 match 会把“如何配置Redis集群”分词(比如分成:如何、配置、Redis、集群)。
  • 然后去 textContent 字段里找包含这些词的文档,并计算一个 相关性评分(_score),词频越高、越稀有的词,得分越高。
② filter 里的 bool.should:权限门禁(OR 逻辑,必须满足其一)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
"filter": [
{
"bool": {
"should": [
{ "term": { "userId": "42" } }, // 条件A:文档是你自己写的
{ "term": { "public": true } }, // 条件B:文档是公开的
{ // 条件C:文档所属部门符合要求
"bool": {
"should": [
{ "term": { "orgTag": "ENGINEERING" } },
{ "term": { "orgTag": "TECH_DEPT" } },
{ "term": { "orgTag": "COMPANY" } }
]
}
}
]
}
}
]

这里的逻辑翻译成人话就是:

只有满足以下条件之一的文档,才有资格进入候选池:

  • 作者 ID 是 42(你自己写的);或者
  • 是公开文档;或者
  • 组织标签属于 “ENGINEERING / TECH_DEPT / COMPANY” 中的任意一个。

filter 里的条件不影响相关性评分,只做“硬过滤”。哪怕这篇文档关键词匹配度 100 分,只要不通过这里的安全检查,就会直接舍弃。

注意:query.bool.filter 只作用于 query 这一路,不会过滤顶层 knn 的命中结果。所以完整示例在 knn 里也加了一份同样的权限条件(knn.filter),否则私密文档仍可能从向量检索这一路进入结果。

模块四:rescore(二次精排)

前面的 knn + query 已经产生了一个初步排名,但 Elasticsearch 觉得“不够精细”,于是启动重打分机制。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
"rescore": {
"window_size": 300, // 只对当前排名前 300 名的文档进行重算
"query": {
"query_weight": 0.2, // 第一阶段的老分数,只占 20% 权重
"rescore_query_weight": 1.0, // 新算的分数,占 100% 权重
"rescore_query": {
"match": {
"textContent": {
"query": "如何配置Redis集群",
"operator": "and" // 四个词(如何/配置/Redis/集群)必须全部同时出现!
}
}
}
}
}
  • window_size: 300:大海捞针捞出来的几十万文档,我只关心当前得分最高的前 300 个,其他的不管了。
  • rescore_query:用 operator: "and" 重新匹配一次。这意味着,只有在 textContent 里 同时包含“如何”、“配置”、“Redis”、“集群”这四个词 的文档,才会在这个环节获得较高的加分。
  • 权重计算:最终得分 = 0.2 * 老分数 + 1.0 * 新分数。rescore_query_weight 设为 1.0(远大于 0.2),相当于用“必须全部包含关键词”这个硬标准,把最精准的文档强行提拔到最前面。

混合检索流程

  1. 并行双路召回:
    • 一路走 knn(向量),捞出 300 条语义相似的。
    • 一路走 query(关键词 + 过滤器),筛出所有符合关键词且通过权限验证的文档,并算出初步分数。
  2. 结果合并与粗排:
    • 系统将两路结果按“或”的关系合并:同一篇文档如果两路都命中,它的分数是 kNN 分数与 query 分数之和;只命中一路的,就只有那一路的分数。然后按合并后的分数(_score)排序,形成一个大的候选池。
  3. 截取前 300 名:
    • 从候选池中只取出当前排名最靠前的 300 个文档(由 window_size 决定)。
  4. 精排重算:
    • 对这 300 个文档执行 rescore_query(要求四个词全匹配),重新计算最终得分并排序。
  5. 输出最终结果:
    • 从精排后的前 300 名里,截取前 10 条(size: 10)返回给调用方。

调优建议

  1. k和 window_size 的关系:通常建议 window_size ≥ knn.k,确保向量捞回来的 300 条都能进入重打分环节。注意 k 是全局的前 k 个,而 window_size 是每个分片各自取前 N 个再重打分,重打分作用在 kNN 与 query 合并后的结果上。顶层 knn 加 rescore 的写法同样需要 8.4 及以上版本。
  2. num_candidates 要大于 k:比如 k=300,num_candidates 可以设 500 或 1000,能提升召回率(但会增加性能开销)。
  3. query_weight 与 rescore_query_weight 的配比:如果你觉得关键词精确匹配更重要,可以把 rescore_query_weight 调得比 query_weight 大很多(就像本例中的 1.0 vs 0.2)。
  4. filter 里多用 term:对于精确匹配(如 ID、状态、标签)要用 term,不要用 match,否则值会被分词器拆散,可能导致权限越界。