查询是否命中取决于四层:字段怎样建、文本怎样变成词项、请求使用哪种查询语言或接口、查询类型怎样定义命中并参与排序。四层中任何一层不同,结果都可能不同。I don't have a key. 放进 Elasticsearch 以后,输入 don't have 为什么有时能查到、有时必须写成 "don't have",也要沿这四层排查,不能只靠记住 match、term、fuzzy 的名字。

判断一条字符串查询前,按这个顺序检查:字段能力 → 词项 → 查询语言或接口 → 查询类型与上下文。不要先猜“ES 的模糊匹配是不是失效了”。

本文用同一条文档贯穿所有例子:

1
2
3
4
{
"message": "I don't have a key.",
"status": "open"
}

排查“为什么没命中”之前,先要确认查询走的是哪条检索路径。term、range、wildcard 等 term-level query 直接从词项值、范围或字符模式构造基础查询结构;普通 text 字段上的 match、match_phrase 等全文查询先执行 analysis,再构造基础查询结构,最后访问索引;knn、sparse_vector、semantic 等查询则使用向量字段或推理配置(inference configuration,告诉 Elasticsearch 使用哪个推理端点、执行什么任务,以及怎样生成或使用语义表示的一组设置)。三条路径依赖的字段能力和排查方法不同,混在一起会把 analyzer 配置误当成推理模型问题,也可能用向量相似度解释普通词项匹配。

本文主要讨论普通 text 字段上的词法全文检索(lexical search):文本经过 analysis 变成词项,查询通过倒排索引寻找共享词项,并可在 query context 中使用 BM25 等统计评分模型计算词法相关性。词法检索不等于所有非语义查询,term-level、地理、关系等查询还有各自的约束对象。向量检索也不天然等于语义检索:向量由能够表达含义的模型产生时,向量相似度才具有语义检索的含义;手工填写的数值向量只能证明向量运算成立。混合检索则组合词法、向量或其他检索路径,再融合多路结果。

先把五个英文词分开

英文词 本文中的简单含义 在 Elasticsearch 中通常指什么
lexical search 本文特指按分析后的词项及其关系做全文检索 text 经过 analysis 后,通过倒排索引和 BM25 等模型匹配、排序
relevance 结果与当前查询有多匹配 排序目标或判断,不是一种数据类型,也不专属于向量检索
similarity 用什么规则计算“匹配得分” 文本字段上的评分算法,或向量检索中的距离/相似度度量
score / _score 本次查询算出的数值分数 用于当前结果集排序的数值;不同查询或不同模型之间不应直接比较
statistics 评分模型使用的统计输入 BM25 使用词频、文档频率、字段长度等信息

因此,BM25 不是“相关性”本身,而是根据统计信息计算词法相关性分数的模型;向量 cosine 也不是“相关性”本身,而是计算向量相似度的一种度量。两者都可以产生 _score,但分数的计算依据和数值含义不同。历史版本的变化是默认评分模型从 TF/IDF 切换为 BM25,不是把 relevance 这个概念从词法检索改成了向量检索。

这里还要区分“广义”和“本文的窄义”。广义上,凡是依赖词项、字符模式或倒排索引的检索,都可能被称为 lexical retrieval;例如 term 和 wildcard 也可以算词项驱动的检索。本文为了排障清晰,把 match、match_phrase、query_string 等需要对输入文本执行 analysis 的路径称为“词法全文检索”,把 term、wildcard、exists、range 等称为“词项级或结构化约束”。因此,本文图中的“词项级或结构化约束”不是“词法全文检索”这一窄义分类;它们都可能属于广义的非语义、倒排索引检索,但执行对象和查询能力不同。

先定义:一份请求里每个对象负责什么

先区分三个容易混用的词:

术语 在本文中的含义 Elasticsearch 中的对应物
search / 搜索 一次完整的搜索操作,可以包括候选文档检索、过滤、评分、融合、排序、分页、聚合和结果取回 通常由 _search Search API 承载
retrieval / 检索、召回 从索引中选出候选文档或 top documents 的过程;它可以走词项级、词法全文、向量或混合路径 可以由传统 query、knn 表达;新版 Search API 也提供 retriever 抽象来编排 standard、knn、rrf 等路径
query / 查询 对“哪些文档满足条件、怎样计算相关性”的一种表达;也可能特指 Query DSL 顶层的 query 参数、某个 query clause,或查询子句里的 query text query 参数、match 等 query clause,以及其中的 query text;同名 query 仍可能根据字段类型选择不同执行路径

search 的范围通常大于 retrieval,retrieval 也不等于某一种 query type。query 是表达检索条件的主要方式之一,但新版 retriever 还可以替代 Search API 中负责返回 top documents 的 query 或 knn 元素,并组合多条检索路径。这里说“召回”时,指的是 retrieval 这一层的候选文档检索,不是一个名为 _retrieve 的通用 API。

flowchart TB
    S[search<br/>一次完整的搜索操作] --> R[retrieval<br/>选出候选文档或 top documents]
    E[query / knn / retriever<br/>表达或编排检索路径] -.描述.-> R

    R --> T[词项级或结构化约束<br/>term / range / wildcard / exists]
    R --> L[词法全文检索<br/>match / match_phrase / query_string]
    R --> V[向量检索<br/>knn / dense_vector / sparse_vector]

    V --> M{向量是否表达文本或内容含义?}
    M -->|是| SEM[语义检索<br/>semantic / semantic_text 上的 match 等]
    M -->|否| NUM[只说明向量相似度<br/>不自动具有语义]

    L --> H[混合检索<br/>RRF / linear 等融合多路结果]
    SEM --> H

    T --> K[评分、排序或重排]
    L --> K
    V --> K
    H --> K
    K --> O[分页、字段取回与 hits]
    S --> A[聚合等其他 Search API 能力]

这张图按排障路径组织概念,不是 Query DSL 类型的穷举表。词项级或结构化查询、词法全文查询和向量查询是三条基础路径;语义检索说明向量或推理表示承载了内容含义;混合检索负责组合多条路径。地理、joining、span、compound 等名称属于 Query DSL 的功能或结构分组,与这张图的检索机制分类并不互斥。例如 bool 是 compound query,可以同时包裹全文条件和结构化过滤条件;geo query 则有自己的空间索引和距离关系。

下面先画一条最常见的 query 路径。Search API 请求体是完整 JSON;Query DSL 是其中表达检索条件的 JSON 风格语言;查询子句是 DSL 树里的一个节点。match、term、range 是叶子查询子句,bool 是组合查询子句。这里的节点名称只是代表性示例,不是根查询类型的穷举;sort、from、size 与 query 同级,且分别属于排序参数和分页参数,不是查询子句。

flowchart TB
    R[Search API 请求体<br/>完整 JSON]
    R --> Q[query<br/>Query DSL 的主入口]
    R --> S[sort<br/>Search API 排序参数]
    R --> P[from / size<br/>Search API 分页参数]
    Q --> ROOT{一个根查询子句}
    ROOT --> DIRECT[match / term / range<br/>可直接作为根节点]
    ROOT --> B[bool<br/>需要组合时才使用]
    B --> M[must / should<br/>继承 bool 的有效上下文]
    B --> F[filter / must_not<br/>filter context]
    M --> L1[match / term / range<br/>叶子查询子句]
    F --> L2[match / term / range<br/>叶子查询子句]
    DIRECT --> SC
    L1 --> SC[_score<br/>相关性信号]
    L2 --> FT[只判断命中或排除]
    SC --> O[命中文档的最终顺序]
    S --> O
    O --> P
    P --> OUT[截取后返回的这一页]

图里的关系给出本文后续会反复使用的定义:

  • 查询语言或接口:KQL、Lucene query string、Query DSL、ES|QL 等,决定请求怎样表达和提交。当前 Elasticsearch 还提供了原生 kql query,可在 Query DSL 中接收 KQL 字符串并 rewrite 成标准查询子句;这与 Kibana 是否在客户端先完成翻译是两件事。
  • 查询子句与层级:match、term、match_phrase、range、bool 都是 DSL 节点;在 Query DSL 语义树中,组合查询节点是子查询的 parent,承载子查询的参数位置可以通俗称为 slot。原始 JSON 的直接容器可能是对象或数组;同一 parent 下的参数互为 siblings。
  • 查询上下文:调用位置与从祖先继承的上下文共同决定节点是否贡献 _score;没有名为 query_context 的 JSON field。
  • 字段、analysis 与词项:mapping 决定字段能力;message 这类 JSON key 的正式叫法是 field name,中文可称“字段名”或“查询字段”。analyzer 在索引和全文查询阶段把文本变成词项,tokenizer 只是 analyzer 的一个步骤。
  • 评分与排序:BM25、向量相似度或 script_score 可以产生 _score;默认按 _score 返回,显式 sort 则另行指定最终顺序。

query 既可能是搜索请求体的顶层参数,也可能是某个查询类型内部承载输入文本的参数;看到同名 JSON key 时,需要结合它所在的对象判断含义。

同一条查询从输入到执行会经过几种不同形态,名称不能混用。假设文档的 message 字段原始值是 I don't have a key.,查询请求是:

1
2
3
4
5
6
7
{
"query": {
"match": {
"message": "don't have"
}
}
}

这里各部分的准确称呼如下:

内容 本文采用的称呼 含义
I don't have a key. document text / 原始字段值 写入文档时 message 字段携带的原始文本
message field name / 字段名、查询字段 当前查询子句要查询的字段;target field 可以作为解释性说法,但不是另一种 DSL 对象
don't have query text 或 query value / 查询文本、查询值 用户交给 match 处理的原始查询输入;不宜只把它叫作 query condition
{ "match": { "message": "don't have" } } query clause / 查询子句 Query DSL 树中的一个完整叶子节点
analyzer 产出的 don't、have query tokens;进入检索结构后也常称 query terms 真正拿去访问倒排索引中已存在 term 的查询侧单位
don't OR have 布尔查询条件或可执行查询结构 match 根据 operator 等参数组合 token 后得到的逻辑;不是原始字符串本身

query condition 是宽泛描述,既可能指整个查询子句,也可能指编译后的布尔条件。为了避免歧义,本文用 query text 指 "don't have",用 query clause 指完整的 match 对象,用 query terms 或可执行查询结构指 analysis 之后的产物。

先认查询入口,再谈 query type

用户在搜索框里输入的字符串,不一定直接成为某个 match 的 query text。Kibana、日志平台、应用后端都可能先用自己的查询语言解析输入,再生成 Query DSL、ES|QL 或另一种原生请求。查询语言解决“这段输入怎样表达条件”,Query DSL 的 query type 解决“某个查询子句怎样命中文档”;两者不在同一层。

flowchart TB
    U[用户输入或程序请求]
    U --> D[Query DSL JSON]
    U --> K[KQL 文本]
    U --> L[Lucene query syntax]
    U --> E["ES|QL 管道"]
    U --> S[SQL]
    U --> Q[EQL]

    D --> SEARCH[_search]
    K -->|Kibana 转换或原生 kql query rewrite| SEARCH
    L -->|query_string 等查询| SEARCH
    E --> QUERY[_query]
    S --> SQLAPI[_sql]
    Q --> EQLAPI[_eql]

    SEARCH --> IDX[同一批 Elasticsearch 数据]
    QUERY --> IDX
    SQLAPI --> IDX
    EQLAPI --> IDX

这些入口服务于不同的任务形状,并不是 Elasticsearch 内部存在多套互不相干的索引:

查询语言或接口 主要用途 常见入口 与 Query DSL 的关系
Query DSL 全文、词项、语义、过滤、聚合以及精细评分控制 _search Elasticsearch 的主要 JSON 查询语言,match、term、bool 都属于这一层。
KQL 在 Kibana 等界面中快速过滤字段、范围和布尔条件 Kibana 查询栏;也可作为 _search 中的 kql query KQL 本身只负责过滤,不负责聚合、转换或排序;原生 kql query 会 rewrite 成标准 Query DSL。
Lucene query syntax 快速文本检索、字段查询、通配符及较高级的字符串查询语法 Kibana Lucene 模式;Query DSL 的 query_string 等 先解析 Lucene 查询字符串,再生成可执行查询,不能把字符串里的运算符当作普通 query text。
ES|QL 把读取、过滤、计算、聚合、排序和截断串成数据处理管道 _query、Kibana ES|QL 模式 有自己的命令和表格流水线,不是 match 的别名,也不只是给 bool 换一种写法。
Elasticsearch SQL 给 SQL、JDBC、ODBC 和 BI 工具提供熟悉的表式查询入口 _sql 由 Elasticsearch 原生执行或转换,不要求调用者手写 Query DSL。
EQL 查询带时间顺序的事件与事件序列 _eql 面向事件型时间序列和安全分析,不等于普通全文检索。

Elastic 当前的查询语言总览还列出了 PromQL。它作为 ES|QL 的 source command 查询时间序列数据,属于较新的专用入口。查询语言继续增加的原因不是“同一件事发明更多语法”,而是应用检索、交互过滤、日志分析、BI 报表和事件序列的输入模型不同。官方也将这些接口定义为互补关系:一个系统可以按任务选用不同入口。

ES|QL 中的 | 是命令分隔符

ES|QL 全称 Elasticsearch Query Language。查询由一个 source command 开始,后面可以接若干 processing command;字符串外的 | 把前一条命令产生的表交给下一条命令:

1
2
3
4
5
FROM logs-*
| WHERE service.name == "checkout"
| STATS errors = COUNT(*) BY host.name
| SORT errors DESC
| LIMIT 10

这条查询按“读取日志 → 过滤服务 → 按主机计数 → 排序 → 取前十条”处理。最终结果是最后一条 processing command 产生的表。优化器可以在不改变语义时调整部分命令的实际执行顺序,但语法上仍是一条由管道连接的查询。

竖线是否有语法含义,取决于它位于哪一层:

1
2
FROM logs-*
| WHERE message.keyword == "ImIntegrationConsumer|original_imMsg_body|body="

第一个 | 位于字符串外,是 ES|QL 管道分隔符;引号里的两个 | 是查询值的一部分。这个示例还假设 mapping 中确实存在 message.keyword。若只有 message、content 或平台自定义全文字段,应按实际 mapping 和字段能力改写。

同一个字符在其他输入语言里可能有完全不同的作用。Lucene、KQL、自定义日志查询语法和日志原文都可以出现 |;离开查询语言上下文讨论“竖线是不是 OR”没有确定答案。

一幅图看懂:索引生成词项,查询生成可执行条件

倒排索引的基本结构与 B+Tree 的访问方向对比见第 00 篇;term dictionary、posting list 中的词频和位置如何保存在 Segment 内,见第 02 篇的倒排索引章节。

索引侧只在文档写入或重建索引时运行,把字段变成倒排索引中稳定的 term、位置和统计信息。查询侧则在每次搜索时运行:先按 Query DSL 类型处理查询值,再生成 Lucene 能执行的查询结构。两侧最终在倒排索引的 term 和 posting list 上相遇。下图只画本文讨论的词法检索主线;语义与向量检索在后文单独说明。

flowchart TB
    subgraph IDX[索引侧:写入或重建索引时]
        D[原始文档字段] --> M{mapping}
        M -->|text| IA[index analyzer<br/>character filter → tokenizer → token filter]
        M -->|keyword| N[index-time normalizer<br/>仍保持一个 token]
        IA --> IT[索引 term + 位置 + 统计信息]
        N --> IT
        IT --> INV[倒排索引<br/>term → posting list]
    end

    subgraph QRY[查询侧:每次搜索时]
        Q[查询输入 + Query DSL 类型]
        Q -->|term / terms| T[不跑全文 analyzer<br/>可做单 token 规范化]
        Q -->|range / prefix / wildcard / regexp / fuzzy| MT[不跑全文 analyzer<br/>构造范围、模式或编辑距离条件并 rewrite]
        Q -->|match / multi_match| A[search analyzer<br/>得到 0~N 个查询 token]
        Q -->|match_phrase / match_phrase_prefix| P[search analyzer<br/>保留 token 顺序与位置约束]
        Q -->|query_string / simple_query_string| QS[先解析查询语法<br/>再按字段执行 analysis]
        A --> B[按 OR / AND / minimum_should_match 组合]
        A -->|配置 fuzziness| F[每个 token 再做编辑距离扩展]
        QS --> B
        QS --> P
        QS --> MT
        T --> LQ[Lucene 可执行查询结构]
        MT --> LQ
        B --> LQ
        F --> LQ
        P --> LQ
        LQ --> C{所在上下文}
        C -->|query context| S[命中 + _score]
        C -->|filter context| H[只判断命中]
    end

    INV --> LQ

