OpenSearch 权威指南——搜索:核心 API

132 阅读33分钟

你已经了解了 OpenSearch 如何对数据进行索引、如何进行分析或规范化,并让数据可被搜索。你也看过了一些示例查询,对 OpenSearch 的搜索 API 有了直观感受。

搜索能力是搜索引擎的核心。你如何为搜索组织数据,以及如何编写检索这些数据的查询,决定了你的应用能否通过呈现用户想买的商品或想找的文档来“取悦”用户。无论你处理的是海量的非结构化文本,还是高度结构化、由你带入搜索引擎的数据,良好的查询都是把“大型数据目录”变成“可穿透信息源”的关键。在完善应用的过程中,你将使用本章的技巧,确保用户能找到他们想要的内容。

本章我们从构建有效的 OpenSearch 搜索开始。先讲 OpenSearch 的搜索处理流程,帮助你理解查询如何被分发以及结果如何被计算。随后,你将亲手加载一个包含 100,000 部电影的数据集,用它来理解不同查询的工作方式。我们会概览 OpenSearch 的查询 API,并介绍搜索结果的分页。接着深入讲解 OpenSearch 的“叶子查询”(单子句查询),覆盖 match 与 term 查询。最后看一下命中高亮与搜索模板。

本章将涵盖以下内容:

  • 查询处理
  • 动手实践:加载数据
  • 叶子查询
  • OpenSearch 查询中的高亮
  • 补全与建议
  • 搜索模板

技术准备

要完成本章示例,你需要:

  • Python 3.10 或更高版本,以及 python-venv。下载与安装见:www.python.org/downloads
  • 一套正在运行的 OpenSearch 集群(参见第 2 章)
  • 一个 IDE(可选但有助于查看与运行代码)。我们使用 Visual Studio Code:code.visualstudio.com/download
  • 书籍配套仓库中 ch5 文件夹包含本章所有示例。你可以将 opensearch_inputs.json 中的示例复制到 OpenSearch Dashboards 的 Dev Tools 页签中执行。

查询处理

当你向 OpenSearch 发送查询时,请求会在返回结果前经历若干处理步骤。

image.png

图 5.1:查询处理链

如图 5.1 所示,首先,你的搜索请求会到达一个“协调节点(coordinating node)”。取决于你的集群部署方式以及你如何将请求发送到集群,协调节点可能是专用协调节点、数据节点、ML 节点,甚至是集群管理节点。对于高规模工作负载,专用协调节点是个好主意,但通常你会让数据节点来承担请求协调(关于扩展与专用协调节点,见第 13、14 章)。

请求队列与线程池
为了管理每个节点执行的工作,OpenSearch 创建了带有队列的线程池来承载任务。你可以使用 _cat/thread_pool API 查看所有队列。(注意:响应是某一时刻的快照。项目通常只在线程池中停留几毫秒。除非集群负载很高,否则你很可能看到所有线程池都是空的。)对请求处理来说,最重要的线程池是 search 线程池。在为集群设置监控时,你应定期记录队列深度。排队时间会直接计入查询响应延迟,因此应扩容到“搜索请求不排队”的程度。

当请求首次到达节点时,它会进入队列等待进一步处理。协调节点反序列化并解析请求,然后将子请求分发到包含被查询索引分片的各个节点。对于搜索请求,协调节点会为 n 个分片创建子请求,其中 n 是索引的主分片数量。每个子请求可以发送到拥有对应主分片或副本分片的节点。索引的数据在主分片集合中被均匀分布,因此向这 n 个分片分发即可覆盖索引中的全部数据。

理解了请求在集群中的协调与分发后,我们转向讨论 OpenSearch 在每个分片内部如何处理请求。可将其概念性分为四个阶段——匹配(matching)合并(merging)打分(ranking)排序(sorting) 。下面依次介绍。

匹配(Matching)

当某节点上的分片需要处理一个查询时,它会从协调节点接收请求并入队。请求出队后,OpenSearch 会创建一个 Lucene 查询,由请求中的各个子句组成。Lucene 会将每个子句转换为一个 posting list(倒排列表) ——也就是匹配该子句的文档 ID 集合。

Lucene 的内部数据结构针对速度做了优化,因此能非常快地为某个子句生成候选 posting list。关于这些数据结构的细节超出本书范围,感兴趣的读者可以深入 Lucene,了解 FSTBKD 树Trie 等。一般来说,这些数据结构与算法在每个字段上的时间复杂度为 log(n) (n 为字段的唯一值个数)。Lucene 还会缓存这些查找结果,使对相同值的后续查询在“匹配阶段”的时间接近常数。

该阶段的输出是一个“集合的集合”,每个内部集合包含某个查询子句匹配的全部文档 ID。Lucene 计算出所有 posting list 后,需要将它们合并,得到完整的匹配文档集。

合并(Merging)

合并 posting list 时,Lucene 会应用集合运算。当查询在两个子句之间指定 AND 时,Lucene 执行 交集(图 5.2a)。

