MATCH 与 TO_TEXT 的组合 🚀
在从未建立索引的数据上享受真正的全文本搜索 ------ 计算列、未映射字段、实时拼接的字符串,甚至来自 S3 的联合数据,统统不在话下。
📌 引言:
搜索的"最后一公里"难题。
Elasticsearch 以强大的倒排索引著称,但索引并非万能。在实际工作中,我们经常遇到这样的场景:
- 某个字段只在极少数的故障排查时才需要搜索(例如堆栈跟踪),平时索引它只会浪费磁盘和堆内存;
- 关键字字段(keyword)被大量用于聚合和排序,但突然有一天你想对它做"分词"搜索;
- 跨多个索引查询时,同一个字段在不同索引中类型不一致(有的 text,有的 keyword),甚至有的索引根本没映射该字段。
过去,面对这些情况,你只能:
- 重新索引(耗时耗钱),或者
- 退而求其次,使用
LIKE或RLIKE做简单的模式匹配,却忍受着大小写、词边界、词干等问题带来的漏报和误报。
现在,Elasticsearch 9.5(技术预览版)和 Elastic Cloud Serverless 带来了一对"黄金搭档"------ MATCH + TO_TEXT,让以上所有烦恼迎刃而解。🎉
🧩 核心问题:ES|QL 中为何需要真正的全文本搜索?
ES|QL 是 Elasticsearch 的新一代查询语言,它允许你像流水线一样组合命令(FROM → EVAL → WHERE → KEEP ...)。它本身已经支持 LIKE(通配符)和 RLIKE(正则表达式)来搜索字符串,但这两种方式都只是字符层面的模式匹配,并不理解"词语"的概念。
举个例子:你想在日志中查找包含单词 "fox" 的消息。
LIKE "*fox*"会漏掉"Fox"(大小写),却误匹配"Outfoxed"(子字符串)和"FOXTROT"------ 完全不是你要的狐狸。RLIKE可以勉强解决大小写,但词边界问题(fox,、fox!、fox?)会让正则表达式变得又臭又长,维护起来如同噩梦。
而真正的全文本搜索(full‑text search)会:
- 将查询词和待搜索内容都通过分析器(analyzer)处理;
- 分词、小写化、去除停用词、提取词干(stemming);
- 然后进行词元(token)对词元的匹配,保证只匹配完整的单词且忽略大小写和标点。
这正是 MATCH 的拿手好戏。而 TO_TEXT 则负责把任何字符串"变成"可分析的文本,即使它从未被映射为 text 类型。
🔧 解决方案:MATCH + TO_TEXT 组合技
1. TO_TEXT ------ 将任何字符串"文本化"
TO_TEXT 是 ES|QL 中首个生成 text 类型 的转换函数。在这之前,所有由 ES|QL 表达式生成的字符串都是 keyword 类型(即原样精确比较)。TO_TEXT(x) 明确告诉 ES|QL:"请把这个值当作全文本对待,后续要用分析器处理它。"
2. MATCH ------ 现在接受任何表达式作为第一个参数
过去,MATCH 的第一个参数必须是已映射的 text 字段,才能利用倒排索引。现在,它可以接受任何表达式:
- 由
EVAL生成的列; - 内联函数结果(如
CONCAT); - 直接从源文档加载的未映射字段 (需配合
SET unmapped_fields="load")。
这样一来,你可以在查询生命周期内临时创建"文本",并立即对它进行全文本搜索。
一个简单的例子 🌰
esql
FROM cooking_blog
| EVAL summary = TO_TEXT(CONCAT(title, description))
| WHERE MATCH(summary, "pancakes")
| KEEP title, author
解释:
CONCAT(title, description)拼接两个字段;TO_TEXT将其转为文本类型;MATCH对临时生成的summary执行全文本搜索,匹配含pancakes的文档。- 整个过程无需任何预先索引或映射配置。
⚙️ 运行时分析 vs. 倒排索引
MATCH 在表达式上执行时,完全绕过了 Lucene 的倒排索引 ,而是在查询时逐行分析。它的工作流程如下:
#mermaid-svg-pqAirDA4HBInVDC3{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;fill:#333;}@keyframes edge-animation-frame{from{stroke-dashoffset:0;}}@keyframes dash{to{stroke-dashoffset:0;}}#mermaid-svg-pqAirDA4HBInVDC3 .edge-animation-slow{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 50s linear infinite;stroke-linecap:round;}#mermaid-svg-pqAirDA4HBInVDC3 .edge-animation-fast{stroke-dasharray:9,5!important;stroke-dashoffset:900;animation:dash 20s linear infinite;stroke-linecap:round;}#mermaid-svg-pqAirDA4HBInVDC3 .error-icon{fill:#552222;}#mermaid-svg-pqAirDA4HBInVDC3 .error-text{fill:#552222;stroke:#552222;}#mermaid-svg-pqAirDA4HBInVDC3 .edge-thickness-normal{stroke-width:1px;}#mermaid-svg-pqAirDA4HBInVDC3 .edge-thickness-thick{stroke-width:3.5px;}#mermaid-svg-pqAirDA4HBInVDC3 .edge-pattern-solid{stroke-dasharray:0;}#mermaid-svg-pqAirDA4HBInVDC3 .edge-thickness-invisible{stroke-width:0;fill:none;}#mermaid-svg-pqAirDA4HBInVDC3 .edge-pattern-dashed{stroke-dasharray:3;}#mermaid-svg-pqAirDA4HBInVDC3 .edge-pattern-dotted{stroke-dasharray:2;}#mermaid-svg-pqAirDA4HBInVDC3 .marker{fill:#333333;stroke:#333333;}#mermaid-svg-pqAirDA4HBInVDC3 .marker.cross{stroke:#333333;}#mermaid-svg-pqAirDA4HBInVDC3 svg{font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:16px;}#mermaid-svg-pqAirDA4HBInVDC3 p{margin:0;}#mermaid-svg-pqAirDA4HBInVDC3 .label{font-family:"trebuchet ms",verdana,arial,sans-serif;color:#333;}#mermaid-svg-pqAirDA4HBInVDC3 .cluster-label text{fill:#333;}#mermaid-svg-pqAirDA4HBInVDC3 .cluster-label span{color:#333;}#mermaid-svg-pqAirDA4HBInVDC3 .cluster-label span p{background-color:transparent;}#mermaid-svg-pqAirDA4HBInVDC3 .label text,#mermaid-svg-pqAirDA4HBInVDC3 span{fill:#333;color:#333;}#mermaid-svg-pqAirDA4HBInVDC3 .node rect,#mermaid-svg-pqAirDA4HBInVDC3 .node circle,#mermaid-svg-pqAirDA4HBInVDC3 .node ellipse,#mermaid-svg-pqAirDA4HBInVDC3 .node polygon,#mermaid-svg-pqAirDA4HBInVDC3 .node path{fill:#ECECFF;stroke:#9370DB;stroke-width:1px;}#mermaid-svg-pqAirDA4HBInVDC3 .rough-node .label text,#mermaid-svg-pqAirDA4HBInVDC3 .node .label text,#mermaid-svg-pqAirDA4HBInVDC3 .image-shape .label,#mermaid-svg-pqAirDA4HBInVDC3 .icon-shape .label{text-anchor:middle;}#mermaid-svg-pqAirDA4HBInVDC3 .node .katex path{fill:#000;stroke:#000;stroke-width:1px;}#mermaid-svg-pqAirDA4HBInVDC3 .rough-node .label,#mermaid-svg-pqAirDA4HBInVDC3 .node .label,#mermaid-svg-pqAirDA4HBInVDC3 .image-shape .label,#mermaid-svg-pqAirDA4HBInVDC3 .icon-shape .label{text-align:center;}#mermaid-svg-pqAirDA4HBInVDC3 .node.clickable{cursor:pointer;}#mermaid-svg-pqAirDA4HBInVDC3 .root .anchor path{fill:#333333!important;stroke-width:0;stroke:#333333;}#mermaid-svg-pqAirDA4HBInVDC3 .arrowheadPath{fill:#333333;}#mermaid-svg-pqAirDA4HBInVDC3 .edgePath .path{stroke:#333333;stroke-width:2.0px;}#mermaid-svg-pqAirDA4HBInVDC3 .flowchart-link{stroke:#333333;fill:none;}#mermaid-svg-pqAirDA4HBInVDC3 .edgeLabel{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pqAirDA4HBInVDC3 .edgeLabel p{background-color:rgba(232,232,232, 0.8);}#mermaid-svg-pqAirDA4HBInVDC3 .edgeLabel rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pqAirDA4HBInVDC3 .labelBkg{background-color:rgba(232, 232, 232, 0.5);}#mermaid-svg-pqAirDA4HBInVDC3 .cluster rect{fill:#ffffde;stroke:#aaaa33;stroke-width:1px;}#mermaid-svg-pqAirDA4HBInVDC3 .cluster text{fill:#333;}#mermaid-svg-pqAirDA4HBInVDC3 .cluster span{color:#333;}#mermaid-svg-pqAirDA4HBInVDC3 div.mermaidTooltip{position:absolute;text-align:center;max-width:200px;padding:2px;font-family:"trebuchet ms",verdana,arial,sans-serif;font-size:12px;background:hsl(80, 100%, 96.2745098039%);border:1px solid #aaaa33;border-radius:2px;pointer-events:none;z-index:100;}#mermaid-svg-pqAirDA4HBInVDC3 .flowchartTitleText{text-anchor:middle;font-size:18px;fill:#333;}#mermaid-svg-pqAirDA4HBInVDC3 rect.text{fill:none;stroke-width:0;}#mermaid-svg-pqAirDA4HBInVDC3 .icon-shape,#mermaid-svg-pqAirDA4HBInVDC3 .image-shape{background-color:rgba(232,232,232, 0.8);text-align:center;}#mermaid-svg-pqAirDA4HBInVDC3 .icon-shape p,#mermaid-svg-pqAirDA4HBInVDC3 .image-shape p{background-color:rgba(232,232,232, 0.8);padding:2px;}#mermaid-svg-pqAirDA4HBInVDC3 .icon-shape .label rect,#mermaid-svg-pqAirDA4HBInVDC3 .image-shape .label rect{opacity:0.5;background-color:rgba(232,232,232, 0.8);fill:rgba(232,232,232, 0.8);}#mermaid-svg-pqAirDA4HBInVDC3 .label-icon{display:inline-block;height:1em;overflow:visible;vertical-align:-0.125em;}#mermaid-svg-pqAirDA4HBInVDC3 .node .label-icon path{fill:currentColor;stroke:revert;stroke-width:revert;}#mermaid-svg-pqAirDA4HBInVDC3 :root{--mermaid-font-family:"trebuchet ms",verdana,arial,sans-serif;} text 类型
(来自 TO_TEXT 或索引映射)
keyword / 数值 / 日期等
词元对词元
精确比较
查询输入
MATCH 第一个参数的类型?
分析器处理值
分词、小写化、词干等
不分析,原值比较
生成词元列表
生成原生常量
匹配逻辑
若任意值词元等于任意查询词元 -> 匹配
值 == 查询常量 -> 匹配
返回匹配行
与此同时,查询字符串也会被分析成一组词元。对于每一行,ES|QL 会按照上表处理。
处理方式速查表 📊
| 表达式类型 | 处理方式 | 匹配行为 |
|---|---|---|
text (通过 TO_TEXT 或来自索引映射) |
分析器将值分词、小写化等 | 词元对词元比较:任意值词元 等于 任意查询词元 即匹配(OR 语义) |
| keyword 、IP 、日期 、数值 | 不分析,查询常量转换为原生类型 | 每行精确比较(值 == 常量) |
| 未映射字段(加载为 keyword) | 若包裹 TO_TEXT,则按 text 处理;否则按 keyword 精确比较 |
同上 |
💡 重点 :运行时
MATCH的语义与针对索引字段下推给 Lucene 的match查询完全一致,只是性能不同 ------ 倒排索引在查询时几乎不扫描文档,而运行时分析需要逐行计算。但它的灵活性是无价的。
🎯 实战用例
以前无法搜索的数据,现在轻松搞定
1️⃣ 搜索未映射字段(无需添加映射)
你有大量日志,其中 stack_trace 字段从未映射(因为平时不需要搜索,索引它太浪费)。现在要排查 NullPointerException:
esql
SET unmapped_fields="load";
FROM app_logs
| WHERE MATCH(TO_TEXT(stack_trace), "java.lang.NullPointerException")
| KEEP @timestamp, service.name, message
SET unmapped_fields="load"告诉 ES|QL 从_source中加载所有未映射字段(作为 keyword)。TO_TEXT(stack_trace)将其转为 text,然后MATCH进行分词匹配。- 仅当需要时才付出计算成本,日常映射保持轻量。
2️⃣ 关键字字段的"临时"全文本搜索(无需重新索引)
product_name 被映射为 keyword 用于聚合和排序,但你现在想搜索"wireless noise cancelling headphones" ------ 希望分词匹配,而不是精确匹配。以前只能重建索引,现在:
esql
FROM products
| WHERE MATCH(TO_TEXT(product_name), "wireless noise cancelling headphones")
| KEEP product_name, brand, price
TO_TEXT 让 keyword 值在查询时被分析,从而支持全文本匹配。如果日后该搜索成为高频操作,再考虑调整映射也不迟。
3️⃣ 跨索引、不同映射的字段统一搜索
假设 2025 年的索引中 message 是 keyword,2026 年的索引中是 text。你想跨两个索引搜索 "connection reset":
esql
FROM logs-2025, logs-2026
| EVAL msg = TO_TEXT(message)
| WHERE MATCH(msg, "connection reset")
TO_TEXT 统一了类型,无论底层的原始类型如何,查询时都会被分析,匹配结果一致。
更进一步,如果 error_details 只在部分索引中映射,而另一些索引中未映射,你可以这样:
esql
SET unmapped_fields="load";
FROM logs-2025, logs-2026
| WHERE MATCH(TO_TEXT(error_details), "timeout")
ES|QL 会自动检测到该字段可能未映射,并逐行评估 整个 MATCH,而不是尝试下推 Lucene。你无需关心哪些索引有该字段,查询总能返回正确结果。
🆚 为什么 MATCH 胜过 LIKE 和 RLIKE?
| 特性 | LIKE(通配符) | RLIKE(正则) | MATCH + TO_TEXT |
|---|---|---|---|
| 大小写不敏感 | ❌ 需额外处理 | ✅ 可做到,但麻烦 | ✅ 自动(通过分析器) |
| 词边界识别 | ❌ 无法区分单词 | ⚠️ 可做到,但表达式复杂 | ✅ 自动分词 |
| 词干提取(stemming) | ❌ | ❌ | ✅(待支持多语言分析器) |
| 停用词处理 | ❌ | ❌ | ✅ |
| 同义词支持 | ❌ | ❌ | ✅(未来支持) |
| 可读性 | ✅ 简单 | ❌ 复杂易错 | ✅ 简洁自然 |
| 性能 | 较慢(扫描) | 更慢(正则引擎) | 逐行分析,比正则快 |
LIKE 和 RLIKE 是"字符游戏",而 MATCH 是"语义理解" ------ 前者适合简单过滤,后者适合真正的用户搜索场景。
🚧 当前限制与未来路线图
作为技术预览版(Elasticsearch 9.5),目前存在一些限制,正在积极解决:
| 限制 | 说明 | 计划 |
|---|---|---|
| 相关性得分 | 运行时 MATCH 不影响 _score |
正在开发中,未来将支持 |
| 查询选项 | 暂不支持模糊性(fuzziness)、操作符(operator)等 | 计划添加 |
| 分析器配置 | 默认使用标准分析器,不可自定义 | 即将支持 36 种语言分析器 |
| 短语匹配 | 目前只有 MATCH,没有 MATCH_PHRASE |
MATCH_PHRASE 已在 Serverless 中可用,9.6 进入 Stack |
| 向量搜索 | 无 | 未来将支持对运行时 dense_vector 执行 kNN |
📌 如果搜索成为日常高频操作,仍然建议将字段索引为
text以利用倒排索引的性能优势;而TO_TEXT则为你提供了"临时"或"低频"场景下的绝佳解决方案。