这幅图给出一条最重要的边界:倒排索引里没有“原始句子等待查询时再分词”。text 字段写入后已经变成固定的索引 term;查询只能生成条件去访问这些 term。查询侧也不能简单分成“最终都是 IN”:

  • match 经 analyzer 产生多个 token 后,常会形成布尔组合,默认近似于多个 term 条件的 OR;
  • match_phrase 除了 term,还要使用位置和顺序,不能退化为普通 IN;
  • wildcard、regexp、prefix、fuzzy 可能枚举或改写为多个可匹配 term,也可能使用 bitset 等执行形式;
  • bool、dis_max 等组合查询继续包裹这些条件,决定逻辑关系或评分合并方式。

因此,更准确的说法是:全文查询先把自然语言编译成 token 和关系约束,term-level 查询直接从词项值、字符模式、范围或编辑距离开始;两者随后都被编译或 rewrite 为 Lucene 可执行查询,再访问倒排索引。 这里的“Lucene 查询结构”是执行层概念,不应重新称为 Query DSL 的 term-level query。使用 _validate/query?rewrite=true 可以查看 Elasticsearch 最终准备执行的 Lucene 查询说明。

可以只记下面三行:

1
2
3
match         ≈ analysis + 基础词项/布尔查询
match_phrase ≈ analysis + 基础词项查询 + 位置约束
term-level ≈ 跳过全文 analysis,直接构造精确值、模式、范围等基础查询

这些基础查询随后访问 term、posting list、位置数据或相应字段的索引结构。不要把它们统一命名为“term-level query”:这个名字在 Elasticsearch 中已经专指一组 Query DSL 入口。更稳妥的简称是“Lucene 基础查询结构”或“底层可执行查询”。

text、引号、match_phrase、fuzziness、_score 仍不在同一层。text 是字段建模;引号属于输入语言的语法;match_phrase 是 Query DSL 查询类型;fuzziness 是查询可选的词项扩展;评分还取决于查询子句所在位置及其继承的有效上下文。

Query DSL 的树:请求参数、容器与查询子句

一次 _search 请求的请求体是最外层 JSON object。query、sort、from、size、aggs、highlight、_source、fields、track_total_hits 等都是请求体的顶层参数,彼此处于同一层:query 中的 Query DSL 决定哪些文档命中,并在 query context 中计算相关性分数;sort 决定命中结果最终按什么规则排序;from 和 size 负责分页,其余参数分别控制聚合或返回内容。没有显式 sort 时,结果默认按 _score 降序;显式按字段排序时,字段值成为主排序键,分数默认不再参与排序,而且可能不计算,除非设置 track_scores: true。sort 也不只支持普通字段,还支持 _score、_doc、地理距离和脚本排序,因此“Query DSL 管命中和评分,sort 管最终顺序”比“Query DSL 管按分数排序,sort 只管按字段排序”更准确。顶层参数远不止这些,图中只列代表性成员,... 表示省略其他合法参数。参见 Search API 与 Sort search results。

flowchart LR
    START((start)) --> LBRACE["{"] --> MEMBER{"选择一个顶层成员"}
    MEMBER --> Q["query : QueryClause"]
    MEMBER --> SORT["sort : SortValue"]
    MEMBER --> PAGE["from / size : number"]
    MEMBER --> RETURN["_source / fields : ReturnSpec"]
    MEMBER --> OTHER["aggs / highlight / track_total_hits / ..."]
    Q --> MORE{"还有顶层成员?"}
    SORT --> MORE
    PAGE --> MORE
    RETURN --> MORE
    OTHER --> MORE
    MORE -->|"是:逗号后继续"| MEMBER
    MORE -->|"否"| RBRACE["}"] --> END((end))

    Q -. "展开 QueryClause" .-> CLAUSE{"恰选一种 query type"}
    CLAUSE --> LEAF["叶子查询<br/>match · term · range · match_phrase<br/>wildcard · match_all · ..."]
    CLAUSE --> COMPOUND["组合或包装查询<br/>bool · dis_max · constant_score<br/>boosting · ..."]
    LEAF --> LEAFBODY["类型专属 body<br/>字段名 → 输入值或选项对象"]
    COMPOUND --> BOOL["bool<br/>must / filter / should / must_not"]
    COMPOUND --> DISMAX["dis_max<br/>queries"]
    COMPOUND --> CONSTANT["constant_score<br/>filter"]
    COMPOUND --> BOOSTING["boosting<br/>positive / negative"]
    BOOL --> BOTH["单个 QueryClause<br/>或 QueryClause 数组"]
    DISMAX --> ARRAY["QueryClause 数组"]
    CONSTANT --> SINGLE["单个 QueryClause"]
    BOOSTING --> SINGLE
    BOTH --> CHILD["每个子句都遵循 QueryClause 结构"]
    ARRAY --> CHILD
    SINGLE --> CHILD
    CHILD -. "递归回到同一入口;可再次选择 bool" .-> CLAUSE

    classDef param fill:#f8f5e8,stroke:#9b7b2f,color:#332b17;
    classDef clause fill:#edf4ff,stroke:#4a76a8,color:#172a40;
    classDef syntax fill:#f4f4f4,stroke:#777,color:#333;
    class Q,SORT,PAGE,RETURN,OTHER param;
    class CLAUSE,LEAF,COMPOUND,BOOL,DISMAX,CONSTANT,BOOSTING,BOTH,ARRAY,SINGLE,CHILD clause;
    class LBRACE,RBRACE,MEMBER,MORE,LEAFBODY syntax;

这张图借用了铁路语法图的读法:沿轨道从 start 走到 end。一次请求可以依次选择多个不同的顶层成员,这表示 query、sort、from 等参数可以共存,不表示同名 key 应重复书写。走到 query 分支时,它的值进入 QueryClause 语法。QueryClause 每次只选择一种 query type。若选择 bool 等组合或包装查询,它的特定参数又会接收一个或一组 QueryClause,轨道由此递归回到同一入口。第二次选择仍然可以是 bool,所以 bool 嵌套 bool 不需要另一套语法。

同一结构写成紧凑语法更直接:

1
2
3
4
5
6
7
8
9
10
11
12
13
SearchRequestBody ::= "{" [TopLevelMember ("," TopLevelMember)*] "}"

TopLevelMember ::= '"query"' ":" QueryClause
| '"sort"' ":" SortValue
| '"from"' ":" Number
| '"size"' ":" Number
| '"_source"' ":" ReturnSpec
| '"fields"' ":" ReturnSpec
| ...

QueryClause ::= "{" QueryType ":" TypeSpecificBody "}"
ChildQueryValue ::= QueryClause
| "[" QueryClause ("," QueryClause)* "]"

QueryType 和 TopLevelMember 都还有其他合法分支。图和语法只描述 Search API 请求体与 Query DSL 的代表性结构,不是所有 Elasticsearch JSON API 的统一语法,也不是查询类型的封闭清单。

query 是顶层参数名,它的 value 是一个完整查询子句对象。例如 { "match": { "message": "timeout" } } 整体才是查询子句;match 是查询类型名,message 是字段名,"timeout" 是输入值。query 不能在同一个对象中直接并列两个根查询类型;需要组合多个条件时,按某个组合查询自己的参数契约组织它们。这里的“简单条件”与“组合条件”描述请求意图,不是决定某个 query type 能否成为根的语法规则。

slot 可以用下面的形状记忆,但它是本文的非正式讲解用语,不是 Elasticsearch 的官方类型名称:

1
2
3
4
5
ParentQueryClause = {
parent_query_type: {
slot_name: slot_value
}
}

也可以把这个形状简写为 parent : { slot_name : paramValue },但这里的 parent 必须具体指某个 parent query type,而不是泛指外层 JSON object。parent_query_type 决定有哪些合法的 slot_name,也决定每个 slot_value 的类型。slot_value 可能是普通输入值、选项对象、一个查询子句,也可能是查询子句数组。只有最后两种位置才是这里讨论的“承载子查询的 slot”。例如 bool 是 parent query type,must 是 slot name,must 数组中的每个元素才是完整的 child query clause。

顶层 query 参数只接一个根查询子句,但这个子句不一定是 bool。只有一个条件时,叶子查询可以直接成为根节点:

1
2
3
4
5
{
"query": {
"match": { "message": "timeout" }
}
}

需要表达“同时满足”“满足其一”“过滤”或“排除”等组合语义时,可以让根节点成为 bool,再由它承载多个子查询。这里的“一棵树只有一个根”不等于“根下面只能是 bool”;bool 本身是一种有实际组合语义的 compound query,不是每个请求都必须套用的固定层级。

{ "match": { "message": "timeout" } } 是一个查询子句,更具体地说是叶子查询子句。不能把“parent”和“slot”当成同一个概念:bool 是这个 match 的语义 parent,must 是 bool 提供的参数位置,match 则是填入该位置的值。按原始 JSON 容器看,match 的直接容器是 bool.must 数组;按 Query DSL 的树语义看,match 是 bool 的子查询。下面这个组合例子中,bool 又是顶层 query 参数的值:

@timestamp 从哪里来

@timestamp 不是每个 Elasticsearch 文档自带的元数据字段,也不是 Kibana 打开索引后自动写入的字段。对普通索引,文档生产者或显式配置的 Elasticsearch ingest pipeline processor 要先提供这个值;mapping 或 index template 负责把它定义为 date、date_nanos 等类型,但 mapping 本身不会替每条文档生成时间值。启用 dynamic mapping 时,Elasticsearch 可以在首次收到该字段后按内容推断 mapping,仍然不会凭空给没有该字段的普通文档补值。Dynamic field mapping说明了字段出现后如何推断类型;date processor则是一种由 Elasticsearch ingest pipeline 解析原始时间并默认写入 @timestamp 的方式。

@timestamp 经常出现,是因为日志与指标生态另外约定了这个名字:ECS要求 ECS event 填充事件发生时间;data stream要求每条文档包含 @timestamp,并把它映射为 date 或 date_nanos。Kibana data view 只是从已有 date 字段中选择默认时间字段,供全局时间过滤器使用,也可以选择不使用时间过滤;创建 data view 不会因此改写原始文档。Kibana data views给出了这两种选择。某些 Elastic integration、受管 pipeline 或特定 index mode 会代为生成或补齐字段,那属于接入方案的行为,不是所有 Elasticsearch 文档的通用属性。

@timestamp、Logstash pipeline 与 Elasticsearch ingest pipeline 处在不同层。Logstash 是 Elasticsearch 之外的独立数据处理程序,不属于 Elasticsearch ingest pipeline。Logstash event 没有现成时间戳时,通常会在接收事件时生成 @timestamp;若日志正文带有事件发生时间,则用 date filter 解析并覆盖该字段。Logstash 输出到 Elasticsearch 后,@timestamp 才作为文档中的普通字段进入 _source。因此,看到 @timestamp 仍要确认它表示事件发生时间,还是 Logstash 首次看到事件的时间。Logstash date filter负责这一步时间语义转换。

Elasticsearch ingest pipeline 则运行在 Elasticsearch 内部:文档写入索引之前,具有 ingest role 的节点按顺序执行 pipeline 中的 processors。仅仅定义或选中 pipeline 不会自动生成 @timestamp,还需要 date、set 等具体 processor 写入或覆盖字段。Logstash 的 Elasticsearch output 可以通过 pipeline 参数指定一条 Elasticsearch ingest pipeline,于是完整链路可以是“数据源 → Logstash pipeline → Bulk API → Elasticsearch ingest pipeline → index”。其中 Logstash 负责独立进程内的采集、转换、排队与批量输出,Elasticsearch ingest pipeline 负责索引前的集群内处理;两层可以串联,也可以只使用其中一层。Elasticsearch ingest pipelines与Logstash Elasticsearch output分别给出了两层的配置入口。

相关教程按这条数据链继续展开:

下面的查询示例假设 mapping 中有 message(text)、status(keyword)、@timestamp(date)和 tie_breaker_id(keyword),并且被查询文档已经写入相应字段。

GET messages/_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
{
"query": {
"bool": {
"must": [
{ "match": { "message": "timeout" } },
{
"bool": {
"should": [
{ "match_phrase": { "message": "connection timeout" } },
{ "match": { "message": "connection refused" } }
],
"minimum_should_match": 1
}
}
],
"filter": [
{
"bool": {
"must": [
{ "term": { "status": "open" } },
{ "range": { "@timestamp": { "gte": "now-1d" } } }
]
}
}
]
}
},
"sort": [
{ "@timestamp": { "order": "desc" } },
{ "tie_breaker_id": { "order": "asc" } }
],
"from": 0,
"size": 20
}

代码块给出可执行形态;结构图固定了请求参数、查询节点与 slot 的层级关系。

因此,must、filter、should、must_not 是 bool 查询的四个直接子参数;以 bool 为 parent 看,它们彼此才互为 siblings。它们是承载查询子句的参数位置,不是与 bool 并列的四种查询;也不能把这些参数直接放在顶层 query 下。一个查询对象在该层通常只表达一种查询类型,不能把 match 和 term 并列在同一个对象中;需要“同时满足”或“满足其一”时,使用 bool 把两个查询子句放进相应参数。bool 可以嵌套 bool,因为组合查询的孩子既可以是叶子查询,也可以是另一棵组合查询。

参数名必须连同它的 owner 一起理解,不能只看裸名字。bool.filter 是 bool 的一个参数,可以接一个查询子句或查询子句数组;constant_score.filter 是 constant_score 的参数,只接一棵过滤查询。两者都叫 filter,却不共享同一套兄弟参数:不能把 bool 的 must、should 或 must_not 直接放进 constant_score。若 constant_score 需要组合多个过滤条件,应先在它的 filter 中放入一个 bool 查询,再使用这个 bool 自己的 filter 或 must 参数。这就是“各 query type 有自己的参数契约”的含义。

评分规则:先看有效上下文,再看 query type

每种 query type 有自己的评分规则,查询树并不存在统一的“所有子分数相加”规则。叶子查询按自身规则计算分数;组合或包装查询则按自己的规则归并、改写或替换子查询分数。节点能否向上贡献分数,首先取决于有效上下文:filter context 中的子树只参与命中判断,不贡献相关性分数。

上下文由调用位置建立,并向后代传递,没有专门的 query_context field。顶层 query 建立 query context;bool.must、bool.should 继承这个 bool 的有效上下文,bool.filter、bool.must_not 和 constant_score.filter 则建立 filter context。整个 bool 已经位于 filter context 时,内层 must 或 should 仍执行布尔命中逻辑,但不会重新开启评分。should 即使不是命中的必要条件,只要匹配且评分有效,也可以增加分数。

下表列出四种代表性规则,假设当前节点评分有效,并暂不叠加其他外层 boost 或重排:

Query type 当前节点怎样产生分数 子查询分数的去向
bool 累加匹配的 must、should 子分数 filter、must_not 不加分
dis_max 最佳子分数 + tie_breaker × 其他匹配子分数之和 tie_breaker 默认为 0,此时只保留最佳分数
boosting 采用 positive 分数;同时命中 negative 时乘以 negative_boost 不累加 negative 的分数,也不因它命中而排除文档
constant_score 为通过内部过滤条件的文档赋予固定分数,等于 boost,默认 1.0 内部 filter 不评分;固定分数由外层包装节点给出

嵌套查询可以按“上下文自上而下,命中与分数自下而上”理解:先确定每个分支的有效上下文,再按各节点规则推导结果。这是语义阅读方法,不是引擎必须按此顺序物化全部结果的执行计划。

以 Merkle 树为例,可以借它理解“子节点结果逐层交给父节点”的递归结构。Merkle 树由叶子 hash 向上计算父 hash;查询树则由叶子查询向上返回命中状态,并且只在评分有效时把分数交给父查询,再由 bool、dis_max、boosting 或 constant_score 按各自规则处理。这个类比只描述递归归并的结构,不表示查询树也归并 hash,更不表示所有父节点都用同一种方式累加分数。

1
2
3
4
5
6
7
8
9
10
search.query                         [QUERY]
└─ outer bool [QUERY]
├─ must → match [QUERY] 产生 s1
├─ must → inner bool [QUERY]
│ └─ should → 两个叶子查询 [QUERY] 产生 s2、s3,再按 bool 规则归并
└─ filter → inner bool [FILTER]
└─ must → term / range [FILTER] 只产生 yes / no

命中:先由叶子判断,再由各 parent query 按自己的规则向上归并。
评分:只有有效上下文为 QUERY 的节点产生或归并分数;FILTER 子树不贡献分数。

对前面的嵌套 bool,若只匹配两个 should 中的一个,就只计入它的分数;若两者都匹配,内层 bool 得分为 s2 + s3,外层 bool 得分为 s1 + s2 + s3。外层 filter 中的内层 bool 则只判断 status 和 @timestamp 是否满足,它的 must 不向外层加分。该请求显式按时间和 ID 排序,分数不决定最终顺序;需要观察分数时可加 track_scores: true。