image.png

图 5.2a:集合 [1,2,3,4,5] 与 [1,3,5,7,9] 的交集 => [1,3,5]

当查询在两个子句之间指定 OR 时,Lucene 执行 并集(图 5.2b)。

image.png

图 5.2b:集合 [1,2,3,4,5] 与 [1,3,5,7,9] 的并集 => [1,2,3,4,5,7,9]

当查询指定 NOT(未图示)时,Lucene 会对匹配文档列表取补集。
完成计算后,Lucene 得到满足查询约束的全部文档。这被称为查询的 匹配集(match set) 。计算匹配集的时间复杂度近似与被相交的最短 posting list 的长度呈线性关系。Lucene 还使用随机化等机制降低该时间,使其在许多情况下呈次线性。下一步是为每个搜索结果进行打分。

打分与排序(Scoring and Sorting)

使用搜索引擎时,你通常带着某种 目标——要买的东西、要找的信息、要去的地方。构建搜索时,你会对每个匹配文档应用一个 排序函数(rank function) (第 6 章将涵盖更复杂的查询与排序)。

排序函数会为每个匹配文档计算一个值,并按该得分对所有匹配文档进行排序,以便将“对搜索者目的更有价值”的结果排在前面。

时间复杂度与 constant_score
你已经看到,搜索的第一阶段是通过检索并合并 posting list 找到匹配文档。匹配阶段的时间复杂度为 O(log n) (检索 posting list)加 O(n)+C(合并 posting list)。换言之,计算匹配集的成本近似与 posting list 的长度线性相关。打分会引入与所选排序算法相关的额外延迟。排序结果的时间复杂度为 O(n log k) (n 为匹配文档数,k 为取回的结果数)。总体上,延迟主要由打分的成本与查询的匹配数量决定。你可以通过增加过滤条件来缩小匹配集,显著改善延迟。OpenSearch 还提供了 constant_score 查询(见第 6 章),通过对匹配不打分来降低延迟。

为文档打分的方法很多,调整单个文档与字段权重、以及整体排序的方式也很多,目标都是为用户的查询呈现最相关的结果。其核心思想是:排序函数应度量“文档中的信息”与“搜索者想要的信息”之间的相似度。直到最近,大多数搜索引擎都主要基于“查询与文档的文本匹配”来计算这种相似度。除此之外,OpenSearch 还提供对文档与字段的 boost、自定义排序函数(第 6 章介绍),以及由 AI 驱动的 语义搜索(第 10 章介绍)。

OpenSearch 的默认打分函数是 TF-IDF 的改进版本 Okapi BM25。它为每个匹配的查询项(term)计算一个值(IDF),并按照该项在文档中的出现次数(TF)进行加权求和。BM25 还引入了文档长度归一化与饱和等改进(详见维基百科:en.wikipedia.org/wiki/Okapi_…)。

image.png

图 5.3:Okapi BM25 函数

该函数对给定查询 (Q) 的每个文档 (D) 计算一个分数:将每个查询词的 IDF(项分)与其在文档中的出现次数(TF)相乘后求和,并对术语饱和与文档长度进行归一化。这意味着(在饱和点之前)匹配越多分越高;常见词的分值低于稀有词;更长的文档分值会更低。

第 6 章你会看到一些具体的打分例子。目前要点是:每个查询词对表达用户意图都有一定价值,而某个文档中该词出现的次数越多,该文档的得分越高。

OpenSearch 会在各个分片内独立计算分片得分并就地排序,使用优先队列输出排序结果,然后将分片排序结果返回给协调节点。协调节点再次使用优先队列合并,生成最终的全局排序结果集。最后一个阶段是取回匹配文档的 _source

取回(Fetching)

在取回阶段,协调节点会把最终的前 n 个匹配文档分派给持有这些文档的节点/分片。分片根据查询请求取回所需字段的源数据(_source),并回传给协调节点。

我应该把 OpenSearch 当作主存储吗?
你可以取回文档的源数据,这可能让 OpenSearch 看起来像是存储数据的便利之处。但是否应该这么做?多数情况下不应该。原因很多:其一,OpenSearch 首要目标是低延迟与高吞吐,存储成本通常高于专为存储而设计的系统;其二,取回阶段需要在分片与协调节点之间传输结果数据,返回的数据越多,集群内部的网络带宽越容易被淹没;其三,OpenSearch 旨在返回查询的前 N 条结果(通常 10–100 条)。你物化与取回的结果越多,上述各算法的延迟就越高。为避免这些问题,你应仅将“需要被搜索的数据”以及指向二级数据存储的“外键”发送到 OpenSearch。当检索到匹配文档及其外键后,再从二级存储取回完整文档呈现给用户。

现在你已经理解了搜索的机制,接下来动手将电影数据加载到 OpenSearch 中。

动手实践:加载数据

在本节中,你将使用第 2 章所述的 Docker 部署来搭建一套 OpenSearch 环境,用于加载并查询示例电影数据。可从本书的 GitHub 仓库获取数据集:
github.com/PacktPublis…

