ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

Elasticsearch查询DSL实战:从match到function_score全解析

Elasticsearch查询DSL实战:从match到function_score全解析 我一度以为搞 ES 查询只要会写 match_all 就够了直到被线上一个“搜不到”的问题折腾到凌晨才明白查询 DSL 才是真正拉开差距的地方。这篇把 ES 里最常用的几类查询——全文查询、分词查询、精确查询、地理坐标查询还有组合查询里的 bool 和 function_score——从头到尾拆开讲一遍包括 RestApi 怎么调、参数怎么选、哪些坑我替你踩过了。适合刚接触 ES 查询、或者用了一段时间但还是靠复制粘贴拼 DSL 的朋友。1. 查询 DSL 的整体设计思路1.1 查询上下文与过滤上下文ES 查询的天然分水岭ES 的查询 DSL 看着复杂但骨架非常清晰。任何一条查询最终都会被扔进查询上下文query context或过滤上下文filter context里执行。这两者的差异决定了你的查询是走相关度打分还是纯走条件筛选。查询上下文做的事是“算分 筛选”。比如 match 查询它不光要找出匹配的文档还要计算每个文档和查询条件的相关度分数最后按 _score 从高到低返回。过滤上下文做的事只有“筛选”它只判断文档满不满足条件不计算相关度而且结果可以被 ES 自动缓存。bool 查询里的 filter 子句就是典型的过滤上下文。这里面的工程意义非常大。如果你只是想做条件过滤比如“状态为已上架”“价格在 100 到 500 之间”就没必要让 ES 去算一堆无意义的分值。把这类条件放进 filter查询性能会有肉眼可见的提升尤其是数据量大、条件又多的时候。我习惯在写 DSL 之前先想清楚哪些子句只负责缩小范围哪些子句才真正参与相关度排序。这个习惯能帮你省掉很多后期调优的麻烦。1.2 分词是全文查询和精确查询的根本差异很多刚接触 ES 的人会被 term 和 match 搞晕——明明都是查同一个字段为什么一个能查到一个查不到答案就在分词analysis上。ES 的全文检索不是直接把整段文字拿去匹配而是先经过分析器analyzer处理。一个分析器由三部分组成字符过滤器character filter处理原始文本分词器tokenizer把文本切成一个个词项词项过滤器token filter再做归一化、去停用词、转小写这类操作。默认的标准分析器会把 Hello World 分成 hello 和 world 两个小写词项。全文查询match、match_phrase 等在执行时会对查询关键词做同样的分词处理然后用分出来的词项去倒排索引里找。精确查询term 等不会做分词它拿查询时的原词直接去词典里找。所以拿 Hello 这个原词去 term 查询一个被标准分析器处理过的 text 字段大概率查不到——因为索引里存的是小写的 hello不是 Hello。这个认知是整个查询体系的基石。后面讲每种查询时你都会发现所有行为差异都能回溯到“是否分词”这一点上。2. 全文查询与分词查询的实操拆解2.1 match 查询全文检索的默认选择match 是应用最广的全文查询它会对查询关键词做分词然后匹配分词后的词项。一个简单的例子GET /products/_search { query: { match: { title: 华为手机 } } }ES 会先把 华为手机 分词假设分成 华为 和 手机然后去找既包含 华为 或包含 手机 的文档。默认情况下只要匹配到其中一个词项就算命中匹配到的词项越多得分越高。这里有两个参数值得你花时间研究。一个是operator默认是or改成and就要求所有分词都必须匹配用在需要严格限定的场景。另一个是minimum_should_match它允许你设置“至少匹配几个词项才算命中”可以用数字也可以用百分比。比如用户搜了五个词你可以要求至少匹配三个才返回避免搜出大量只沾一点边的垃圾结果。在电商搜索场景里我建议把minimum_should_match设成百分比而不是固定数字。因为用户的查询词有长有短固定数字在长查询下会显得过于严格在短查询下又起不到过滤作用。百分比是相对分词数量计算的表现更稳健。2.2 multi_match多字段全文搜索与字段权重业务里几乎没有只搜一个字段的场景。商品搜索可能要同时匹配标题、副标题、卖点文章搜索可能要同时匹配标题和正文。这时候就要用 multi_matchGET /products/_search { query: { multi_match: { query: 华为, fields: [title^3, sub_title^2, description] } } }^后面的数字是字段权重。title 的权重是 3意味着标题命中“华为”对得分的贡献是 description 的 3 倍。这个机制非常有用——同样是搜“华为”标题命中比正文里顺带提到一次的相关性要高得多用权重可以让排序更符合业务直觉。多字段查询还有几种类型best_fields、most_fields、cross_fields默认是 best_fields也就是取单个字段里得分最高的那个作为最终得分。如果你希望多个字段的匹配得分累加可以用type: most_fields。跨字段搜索适合“名 姓”这种拆到两个字段里的场景用 cross_fields 可以避免只匹配到一半的情况。实际做搜索系统时我一般先用 best_fields等排序效果不理想再切换类型对比不要一上来就堆参数。2.3 match_phrase 与分词细节match 是分词后“能匹配到就算”match_phrase 则要求分词后的词项必须按顺序相邻出现。它的名字已经很直白了——短语匹配。GET /products/_search { query: { match_phrase: { title: 华为 手机 } } }如果你索引了“华为新款手机”用 match 能查到因为“华为”和“手机”都命中了。用 match_phrase 也能查到因为“华为”“手机”这两个词项在原文里是连着的。但如果你想查“手机 华为”match_phrase 就查不到了因为分词后的顺序对不上。match_phrase 还有一个slop参数它允许词项之间有间隔。比如原文是“华为的智能手机”查询“华为手机”slop 设为 1 就能匹配上因为“手机”和“华为”之间只隔了一个“的”。设置 slop 时要注意值越大匹配越宽松得分反而可能被稀释。一般从 0 和 1 起步不要一上来就设 5、6。分词细节坑比较多。举一个我实际遇到过的例子索引里有一条“iPhone 15 Pro”我用 match_phrase 查“iPhone 15”能查到查“iphone15”却查不到。原因就是默认分词器把“iphone15”切成了单个词项而原文分词后是“iphone”“15”两个词项。像这种问题用_analyzeAPI 看一下分词结果就能定位POST /_analyze { analyzer: standard, text: iphone15 }返回结果会直接告诉你分成了哪些词项很多“搜不到”的问题其实都是分词问题。3. 精确查询与范围查询的实战要点3.1 term、terms 查询keyword 与 text 的经典坑term 查询是精确匹配的典型代表它不做分词直接拿查询词去词典里比对。term 只能用在 keyword 类型的字段、数值类型字段或者已经通过fielddata开启聚合的 text 字段上。GET /products/_search { query: { term: { status: active } } }这里有一个所有新手都会踩的坑text 类型的字段用 term 查经常查不到数据。原因我在前面已经讲过了——text 字段会被分词索引里存的是分词后的词项term 拿着完整原词去匹配自然对不上。如果你看到某个查询“为什么 term 查不到”第一反应应该去查字段映射mapping确认它是 text 还是 keyword。terms 是 term 的多值版本一个字段匹配多个精确值之一就算命中适合做标签过滤、状态过滤GET /products/_search { query: { terms: { sku: [sku-1001, sku-1002, sku-1003] } } }实际开发中我见过不少人用 term 查 keyword 字段时传入了带大小写差异的值结果同样查不到。keyword 默认不会做大小写归一化所以“Active”和“active”是两个完全不同的词项。如果你的数据写入时大小写不统一要么写入前做好规范化要么在查询时用 term 的case_insensitive参数这个参数从 ES 7.10 开始支持。3.2 range 查询数字与日期边界范围查询用于数值、日期这类有序字段。比如价格筛选、时间筛选都靠它GET /products/_search { query: { range: { price: { gte: 100, lte: 500 } } } }四个边界参数分别是gt、gte、lt、lte对应大于、大于等于、小于、小于等于。日期的 range 查询稍微复杂一点因为涉及格式和时区GET /orders/_search { query: { range: { create_time: { gte: 2024-01-01, lte: 2024-01-31, format: yyyy-MM-dd } } } }format用来指定查询时传入日期的格式不传的话默认用字段映射里的格式。另外ES 还支持now-1d、now/d这类日期数学表达式适合做相对时间范围。比如查最近 7 天的数据可以直接写gte: now-7d。这里要注意时区问题默认时区是 UTC如果你的业务在东八区最好在查询里加上time_zone: 08:00不然你会发现边界总是差 8 个小时。4. 地理坐标查询从建模到落地4.1 geo_point 字段映射与数据写入地理坐标查询的前提是字段类型必须是geo_point并且数据能被正确解析为经纬度坐标。映射这么建PUT /stores { mappings: { properties: { name: { type: keyword }, location: { type: geo_point } } } }写入坐标的方式有三种常用的字符串31.2304,121.4737纬度,经度、数组[121.4737, 31.2304]经度,纬度、对象{lat: 31.2304, lon: 121.4737}。这三种里最容易混淆的就是数组它的顺序是经度在前、纬度在后跟直觉相反。我自己就吃过这个亏写入数据后查周边的门店结果位置跑到了海里排查半天才发现是经纬度写反了。如果你不想记这些顺序用对象形式最不容易出错。4.2 四种地理查询方式对比与实操geo_point 字段支持的地理查询主要包括geo_distance距离中心点多少范围内、geo_bounding_box矩形范围内、geo_polygon多边形范围内、geo_shape复杂地理形状关系。最常用的是 geo_distance它的语义最贴近业务——比如“查询当前位置 3 公里内的门店”GET /stores/_search { query: { geo_distance: { distance: 3km, location: { lat: 31.2304, lon: 121.4737 } } } }distance 支持米和千米3km、1500m都可以。如果你需要按距离从近到远排序加上 sort 子句GET /stores/_search { query: { geo_distance: { distance: 5km, location: { lat: 31.2304, lon: 121.4737 } } } }, sort: [ { _geo_distance: { location: { lat: 31.2304, lon: 121.4737 }, order: asc, unit: km } } ] }geo_bounding_box 适合做地图视野范围内的筛选前端地图移动时把可视范围的左上角和右下角坐标传过来就能只返回视野内的数据。geo_polygon 适合不规则区域比如某个商圈的自定义范围但计算成本比 bounding box 高。工程上如果没有特殊需求能用 bounding box 就不要上 polygon。需要注意的是地理距离查询在query上下文里也会参与打分距离越近得分越高。如果你只是做范围过滤不想要距离影响排序可以把 geo_distance 放到bool的 filter 子句里这样距离只负责筛选排序可以完全由你控制。5. 组合查询bool 与 function_score5.1 bool 查询must、should、filter 的协作机制真实业务里的查询条件几乎没有单条的都是“关键词 筛选条件 排序规则”混在一起。bool 查询就是用来组装这些条件的容器。它有四种子句must必须满足参与打分filter必须满足不参与打分有缓存should可以不满足满足则加分如果 bool 里没有 must/filter至少满足一个 should 才会返回must_not必须不满足不参与打分一个典型的电商搜索请求长这样GET /products/_search { query: { bool: { must: [ { match: { title: 华为 } } ], filter: [ { term: { status: active } }, { range: { price: { gte: 100, lte: 500 } } } ], must_not: [ { term: { is_deleted: true } } ] } } }这段查询的意思是标题必须匹配“华为”状态必须是 active价格必须在 100 到 500 之间且未删除。只有 match 条件会参与打分filter 和 must_not 里的条件只做筛选。结果里 _score 完全由 title 的匹配程度决定不会被价格、状态这些条件干扰。你可能会问为什么 filter 比 must 省资源因为 must 要为每个命中文档计算分数filter 只需要做布尔判断而且相同 filter 的结果可以被 ES 缓存下一次同样的条件来的时候直接走缓存。线上排查慢查询时我经常发现根因就是什么条件都往 must 里塞导致大量无效算分。如果想让查询更快第一件事就是把不参与排序的条件全部挪到 filter 里。should 的用法稍微绕一点。当 bool 里没有 must 或 filter 时should 之间是 or 的关系至少满足一个。当 bool 里有 must 或 filter 时should 变成可选加分项。比如搜索“华为手机”优先让标题里同时有“华为”和“手机”的排前面可以用 should 配合 match 来实现。5.2 function_score用业务规则修正相关度bool 解决了条件组合但排序时你可能还有业务规则同样的关键词库存充足的商品要排在前面销量高的要排在前面离用户近的门店要排在前面。function_score 就是用来干这个的。它的作用是在基础查询的得分之上叠加自定义算分函数最终生成一个新的 _score。GET /products/_search { query: { function_score: { query: { match: { title: 华为 } }, functions: [ { weight: 2, filter: { term: { is_stock: true } } }, { field_value_factor: { field: sales, factor: 1.2, modifier: log1p } } ], score_mode: sum, boost_mode: multiply } } }这段查询拆开看。query 子句照旧做全文搜索functions 数组里定义了两个算分函数。第一个是 weight配合 filter 使用当文档命中了“有库存”这个条件就给基础得分额外加上权重 2。第二个是 field_value_factor它读取字段 sales 的值经过 factor 和 modifier 的处理后参与最终算分。modifier选log1p是为了避免销量数值过大导致的分数膨胀——log1p 就是取 log(1 值)能把销量从 1000、10000 这种量级压缩到个位数水平。这里有两个参数是 function_score 的灵魂score_mode决定多个函数算出的分数怎么合并可选multiply、sum、avg、first、max、min默认是 multiply。boost_mode决定函数分数和基础查询分数怎么合并可选multiply、sum、replace、avg、max、min默认也是 multiply。一般经验是如果函数分数是“奖励”性质用 sum 比较直观如果是“加权”性质用 multiply 更合适。但要注意默认的 multiply 有个坑——如果某个字段值为 0整个文档得分会被打成 0所以 field_value_factor 最好配合 modifier 和缺失值处理来用。function_score 还有一个常见的应用场景是地理位置加权。比如外卖列表里用户搜“黄焖鸡”你希望距离近的排在前面但又不能完全按距离排序否则全城的店都不见了。可以用 gauss 函数它按距离衰减得分functions: [ { gauss: { location: { origin: { lat: 31.2304, lon: 121.4737 }, scale: 2km } } } ]scale 参数表示在 2 公里距离处分值衰减到约三分之一。这个是做“本地生活”类搜索最常用的手段之一。6. RestApi 调用实践6.1 基于 RestHighLevelClient 的查询封装前面讲的都是原始 DSL实际项目里我们一般通过 ES 的 RestApi 来发送这些查询。Java 技术栈里RestHighLevelClient 是最常见的客户端ES 8.x 之后官方推荐用新的 ElasticsearchClient但大量存量项目还在用旧的 API且核心 query 逻辑差别不大。一个完整的 match 查询封装长这样SearchRequest searchRequest new SearchRequest(products); SearchSourceBuilder sourceBuilder new SearchSourceBuilder(); sourceBuilder.query(QueryBuilders.matchQuery(title, 华为)); sourceBuilder.from(0); sourceBuilder.size(10); sourceBuilder.sort(price, SortOrder.ASC); searchRequest.source(sourceBuilder); try (RestHighLevelClient client new RestHighLevelClient( RestClient.builder(new HttpHost(localhost, 9200, http)))) { SearchResponse response client.search(searchRequest, RequestOptions.DEFAULT); SearchHits hits response.getHits(); for (SearchHit hit : hits.getHits()) { String sourceAsString hit.getSourceAsString(); float score hit.getScore(); System.out.println(sourceAsString score score); } }Java API 的 query builders 和 DSL 基本一一对应bool 查询对应QueryBuilders.boolQuery()下面可以继续加must(QueryBuilders.matchQuery(...))、filter(QueryBuilders.termQuery(...))。function_score 对应QueryBuilders.functionScoreQuery(...)。用代码拼 DSL 的时候最常见的报错是查询条件拼进去后语法不对建议先单独把生成的查询转成字符串打印出来再放到 Kibana 的 Dev Tools 里验证System.out.println(sourceBuilder.toString());sourceBuilder 的 toString 输出就是一份标准 DSL这个调试方法能省掉非常多“代码里能跑但结果不对”的排查时间。6.2 基于 RestApi 的 HTTP 调用与调试技巧不写 Java 的项目直接走 HTTP RestApi 也很简单。ES 的查询接口本质就是一个 POST 请求body 里放 DSL。用 curl 调试更直观curl -X POST http://localhost:9200/products/_search?pretty \ -H Content-Type: application/json \ -d { query: { bool: { must: [ { match: { title: 华为 } } ], filter: [ { term: { status: active } } ] } } }我习惯先在 Kibana 的 Dev Tools 里把 DSL 跑通再挪到项目代码里。Dev Tools 有语法高亮和自动补全还能显示响应时间很适合做查询调优。响应体里有几个关键层级hits.total是命中文档数hits.hits是结果数组每个 hit 里_source是原始文档_score是相关度分数。排查问题时我会先看_score是不是符合预期再看took字段它是查询耗时单位毫秒。如果耗时异常检查是否有条件放在 query 上下文里做了大量无意义算分或者某个字段没建索引导致无法高效过滤。另外要说一下分页。from size是基础分页但深分页性能极差因为 ES 要先把所有命中的文档找出来再排序、再跳过前 from 条。20 万条数据翻到第 100 页这种用法直接可以把集群拖垮。一般超过 10000 条就是深分页这时候考虑用 search_after游标式翻页或者 scroll快照式批量拉取。7. 常见问题与排查技巧实录7.1 常见问题速查表我整理了一份高频问题对照表很多是团队里新人反复问过的。现象可能原因建议处理方式term 查 text 字段没结果text 字段被分词term 不做分词匹配确认字段类型改用 keyword 字段或 match 查询match 查 keyword 字段没结果keyword 不做分词match 查询做了分词精确匹配用 term模糊搜索换 text 字段日期范围查询比预期少 8 小时时区默认 UTC未指定东八区查询时加time_zone: 08:00地理坐标查询结果漂移经纬度写入顺序搞反数组格式先经度后纬度统一用对象形式写入{lat: ..., lon: ...}function_score 整体分数异常字段是 0 或负数乘算后压掉基础分给 field_value_factor 加 modifier配合missing: 1处理缺失值深分页超时或内存报警用了大 from 值翻页超过 10000 条改用 search_after 或 scroll同样的查询重启后结果数量变了数据写入还没 refresh默认 1 秒后才可见按需等待 refresh 或使用实时性要求不高的场景7.2 排查思路从 explain 到字段映射检查遇到“查询结果不符合预期”的问题我的排查路径基本是固定的。第一步先确认字段映射看看查询字段到底是什么类型。用GET /products/_mapping一条命令就能看到所有字段的类型。第二步验证分词结果。用_analyzeAPI 看看查询关键词会被切成什么词项这一步能判断是查法不对还是分词不对。第三步检查实际存储的数据。GET /products/_doc/文档id直接看 _source确认字段值到底是什么样的——我排查过不少“查询条件明明正确但查不到”的问题最后发现是写入的数据本身就不对。还有一个排查神器是 explain你可以在查询里加上explain: true返回结果里会详细说明每个文档为什么得分、命中或者没命中。这对分析 match 查询的得分计算特别有用。不过线上生产环境不建议对这个参数做全局开关它的额外开销很大适合在测试环境围绕具体文档做分析。排查“为什么搜不到”和生产问题的思维不太一样。生产问题讲究快速排除我会先用最朴素的 match_all 确认数据有没有进来再用 match 确认能不能搜到一步一步缩小范围。这个方法看起来笨其实是最快的。回到文章开头的那个问题——为什么同样一个字段match 查得到而 term 查不到当你理解了分词原理看任何查询行为都会有一种“原来如此”的透彻感。这也正是学习 ES 查询 DSL 最值得花时间的地方它不是几十条 API 的记忆题而是一套有规律的、围绕分词与打分组织起来的体系。把它彻底吃透你再看任何 Search API 文档都能迅速知道它属于哪个位置、适合做什么事。
返回列表