Filter context 的入口不限于 query 树

filter context 由调用位置建立,不要求总是内嵌在某个 query context 之内。只看 Search API 的顶层 query 树,过滤分支确实位于根查询之下;但查询上下文是求值模式,不是必须嵌套的 JSON 对象。过滤聚合也能在 aggs 下独立建立 filter context,不是顶层 query 的子节点。Query and filter context明确列出了这个入口。

例如,假设 status 为 keyword,下面的请求没有显式 query,只统计 status = open 的文档数量:

1
2
3
4
5
6
7
8
{
"size": 0,
"aggs": {
"open_messages": {
"filter": { "term": { "status": "open" } }
}
}
}

省略 query 时搜索范围默认为全部文档;aggs.open_messages.filter 在这个范围内建立过滤桶,结果位于 aggregations.open_messages.doc_count。这里的过滤条件不产生相关性分数,也不因默认搜索范围为全部文档就成为某个显式 query 节点的孩子。参见 Filter aggregation。

查询子句与其他顶层参数可以共存

下列请求体沿用上面的 mapping 与文档前提。每个代码块都是合法 JSON;请求方法和路径写在代码块标题之外。

叶子查询可以直接作为 query 的 value:

1
2
3
4
5
6
7
{
"query": {
"match": {
"message": "timeout"
}
}
}

组合查询的参数值可以承载一个或多个完整查询子句;同一请求体还可以包含分页、排序和返回字段参数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
{
"query": {
"bool": {
"must": [
{ "match": { "message": { "query": "timeout", "operator": "and" } } }
],
"filter": [
{ "term": { "status": "open" } },
{ "range": { "@timestamp": { "gte": "now-1d" } } }
]
}
},
"sort": [
{ "@timestamp": { "order": "desc" } },
{ "tie_breaker_id": { "order": "asc" } }
],
"from": 0,
"size": 20,
"_source": ["message", "status", "@timestamp"],
"fields": ["status"],
"track_total_hits": true
}

同一个 query type 为什么有不同写法

Query DSL 可以看成一棵 JSON 表示的抽象语法树。结构上只有两类查询子句:match、term、range 等 leaf query 直接查询字段,bool、dis_max、boosting 等 compound query 包裹并组合其他查询。至于某个 query type 内部有哪些字段、能否短写,则由该类型自己的参数 schema 决定,没有一套适用于所有 query type 的万能对象格式。

match 的常见情况有两种。只有查询值时,可以使用短写:

1
2
3
4
5
{
"match": {
"field1": "value1"
}
}

需要指定 operator、analyzer、fuzziness 或 boost 等选项时,字段名仍在外层,只把字段值展开成完整对象:

1
2
3
4
5
6
7
8
9
{
"match": {
"field1": {
"query": "value1",
"operator": "and",
"boost": 2.0
}
}
}

短写和完整写法表达的是同一个 match query;短写只是省略默认选项后的语法糖。下面这种写法却不是 match 的另一个变体:

1
2
3
4
5
6
{
"match": {
"query": "value1",
"fields": ["field1", "field2"]
}
}

match 是单字段查询,query 在这里会落到本应填写字段名的位置,fields 也不是它支持的同级参数。多字段全文查询使用 multi_match:

1
2
3
4
5
6
{
"multi_match": {
"query": "value1",
"fields": ["field1", "field2"]
}
}

这几种形态可以用三条规则辨认:单字段 leaf query 常把字段名作为动态 key,部分类型同时提供“值短写”和“选项对象”两种形式;multi_match、combined_fields、query_string 等查询围绕一段输入协调多个字段,因此把 query、fields 写成固定参数;compound query 的 body 则主要容纳子查询和组合参数。具体 query type 还会继续定义自己的结构,所以“共有多少种 JSON 变体”没有一个跨版本的固定数字。这样的设计既让最常见的单字段查询保持简短,又让复杂选项和子查询能在同一棵树中递归组合,同时避免把所有查询能力塞进一个含义不稳定的万能对象。

range、sort 与分页参数各管一段

range 的正确称呼是 range query / range 查询子句:它可放进上面 bool.filter 的数组。sort 不宜称为它的 subclause;sort 是和 query 同级的 Search API 参数,数组中的每项才是一个排序规格(sort specification)。from 与 size 也和 query 同级,它们在结果排好序以后截取一页。

flowchart LR
    SQLW[SQL WHERE<br/>price BETWEEN 10 AND 20] --> ESR[Query DSL range<br/>gte: 10, lte: 20]
    SQLW2[SQL WHERE<br/>price &gt; 10 AND price &lt; 20] --> ESR2[Query DSL range<br/>gt: 10, lt: 20]
    SQLO[SQL ORDER BY] --> ESS[Search API sort]
    SQLOFF[SQL OFFSET 40] --> ESF[Search API from: 40]
    SQLL[SQL LIMIT 20] --> ESZ[Search API size: 20]

这个类比只用于记忆职责:range 决定哪些文档算命中,近似 SQL WHERE 里的 BETWEEN 或大于、小于谓词;gte、lte 包含边界,gt、lt 不包含边界。sort 近似 ORDER BY,决定命中文档的先后。from 近似 OFFSET,表示跳过前多少条;size 近似 LIMIT,表示最多返回多少条。它们不是四种并列的查询子句,而是“筛选 → 排序 → 截页”三个阶段的不同参数。

默认 from: 0、size: 10。from 加 size 适合普通浅分页,但页越深,各分片需要收集并排序的前置结果越多;默认也不能用它们翻过第 10,000 条命中结果,这一上限由 index.max_result_window 控制。深分页使用带稳定 sort 的 search_after;跨多次请求还要保持一致索引视图时,再配合 PIT。search_after 更像拿上一页最后一条记录的排序值作为游标,不等于继续增大 SQL 式 OFFSET。Elastic 的分页文档给出了这两条路径。

分片为什么需要收集 from + size 个候选,见第 06 篇的 Query 阶段。

query + aggs 请求会分成 hits 与 aggregations 两条路径

“筛选 → 排序 → 截页”适合解释 hits,但加入 aggs 以后,不能再把 Search API 理解成“先查出一份结果,再依次聚合、排序、分页”的单线流水线。对本文讨论的 query + aggs 请求,更准确的逻辑模型是:顶层 query 确定匹配范围,然后 hits 与 aggregations 从这个范围分别计算。hits 路径负责命中文档的排序、截页和字段返回;aggregation 路径负责生成桶与指标,并使用聚合自己的排序、截断和 pipeline aggregation。顶层 sort、from、size 不会把 aggregation buckets 当作输入。

flowchart TB
    BODY[Search request body] --> Q[query<br/>确定逻辑匹配范围;query context 可产生 _score]
    Q --> H[hits 路径]
    Q --> A[aggregations 路径]

    H --> PF[post_filter,可选<br/>只进一步缩小 hits]
    PF --> HS[_score 或顶层 sort]
    HS --> HP[from + size<br/>或 search_after]
    HP --> HF[_source / fields / highlight]

    A --> AB[bucket / metric / sub-aggregation]
    AB --> AO[聚合自己的 order / size / bucket_sort]

这张图描述的是请求各部分的逻辑职责,不是 Elasticsearch 内部必须物化出一个“初始结果集合”,再按图逐步执行的物理计划。Search API 请求体是声明式参数对象,同级 JSON key 的书写顺序也不表示执行顺序。Search Profile API展示了查询阶段中的 top hits collector 与 aggregation collector;两条路径可以在同一查询阶段收集数据,而不是由 aggregation 等待一份已经分页的 hits 列表。

post_filter 最能说明两条路径的区别:普通顶层 query 会同时限定 hits 和 aggregations;post_filter 在 aggregations 计算后只缩小 hits,不改变已经计算的聚合结果。反过来,global aggregation可以明确忽略顶层 query,在当前 search execution context 的全部文档上聚合。因此,“query 产生初始集合”是一种便于理解的默认模型,不是覆盖所有聚合和过滤参数的绝对规则。Filter search results给出了 query、post_filter 与 aggregations 的完整示例。

Bucket、Metric、Pipeline 三类聚合的职责、嵌套 aggs 的实验和分片中间结果的归并过程,见第 09 篇:搜索之上的实时分析。

管道是常见的 QL 设计,不是所有查询语言的统一语法

许多查询语言都能用“数据源 → 筛选 → 分组或聚合 → 排序 → 限制结果数”解释逻辑数据流,但不必设计成同一种表面语法。Query DSL 使用递归 JSON 树表达查询条件和组合关系;ES|QL 使用有顺序的管道,每条命令消费上一条命令产生的表;SQL 使用子句表达关系运算,而且书写顺序不等于逻辑处理顺序。以 SELECT 为例,语句先写 SELECT,逻辑处理通常先从 FROM、WHERE 开始,再进入 GROUP BY、HAVING、结果表达式、ORDER BY 和 LIMIT。PostgreSQL 的 SELECT 文档列出了这套处理顺序;优化器生成的物理执行计划还可以继续改写或重排。

因此,阅读一种 QL 时要分清三层:表面语法规定怎样写,逻辑数据流规定每一步在什么关系或结果上运算,物理执行计划规定引擎实际怎样扫描、收集、归并和优化。同一条逻辑链可以写成 JSON 树、SQL 子句或管道;同一种语法也不保证按文本从左到右物理执行。

如果“完整的 QL”是指用一段查询文本同时表达数据源、筛选、聚合、排序和限制结果数,Elasticsearch 提供了 ES|QL 和 SQL。它们与 _search 的 Query DSL 是互补入口,不是覆盖关系,也不能据此认定其中一种包含 Search API 的全部能力。假设 messages 索引中的 status、service 都是 keyword,下面两个请求都按服务统计 open 文档并返回计数最高的 20 组。

ES|QL 使用 POST /_query:

1
2
3
{
"query": "FROM messages | WHERE status == \"open\" | STATS message_count = COUNT(*) BY service | SORT message_count DESC | LIMIT 20"
}

Elasticsearch SQL 使用 POST /_sql:

1
2
3
{
"query": "SELECT service, COUNT(*) AS message_count FROM messages WHERE status = 'open' GROUP BY service ORDER BY message_count DESC LIMIT 20"
}

这两个请求返回的是聚合后的表格行,不等于 _search 返回的文档 hits。前文的 ES|QL 管道适合交互分析和表格变换;Search API 更适合用 Query DSL、aggregations、排序、分页与字段返回参数精确控制搜索响应;Elasticsearch SQL则为熟悉关系型语法的调用方提供 SQL 入口。“完整”应描述一条语言能否表达当前任务,不应当作跨接口的功能全集标签。

多字段排序:逻辑相同,数据模型与执行方式不同

这里更准确的叫法是“按多个字段排序”,不是“对复合字段排序”。对普通单值字段,Elasticsearch 与关系型数据库采用相同的字典序规则:sort: [A, B] 与 ORDER BY A, B 都先比较 A,只有 A 相等才比较 B;后续字段依次只负责打破前面字段的平局。Elastic 的排序文档也明确规定,数组中后一个排序字段只在此前字段相等时使用。

flowchart LR
    R[两条结果待比较] --> A{字段 A 是否相等?}
    A -->|否| AO[由 A 决定顺序]
    A -->|是| B{字段 B 是否相等?}
    B -->|否| BO[由 B 决定顺序]
    B -->|是| T[继续比较下一字段<br/>或稳定 tie-breaker]

因此,两者的多字段排序语义相同,但执行方式不同。RDBMS 可能利用复合索引顺序,也可能额外排序;Elasticsearch 会在各分片取 top-N,再由协调节点归并。复合索引的“最左前缀”主要影响 RDBMS 能否省掉额外排序,不会改变 ORDER BY A, B 的比较顺序。MySQL 的 ORDER BY 优化文档说明了索引扫描与额外排序两条路径。

实际结果不一致时,优先核对三项 ES 与 RDBMS 的模型差异:ES 数组字段要用 mode 从多个值中选一个排序值;keyword 与数据库 collation 的字符串比较规则可能不同;缺失值、SQL NULL 与最终 tie-breaker 的默认规则也可能不同。普通单值数值字段在这些规则都对齐后,sort: [A, B] 与 ORDER BY A, B 不应因为“ES 有另一套复合排序语义”而得出不同顺序。

text 与 keyword:从一个 string 拆成两种字段契约

text 面向按词搜索,keyword 面向把整个值用于过滤、排序或聚合。两者是不同的字段契约,不是同一种字符串的“模糊版”和“精确版”。

字段 写入 I don't have a key. 后的关键形态 典型用途 默认可排序、聚合
text 经过 analyzer,形成多个词项及其位置 标题、正文、日志消息 否
keyword 作为一个完整值形成一个词项 状态、标签、ID、邮箱、主机名 是

keyword 作为正式字段类型是在 Elasticsearch 5.0 出现的,但“把整个字符串作为一个词项”的能力更早就存在。ES 2.x 及以前使用统一的 string 类型,再通过 index 参数区分全文文本、完整值和不可搜索三种状态。5.0 把前两种状态提升为独立类型:string + index: analyzed 的后继是 text,string + index: not_analyzed 的后继是 keyword。

flowchart TB
    A[ES 2.x 及以前<br/>type: string] --> B{index 参数}
    B -->|analyzed| C[分析字符串<br/>多个 term、位置、相关性]
    B -->|not_analyzed| D[完整字符串<br/>单个 term]
    B -->|no| E[不建立搜索索引]

    C --> F[ES 5.0<br/>type: text]
    D --> G[ES 5.0<br/>type: keyword]
    E --> H[ES 5.0<br/>index: false]

    F --> F1[analyzer<br/>positions / norms<br/>fielddata 默认关闭]
    G --> G1[单个完整值 term<br/>doc_values 默认开启]
    G1 --> G2[ES 5.2<br/>增加 normalizer]

    J[动态映射遇到 JSON string] --> K[text 主字段]
    J --> L[keyword 子字段]
    K --> M[全文搜索]
    L --> N[完整值过滤、排序、聚合]

这次拆分不是简单改名,而是把“类型 + 模式开关”改成“类型直接表达索引意图”。旧设计里的 string 实际隐藏了两个能力差异很大的子类型,导致某些参数只对 analyzed 有意义,另一些参数只对 not_analyzed 有意义。拆分之后,index 不再承担 analyzed / not_analyzed / no 三态选择,只负责回答是否建索引;怎样建索引由 text 或 keyword 决定。Elastic 对这次拆型的说明把 position_increment_gap 和 ignore_above 的歧义列为直接原因。

因此,“keyword 是 not-analyzed 的 text”只有在 text 泛指普通字符串时才能作为历史速记。现代 Query DSL 和 mapping 中的 text 已经是正式类型名,text 与 keyword 没有类型交集:前者把自然语言分析成词项流,后者把完整值保留为一个词项。二者都能写入倒排索引,也可能响应部分相同的查询,但字段能力、默认索引结构和适用访问路径不同。

拆型还让两类字段拥有不同的安全默认值。text 面向全文检索,默认保留位置和 norms,并关闭可能大量占用堆内存的 fielddata;keyword 面向完整值过滤、排序和聚合,默认开启磁盘列式的 doc values。ES 5.x 为旧 string mapping 提供过兼容转换,6.0 再移除这一过渡层。Elasticsearch 5.0 发布说明明确把相关性文本与标识符字符串视为两种不同用途。

5.2 增加 normalizer,解决完整值仍需小写化、字符折叠等规范化的问题。这个补充没有把 keyword 重新变回 text:normalizer 不含 tokenizer,而且必须保证只产生一个词项,所以 keyword 的“完整值单位”不变量仍然成立。

text 不是“更模糊的字符串”;它是可分析的文本。默认 standard analyzer 依照 Unicode 词边界切分并转小写。它不会默认删除英语停用词,也不会默认做词干化。因此这个句子的预期词项是:

1
2
3
4
I don't have a key.
↓ standard analyzer
i | don't | have | a | key
位置:0 1 2 3 4

keyword 也不是“不处理”的绝对同义词。它不使用 analyzer,但可配置 normalizer,例如统一转小写;normalizer 的结果仍是一个词项,而不是多个词项。

同一个业务值经常既要全文搜索,又要筛选、聚合和排序。此时不要在 text 上硬开 fielddata,而是用 multi-field 建两份适合不同访问路径的索引:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
PUT messages
{
"mappings": {
"properties": {
"message": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword",
"ignore_above": 256
}
}
},
"status": { "type": "keyword" }
}
}
}