我们还提供了一个 Python 辅助脚本,用来读取电影数据文件、进行转换,并通过 OpenSearch 的 _bulk API 发送数据。下面是下载脚本和数据集并运行该脚本把数据加载到 OpenSearch 的步骤。

首先获取数据集并下载脚本,克隆仓库至本地:

git clone https://github.com/PacktPublishing/The-Definitive-guide-to-OpenSearch

仓库完整内容会被拉到本地。查看目录可见每章都有一个文件夹。切换到第 5 章目录(ch5):

cd The-Definitive-guide-to-OpenSearch/ch5

你将从该目录运行本章示例代码。先创建一个 Python 虚拟环境:

python3 -m venv .venv
source .venv/bin/activate

安装 OpenSearch 的 Python 客户端 opensearch-py,它封装了 OpenSearch 的高/低层 API:

pip install opensearch-py

ch5 目录中可以看到 load.py,它会读取 movies_100k_LLM_generated.json 文件,并用 _bulk API 将记录发送到 OpenSearch。若使用 IDE,请打开 ch5 目录以访问文件。打开并查看 load.py,在文件顶部可以看到需要设定的一些常量。你可以在命令行环境中设置这些值,或直接修改 load.py 中的默认值。若使用 VS Code,可在编辑器内打开终端(Terminal 菜单)并在那里设置环境变量。

image.png

图 5.4:在 load.py 脚本中设置这些常量

重要提示
如果你连接的是 Amazon OpenSearch Service,请使用端口 443。若在本地运行,请将端口改为本地安装时设置的端口,通常为 9200

继续阅读代码,你会看到几个工具函数以及主函数。主函数会先删除已有的 movies 索引,然后创建一个新的 movies 索引并设置映射。随后通过一个循环逐行读取文件中的电影数据,调用 clean_data 方法进行规整与清洗,累积到缓冲区后,使用 bulk 辅助函数发送到 OpenSearch。

数据准备
clean_data 函数体现了任何搜索系统中必不可少的环节。几乎所有情况下,你都会把来自其他系统的数据迁移到 OpenSearch 以提供检索。当数据格式不同、由终端用户产生、或需要富化时,你都需要实现 clean_data 的某种“近亲”。在很多项目中,你甚至会搭建整套系统来规范化、清洗并为搜索做准备。此类系统的设计与目标超出本书范围。

确保 OpenSearch 已启动可用、虚拟环境处于激活状态、且前述环境变量均已设置,然后运行脚本:

python load.py

打开 OpenSearch Dashboards,进入 Dev Tools,执行以下命令确认数据成功加载:

GET /_cat/indices?v

你应能看到名为 movies 的索引,包含 100,000 条文档。

花点时间查看 load.py 中的映射,以及 movies_100k_LLM_generated.json 中的一些数据。
每个电影文档包含 titleplotactorsdirectors,以及诸如 yeardurationrevenuerating 等元数据字段。在处理数据时,你会把结构化与非结构化信息组合到“可检索实体”(文档)中,以支持更准确的召回与排序。这些信息会在映射中体现为字段,也会在你的查询中被访问。

数据加载完成后,就可以深入 OpenSearch 的查询 API 了。下一节将先做总体概览。

OpenSearch 的查询 API 与支持的语言

OpenSearch 的 API 很广,涵盖多种请求类型:有的用于管理,有的用于运维,还有的用于控制数据的存储与处理。人们口中常说的“搜索查询”,通常指通过术语与逻辑在索引中匹配数据、并返回按相关性排序结果的 API 调用。

OpenSearch 支持多种方式发起查询,其中最常见的是在 GET 请求体中提供 JSON。它也支持使用各语言的客户端(如你刚安装的 Python 客户端)、REST URL 参数以及 OpenSearch Dashboards 来执行查询。支持多种查询语言:最常见、且本书主要使用的是 OpenSearch Query DSL;此外还支持 Simple QueryLucene QuerySQLPPL(Piped Processing Language)DQL(Dashboards Query Language) 。完整范围请见官方文档。就本书而言,我们聚焦 通过 REST GET 的 JSON 请求体 发送的 Query DSL

你最常用的两个接口是 _search_msearch:前者一次执行一个查询,后者并行执行多个查询。二者都是 REST GET 请求,URL 中指定要搜索的一个或多个索引。你可以在 URL 中指定多个索引名(逗号分隔)、使用通配符或二者组合,同时用 URL 参数控制查询处理与响应的各方面。一些常用 URL 参数如下:

  • allow_partial_search_results(布尔):允许在部分分片超时的情况下返回结果,并标记数据不完整。
  • default_operatorANDOR,默认 OR):控制 query string 查询的词项默认逻辑。通常你会在查询子句内设置,但某些查询形式也可通过 URL 参数设置。默认使用 OR,因为通常依赖相关性排序把合适的结果顶到前面。
  • explain(布尔):让 OpenSearch 返回每个文档的打分细节。也可在请求体中设置。
  • q:在 URL 中直接指定 Lucene 语法的查询。
  • routing:指定哈希键来控制哪些分片接收查询,多租户(租户数很多)场景常用。