message 用于 match 和 match_phrase;message.keyword 用于完整值的筛选、排序或聚合。注意两条容易混淆的默认规则:动态 mapping 遇到普通字符串通常会生成 text 加 .keyword 子字段;显式写成 { "type": "text" } 时,不会自动补 .keyword,需要手动写 fields。

Multi-field、object 与 nested:点号相同,结构不同

先定义三者各自处理什么。mapping 参数 fields 为同一个输入值建立多个索引表示。properties 定义 JSON 对象中实际存在的子字段。nested 是一种保留对象边界的字段类型,尤其用于需要按同一数组元素匹配的对象数组。message.keyword 与 users.name 都带点号,但前者可能来自 multi-field,后者可能来自对象的子字段,不能只靠名字判断结构。

把 multi-field 称为“字段的视图”,可以帮助理解同一个值的不同用途,但它是在索引时生成的表示。上例写入 { "message": "I don't have a key." } 时,message 形成分析后的词项,message.keyword 形成完整值词项;默认保存的 _source 仍只有原始 message。“伪字段”只适合描述它没有独立的业务 JSON 输入。索引里的 message.keyword 是实际可查询的字段,其 mapping 不继承主字段配置。Multi-fields 官方文档说明了这两个表示与 _source 的关系。

flowchart TB
    subgraph MODEL[当前建模:两种独立的选择]
        V[同一个字符串值] --> F[fields:多种索引表示]
        F --> T[message:text<br/>分析后的多个词项]
        F --> K[message.keyword:keyword<br/>一个完整值词项]
        A[users:对象数组] --> P[properties:定义 name、role 等子字段]
        P --> O[object<br/>子字段各自成为多值字段<br/>丢失数组元素之间的配对]
        P --> N[nested<br/>每个元素建立隐藏 Lucene 文档<br/>保留同一对象内的字段配对]
        N --> NF[其中的 users.name<br/>仍可通过 fields 配置 text 和 keyword]
    end
    subgraph HISTORY[历史:多字段写法的演进与对象建模并行]
        H0[ES 1.0 之前<br/>专门的 multi_field 类型] --> H1[ES 1.0<br/>普通字段上的 fields 参数]
        H1 --> H5[ES 5.0<br/>string 拆为 text 与 keyword<br/>动态字符串映射通常使用两者组合]
        R[至少在 2013 年已有<br/>object 与 nested 两种对象建模方式]
    end

普通 object 的问题出现在需要组合匹配对象数组时。例如文档包含以下两个用户:

1
2
3
4
5
6
{
"users": [
{ "name": "Alice", "role": "reader" },
{ "name": "Bob", "role": "admin" }
]
}

若 users 是 object,且 name、role 都映射为 keyword,搜索索引中相当于得到 users.name = [Alice, Bob]、users.role = [reader, admin]。根文档上的两个 term 条件 name = Alice AND role = admin 都能满足,尽管没有一个用户同时满足它们。_source 仍保存原来的数组;丢失配对的是普通 object 的搜索索引表示。

若业务要求“同一个用户既叫 Alice 又是 admin”,就将 users 映射为 nested,并把这两个条件放在同一个 nested 查询里。每个数组元素作为隐藏 Lucene 文档参与匹配,整个条件要在同一个元素中成立,根文档才会命中;分别放在两个 nested 查询里仍可能由不同元素满足。nested 排序、聚合和 inner_hits 也需要相应的 nested 作用域,并会增加索引文档数量和处理成本。Nested 官方文档展示了普通 object 的跨元素匹配及 nested 的处理方式。

两种配置可以同时使用。下面是 mapping 的 properties 片段:users 保留对象边界,users.name 支持全文搜索,users.name.keyword 支持完整值匹配。这些叶子字段的搜索仍应位于 path: "users" 的 nested 查询内。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
"users": {
"type": "nested",
"properties": {
"name": {
"type": "text",
"fields": {
"keyword": { "type": "keyword" }
}
},
"role": { "type": "keyword" }
}
}
}

multi-field 的配置入口发生过变化。ES 1.0 用普通字段上的 fields 参数替代专门的 multi_field 类型。ES 5.0 又把 string 拆成 text 与 keyword,让两种用途的组合更明确。官方升级指南记录了前一次变化,前面的 string 演进图解释了后一次变化。nested 不是这条演进线上的后继类型:2013 年的官方关系建模文章已经并列讨论普通对象与 nested。旧文献中的索引级 mapping types 又是另一概念,不能和这些字段类型混为一谈。

选择配置时,先问“同一个值是否需要多种索引方式”,再问“多个条件是否必须在同一个数组对象内成立”。第一个问题决定是否用 fields,第二个问题决定对象数组是否需要 nested;普通 JSON 层级本身不要求使用 nested。

字段能力速查表

“一个字段还有多少配置”没有一个脱离字段类型的固定数字。更容易记的是四种能力:能否高效查、能否保留词的位置、能否给每个文档取值、能否为相关性保留归一化信息。

能力与参数 text 默认 keyword 默认 作用与边界
index true true 建立可高效检索的索引。关掉后,text 无法搜索;保留 doc values 的 keyword、数值、日期仍可查询,但会慢。
doc_values 默认关闭;普通 text 排序、聚合不走它 true 面向“按文档取值”的磁盘列式结构,供排序、聚合、脚本使用。不是全文检索索引。
index_options positions docs text 默认保留文档、词频和位置;位置是普通短语查询的基础。keyword 只有一个词项,不存在多词短语。
norms true false 为评分保存字段长度等归一化信息。关闭可省空间,也会失去这部分评分信号。
store false false 单独保存字段值以便 stored_fields 取回;不影响 _source,也不决定能否搜索。
term_vector no 不适用 额外保存词项及可选位置、偏移等,常用于特定高亮或分析需求;普通短语查询不需要它。
fielddata false 不适用 让 text 的分析后词项可用于排序、聚合;会占堆内存,优先改用 .keyword。

五种字段表示:搜索、计算与取回

倒排索引从词项找文档,doc values 与 fielddata 按文档读取用于计算的字段值,_source 与 stored fields 保存用于取回的内容。这三种职责需要不同的访问方向和物理表示。

doc_values 这个名字已经写出了它的访问方向:doc → values,拿着文档号读取这个文档的字段值。倒排索引则是相反方向:value / term → docs,拿着查询词项找到包含它的文档。搜索首先需要后者;命中以后再排序、聚合或执行脚本,需要前者。

flowchart LR
    S[写入字段值] --> I[倒排索引<br/>term → doc IDs]
    S --> DV[doc values<br/>doc ID → field value<br/>索引时生成、磁盘列式]
    S --> SRC[_source<br/>整份原始 JSON]
    S --> SF[stored fields<br/>store: true 的独立叶子字段]

    Q[查询 term] --> I
    I --> H[候选 doc IDs]
    H --> DV
    DV --> R[排序 / 聚合 / 脚本]

    I -->|text 开启 fielddata 时<br/>按需反向装载| FD[fielddata<br/>doc ID → analyzed terms<br/>JVM heap]
    H --> FD
    FD --> R

    H --> F[fetch phase]
    SRC --> F
    SF --> F
    F --> OUT[返回文档或字段]

两者解决的访问问题相似,生成时机和存放位置不同。Doc values 在索引阶段随 segment 一起生成,按字段列式存放在磁盘上,并主要依靠文件系统缓存;keyword、数值、日期等字段默认开启。Fielddata 通常用于 text,首次需要时根据倒排索引装载“文档到分析后词项”的视图,并占用 JVM heap。构建和常驻成本都高,因此 text 默认关闭 fielddata。Elastic 的 doc values 文档也用“term 找文档”与“文档取值”区分这两个方向。

Fielddata 取到的不是原始字符串。New York 写入 text 字段后若形成 new、york 两个词项,fielddata 聚合的是这两个分析后词项,而不是一个 New York 桶。业务需要按原始完整值排序或聚合时,应建立 .keyword multi-field,让它在写入时生成 doc values;不要把 fielddata: true 当作 .keyword 的替代品。

_source 和 stored fields 属于另一条路径。_source 默认保存写入时的整份 JSON,本身不进入搜索索引,主要供结果返回、更新、reindex、调试和高亮使用;即使只做 source filtering,底层通常仍要加载并解析整份 _source。Stored fields 则是 mapping 中显式配置 store: true 的独立叶子字段,可以不解析整份 _source 就单独取回,但会增加一份存储,通常只有在文档很大而返回字段很少时才值得使用。官方建议大多数场景优先使用 fields 或 _source,不要默认给每个字段打开 store。_source 文档和stored fields 文档分别说明了整份文档与独立字段的取回路径。

这些表示在分布式搜索中怎样衔接,见第 06 篇:Query-Then-Fetch 的两阶段流程。其中 Query 阶段收集各分片候选并归并排序,Fetch 阶段再取回最终需要的文档内容。

五者可以按搜索请求的阶段记忆:

  • 倒排索引:从 term 找 doc IDs,负责“找”。
  • Doc values:从 doc ID 读取字段列,负责“算”,例如排序、聚合和脚本。
  • Fielddata:为 text 在堆内按需建立 doc ID 到分析后词项的视图,是昂贵的“临时算”。
  • _source:保存整份原始 JSON,负责“整份取回”。
  • Stored fields:额外保存指定叶子字段,负责“单字段取回”。

关系型数据库并非不需要这种访问能力,而是它的基础存储已经保留了“行 → 各列值”的方向。行存表读取一行后就能取得列值,B-tree 叶子节点或行定位器也能协助排序和分组;列式数据库则直接维护按列组织的数据。Elasticsearch 的倒排索引首先优化的是“term → doc IDs”,_source 又是面向整份 JSON 取回的存储,不适合为大量命中文档逐个解析某一字段。Doc values 因此成为并列的第二种物理表示:倒排索引负责找文档,doc values 负责按文档读取字段列。

短语匹配特别容易被误配。text 的 index_options: positions 默认已经打开,match_phrase 因而可以工作;index_phrases: true 是为常见的两词短语额外建索引、提高性能,不是“开启短语匹配”的开关。若把 index_options 降到不保存位置的级别,短语查询就失去所需信息。

评分也不是某个字段的单独开关。字段上的 norms、similarity、index_options 决定可用的评分信息;查询是否真的计算 _score,还取决于查询子句的有效上下文。上下文不是名为 query_context 的 JSON 配置项,而是沿 Query DSL 树从外向内传递:顶层 query 建立 query context,bool.filter、bool.must_not 与 constant_score.filter 等位置把自己的后代切换到 filter context。进入 filter context 后,内层 must 或 should 继续决定命中逻辑,但不恢复评分。

一个仍处于 query context 的 bool 可以同时包含评分分支和过滤分支:filter 分支限制命中,不增加分数;匹配的 must 与 should 分数按 bool 规则相加。若它只有 filter 或 must_not,没有评分子句,普通结果的 _score 为 0。若整个 bool 已处于 filter context,则不向上贡献分数。简化后是:

1
2
3
4
5
6
7
8
9
10
11
是否命中 = must 全部满足
+ filter 全部满足
+ must_not 全部不满足
+ should 满足 minimum_should_match

若 bool 的有效上下文为 QUERY:
bool 的 _score = 匹配到的 must 子分数之和
+ 匹配到的 should 子分数之和

若 bool 的有效上下文为 FILTER:
整棵子树只贡献 yes / no,不向上贡献分数

“子分数相加”是 bool 的归并规则,不适用于所有 compound query。其他类型应按自身参数与评分契约推导结果。Elastic 的 bool query 文档说明,匹配更多评分子句会增加最终分数。

Analyzer、tokenizer 与 normalizer:字段如何选择处理管道

三者不在同一层级。Analyzer 和 normalizer 是字段可以引用的两类处理管道;tokenizer 是 analyzer 内部必有的一个步骤,不是单独负责“文档分词”的字段类型。text 字段通过 analyzer、search_analyzer 和 search_quote_analyzer 选择全文分析管道,keyword 字段通过 normalizer 选择单词项规范化管道。

flowchart TB
    subgraph FM[字段 mapping 选择管道]
        T[message<br/>type: text]
        K[status<br/>type: keyword]
        T -->|analyzer| IA[索引阶段 analyzer]
        T -->|search_analyzer| SA[全文查询 analyzer]
        T -->|search_quote_analyzer| QA[短语查询 analyzer]
        K -->|normalizer| N[索引与查询阶段 normalizer]
    end

    subgraph AP[Analyzer 的组成]
        IA --> CF[0..N 个 character filter]
        SA --> CF
        QA --> CF
        CF --> TK[恰好 1 个 tokenizer]
        TK --> TF[0..N 个 token filter]
        TF --> TS[0..N 个 token<br/>可拆分、删除、补充并保留位置]
    end

    subgraph NP[Normalizer 的组成]
        N --> NCF[0..N 个允许的 character filter]
        NCF --> NT[没有 tokenizer]
        NT --> NTF[0..N 个单词项 token filter]
        NTF --> ONE[恰好 1 个规范化 term]
    end

    TS --> TI[text 的倒排索引<br/>term、频率、位置]
    ONE --> KI[keyword 的倒排索引<br/>完整值 term]
    K --> DV[doc_values 默认开启<br/>排序、聚合、脚本]

一个 analyzer 有零到多个 character filter、恰好一个 tokenizer、零到多个 token filter。Character filter 在切分前改写字符流;tokenizer 决定“怎样切”;token filter 决定“切出的 token 怎样改、删或补”。因此 analyzer 可以产生零个、一个或多个 token,并携带位置和偏移信息。例如 english analyzer 可能删除停用词并做词干化,standard 默认不会。中文文本通常需要面向中文的 analyzer;仅靠空格切分无法得到合适的中文词项。

Normalizer 与 analyzer 相似,但它没有 tokenizer,只允许不会破坏单词项约束的 character filter 和 token filter。它既在 keyword 值写入时执行,也会在查询该字段时处理查询值;例如配置小写 normalizer 后,写入的 OPEN 和 term 查询里的 Open 都可以归一为 open。这说明 term-level query 不运行全文 analyzer,却不等于查询值绝不做任何字段级规范化。Elastic 的 normalizer 文档明确列出了索引时与查询时两个阶段。

图中的连线表示 mapping 参数的作用范围,而不是三个组件可以任意互换。analyzer、search_analyzer、search_quote_analyzer 只能配置在支持全文分析的字段上;normalizer 属于 keyword 的完整值处理路径;tokenizer 只能出现在 analyzer 定义内部。配置 analyzer 会改变索引 term、查询 term、位置和短语行为,配置 normalizer 会改变完整值 term 及其排序、聚合键,但仍保持一个 term。

不要把 analyzer 理解成“处理查询输入的分词器”,也不要把 tokenizer 理解成“只处理文档输入的分词器”。analyzer 是完整的分析管道:text 字段写入时会用 index analyzer 处理文档文本,全文查询时会用 search analyzer 处理查询文本。未专门配置时,字段的 analyzer 通常同时承担两个角色;search_analyzer 和 search_quote_analyzer 才用于显式分开搜索阶段。tokenizer 只是 analyzer 的一个步骤,所以它会随所在 analyzer 用在索引阶段或搜索阶段,而不是天然只服务于文档。

搜索阶段并不存在一个必然凌驾于所有字段之上的“全局 query analyzer”。Elasticsearch 按以下优先级为全文查询选择 analyzer:

1
2
3
4
5
6
7
8
9
查询子句显式指定 analyzer
↓ 未指定
字段 mapping 的 search_analyzer
↓ 未指定
索引的 analysis.analyzer.default_search
↓ 未指定
字段 mapping 的 analyzer
↓ 未指定
standard analyzer

因此,一个 multi_match 同时查询多个字段时,默认由每个字段分别选择自己的搜索 analyzer。同一份 query text 可能在 title 上按 english analyzer 生成一组 query terms,在 message 上按 standard analyzer 生成另一组。只有查询子句显式写了 "analyzer": "standard" 等参数,才会覆盖这些字段自己的选择。cross_fields 还会按 analyzer 对字段分组;强行覆盖 analyzer 会改变分组和最终查询结构。

词法检索比较的是两侧分析后的词项:查询词项与文档词项。默认不是把原始句子做等值比较,也不是计算句意距离。大小写归一、同义词和词干化会改变词项,所以能够扩大匹配;它们来自配置规则,不是模型理解出来的语义相近。

用目标索引和目标字段运行 _analyze,不要只猜:

1
2
3
4
5
POST messages/_analyze
{
"field": "message",
"text": "I don't have a key."
}

field 很重要:它会使用该字段实际配置的 analyzer。若索引时与搜索时使用了不同的 analyzer,同一个字符串在两个阶段可能得到不同的词项流;对应配置包括 analyzer、search_analyzer 和 search_quote_analyzer。match 等全文查询会分析其输入;term 不会,因此不能把后者当作“由 analyzer 自动处理的精确匹配”。

为什么 don't have 有时命中,有时要加双引号

“不加引号查不到,加双引号能查到”不是一条 Elasticsearch 通则。排查应先确认四件事:查询栏当前是 KQL、Lucene 还是 ES|QL;是否写了字段名;这个字段是 text、keyword 还是 multi-field;_analyze 的两个词项是否真的存在。未指定字段的裸词会受 index.query.default_field 控制,可能根本没有搜索 message。

先固定前提:message 是上面的 text 字段,索引与搜索都用 standard analyzer,查询目标确实是 message。此时三种 Query DSL 的语义不同:

Query DSL 对 don't have 做什么 对这条文档的含义
match 分析为 don't、have,默认任一词项即可匹配 会命中;词序无要求。operator: "and" 时要求两个词都存在。
match_phrase 分析为同样的词项,并检查位置和顺序 会命中,因为 don't@1 后面紧跟 have@2。
term 不分析,直接找一个名为 don't have 的词项 不会命中;字段里只有 don't 和 have 两个词项。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GET messages/_search
{
"query": {
"bool": {
"must": [
{
"match_phrase": {
"message": "don't have"
}
}
],
"filter": [
{ "term": { "status": "open" } }
]
}
}
}

match_phrase 不是“整字段精确相等”。它只要求查询词项以指定顺序、默认不留间隔地出现在字段中;I don't have a key. 可以命中 don't have。slop 可以放宽词项的位置距离,仍然不是原始字符串比较。

match_phrase 精确的是词项位置,不是原始字符串

match_phrase 容易被叫成“精确匹配”,但这里精确的对象只是 analysis 之后的词项序列及其位置关系。它既不要求 _source 里的原始字符串完全相等,也不要求整个字段只能包含这些词项。

用 quick fox 查询 the quick brown fox,位置约束会变得具体:

1
2
3
4
5
查询短语:quick@0 ──紧邻──> fox@1
文档词项:the@0 → quick@1 → brown@2 → fox@3

slop: 0 要求 fox 紧跟 quick;实际隔着 brown,所以不命中
slop: 1 允许这一次位置偏移,所以可以命中

slop 在这里不是“质量低劣”,而是短语查询允许的位置松弛量。Lucene 将它定义为查询词项位置与文档词项位置之间允许的最大 edit distance。数值越大,短语的邻近约束越宽松;它不表示字符拼写错误,也不改变 analyzer 产出的 term。相邻两个词项倒序需要两次位置移动,所以 fox quick 要匹配查询 quick fox,至少需要 slop: 2。

默认 slop: 0 约束的是词项窗口,不是完整原文。quick fox、The quick fox jumps、QUICK, fox 与 quick | fox 在 standard analyzer 下都可能形成相邻的 quick → fox,因而都能命中同一个 match_phrase;quick brown fox 则多出一个中间词项,默认不能命中。

flowchart LR
    D["文档原文<br/>I don't have a key."] --> DA[字段 analyzer]
    DA --> DT["i@0 · don't@1 · have@2 · a@3 · key@4"]
    Q["查询文本<br/>don't have"] --> QA["search analyzer<br/>短语查询可用 search_quote_analyzer"]
    QA --> QT["don't@0 · have@1"]
    QT --> P{"文档中是否存在<br/>同序且相邻的位置窗口?"}
    DT --> P
    P -->|"don't@1 · have@2"| H[命中]
    H --> N[前后仍可有其他词项<br/>所以不是整字段相等]

因此要把四种常被口头叫作“完整”或“精确”的要求分开:

  • match 默认要求“分析后的任一词项出现”;operator: "and" 只把它收紧为“所有词项都出现”,仍不要求相邻或有序。
  • match_phrase 要求“分析后的词项按位置构成短语”;默认 slop: 0,前后出现其他词项不影响命中。
  • term 要求“一个索引词项相等”;在 text 字段上,它比较的不是原始整句。
  • keyword 加 term 才通常表达“整个业务字段值相等”,是否忽略大小写等差异取决于字段的 normalizer。

短语匹配也不等于对原始文本做 contains("don't have")。例如查询 quick | brown 与文档 quick brown 在 standard analyzer 下都会得到相邻的 quick、brown;| 被当作分隔符丢弃,因此 match_phrase 可以命中。大小写、标点、字符规范化、停用词、词干或同义词过滤都可能改变两侧的词项和位置;原始字符串不同,只要最终的词项位置条件相同,仍可能命中。反过来,原文肉眼包含某段字符,如果索引 analyzer 与搜索 analyzer 产出的词项或位置不一致,也可能不命中。字符拼写容错属于 fuzzy 或 fuzziness,不属于 slop。

实际的 Kibana 查询栏还多了一层:输入的语言。Discover 默认使用 KQL,也可以切换到 Lucene 或 ES|QL。它们的引号规则不同,不能把任意一处双引号理解为“ES 要做短语查询”。这里的“查询语言”不是 term、match 等 JSON key;这些 key 属于 Query DSL 的查询类型。

查询语言的语法解析发生在 analyzer 之前。以 KQL 为例:

1
2
3
4
5
6
7
8
9
10
11
输入:quick AND fox
└────┬────┘
KQL 先把 AND 识别为逻辑运算符
↓
quick、fox 分别交给目标字段处理

输入:"quick AND fox"
└──────┬──────┘
引号把整段声明为短语值
↓
文本再交给字段的短语搜索 analyzer

因此,在 KQL 或 Lucene 查询栏中,AND 可以是查询语言的逻辑运算符;要搜索正文中的单词 AND,需要按当前查询语言把它放进引号或正确转义。Loghouse 若表现为“裸写 AND 是逻辑与、加引号才按正文搜索”,说明它在 analysis 前还有一层查询语法解析,但具体是 KQL、Lucene query string 还是自定义语法,仍要以产品说明或实际请求体为准。

同样的字符放进 match 则不同:

1
2
3
4
5
{
"match": {
"message": "quick AND fox"
}
}

这里的双引号只负责界定 JSON 字符串,match 不会把字符串里的 AND 当成布尔语法。standard analyzer 通常会得到 quick、and、fox 三个 term,再由 match 的 operator 参数决定这些 term 按 OR 还是 AND 组合。要在 Query DSL 中表达短语,应显式使用 match_phrase,而不是在 match 的字符串值里再套查询语言语法。

当前 Elasticsearch 本身也支持 kql query。它把 KQL 表达式作为一个查询值接收,再 rewrite 成标准 Query DSL:

1
2
3
4
5
6
7
8
GET messages/_search
{
"query": {
"kql": {
"query": "message: \"quick brown\""
}
}
}

所以“KQL 是查询语言”和“kql 是 Query DSL 的一种 query type”可以同时成立。早期或某些 Kibana 路径会在客户端把 KQL 转换为 bool、match_phrase、range 等查询后再提交;较新的接口也可以把外层 kql query 交给 Elasticsearch rewrite。不能仅凭搜索框的显示结果判断请求走了哪条路径,Inspect 或浏览器 Network 中的实际 request payload 才是证据。

所在位置 don't have "don't have" 单引号
Kibana KQL,message: 是 text 无引号的多个词按 text 的分析结果匹配,词序不固定;AND、OR、NOT 会先被解析为逻辑运算符 双引号把内容声明为短语值,再按字段配置分析并保持词项顺序 不是 KQL 的字符串引号语法;使用双引号并按需反斜杠转义。
Lucene query_string 一个或多个 term,默认操作符及字段设置会影响结果 双引号表示 phrase 不承担 phrase 语义。
Dev Tools 的 JSON "query": "don't have" 只是 JSON 字符串值;若它在 match 里仍是 match JSON 的双引号是语法;只有显式 match_phrase 或 query-string 内嵌双引号才表达短语 JSON 不接受单引号作为字符串定界。
ES|QL 由 ES QL 的表达式语法决定 不要套用 KQL/Lucene 规则

这张表只覆盖当前例子会遇到的四种输入形式,不是 Elasticsearch 全部查询语言的枚举。SQL、EQL 等接口服务于其他查询场景;Query DSL 内部还有更多查询类型和组合方式。查询类型的分类与 bool 组合由本系列第 08 篇展开,KQL、Lucene 与 Query DSL 的翻译关系则在 Kibana 系列第 04 篇说明。

未指定字段的 KQL 裸词也不能简单描述成“固定生成一个 match”。若最终使用 multi_match,没有显式 fields 时会读取 index.query.default_field,其默认值是 *,再扩展为 mapping 中适合 term 查询的非元数据字段;若使用原生 kql query,default_field 也遵循同一索引设置。字段类型不同,rewrite 后的叶子查询也可能不同。

例如某个调用链最终形成下面的 multi_match:

1
2
3
4
5
6
7
8
{
"multi_match": {
"query": "ImIntegrationConsumer|original_imMsg_body|body=",
"fields": ["content^2", "message", "raw_log"],
"type": "best_fields",
"lenient": true
}
}

query 是交给各目标字段处理的 query text;fields 才是目标字段列表,Query DSL 没有 target_field 参数;content^2 把该字段的评分权重提高到两倍。best_fields 会为每个字段生成一个 match,再主要采用最佳字段的 _score;lenient: true 只表示忽略“文本无法按数字、日期等字段格式解析”之类的格式错误,不表示模糊匹配。省略 fields 才会回退到 index.query.default_field。

如果请求体确实是这个 multi_match,并且这些字段使用 standard analyzer,上面的查询值通常产生三个 query terms:

1
2
3
imintegrationconsumer
original_immsg_body
body

| 和末尾的 = 不会成为这些 term,更不等于布尔 OR。日志界面把 ImIntegrationConsumer|original_imMsg_body|body 连同中间的 | 一起标黄,只能说明显示层选择了这一段字符;界面可能合并相邻 token 的 offset,也可能使用自己的字符串高亮。高亮不负责定义命中逻辑,不能用黄色区域反推 analyzer 或 query type。

Kibana Discover 的典型路径更明确:启用默认的 doc_table:highlight 后,Kibana 会在搜索请求中加入 highlight 配置,Elasticsearch 返回带标记的片段,Kibana 再把标记渲染成高亮。即便如此,高亮器提取匹配词时也不保证完整重现复杂查询的布尔逻辑;黄色区域仍不是 query type、AND/OR 条件或最终命中原因的证明。Loghouse 是否复用同一机制,只能从实际请求体和响应中的 highlight 字段判断。

Loghouse 反例:完整标记精准,body 单独查询泛滥

产品搜索框是一层独立接口,不能先验地把它等同于 match + standard analyzer。只有确认请求体确实生成了 match、multi_match 或原生 kql query,才进入相应 query type 和 analyzer 的分析。

下面的日志对照表明,完整输入的约束比单独的 body 更强,但不足以确定平台使用了哪种查询。在相同索引和时间范围内输入:

1
ImIntegrationConsumer|original_imMsg_body|body=

可以精准得到包含下面固定标记的日志:

1
ImIntegrationConsumer|original_imMsg_body|body={}

只输入 body,则会出现大量无关记录,例如:

1
OuterEventHandleDistribute|outer_event-distribute_push, body:{}

这组结果否定了“完整查询被当成 ImIntegrationConsumer OR original_imMsg_body OR body,实际只靠 body 命中”的解释。如果真是这种 OR,完整查询的结果集至少会包含上面的 OuterEventHandleDistribute 日志,不会保持精准。

它同样不能反向证明 Loghouse 使用了 match_phrase、wildcard 或某一种固定 Query DSL。当前证据只能说明,Loghouse 对完整输入施加了比单独 body 更强的约束。实现可能是连续字符片段检索、字符或 n-gram 专用索引、保留分隔符的位置约束,也可能是平台自定义语法生成的组合条件。只看输入框和黄色高亮无法在这些实现之间做出选择。

两组对照可以继续缩小范围:

1
2
original_imMsg_body|body=
ImIntegrationConsumer|body=

第一段在目标日志中连续存在,第二段并不连续。若只有第一条命中,行为更接近连续片段或位置约束;若两条都命中,| 更可能被平台解析成组合语法。无论结果如何,正式结论都应回到浏览器 Network 或产品 Inspect 中的实际请求:请求发往哪个 endpoint,payload 使用什么语言,目标字段是什么,服务端响应是否带有 highlight。

全文、精确、短语、模糊、评分:五个独立轴

“精确匹配 vs 模糊匹配”只覆盖了查询世界的一小部分。更可靠的分类方式是先问查询在约束什么:词项值、词项位置、字符编辑距离,还是排序分数。

想约束的东西 常用查询 会分析输入 是否默认要求顺序 是否天然算分
已经存在的单个词项或完整值 term、terms 否 不适用 放在 query context 时可算分;放在 filter 时不算。
多个分析后的词项 match、multi_match 是 否 query context 会算分。
词项的位置与相邻关系 match_phrase 是 是 query context 会算分。
拼写相近的词项 fuzzy、match 加 fuzziness fuzzy 直接作用于给定 term;match 先分析再对词项容错 否 query context 会算分。
字符模式 prefix、wildcard、regexp 通常不走全文 analysis 取决于模式 与是否评分无必然绑定。

“精确匹配”不是 Query DSL 的统一类别

官方把 term、terms、terms_set、ids、range、prefix、wildcard、regexp、fuzzy 等放在 term-level queries 下。它们通常直接针对索引里已经存在的词项或结构化值,不经过全文查询的 analyzer;但 keyword 字段配置了 normalizer 时,文档值在索引时会被规范化,查询值在搜索时也会经过同一个 normalizer。因此,“term-level = 完全相等”与“完全相等 = 一定不经过任何处理”都不准确。

需要把三种“精确”分开:

目标 推荐查询 实际含义
一个索引词项精确相等 term 查一个已经存在的词项;text 字段上的整句通常不是一个词项。
多个候选值中的任意一个 terms 词项 A 或 词项 B 命中即可,适合 IN;要求多个词项同时满足时用 terms_set。
整个业务字符串相等 keyword + term keyword 把完整值作为一个词项;是否大小写归一化取决于 normalizer。
一串词按顺序出现 match_phrase 先分析输入,再检查词项位置;这是短语包含,不是整字段字符串相等。

下面两个查询分别表达“候选值属于集合”和“分析后词项构成短语”,不能把后者理解成多个值的 IN:

1
2
3
4
5
{
"query": {
"terms": { "status": ["open", "pending"] }
}
}
1
2
3
4
5
{
"query": {
"match_phrase": { "message": "don't have" }
}
}

前者是在一个字段里做多个词项值的 IN;后者是在 text 字段里做分析后的短语包含。match_phrase 可以命中 I don't have a key.,但不能据此推出整字段等于 don't have。要做整字段相等,应把值索引到 keyword(或其他适合完整值的字段),再使用 term。

match 的 OR 到底发生在哪里

match 的默认规则可以压缩成一句话:先用搜索 analyzer 把输入变成词项,再把这些词项按 operator: "or" 组合;命中其中至少一个词项的文档就有资格成为 hit。 operator: "and" 或 minimum_should_match 可以收紧这个条件。

这不等于“所有 delimiter 都被当成 OR”。空格、逗号、句号等字符是否成为边界,先由 tokenizer 和 token filter 决定;它们只是可能影响词项如何产生,不是查询语言里的布尔运算符。真正的 OR 是 analyzer 产出词项之后,match 查询对这些词项的组合逻辑。某些 analyzer 会保留标点、合并字符、删除停用词或生成同义词,所以不能仅凭字符长相推断最终查询。

例如查询文本 connection timeout 经 standard analyzer 得到两个词项时,默认语义近似于 connection OR timeout:含有任一词项的文档都可能命中,两个词项都出现的文档通常因为 BM25 累积了更多贡献而排名更高。这解释了为什么结果可以既不是整句相等,也不是原始字符串的连续 contains。如果业务要求两个词都存在,用 operator: "and";如果还要求相邻且有序,用 match_phrase。

fuzziness 衡量的是编辑距离,例如少一个字符、替换一个字符或相邻字符换位,不是“语义距离”。全文查询也不等于会模糊拼写:match 默认做的是分析后的词项查询,拼错 elastisearch 并不会自动命中 elasticsearch,除非显式配置 fuzziness、同义词等机制。

评分是另一个维度。match_phrase 可以只作过滤,也可以在 query context 中按 BM25 等相似度参与排序;term 也一样。查询类型回答“什么算命中”,上下文回答“命中后是否影响排序”。

“参与评分”也不等于“计算语义距离”。对普通 text 字段上的 match,可以用下面的心智模型理解:

  1. search analyzer 把输入变成查询词项;
  2. 倒排索引用这些词项的 posting list 找到可能命中的文档;
  3. 引擎在收集命中文档时,按 BM25 等相似度计算分数并保留靠前结果。