总计大约有 45 个 URL 参数,详见文档。除 URL 外,你还会在 JSON 请求体 中设置参数。下一节我们先给出 Query DSL 查询的整体结构,再进入各类查询细节。

Query DSL 查询的结构

Query DSL 提供了用于表达查询逻辑的丰富语法,以及用于控制输出的语法:

GET <index or indices>/_search
{
  "query": { … },
  "aggregations": { … },
  "_source": { … },
  "from": <integer>,
  "size": <integer>,
  "profile": <Boolean>,
  "explain": <Boolean>,
  …
}

如上所示,你可以指定一个或多个索引。多个索引可用逗号分隔、通配符,或二者结合。

  • 使用 query 指定查询本身。
  • 使用 aggregations(可简写为 aggs)指定需要聚合统计的字段。
  • 使用 _source 指定要在最终结果中返回的字段。
  • 使用 fromsize 在前 10,000 条结果内分页(分页细节稍后介绍)。
  • 使用 profileexplain 控制是否在结果中返回计时信息与相关性解释。谨慎使用,它们会显著增加延迟,仅用于调试。

下一节我们执行一个查询并检查结果,以理解完整的查询流程。

match_all:最基础的查询

既然你已了解查询的总体结构与运行方式,我们先执行一个最简单的查询并查看结果。match_all 顾名思义会匹配索引中的所有文档。从浏览器进入 OpenSearch Dashboards 的 Dev Tools,执行:

示例源码
本章全部示例可在仓库 ch5 目录下的 opensearch_inputs.json 中找到。

GET movies/_search
{
  "query": {
    "match_all": {}
  }
}

查看右侧返回结果。查询响应结构如下:

{
  <metadata>,
  "hits": {
    <metadata>,
    "hits": []
  },
  "aggregations": {}}

顶层 metadata 给出服务端处理耗时,以及是否某些分片超时;还会包含 _shards 的详细信息(成功/失败分片数)。
随后是可能为空的 hits(匹配文档)。若存在结果,则在 "hits" 段返回数据;其中的元数据包含匹配的文档总数,hits.hits 则是从 OpenSearch 返回的源数据。
若查询包含 aggregations,结果会出现在 "aggregations" 段。响应中还可能包含其他元素(例如 高亮),我们稍后介绍。

OpenSearch 允许你控制每次查询返回多少、哪些结果。你可以通过 分页 扩展单次结果集之外的内容,并获取更多结果。下面介绍分页。

分页

OpenSearch 默认在 hits 中返回前 10 条结果。可用 fromsize 调整行为:它们提供对前 10,000 条结果的随机访问,便于在应用中以“逐页增加 from/size”的方式分页。例如,要获取上个 match_all 的第 2 页:

GET movies/_search
{
  "from": 10,
  "size": 10,
  "query": { "match_all": {} }
}

from 从 0 开始计数。from: 0, size: 10 表示第 1 页;from: 10, size: 10 表示第 2 页,以此类推。随机访问分页有两个限制:

  1. 无法获取 第 10,001 条及之后的结果;
  2. 若索引在查询期间发生增删改,跨请求无法保证结果顺序稳定,可能出现重复或遗漏。

Scroll API 提供了一种在分页时保持结果顺序的方法。使用方式是在 URL 中添加 scroll 参数指定上下文保持时间:

GET movies/_search?scroll=5m
{
  "size": 1000,
  "query": { "match_all": {} }
}

OpenSearch 的响应会在元数据中包含一个 _scroll_id,以及第一页结果。注意你要通过 size(最多 10,000)控制每页条数:

{
  "_scroll_id": "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFnl5TXBJSWdJUVVDN1ZmVEdKaUltQkEAAAAAAAAAFRZXQkdTUnl1TVR2eXd4dmFZTWRFYmZ3",
  "took": 27,
  "timed_out": false,"hits": {}
}

获取下一页时,使用该 scroll_id 调用滚动接口。注意此时不需要在 URL 中再指定索引名,因为滚动上下文已保存:

GET _search/scroll
{
  "scroll_id": "FGluY2x1ZGVfY29udGV4dF91dWlkDXF1ZXJ5QW5kRmV0Y2gBFnl5TXBJSWdJUVVDN1ZmVEdKaUltQkEAAAAAAAAAFxZXQkdTUnl1TVR2eXd4dmFZTWRFYmZ3"
}

通过设置 scroll 上下文,可以在索引发生变更时依然保持结果顺序,逐页取回供前端展示。并行滚动可参考文档中的 scroll slicing。滚动虽然好用,但也有不足:例如某一页请求失败后,无法回退取回那一页,因为上下文已经向后推进。

PIT(Point-in-Time) 搜索同样能保持结果集的排序,但比 scroll 更灵活,支持随机访问,也可结合 search_after 做分页。