这不是必须先物化“全部候选文档”、再单独跑一次 BM25 的固定两阶段流水线;匹配和评分会随着 posting list 的遍历、过滤与 top-N 收集交织执行。对每个实际出现在文档字段里的查询词项,BM25 会综合它在该文档字段中的出现次数、它在索引中出现于多少文档、以及字段长度等统计量;多词查询再按查询结构组合这些贡献。它不会挑一个“代表文档词项”来和整条 query 比较,文档中没有匹配到的其他词项通常不会直接加分。

因此,统计相关性按 query term 分解。某个查询词只有在同一字段找到相同 document term 时才产生贡献;词频、文档频率和字段长度再决定贡献大小。这不是句子含义,也不是两个词袋的一次整体相似度。两句话即使意思接近,只要没有共享词项或配置过的同义词,也可能完全无法互相召回。

非 term-level 不只有 match

term-level 与“其他查询”不是只有两个固定成员的完整分类。它是 Query DSL 的一个功能分组;官方还按用途把查询分为全文、复合、地理、joining、span、向量等组。一个查询同时还有结构分类:它要么是 leaf query,要么是 compound query。因此“match 是唯一非 term-level 查询”不成立。

在本文涉及的词法检索范围内,全文查询至少包括以下九类:

全文查询类型 主要约束
match 分析输入后按词项匹配,可配置 OR、AND、模糊和最少匹配数。
match_phrase 分析输入后约束词项顺序与位置。
match_phrase_prefix 短语约束,但最后一个词项按前缀扩展。
match_bool_prefix 前面的词项按 term 处理,最后一个词项按 prefix 处理。
multi_match 把 match 扩展到多个字段,并按指定类型组合评分。
combined_fields 把多个 text 字段按一个组合字段的视角分析、匹配和评分。
query_string 先解析 Lucene 查询字符串,再构造 Query DSL;支持字段、布尔运算和短语语法。
simple_query_string 更宽容的查询字符串解析器,适合直接暴露给用户。
intervals 对词项的顺序、间隔和嵌套关系提供更细的约束。

match_phrase 与 wildcard、regexp 为什么不能互相替代

这里容易混淆两条轴线。field type 决定值怎样被索引、保留哪些可查询信息;query type 决定本次查询拿什么条件去使用这些信息。regexp 只有 query type,没有同名 field type。wildcard 比较特殊:它既是 query type,也是 keyword 家族中的 field type,但两个同名概念并不重复。

下面 mapping 中的 wildcard 是 field type,决定 path 怎样建立索引:

1
2
3
4
5
6
7
8
PUT logs
{
"mappings": {
"properties": {
"path": { "type": "wildcard" }
}
}
}

下面查询中的 wildcard 是 query type,决定本次用什么字符模式匹配 path:

1
2
3
4
5
6
7
8
GET logs/_search
{
"query": {
"wildcard": {
"path": "*/api/*"
}
}
}

两者不要求成对出现。wildcard query 也能用于 keyword 字段;把字段映射成 wildcard field,是为了优化特定数据分布下的字符模式查询,不是使用该 query type 的语法前提。

match_phrase 是全文查询。它先用 analyzer 把查询文本拆成词项,再检查这些词项在文档中的位置和顺序。它匹配的是“分析后的几个词是否按短语关系出现”,不是原始字符串是否符合某个字符模板。因此它依赖 text 字段保存的 token position,也会受小写化、停用词、词干和同义词等 analysis 配置影响。

wildcard 和 regexp 是 term-level query。它们把 *、? 或正则表达式作用于索引词项,不执行短语位置判断。在 text 字段上,模式面对的是 analyzer 产出的单个 term,不能跨越多个 term,也无法恢复分词时丢掉的原始标点和大小写。在 keyword 或 wildcard 字段上,完整字段值没有被切成自然语言词项,字符模式才更接近对原字符串做 grep 式搜索。

例如原值是 GET /api/UserProfile?id=42。若字段是使用 standard analyzer 的 text,索引词项近似为 get、api、userprofile、id、42:match_phrase 查询 api userprofile 可以利用相邻位置命中;小写模式 *profile* 也可能命中单个词项 userprofile,但模式 */api/UserProfile?id=* 不会跨词项重新拼回原字符串。若同一个值存入 wildcard 字段,后一个模式才是在完整值上表达预期的字符序列约束。

字段与查询组合 实际匹配单位 适合的需求
text + match_phrase 分析后的多个词项及其位置 人类语言中的短语、顺序和邻近关系
text + wildcard / regexp 单个分析后词项 少量明确的 term 模式;通常要警惕与原字符串语义不一致
keyword + wildcard / regexp 作为一个词项保存的完整值 ID、路径、主机名等结构化字符串的字符模式
wildcard field + wildcard / regexp query 未分词的完整值;内部用 n-gram 粗筛后校验原值 经常对长字符串或高基数字段做 grep 式搜索

wildcard field 的存在不是为了增加一种新的匹配语法,而是为既有的 wildcard、regexp 查询准备更合适的索引布局。普通 keyword 字段也能执行这些查询;当字段值很长、基数很高,或者经常使用前导通配符时,才需要比较 wildcard field 的索引成本与查询收益。反过来,wildcard field 不保存短语查询需要的词项位置,不能替代 text + match_phrase。

match_phrase_prefix 也没有消除字符模式查询的必要。它只把分析后短语的最后一个词项按前缀扩展,仍然受短语顺序和 token 边界约束;任意位置的子串、复杂字符集合或重复规则仍属于 wildcard、regexp 的问题。

用 the quick brown fox 比较 match 家族

match 约束词项是否出现,match_phrase 进一步约束位置与顺序;match_phrase_prefix 只对最后一个词项做前缀扩展,match_bool_prefix 则不要求短语顺序。下面用同一字段和文档比较这些差别。

下面建立一个独立索引,固定 message 使用 standard analyzer,并写入一条文档:

1
2
3
4
5
6
7
8
9
PUT quick-fox-demo
{
"mappings": {
"properties": {
"title": { "type": "text", "analyzer": "english" },
"message": { "type": "text", "analyzer": "standard" }
}
}
}
1
2
3
4
5
PUT quick-fox-demo/_doc/1?refresh=true
{
"title": "A fox story",
"message": "the quick brown fox"
}

message 的词项和位置为:

1
the@0  quick@1  brown@2  fox@3
Query type 查询文本 生成的主要条件 是否命中 原因
match quick fox 默认近似 quick OR fox 是 任一词项即可;本文档恰好两个都有。
match + operator: "and" quick fox quick AND fox 是 两个词项都存在,不要求相邻或保持查询顺序。
match_phrase quick brown 两个查询词项的相对位置为 0、1 是 文档中存在 quick@1、brown@2 这个同序、相邻的位置窗口。
match_phrase quick fox 要求 quick 后紧跟 fox 否 中间隔着 brown;提高 slop 后才可能命中。
match_phrase_prefix quick bro quick 后紧跟以 bro 开头的 term 是 最后一个查询词项作为前缀,可扩展到 brown。
match_bool_prefix + operator: "and" fox qui fox AND qui* 是 fox 和以 qui 开头的 term 都存在,但不要求按短语顺序排列。

对应的 Query DSL 可以直接在这个索引上执行:

1
2
3
4
5
6
7
8
9
10
11
GET quick-fox-demo/_search
{
"query": {
"match": {
"message": {
"query": "quick fox",
"operator": "and"
}
}
}
}
1
2
3
4
5
6
7
8
GET quick-fox-demo/_search
{
"query": {
"match_phrase": {
"message": "quick brown"
}
}
}
1
2
3
4
5
6
7
8
GET quick-fox-demo/_search
{
"query": {
"match_phrase_prefix": {
"message": "quick bro"
}
}
}
1
2
3
4
5
6
7
8
9
10
11
GET quick-fox-demo/_search
{
"query": {
"match_bool_prefix": {
"message": {
"query": "fox qui",
"operator": "and"
}
}
}
}

这四类查询与普通 SQL 字符串比较不在同一抽象层。假设关系表中也存着同一个字符串:

1
2
INSERT INTO documents(message)
VALUES ('the quick brown fox');
SQL 条件 是否命中 与 ES 的差别
message = 'quick brown' 否 = 比较整个字段值;近似于 keyword + term 的业务语义,不是 match_phrase。
message LIKE '%quick brown%' 是 比较连续字符子串;表面接近本例的 match_phrase,但不经过 analyzer,也不理解 token position。
message LIKE '%quick fox%' 否 原字符串中没有连续的 quick fox;ES 的 match 却可以命中,因为它查独立词项。
message LIKE 'the quick bro%' 是 SQL % 是字符通配;match_phrase_prefix 的前缀只作用于最后一个分析后的 term。

SQL 的 LIKE 不会自动执行小写化、词干化、停用词删除或同义词展开。数据库自带的全文索引可以提供与 ES 更接近的能力,但那已经不是普通 =、LIKE 的语义。最容易记住的边界是:SQL LIKE 主要约束字符序列,match 家族主要约束 analysis 后的词项及其位置。

Elasticsearch 与 MySQL FULLTEXT 的差别

MySQL 也有真正的全文检索,不需要用 LIKE '%词%' 模拟。以 MySQL 8.4 为例,InnoDB 和 MyISAM 可以在 CHAR、VARCHAR、TEXT 列上建立 FULLTEXT 索引,再用 MATCH(columns) AGAINST(query) 搜索。InnoDB 的 FULLTEXT 同样采用倒排索引,保存 word 到 document 的映射,并为邻近搜索记录位置信息。因此,“Elasticsearch 用倒排索引,MySQL 只能逐行扫描”并不成立。

两者的主要差别在于全文检索能力如何暴露和配置:

维度 Elasticsearch MySQL 8.4 FULLTEXT
建模入口 mapping 中的 text、analyzer、multi-field 等字段能力 文本列上的 FULLTEXT 索引
文本处理 analyzer 可组合 character filter、tokenizer、token filter,并可区分 index/search analyzer 内置 parser 受停用词、最小词长等参数影响;中文等无天然分隔符的语言可选 n-gram parser,日文还可用 MeCab 插件
查询入口 match、match_phrase、intervals、query_string 等 Query DSL,可继续嵌入 bool、过滤、聚合和重排 MATCH() AGAINST(),提供 natural language、boolean 和 query expansion 三种模式
短语 match_phrase 按分析后的 term position 与 slop 判断 双引号短语要求相同单词按相同顺序出现,标点不必逐字符相同
相关性 默认使用 BM25,并通过 Query DSL、字段 boost、similarity 等机制组合或调整 natural language mode 返回 relevance;boolean mode 默认不按 relevance 降序排列

最简单的心智模型是:MySQL FULLTEXT 是关系数据库内建的一套全文索引与 MATCH ... AGAINST 查询能力;Elasticsearch 把文本分析、查询组合、相关性排序以及分布式搜索作为核心能力展开。 两者共享“解析文本 → 建倒排索引 → 按词项检索”的主干,但 parser/analyzer、查询语法、评分和可组合能力不同,不能把同一段输入在两边的分词结果与得分视为等价。

multi_match 不改变上述单字段语义,而是把同一 query text 分发给多个字段。下面的 best_fields 近似生成两个 match,再通过 dis_max 主要采用得分更高的字段:

1
2
3
4
5
6
7
8
9
10
GET quick-fox-demo/_search
{
"query": {
"multi_match": {
"query": "quick fox",
"fields": ["title^2", "message"],
"type": "best_fields"
}
}
}

它不是 SQL 的 WHERE title LIKE ... OR message LIKE ... 的直接翻译:两个 match 会分别使用目标字段的搜索 analyzer,best_fields 还会按最佳字段组合 _score。most_fields 会合并多个字段的分数;cross_fields 把 analyzer 相同的字段按一个逻辑字段处理;phrase、phrase_prefix 和 bool_prefix 则分别在每个字段上采用对应的 match 变体。

上面的全文查询类型列表不是所有 Query DSL 类型的总数,也不是版本无关的固定枚举;查询类型会随 Elasticsearch 版本和新增检索能力扩展。判断一条查询属于哪一组,应分别问两个问题:它在功能上查什么(词项、全文、地理、向量或关系),在结构上是叶子节点还是包裹其他查询的组合节点。match 只是全文查询中最常用的一个,不是非 term-level 的代名词。

向量检索和语义检索不是同义词。dense_vector 或 sparse_vector 既可以保存模型生成的表示,也可以保存手工数值或其他系统输出。字段类型无法决定这些向量是否具有语义;只有向量编码了文本、图像或其他内容的含义,相似度检索才属于语义检索。semantic_text 把推理端点、语义表示和检索路径封装在字段能力中,可以由 match 或 semantic 等查询触发;同一个 match 用在普通 text 字段上仍是词法全文检索。

inference configuration 是什么

可以先记住这个简单定义:

An inference configuration is a set of settings that tells Elasticsearch which inference endpoint to use, what task to run, and how to generate or use the resulting representation.

它不是“语义相似度算法”本身,也不是一个单独的 query type,而是把查询或索引阶段所需的推理调用配置起来。以 semantic_text 为例,inference_id 通常指定生成索引表示时使用的推理端点,search_inference_id 可以单独指定查询时的端点;如果没有单独配置搜索端点,查询通常沿用 inference_id。推理端点背后还要有实际可用的模型、任务类型和许可,写一个端点名称不会自动部署模型。

对 dense_vector 的 kNN,常见度量是 cosine、dot product 或 L2 norm,不是所有向量检索都“最后算余弦”;semantic_text 还可能使用稀疏学习检索。词法、向量和语义路径都可以把结果写进 _score,但 _score 只是当前查询使用的排序分数,不承诺固定的单位和含义。BM25 分数、向量相似度以及混合检索融合后的分数不能因为字段名相同就直接视为同一种量。

_score 与 sort:不是自动混合,而是显式指定优先级

词频饱和、IDF、字段长度归一化与 k1、b 参数的计算展开在第 07 篇:BM25 与打分机制,修改 similarity 与自定义评分的入口见该篇的调整打分行为。

没有显式 sort 时,搜索结果默认按相关性 _score 从高到低返回。这里的“相关性”(relevance)是一个结果排序概念,不专属于向量检索:它表示文档与当前查询的匹配程度。普通 text 上的 BM25 使用词频、文档频率和字段长度等统计信息产生词法相关性分数;向量检索则根据向量距离或相似度产生另一种相关性信号。历史版本中默认的具体评分模型可能不同:Elasticsearch 5.0 起默认相似度从 TF/IDF 切换为 BM25,但 relevance 这个概念本身没有因此从词法检索转移到向量检索。写了 sort 后,Elasticsearch 不会自动把“时间、range、评分”折成一个综合分;sort 数组从左到右决定优先级,后一项只在前一项并列时才参与排序。range 仍是负责选出或排除文档的查询子句,不是排序信号。

boost 与 boosting 不是同一个层级

boost 通常是某个查询子句的参数,用来调整该子句对相关性分数的相对贡献。默认值通常是 1.0;大于 1.0 提高权重,0 到 1.0 之间降低权重。例如下面仍然只有一个 match query,只是把它的得分贡献放大:

1
2
3
4
5
6
7
8
{
"match": {
"title": {
"query": "elasticsearch",
"boost": 2.0
}
}
}

multi_match 的 fields: ["title^3", "body"] 也是权重表达:^3 给 title 更高的字段权重。它与查询参数 boost 的落点不同,但都在调整评分信号,既不会改变 mapping,也不会把未命中的文档变成命中。

boosting 则是一个完整的 compound query type。它接收 positive、negative 两棵子查询:返回结果必须命中 positive;如果同时命中 negative,原得分再乘以 negative_boost。因此它表达的是“保留,但降权”,而 bool.must_not 表达的是“排除”。

1
2
3
4
5
6
7
8
9
10
11
{
"boosting": {
"positive": {
"match": { "message": "timeout" }
},
"negative": {
"term": { "environment": "test" }
},
"negative_boost": 0.2
}
}

这里 negative_boost: 0.2 只作用于同时命中正、负两棵子查询的文档。它不是普通 boost 参数的反义词,也不是把 boost 写成对象后的扩展形式。辨认这组名称时仍看 JSON 层级:出现在某个 query body 内的 boost 是参数,出现在 query type 位置的 boosting 是一棵可以包裹其他查询的节点。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
GET messages/_search
{
"query": {
"bool": {
"must": [
{ "match": { "message": "timeout" } }
],
"filter": [
{ "range": { "@timestamp": { "gte": "now-7d" } } }
]
}
},
"sort": [
{ "_score": { "order": "desc" } },
{ "@timestamp": { "order": "desc" } }
]
}

这个例子先在七天内筛选,再让相关性排第一、时间排第二。若业务要“最新优先、相关性只作同一时刻的 tie-break”,就交换两个排序规格。若只按普通字段排序,ES 默认不计算 _score;需要在响应中保留它作展示或诊断时,设置 track_scores: true。这正是 ES 对两种机制的分工:查询上下文决定哪些条件可产生相关性信号,sort 决定这些信号是否以及以什么优先级参与最终输出顺序。

RAG 的 retrieve:ES 打分怎样参与取证

RAG 的 retrieve 是为生成答案挑选外部证据。ES 的 _score 是一次搜索中给命中文档排序的信号,两者处在不同层次:RAG 可以调用 ES 搜索,取回片段交给模型;也可以使用其他检索后端。ES 搜索本身也可以只返回结果列表,不接生成模型。

在一种常见实现里,长文先切成片段并写入 ES,片段保留原文来源。用户提问后,应用对 ES 发起查询,从返回结果中选出能放进上下文窗口的片段,再让模型依据这些片段生成答案。片段切分、选择多少条、如何呈现来源,都由 RAG 应用设计;不是 _score 自动完成的工作。Elastic 的 RAG 说明也把 ES 检索与模型生成列为两个步骤。

flowchart LR
    A[用户问题] --> B[ES: BM25 / 向量 / 混合检索]
    B --> C[候选片段与排序分数]
    C --> D[可选重排并选取片段]
    D --> E[片段与来源进入模型上下文]
    E --> F[模型生成答案]
关心的问题 ES 搜索与打分 RAG 的 retrieve
要交付什么 与查询匹配的文档或片段,以及排序结果 供模型回答当前问题的证据片段
怎样找与排 普通 text 上的 match 可用 BM25;semantic_text 上的 match 可执行语义检索;也可组合向量检索与重排 选择一种或多种检索策略,可直接使用 ES 的结果与排序
分数说明什么 _score 取决于查询与排序方式,只能在相应搜索结果中解释 分数可用于选候选,不能当作“答案正确概率”

因此,RAG 并不等于“先把所有文本转成向量”。词项明确的问题可用 BM25;需要找不同措辞的相关内容时可用向量;两者也能组合,再对少量候选做语义重排。选择哪条路,决定了上文的分词、字段类型和短语匹配知识是否直接参与召回。Elastic 的 retriever 文档列出了传统查询、kNN、混合排序和重排的组合方式。

仍用 I don't have a key. 举例:问题是 Do I have a key?,ES 可能因为 I、have、a、key 等词项把这句话排在前面;它确实是回答问题的相关证据,但句子表达的答案是“没有”。高 _score 不代表“有钥匙”,也不保证模型读懂否定。检索阶段解决“把哪段材料交给模型”,生成阶段才根据材料组织答案;证据不够时,应用还需要决定是否拒绝回答。

一条可复用的排查路径

查询没有命中时,先确认文档已经对搜索可见,具体机制见第 05 篇的 Refresh 章节;请求若指定了 routing,核对它是否覆盖目标文档所在分片,见第 10 篇的 Custom Routing。再按数据流从左到右检查:

flowchart TD
    A[确认目标 index 与时间范围] --> B["GET index/_mapping<br/>字段类型和 multi-field"]
    B --> C{"查询字段是否可搜索?"}
    C -->|否| D["检查 index:false<br/>或改用合适字段"]
    C -->|是| E["POST index/_analyze<br/>用 field 观察实际词项"]
    E --> F["确认 endpoint 和查询栏语言<br/>KQL / Lucene / ES|QL / JSON / 产品自定义语法"]
    F --> G["核对实际请求<br/>再选择或识别 query type"]
    G --> H["需要排序吗?<br/>query context 或 filter context"]
    H --> I[用 _explain 核对命中与分数]

排查的核心不是背 API,而是让写入路径和查询路径可见。_mapping 回答“字段具备什么能力”,_analyze 回答“这条文本最后变成什么”,_explain 回答“为何命中或为何得分”。三者合起来能把大多数“明明包含这句话却查不到”的问题从猜测变成可验证的事实。

记忆卡

看到的需求 先选什么 不要误用成
搜一段自然语言正文 text + match term 查整句
句子中必须按顺序出现几个词 text + match_phrase keyword 的完整值相等
状态、标签、ID 分组或排序 keyword / 数值 / 日期的 doc values 在 text 上盲开 fielddata
同一字符串既全文搜又聚合排序 text + .keyword multi-field 指望显式 text 自动生成子字段
对象数组中多个条件必须由同一个元素满足 nested + 同一个 nested 查询 普通 object 的跨元素匹配
容忍拼写错误 match + fuzziness 或 fuzzy 以为全文检索天然理解语义
Kibana 里双引号改变结果 先确认 KQL / Lucene / ES QL
日志搜索框的完整标记精准,拆成单词却过宽或没结果 查看 Network / Inspect 中的 endpoint、payload 和字段 先验假设它是 match + standard analyzer
用搜索结果给模型提供依据 RAG retrieve 选证据片段,可复用 ES 的检索与排序 把 _score 当成答案正确概率

模拟实验:把 mapping、文档与查询放在一起

本节提供一套可随版本升级复查的实验。先区分三个对象:field type 决定值怎样表示,mapping parameter 调整某类字段的能力,query type 决定怎样使用这些能力。“全部字段配置组合”不能直接写成一份 mapping:text 不支持普通 doc_values,normalizer 与 tokenizer 的规则不同,向量、对象和指标又各有专用参数。参数值也可以任意变化,组合无法靠一个样本穷尽。

实验采用“字段类型目录 + 代表性合法配置 + 能力对照 + 预期失败”的覆盖方式。正文给出完整主 mapping 和三份文档;附件包含全部请求及逐项记录。截至 2026-10-08 对照的官方字段类型目录,实验包含 54 个命名字段类型。这个数字属于本次目录快照,不是 ES 永久固定的类型数量;array、multi-field 和 runtime field 也不应再算成三个普通字段类型。

完整请求:mapping、写入和查询实验。覆盖与结果记录:字段目录及实验记录。离线检查:JSON 和覆盖一致性检查。

验证状态:服务器实验 NOT_RUN。 本节请求已按官方文档核对,离线检查只验证 JSON、字段引用结构和附件一致性。没有实测的命中集合、错误和分数均写为“预期”。主实验采用 8.19 / 9.x 文档中的语法;扩展实验另列版本、插件、许可和推理端点要求。滚动官方文档可能包含较新的功能,运行前必须记录目标集群版本,不能只写“支持 ES 9”。

复制到 Kibana、Postman 或 curl

以 Kibana Console 请求文件 为唯一请求源。正文里的 http 代码块也可直接复制到 Kibana Dev Tools;附件中的编号、前提和预期结果都是注释,不属于 JSON。

使用 Postman 时,下载并导入 Postman Collection v2.1,把集合变量 baseUrl 改成目标集群地址,在集合 Authorization 中配置认证。集合按主实验、反例和扩展实验分组;先执行环境检查,再创建索引并写入三份文档,最后执行查询。不要对整个集合直接点 Run:部分请求故意报错,扩展组也有不同前提。

请求格式导出脚本 只输出文本,不发送请求。把它与 `mapping-lab.http`、`mapping-lab-coverage.json` 放在同一目录,就可以按编号导出 curl:
1
node export-mapping-lab.mjs curl Q01 Q03

输出的每条 curl 命令可以复制执行;默认地址为 http://localhost:9200,也可先设置 export ES_URL='https://your-es-host:9200'。有认证时在命令中补上对应的 --header 'Authorization: ApiKey …' 或 --user;示例不包含真实凭据。导出器保留请求方法、路径、JSON 和字符串中的单引号,带 body 的 GET 不会被改成 POST。

修改请求文件后,用同一导出器重新生成 Postman 集合,避免维护两套查询:

1
node export-mapping-lab.mjs postman > mapping-lab.postman_collection.json

Postman 导入文件、curl 命令和离线检查均不证明 ES 已接受请求。它们共享同一组 137 个实验请求,服务器执行结果仍需记录。

先看字段与实验的覆盖关系

字段用途 本次目录中的类型 mapping 与实验位置
普通字符串与全文 keyword、constant_keyword、wildcard、text、match_only_text、token_count 主实验;全文、完整值、模式、词数及分析对照
自动补全 completion、search_as_you_type 主实验;分别通过 suggest 与 bool-prefix 查询
数值与布尔 byte、short、integer、long、unsigned_long、float、double、half_float、scaled_float、boolean 主实验;NUM01–09、范围、排序、聚合
日期、网络与版本 date、date_nanos、ip、version 主实验;时间范围、CIDR、版本顺序
区间值 integer_range、long_range、float_range、double_range、date_range、ip_range 主实验;RANGE01–06 检查包含与相交关系
对象与名称 object、nested、flattened、alias 主实验;跨元素匹配、同元素匹配、任意键和名称转发
空间 geo_point、geo_shape、point、shape 主实验;地理距离、包围盒、地理与平面形状相交
排名与向量 dense_vector、sparse_vector、rank_feature、rank_features 主实验;kNN、余弦脚本、稀疏向量与数值特征
取回与预聚合 binary、histogram、aggregate_metric_double 主实验;stored fields 取回和专用聚合
文档关系与反向匹配 join、percolator 独立 JOIN / PERC 实验;需要额外文档或存储查询
插件字段 annotated_text、murmur3 PLUGIN 实验;需要 mapper-annotated-text、mapper-murmur3
较新类型与推理字段 passthrough、pattern_text、rank_vectors、exponential_histogram、tdigest、semantic_text、semantic NEW / SEM / MULTI 实验;按实际版本、许可和模型单独运行

字段有时提供搜索能力,有时提供聚合或取回能力。因此,“模拟全部查询能力”要同时观察 Search API 的 query、sort、aggs、suggest 与取回参数,不能把所有字段都塞进一个 match。例如 T-digest 实验用 ES|QL 聚合,它位于 /_query 接口,与 Search API 的 Query DSL 分开。

flowchart LR
    V[记录版本、许可、插件] --> M[创建独立实验 mapping]
    M --> D[写入文档 1、2、3]
    D --> A[观察 analyzer、normalizer、词项]
    A --> Q[运行带编号的查询与反例]
    Q --> R[记录命中、排序、取值、错误]
    R --> U[升级后用新索引重跑]
    U --> C[比较实际结果与原来的记录]

创建主 mapping,再写入三份文档

先在 Kibana Dev Tools 运行附件 ENV01–03,记录 version.number、build_hash、license 和 plugins。主实验使用新索引名 es-query-lab-v1;名字已存在时,换一个实验后缀并同步替换请求路径。附件没有删除索引的步骤。字段数量和调试参数用于教学,不能直接作为生产 schema。

以下是 SETUP01 的完整请求。最有辨识度的是 title:同一个输入同时生成全文词项、完整值、英语分析结果与词数。users_object 和 users 接收同一组数据,用来对照 object 与 nested。source_only、indexed_only、cold_metric 则拆开搜索索引、doc values 与原始值取回三种能力。

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
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
PUT /es-query-lab-v1
{
"settings": {
"number_of_shards": 1,
"number_of_replicas": 0,
"analysis": {
"analyzer": {
"lab_text": {
"type": "custom",
"char_filter": [
"html_strip"
],
"tokenizer": "standard",
"filter": [
"lowercase",
"asciifolding"
]
}
},
"normalizer": {
"lab_fold": {
"type": "custom",
"filter": [
"lowercase",
"asciifolding"
]
}
}
}
},
"mappings": {
"dynamic": "strict",
"_source": {
"enabled": true
},
"_meta": {
"lab_revision": "2026-10-08",
"execution_status": "NOT_RUN"
},
"runtime": {
"price_with_tax": {
"type": "double",
"script": {
"source": "if (doc['price'].size() != 0) emit(doc['price'].value * 1.1);"
}
}
},
"properties": {
"id": {
"type": "keyword",
"store": true
},
"title": {
"type": "text",
"analyzer": "lab_text",
"search_analyzer": "lab_text",
"search_quote_analyzer": "lab_text",
"index_options": "offsets",
"norms": true,
"similarity": "BM25",
"term_vector": "with_positions_offsets",
"index_phrases": true,
"index_prefixes": {
"min_chars": 2,
"max_chars": 5
},
"position_increment_gap": 100,
"copy_to": "all_text",
"meta": {
"purpose": "全文、完整值、词数对照"
},
"fields": {
"raw": {
"type": "keyword",
"normalizer": "lab_fold",
"ignore_above": 256,
"eager_global_ordinals": true
},
"english": {
"type": "text",
"analyzer": "english"
},
"count": {
"type": "token_count",
"analyzer": "lab_text"
}
}
},
"message": {
"type": "text",
"analyzer": "lab_text",
"copy_to": "all_text"
},
"all_text": {
"type": "text",
"analyzer": "lab_text"
},
"body_light": {
"type": "match_only_text"
},
"token_stats": {
"type": "text",
"fielddata": true,
"fielddata_frequency_filter": {
"min": 0,
"max": 1,
"min_segment_size": 0
}
},
"no_positions": {
"type": "text",
"index_options": "freqs"
},
"no_norms": {
"type": "text",
"norms": false
},
"status": {
"type": "keyword",
"normalizer": "lab_fold",
"null_value": "unknown"
},
"tenant": {
"type": "constant_keyword",
"value": "lab"
},
"path": {
"type": "wildcard"
},
"tags": {
"type": "keyword"
},
"required_tags": {
"type": "integer"
},
"tiny_label": {
"type": "keyword",
"ignore_above": 4
},
"indexed_only": {
"type": "keyword",
"doc_values": false
},
"source_only": {
"type": "keyword",
"index": false,
"doc_values": false
},
"cold_metric": {
"type": "long",
"index": false,
"doc_values": true
},
"enabled": {
"type": "boolean",
"null_value": false
},
"i8": {
"type": "byte"
},
"i16": {
"type": "short"
},
"i32": {
"type": "integer"
},
"i64": {
"type": "long"
},
"u64": {
"type": "unsigned_long"
},
"f32": {
"type": "float"
},
"f64": {
"type": "double"
},
"f16": {
"type": "half_float"
},
"money": {
"type": "scaled_float",
"scaling_factor": 100
},
"price": {
"type": "double",
"coerce": true
},
"malformed_number": {
"type": "integer",
"ignore_malformed": true
},
"created_at": {
"type": "date",
"format": "strict_date_optional_time||epoch_millis"
},
"created_nanos": {
"type": "date_nanos"
},
"client_ip": {
"type": "ip"
},
"release": {
"type": "version"
},
"blob": {
"type": "binary",
"store": true,
"doc_values": true
},
"profile": {
"type": "object",
"dynamic": false,
"properties": {
"region": {
"type": "keyword"
}
}
},
"attributes": {
"type": "flattened",
"depth_limit": 20
},
"users_object": {
"type": "object",
"properties": {
"name": {
"type": "keyword"
},
"role": {
"type": "keyword"
}
}
},
"users": {
"type": "nested",
"dynamic": "strict",
"include_in_parent": false,
"include_in_root": false,
"properties": {
"name": {
"type": "text",
"fields": {
"keyword": {
"type": "keyword"
}
}
},
"role": {
"type": "keyword"
}
}
},
"opaque": {
"type": "object",
"enabled": false
},
"literal_metrics": {
"type": "object",
"subobjects": false,
"properties": {
"time.max": {
"type": "long"
},
"time.min": {
"type": "long"
}
}
},
"integer_span": {
"type": "integer_range"
},
"long_span": {
"type": "long_range"
},
"float_span": {
"type": "float_range"
},
"double_span": {
"type": "double_range"
},
"date_span": {
"type": "date_range",
"format": "strict_date_optional_time"
},
"ip_span": {
"type": "ip_range"
},
"location": {
"type": "geo_point"
},
"area": {
"type": "geo_shape"
},
"xy": {
"type": "point"
},
"xy_area": {
"type": "shape"
},
"suggestion": {
"type": "completion",
"analyzer": "simple",
"preserve_separators": true,
"preserve_position_increments": true,
"max_input_length": 50
},
"autocomplete": {
"type": "search_as_you_type",
"max_shingle_size": 3
},
"embedding": {
"type": "dense_vector",
"dims": 3,
"element_type": "float",
"index": true,
"similarity": "cosine",
"index_options": {
"type": "hnsw",
"m": 16,
"ef_construction": 100
}
},
"sparse": {
"type": "sparse_vector"
},
"popularity": {
"type": "rank_feature",
"positive_score_impact": true
},
"features": {
"type": "rank_features"
},
"latency_hist": {
"type": "histogram"
},
"latency_metrics": {
"type": "aggregate_metric_double",
"metrics": [
"min",
"max",
"sum",
"value_count"
],
"default_metric": "max"
},
"id_alias": {
"type": "alias",
"path": "id"
}
}
}
}