使用 PIT 时,先创建一个 PIT ID:

POST movies/_search/point_in_time?keep_alive=1h

OpenSearch 会返回形如如下的 pit_id

{
  "pit_id": "g4iFQQEGbW92aWVzFnp2bFBKaTZ1VFZXWVdWS3hDeEVmSVEAFjlDTGdrWmFwUVRhcENtMW51ZWp4b1EAAAAAAAAAAA0WSlB0UGY5WndSbFdjZGNMTkpDNDI0QQEWenZsUEppNnVUVldZV1ZLeEN4RWZJUQAA",
  "_shards": {}
}

之后在针对该索引(或索引集合)的任何查询中使用该 pit_id。与 scroll 不同,PIT 不绑定单个查询;它会把索引/索引集合“冻结”在某个时间点,供后续查询使用:

GET _search
{
  "from": 100,
  "size": 20,
  "pit": {
    "id": "g4iFQQEGbW92aWVzFnp2bFBKaTZ1VFZXWVdWS3hDeEVmSVEAFjlDTGdrWmFwUVRhcENtMW51ZWp4b1EAAAAAAAAAAA0WSlB0UGY5WndSbFdjZGNMTkpDNDI0QQEWenZsUEppNnVUVldZV1ZLeEN4RWZJUQAA"
  },
  "query": {
    "simple_query_string": {
      "query": "Star Wars",
      "fields": ["title"]
    }
  }
}

与 scroll 一样,URL 中不再需要指定索引,因为它们包含在 PIT 上下文中。

分页的限制与注意事项
也许你会好奇,为何随机访问与其他分页方式有 10,000 的上限?要返回第 n 条结果,OpenSearch 必须先物化前 n-1 条结果;并且需要在每个分片上各自这么做,再把所有分片结果回传协调节点进行合并排序。随着 n 增大,查询延迟与分片—协调节点之间的网络传输成本都会显著增加。虽然可以调整 10,000 的限制,但并不建议这么做。相反,应使用 scrollPIT,它们更高效。不过,这两者也会占用资源:scroll 与 PIT 都会在 heap 上保存其冻结的上下文,请谨慎设置上下文保留时间,否则可能耗尽堆内存。

多数情况下你并不需要“深度分页”。即便索引在变更,随机访问分页通常也够用。此外,结果的边际价值会随着 n 增加而迅速下降——用户很少会在意第 4,292 条结果是否相关!更好的做法是在 UI 中加入 搜索分面(facets) 等功能,引导用户收窄查询,把更相关的结果带到前面。

理解了如何分页之后,我们将深入介绍可运行的各种查询类型。

叶子查询(Leaf queries)

OpenSearch 的查询分为两类——叶子查询(leaf queries)复合查询(compound queries) 。叶子查询直接指定要匹配的字段与取值。你已经见过一些叶子查询的例子,如 simple_query_stringmatchmatch_all。复合查询(第 6 章介绍)允许你把多个叶子或复合查询嵌套组合,构建出能返回更合适结果的查询逻辑。现在我们先聚焦叶子查询,看看 OpenSearch 支持的各种单字段匹配方式。

进一步来看,叶子查询可分为文本查询(text queries)词项查询(term queries) 。当字段类型是 text、需要对文本进行分析,并且查询本身也应被分析后再匹配时,使用文本查询;而当你需要精确匹配词项时,使用词项查询。

我们提供的数据集中有 100,000 部电影。可回看上一节的映射(或再次打开 load.py)。既然我们是科幻迷,就以 Star Wars 为例,按不同场景跑一圈各种查询类型。

文本查询(Text queries)

文本查询有多种:词匹配、短语匹配、前缀匹配等等。本节你将依次尝试这些 match 家族查询。

你已经见过最简单的文本查询:simple_query_string。它会对查询进行分析,并在一个或多个字段上匹配词项以取回文档。在 OpenSearch Dashboards 的 Dev Tools 中执行:

GET movies/_search
{
  "size": 20,
  "_source": "title",
  "query": {
    "simple_query_string": {
      "query": "Star Wars",
      "fields": ["title"]
    }
  }
}

该查询匹配到 209 部电影,滚动即可看到很多 Star Wars 系列的标题。

query_stringsimple_query_string 类似,但支持 Lucene 语法,能更精细地控制匹配:

GET movies/_search
{
  "size": 20,
  "_source": ["title", "plot"],
  "query": {
    "query_string": {
      "query": "title:Episode AND plot:Lake~1"
    }
  }
}

此查询匹配标题含 Episode 且剧情含 Lake 的电影。这里 ~1 开启模糊匹配,允许与 Lake 只差 1 个编辑距离的词(例如 Luke)也能匹配。结果里会出现 South of Heaven: Episode 2--The Shadow,因为其中的人名 JakeLake 只差一个字符。注意:模糊匹配可能带来这种“意外之喜(或惊吓)”,请谨慎使用。完整语法与操作符请参考文档。

另一类常用的是 match 查询,最基础形式如下:

GET movies/_search
{
  "size": 20,
  "_source": "title",
  "query": {
    "match": { "title": "Star Wars" }
  }
}

该查询在 movies 中匹配到 209 个文档。查询文本 Star Wars 会被分析为两个词项:starwar,并在标题中逐个匹配。默认逻辑操作符是 "or",因此你会在结果尾部看到只含其一的结果,如 StarRobot Wars 等。

如果你更想匹配同时包含 star wars 的标题,可以把操作符改为 "and"

GET movies/_search
{
  "size": 20,
  "_source": "title",
  "query": {
    "match": {
      "title": {
        "query": "Star Wars",
        "operator": "and"
      }
    }
  }
}

此时仅匹配 27 个文档——只含其一的标题被排除了。

但第二个结果竟是 Star Battleship Wars,这并非 Star Wars 系列。问题在于你希望匹配短语“star wars”。在 match 家族里可使用 match_phrase 按短语顺序匹配:

GET movies/_search
{
  "size": 20,
  "_source": "title",
  "query": {
    "match_phrase": { "title": "star wars" }
  }
}

该查询返回 26 个文档,Star Battleship Wars 不再出现,且所有结果标题都包含短语 star wars。我们已把 209 个嘈杂结果收敛到 26 个更靠谱的匹配。若想进一步优化,需要引入更多信息,比如 plot 字段。

matchmatch_phrase 适合单字段匹配。若想跨字段,则用 multi_match。比如你喜欢的角色是 Luke Skywalker,可同时在 titleplot 中搜索:

GET movies/_search
{
  "_source": ["title", "plot"],
  "size": 20,
  "query": {
    "multi_match": {
      "query": "Luke Skywalker",
      "fields": ["title^4", "plot"]
    }
  }
}

该查询匹配 110 个结果,既包含标题命中,也包含剧情命中。multi_match 支持字段加权(boost):用 ^ 指定权重,上例让 title 的命中得分是 plot 的 4 倍(^4)。由于提升了 title,一些仅含 Luke 的噪声标题会浮到前面。试着去掉权重,你会看到 Star Wars 相关的结果更靠前。

字段加权提示
在提升相关性时常会对字段赋予不同权重。对电影数据而言,title 极具代表性;在电商场景中,商品名同理。第 6 章我们会更系统地讨论相关性与排序。现在你可以尝试不同权重,观察排名变化。

match_phrase_prefixmatch_bool_prefix 可以把短语作为前缀匹配:短语里的最后一个词按前缀处理。

GET movies/_search
{
  "size": 20,
  "_source": "title",
  "query": {
    "match_phrase_prefix": { "title": "Star Wars Episo" }
  }
}

该查询返回 9 个结果:基本都是以 Star Wars: Episode 开头的标题(分析阶段会去掉冒号)。

小结:文本查询会对查询词进行分析,并与文档中 text 字段的“已分析词项”比较,因而更宽松灵活。接下来看看要求精确匹配的词项查询。

词项查询(Term queries)

词项查询要求精确匹配词项。最常见的用途是匹配 keyword 字段(经过规范化不分析)。keyword 适用于应用生成的、语义固定的字符串。genres 字段的映射包含子字段 genres.keyword,保存未分析的体裁名称。试试:

GET movies/_search
{
  "query": {
    "term": {
      "genres.keyword": { "value": "Adventure" }
    }
  }
}

匹配 6,947 个文档。由于 keyword 字段不分析,必须精确区分大小写。若把查询值改为 "adventure"(小写 a),将得不到结果。

要匹配多个取值可用 terms

GET movies/_search
{
  "track_total_hits": true,
  "query": {
    "terms": {
      "genres.keyword": ["Documentary","Adventure"]
    }
  }
}

关于 track_total_hits
为优化性能,OpenSearch 会将 hits.total.value 封顶为 10,000,与前文随机访问分页上限一致。这让引擎可采用启发式策略减少物化的命中文档数量。若需精确总数,可用 _count API,或在查询体中加入 track_total_hits谨慎使用,它会显著增加查询延迟。

上述查询匹配到 20,873 部电影。单独查询时,Documentary 有 14,183 部,Adventure 有 6,947 部(并集应为 21,130)。两者主题差异较大,因此重叠不多:21,130 - 20,873 = 257 部同时属于两类。terms 给出的是并集

若想要交集(两类都包含)?使用 terms_set 指定最小匹配个数:

GET movies/_search
{
  "track_total_hits": true,
  "query": {
    "terms_set": {
      "genres.keyword": {
        "terms": ["Documentary","Adventure"],
        "minimum_should_match_script": { "source": "2" }
      }
    }
  }
}

结果为 257,与上面的推导一致。terms_set 还支持 minimum_should_match_field 由文档自身字段决定阈值,或用脚本(此处为 Painless)指定。

词项类查询不仅包含精确匹配,还包括通配与其他非精确匹配。它们很强大,但可能很慢。回忆倒排索引:对前缀查询,OpenSearch 需要在词典中找到第一个匹配前缀的位置,然后向后遍历到最后一个匹配前缀,并对这些词项做并集,这可能非常昂贵!请谨慎使用。

一种简单的“非精确”是范围匹配。range 常用于数值字段(也可用于文本,如 keyword)。例如查找 2005 到 2008 年上映的电影:

GET movies/_search
{
  "query": {
    "range": {
      "year": { "gte": 2005, "lte": 2008 }
    }
  }
}

匹配 5,545 部。支持 gtgteltlte 等上下界。

前缀查询示例:标题以 Star Wars 开头的电影:

GET movies/_search
{
  "query": {
    "prefix": {
      "title.keyword": { "value": "Star Wars" }
    }
  }
}

该查询在 title.keyword 上匹配到 15 个文档。为什么用 title.keyword 而不是 title?因为 title已分析字段:

  1. 分析会统一小写,Star 不等于 star;可用 case_insensitive 参数缓解;
  2. 分析将 Star Wars 拆成两个词项 starwars,二者都不是“Star Wars”这个前缀;而 title.keyword 的规范化保留了整体词项,具备该前缀。
    因此,几乎不要对“多词项”的已分析字段做词项类查询,结果不可预期。

后缀匹配如何做?OpenSearch 没有直接的后缀查询,但可通过字符串反转实现:在索引阶段把字符串反转(数据准备时处理,或用 copy_to + reverse token filter 生成反转字段),查询时把目标后缀反转后做前缀匹配。比如查找标题以 evenge 结尾的电影,就对 reverse_title 匹配前缀 egneve

GET movies/_search
{
  "query": {
    "prefix": { "reverse_title": { "value": "egneve" } }
  }
}

注意:reverse_title 是索引时生成的辅助字段,结果的 _source 中可能看不到它。

若想匹配中间子串,OpenSearch 支持 wildcardregexpfuzzy 等查询,灵活度极高。例如某些 ID/URL 中包含编码子串。thumbnail 字段的 URL 中包含以 UXUY 开头的编码,抓取包含该子串的电影可用正则:

GET movies/_search
{
  "query": {
    "regexp": { "thumbnail": ".*UX.*" }
  }
}

警告:这类查询灵活但代价高,字符串操作与大量词项并集会显著增加延迟。请慎用!另可参考 wildcard 字段类型,在此类场景下会更高效一些。

到这里,我们覆盖了大量叶子查询。叶子查询让你基于单字段取回结果;要在多字段上同时施加条件,需要把叶子查询组合成复合查询(第 6 章)。下一节将介绍高亮(hit highlighting) ,帮助用户理解“为什么这个文档会命中”。

在 OpenSearch 查询中实现高亮

高亮(Highlighting)是 OpenSearch 中一项强大的功能,可将与搜索查询匹配的文档片段重点标示出来。它在需要向用户展示“为什么这个结果会被返回”的搜索应用中尤其有用:你可以直接显示文本里与查询相关的部分。

当你在查询中启用高亮时,OpenSearch 会随匹配文档一同返回额外的信息,说明哪些文本片段命中了查询。这些信息位于响应中的特殊 highlight 区域;匹配文本会被 HTML 标签(如 <em><strong>)包裹。

比如,你想在 plot 字段中搜索 Luke Skywalker。可以使用 match 查询并添加高亮:

GET movies/_search
{
  "_source": false,
  "query": {
    "match": {
      "plot": "Luke Skywalker"
    }
  },
  "highlight": {
    "fields": {
      "plot": {}
    }
  }
}

查看结果。由于查询设置了 "_source": false,OpenSearch 省略了 _source 字段,但包含了 plot 字段的 highlight。你会看到 LukeSkywalker(分别独立的词)被 <em> 标签包围。你可以用多种方式自定义高亮:通过 pre_tagspost_tags 替换 <em>/</em>;用 fragment_size 控制高亮片段长度;用 number_of_fragments 控制返回片段的数量;还可以设置片段的排序方式。

本节你已了解高亮如何通过强调查询关键词来优化搜索体验,帮助用户更快理解文档为何匹配。如果你的 UI 提供结果预览,高亮也能让用户在“提交搜索”前先大致判断结果是否相关。下一节将介绍如何为用户提供自动补全(typeahead/autocomplete)。

自动补全与建议(Completions and suggestions)

随着搜索 UI 的演进,工程师们为用户构建了更快更准的取回方式。得益于硬件与引擎性能提升,搜索可以在用户输入的同时作出响应,这就是 typeahead / autocomplete。你需要在 UI 中为搜索框加上联动:将用户输入发送到后端,从“词项索引”取回补全词,或直接取回可展示的结果列表,作为下拉候选;用户选择候选项即可,无需完整输入查询。

提供补全大致有四种方式。你可以直接使用前缀查询或 ngram,也可以使用 OpenSearch 的两种专用字段类型来更高效地做补全:completionsearch_as_you_type