这里把 search_analyzer 显式设成与索引 analyzer 相同,便于先观察对称过程;以后可增加单独实验比较两者。lab_text 先去掉 HTML 标签,再使用 standard tokenizer 和两个 token filters。lab_fold 只有规范化过滤器,status: OPEN 最终成为单个 open 词项。

第一份文档 DOC01 给几乎每种主字段一个值。HTML 是有意加入的分析材料:title 的全文表示去掉标签,title.raw 的 keyword 表示仍保留标签。64 位无符号最大值写成字符串,避免 JavaScript 在生成 JSON 时先丢失整数精度。

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
PUT /es-query-lab-v1/_doc/1?refresh=wait_for
{
"id": "1",
"title": "<b>I don't have a key.</b>",
"message": "The door is locked.",
"body_light": "The door is locked.",
"token_stats": "key door",
"no_positions": "don't have",
"no_norms": "key door",
"status": "OPEN",
"tenant": "lab",
"path": "/srv/payment/error.log",
"tags": ["red","blue"],
"required_tags": 2,
"tiny_label": "toolong",
"indexed_only": "visible",
"source_only": "source-only",
"cold_metric": 42,
"enabled": true,
"i8": 12,
"i16": 1234,
"i32": 123456,
"i64": 123456789,
"u64": "18446744073709551615",
"f32": 1.25,
"f64": 1.25,
"f16": 1.25,
"money": 19.99,
"price": "10.5",
"malformed_number": "bad-number",
"created_at": "2026-10-08T10:00:00Z",
"created_nanos": "2026-10-08T10:00:00.123456789Z",
"client_ip": "192.0.2.10",
"release": "1.10.0",
"blob": "aGVsbG8=",
"profile": {"region":"cn","extra":"kept-in-source"},
"attributes": {"color":"red","priority":10},
"users_object": [{"name":"Alice","role":"reader"},{"name":"Bob","role":"admin"}],
"users": [{"name":"Alice","role":"reader"},{"name":"Bob","role":"admin"}],
"literal_metrics": {"time.max":9,"time.min":1},
"opaque": {"anything":["kept",123]},
"integer_span": {"gte":1,"lte":10},
"long_span": {"gte":1,"lte":10},
"float_span": {"gte":1,"lte":10},
"double_span": {"gte":1,"lte":10},
"date_span": {"gte":"2026-10-01T00:00:00Z","lte":"2026-10-31T00:00:00Z"},
"ip_span": "192.0.2.0/24",
"location": {"lat":39.9,"lon":116.4},
"area": {"type":"envelope","coordinates":[[116,40],[117,39]]},
"xy": {"x":2,"y":3},
"xy_area": {"type":"polygon","coordinates":[[[0,0],[10,0],[10,10],[0,10],[0,0]]]},
"suggestion": {"input":["key guide","door guide"],"weight":5},
"autocomplete": "key management guide",
"embedding": [1,0,0],
"sparse": {"key":1.2,"door":0.8},
"popularity": 10,
"features": {"quality":3,"recency":2},
"latency_hist": {"values":[10,20],"counts":[2,3]},
"latency_metrics": {"min":10,"max":20,"sum":80,"value_count":5}
}

另外两份文档提供命中与排序对照。它们不必填写全部字段;tenant 的 constant keyword 有配置值,而 status 的 null_value 只替代显式的 null。文档 2 的标题有四个词项,文档 1 的标题有五个词项。

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
PUT /es-query-lab-v1/_doc/2?refresh=wait_for
{
"id": "2",
"title": "I have a key.",
"message": "The door is open.",
"status": "CLOSED",
"tags": ["red"],
"required_tags": 1,
"enabled": true,
"price": 20,
"created_at": "2026-10-08T11:00:00Z",
"location": {"lat":31.2,"lon":121.5},
"embedding": [0,1,0],
"sparse": {"key":0.4,"open":1.5},
"popularity": 2
}

PUT /es-query-lab-v1/_doc/3?refresh=wait_for
{
"id": "3",
"title": "The lock is broken.",
"message": "Repair the lock.",
"status": null,
"tags": ["green"],
"required_tags": 1,
"enabled": false,
"price": 30,
"created_at": "2026-10-08T12:00:00Z",
"embedding": [0,0,1]
}

refresh=wait_for 用于等待这些写入变得可搜索。成功写入后,再从 OBS01–07 观察 mapping、field caps、分析结果、term vectors、explain 与 query rewrite。写入 HTTP 成功本身不能证明随后查询会命中。

从同一份数据观察不同查询能力

同一输入在默认 OR、AND 和短语查询下可以产生不同的命中集合。以下结果均为预期:Q01 命中文档 1、2,因为默认 OR 下文档 2 的 have 足以满足条件;Q02 要求两个查询词项都出现,只命中文档 1;Q03 还要求短语位置关系,也只命中文档 1,但不要求整段标题等于查询字符串。对应请求如下:

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
POST /es-query-lab-v1/_search
{
"size": 10,
"track_total_hits": true,
"query": {
"match": {
"title": "don't have"
}
}
}

POST /es-query-lab-v1/_search
{
"size": 10,
"track_total_hits": true,
"query": {
"match": {
"title": {
"query": "don't have",
"operator": "and"
}
}
}
}

POST /es-query-lab-v1/_search
{
"size": 10,
"track_total_hits": true,
"query": {
"match_phrase": {
"title": "don't have"
}
}
}

再比较分析与完整值。Q05 把 don't have 当作一个词项查 text,预期没有命中。Q06 对 keyword 的查询值 OPEN 做 normalizer 规范化,预期命中文档 1。这也能直接验证 normalizer 会参与查询,而不只参与 indexing。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
POST /es-query-lab-v1/_search
{
"size": 10,
"track_total_hits": true,
"query": {
"term": {
"title": "don't have"
}
}
}

POST /es-query-lab-v1/_search
{
"size": 10,
"track_total_hits": true,
"query": {
"term": {
"status": "OPEN"
}
}
}

排序实验 Q19 先用 match 找到文档 1、2,再按 price 降序,预期返回顺序为 2、1。track_scores 请求保留分数,排序仍以显式 sort 为准。Q20 则对全部文档排序并跳过前两条,预期只返回文档 1;这里的 from 与 size 没有充当 range 条件。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
POST /es-query-lab-v1/_search
{
"size": 2,
"track_total_hits": true,
"sort": [
{
"price": "desc"
},
{
"id": "asc"
}
],
"from": 0,
"track_scores": true,
"query": {
"match": {
"title": "key"
}
}
}

完整附件将其余能力放进编号实验。表中的“命中”只指集合,不承诺 BM25 的具体数值或无显式排序时的最终次序。

请求编号 能力对照 主实验中的预期观察
Q04–07 完整值、错误词项与 normalizer Q04 的完整 HTML 值匹配文档 1;Q05 没有命中;Q06 命中 1;Q07 命中 1、2
Q08–13 集合、前缀、字符模式、编辑距离 terms_set 命中 1、2;路径 wildcard/regexp 命中 1;kee 的 fuzzy/match fuzziness 命中 1、2
Q14–18 数值/日期范围、布尔、null、bool 槽位 数值范围命中 1、2,时间范围命中 2、3;混合 bool 命中 1
Q19–24 排序分页与五种 compound query 显式排序为 2、1;constant_score 得到固定分数;boosting 的 negative 降分但不排除
Q25–32 多字段、字符串查询语法、补全、位置查询 multi_match / combined_fields 查多个字段;intervals / span_near 匹配位置关系
Q33–38 对象边界、flattened、点号、alias 普通 object 能跨元素命中;同一 nested 查询下 Alice+admin 没有命中,Alice+reader 命中 1
NUM01–09、RANGE01–06 数值类型及区间值 数值精确查询命中 1;区间的 contains/intersects 使用区间关系
Q39–47 IP、版本、纳秒日期、空间与 suggest CIDR、版本范围和北京空间查询命中 1;completion 返回建议而不是普通 hits 排名
Q48–53 排名特征、稠密/稀疏向量、runtime 数值特征改变分数;cosine 脚本对文档 1 得到 2,对 2、3 得到 1;runtime 价格条件命中 1
Q54–60 字段能力关闭及索引时忽略值 doc-values-only 仍可查询;过长值、非法数字、未索引值或被跳过对象不会因存在于原始 JSON 就命中
Q61–65 词数、常量、轻量 text、norms、copy_to token_count = 5 命中 1;constant keyword 命中全部;copy_to 的目标无需出现在输入 JSON
Q66–70 聚合、取回、脚本、词项相似与空查询 histogram / aggregate metric 平均值预期为 16;不同取回结构给出不同表示
NEG01–04 有意失败的请求 普通 text 不存 positions 时短语查询报错;关掉 keyword doc values 后排序报错;未知字段和错误常量写入失败

区间值值得单独看:price 是一个数值,integer_span 是一个区间对象。两者都能使用 range query,但后者还比较两个区间之间的关系。附件 RANGE01 的输入是 [5,6],mapping 中文档 1 保存 [1,10],relation: contains 要求存储区间包含查询区间。它仍与结果分页无关。

比较倒排索引、doc values、fielddata 与取回值

Q66 的三个方向不同:status 的 terms 聚合按完整值分组,latency 的聚合读取预聚合统计,token_stats 的聚合读取分析后的词项。只有 token_stats 为演示开启了 fielddata;实际业务的字符串排序、分组通常优先使用 keyword。词项聚合的结果是 key、door,不是原句,也不是“文档的代表词”。

Q67 将几种取回方式放在同一份响应中:

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
POST /es-query-lab-v1/_search
{
"size": 10,
"track_total_hits": true,
"_source": [
"title",
"status",
"tiny_label",
"malformed_number",
"opaque"
],
"stored_fields": [
"id",
"blob"
],
"docvalue_fields": [
"status",
"price",
{
"field": "created_at",
"format": "strict_date_time"
}
],
"fields": [
"id_alias",
"title.count",
"price_with_tax"
],
"query": {
"ids": {
"values": [
"1"
]
}
}
}

预期 _source.status 仍是 OPEN,而 docvalue_fields.status 是规范化后的 open。stored_fields 单独取回 id 和 binary;fields 可取 alias、multi-field 或 runtime 值。Search API 的这个 fields 参数与 mapping 的 fields 参数同名,位置和职责不同。

tiny_label 与 malformed_number 的值仍在原始 source 中,但对应查询值被忽略。查询不到时应检查 _ignored 及实际字段能力。向量的 source 保存与默认返回策略也有版本差异,不能把某个版本的默认响应当作原始向量必然完整保存的证据。

独立扩展:关系、插件、语义与较新类型

实验组 前提与关键请求 能观察到什么
JOIN01–06 / PERC01–03 join 的父子文档使用相同 routing;percolator 先声明被查询字段 has_child 返回父文档,has_parent / parent_id 返回子文档;percolate 用输入文档找到存储查询
PLUGIN01–04 安装两个对应 mapper 插件;只运行这一组 annotated_text 注入 Key Entity 词项;murmur3 子字段提供哈希 cardinality 聚合
NEW01–08 集群必须识别这一组的全部新类型与参数;pattern_text 还需要适当订阅 passthrough 的无前缀名称、日志模板分组、数值 index_terms、rank_vectors 的 max-sim,以及两类分布聚合
SEM01–03 / MULTI01–03 替换附件的 inference endpoint ID;检查任务类型、模型和许可 对 semantic_text / semantic 的 match 调用推理路径,结果不再是普通 text 的 BM25
TS01–03 / DYN01–04 时间序列样本时间落在配置范围内;dynamic template 使用单独索引 time_series_dimension / metric 的用途,以及动态字段映射与显式 strict mapping 的差异

这些是独立实验,不能一次全选执行。NEW01 把新类型集中展示;如果目标版本只支持其中一部分,就为支持的部分建立另一个索引并记录删减项。出现 unknown field type 或 unknown parameter 时,应先对照该服务器版本的文档,不能将它归因于查询条件。

Pattern text 文档说明了订阅要求、单值限制和固定评分;数值字段文档把 index_terms 定义为 integer/long 的倒排索引选择;rank_vectors 文档给出了多向量 max-sim 评分。它们各自改变特定访问路径,不能泛化成每种 field 都能使用的参数。

Exponential histogram和 T-digest的示例值是分布摘要,不是普通数值数组。附件 NEW07 用 Query DSL 的 percentiles 聚合前者,NEW08 用 ES|QL 聚合后者。主实验的 aggregate_metric_double 则显式保留 default_metric 以对照较早版本;该参数在 9.4 被标为 deprecated,升级时应记录警告和行为变化。

语义实验必须先拥有可用的推理端点。附件中的 lab-text-endpoint、lab-multimodal-endpoint 是需要替换的名称,不是 ES 内置端点;也不会因为写了这两个名称就自动部署模型。Semantic 字段参考规定,semantic 只能用于 9.5 或以后创建的索引,并要求 embedding 任务端点。其中文本输入可以是字符串,其他模态还需要模型支持和相应输入格式。本节 MULTI 使用文本,先验证字段配置与推理路径,不预设一个模型支持全部模态。

手填的 [1,0,0] 和 {key:1.2,door:0.8} 用于检验向量运算,并没有从语言模型获得。Q51 可以验证余弦相似度加一,Q52 可以验证稀疏维度的加权匹配,但都不能据此宣称模型理解了“没有钥匙”。SEM / MULTI 才是由真实模型处理输入的实验,实际排序应与词项实验分别记录。

字段参数怎样随着版本扩充

官方 mapping 参数目录是复查入口,各字段自己的文档才定义适用范围。主实验和扩展实验把本次目录中的公共参数分配到了合法位置:

参数组合 本实验的位置 修改后应检查什么
analyzer、search_analyzer、normalizer、fields、copy_to title、status、all_text 索引词项与查询词项,以及 source 中没有的派生表示
index_options、index_phrases、index_prefixes、position_increment_gap、norms、similarity、term_vector title、no_positions、no_norms 位置、短语、前缀、数组边界和评分信息
index、doc_values、store、fielddata、eager_global_ordinals source_only、cold_metric、id/blob、token_stats、title.raw 搜索、排序、聚合与取回能否分别工作;部分参数主要影响开销
coerce、ignore_malformed、ignore_above、null_value、format price、malformed_number、tiny_label、status、created_at 原始输入与实际可查询值、被忽略字段和 null 行为
dynamic、properties、enabled、subobjects、meta root、profile/users、opaque、literal_metrics、title 新字段怎样处理、对象是否解析、点号怎样解释及元数据
index_terms 与类型专用参数 NEW、向量、区间、补全、指标、TS 实验 版本、维度、算法、量化、缩放因子、指标子项和路由约束

search_quote_analyzer、fielddata_frequency_filter、scaling_factor、dims、element_type、depth_limit、relations、metrics、priority、inference_id 等属于字段专用配置,继续从相应 field type 文档查。实验已经配置其中的代表项,但没有枚举每个专用参数的所有值。算法默认值、量化方式、synonyms、source 模式等变更应新增对照实验,不能只改一个参数后假定旧记录仍成立。

升级后复查这套材料的顺序是:

  1. 保存 ENV01–03 的实际返回,补上版本、build hash、许可、插件和模型信息。
  2. 对照字段类型与参数目录,给新增能力增加“mapping + doc + 查询或聚合 + 预期”四项,并同步覆盖文件。
  3. 使用新实验索引重跑原有案例,先记录实际行为,再比较命中集合、排序、取回值、警告和错误。纯性能参数需要另外测开销,三份文档无法证明性能收益。
  4. 将每个案例的 observed 填成实际结果,标记 PASS / FAIL / 不适用;仅把真正运行过的案例从 NOT_RUN 改掉。模型、分析器或旧文档索引表示发生变化时,还要说明是否重新索引。

附件检查脚本使用 Node 自带模块,不需要新增依赖。在同名素材目录执行:

1
node check-mapping-lab.mjs

它验证请求 JSON、案例编号、字段类型覆盖、主文档字段和向量维度,并检查几组关键对照数据。它不连接 ES,也不替代服务器上的 mapping 校验、查询执行或升级回归。可复用的是这套“配置、输入、操作、实际结果”之间的对应关系;版本升级需要重跑实验才能形成新的证据。

系列导航

实际运行本文实验:用 Docker 或 Podman 搭建 HTTP 实验集群,直接复用本篇附件并记录每个请求的实际响应。

这一篇放在第 08 篇之后,作为查询段的实战补充。它不替代既有章节,而是把字段、分析、评分和 Query DSL 串成一条可调试的路径:

上一篇 下一篇
Query DSL 深入:从 match 到 bool 的查询体系 Aggregation 框架:搜索之上的实时分析

参考资料