completion 字段类型基于字段值构建 FST(有限状态机)。我们在 movies 索引的映射中包含了 completions_title,类型为 completion,并使用 copy_totitle 填充它。通过 suggest 查询获取补全:

GET movies/_search
{
  "suggest": {
    "autocomplete": {
      "prefix": "Star Wars",
      "completion": {
        "field": "completions_title",
        "size": 10,
        "fuzzy": { "fuzziness": "AUTO" }
      }
    }
  }
}

该查询的 hits 会包含文档的完整 _source;你也可以通过 _source 参数仅返回所需字段(如 title)。completion 里用 size 控制返回补全数量,用 fuzziness 设置编辑距离模糊度。例如上面结果里可能出现 Star Warrior — Making a Space Adventure(将 WarsWarr 视作一次编辑距离)。去掉 fuzziness 后,你将只看到 Star Wars 相关项。

search_as_you_type 字段类型专为前缀与中缀匹配优化。我们在映射中加入了 sayt_title(即 search-as-you-type)。索引该字段时,OpenSearch 会对其做分析,生成词项,并构建 2-gram、3-gram 的 term n-grams,同时通过 token filter 生成 edge 与普通 n-grams。以 Star Wars: Episode IV A New Hope 为例,其 2-gram / 3-gram 大致如下:

  • 2-gramsStar WarsWars EpisodeEpisode IV、…
  • 3-gramsStar Wars EpisodeWars Episode IVEpisode IV A、…
  • Term ngrams(单词级):SStSta、…

你可以利用其预定义子字段 ._2gram._3gram 来匹配,或直接对字段本身做前缀匹配:

GET movies/_search
{
  "query": {
    "match_phrase_prefix": {
      "sayt_title": "Star wa"
    }
  }
}

除了 search_as_you_type,OpenSearch 还能为“你是不是想找(did-you-mean)”提供建议:当查询无结果时,在“空结果页(404)”上给出纠错建议,帮助用户到达目标。Term SuggesterPhrase Suggester 使用编辑距离给出单词或短语级的拼写纠正。它们不需要特殊字段类型,使用普通的已分析字段(比如 title)即可。示例:为错误拼写 Epsiode 提供纠正:

GET movies/_search
{
  "suggest": {
    "spell-check": {
      "text": "Epsiode",
      "term": { "field": "title" }
    }
  }
}

OpenSearch 会在 suggest 部分(以你的标签名,如 spell-check)返回可能的纠正项:episodeepisodes

Phrase Suggester 则需要在映射中配置一个带 shingles(2/3-gram)的 term-level 字段。我们包含了 title.trigram 字段(包含 2 与 3-gram)。当用户输入 stra wars epsiode 这类错误短语时,可以这样获取候选纠正:

GET movies/_search
{
  "suggest": {
    "phrase-check": {
      "text": "stra wars epsiode",
      "phrase": { "field": "title.trigram" }
    }
  }
}

OpenSearch 会将正确的 star wars episode 作为首个建议。一个常见做法是在“零结果页”展示这些替代建议。

本节你学到了多种帮助用户“更快到达目标”的 UI 能力:completion 与 search-as-you-type 的即时提示,以及 term/phrase suggestions 的纠错补救。

搜索模板(Search templates)

把所有查询都写在前端代码里很直接,OpenSearch 的语言客户端也能处理不少细节。但将查询硬编码会降低灵活性,使升级 OpenSearch 或尝试不同匹配与排序策略变得困难。OpenSearch 提供搜索模板能力,让你将查询与业务代码解耦,便于在不改动应用的情况下调整查询。

搜索模板使用 Mustache 语言描述查询。首先保存模板脚本:

POST _scripts/year_range_query
{
  "script": {
    "lang": "mustache",
    "source": {
      "from": "{{from}}{{^from}}0{{/from}}",
      "size": "{{size}}{{^size}}10{{/size}}",
      "query": {
        "bool": {
          "must": [
            { "match": { "title": "{{title}}" }},
            { "range": { "year": { "gte": "{{year_min}}", "lte": "{{year_max}}" }}}
          ]
        }
      }
    }
  }
}

该模板支持传入一个标题关键词(如来自搜索框)与年份范围(year_min/year_max)。运行时通过 _search 的模板接口并指定模板名:

GET movies/_search/template
{
  "id": "year_range_query",
  "params": {
    "title": "Star Wars",
    "year_min": 1970,
    "year_max": 1980
  }
}

该查询会返回在 1970–1980 年间上映且标题匹配 Star Wars 的 6 条结果。Mustache 语法允许构建更丰富的模板与参数化方式,详见文档的 Search Templates 章节。

小结

本章围绕 OpenSearch 的查询处理打下了基础。你学习了如何构建查询与交互界面以助用户更快达到信息目标;了解了请求分发与耗时所在,便于保持页面“够快”;初步掌握了 matchterms 等叶子查询的文本匹配;学会使用高亮解释命中原因;并通过建议与补全为用户提供输入时提示与“零结果”兜底。下一章我们将更深入地探索 OpenSearch 的高级查询与聚合能力。