跳到正文

查询与相关性评分 ​

一句话:ES 查询先分「算分的 query」和「不算分的 filter」,再用 BM25 把匹配程度变成分数;分页的成本不在公式,而在每个分片要返回多少候选文档。

适用版本:Elasticsearch 8.x;BM25 自 5.0 起为默认相似度。

为什么需要 ​

全文检索不只要「匹配到」,还要「排得对」:标题完全匹配的应排在正文偶然出现的前面;状态、价格、租户只负责过滤,不应影响相关性。ES 用 query/filter 分工和 BM25 解决这两件事。但评分和分页都有成本:query 要算分,filter 可缓存;深分页会让每个分片返回大量候选,coordinating node 再归并。

核心机制 ​

Query 与 Filter ​

QueryFilter
目的这个文档有多匹配这个文档符不符合条件
算分是,影响 _score否
缓存不缓存结果可缓存为 bitset
典型match、multi_matchterm、range、exists
json
{"query":{"bool":{
  "must":[{"match":{"title":"蓝牙耳机"}}],
  "filter":[{"term":{"status":"published"}},
            {"range":{"price":{"gte":100,"lte":500}}}]}}}

原则:需要影响排序的放 must,只做筛选的放 filter。把状态、时间、租户塞进 must 是常见的性能浪费。

BM25:词频不是线性加分 ​

ES 5.0 之前用 TF-IDF,5.0 起默认 BM25:

score = IDF(q) × [ f(q,d) × (k1+1) / (f(q,d) + k1 × (1 - b + b × |d| / avgdl)) ]
k1 = 1.2(控制词频饱和);b = 0.75(控制长度归一化)
  • 词频饱和:词出现 3 次和 10 次的得分差距远小于线性增长,抑制关键词堆砌。
  • 长度归一化:短文档出现一次关键词,通常比长文档出现一次更相关。

k1 越大越接近线性 TF;b 越大长度惩罚越强。默认值适用多数场景,调参要有评测集。

常用查询类型 ​

类型分词算分用法
match是是全文搜索
term否否(filter 中)keyword 精确匹配
match_phrase是是短语,要求位置相邻
multi_match是是多字段搜索
json
// term 只能查 keyword;查 text 字段会查不到,因为 text 已被分词
{"term":{"order_id":"ORD-2024-001"}}
// match_phrase:slop=0 要求词连续;slop=1 允许中间隔一个词
{"match_phrase":{"content":{"query":"全文检索","slop":0}}}

function_score:把业务信号加进排序 ​

json
{"query":{"function_score":{
  "query":{"match":{"title":"蓝牙耳机"}},
  "functions":[
    {"field_value_factor":{"field":"sales","factor":0.1,
                           "modifier":"log1p","missing":1}},
    {"gauss":{"created_at":{"origin":"now","scale":"30d","decay":0.5}}}],
  "score_mode":"sum","boost_mode":"sum"}}}

field_value_factor 把数值字段转成分数,modifier 防止大值压倒文本相关性;gauss/exp/linear 按距离或时间衰减;boost_mode 决定函数分与 query 分相加、相乘还是替换。权重用评测集定,不要直接上生产试。

profile 与深分页 ​

json
GET /products/_search
{"profile":true,"query":{"match":{"title":"蓝牙耳机"}}}

profile 返回每个 query 节点的 time_in_nanos、breakdown 和 Lucene 描述;用它判断是倒排查找慢、评分慢,还是 filter 没命中缓存。

index.max_result_window 默认 10000,超过报错。

方案原理适用
from+size每个 shard 取 from+size 再归并浅分页
search_after + PIT用上一页最后一条的排序值做游标实时深分页,推荐
scroll生成快照,游标遍历全量导出;不推荐用户翻页

search_after 必须配合确定性排序;用 PIT 固定索引视图,并用唯一字段或 _shard_doc 做 tiebreaker:

json
POST /products/_pit?keep_alive=1m
GET /_search
{"size":10,"query":{"match":{"title":"蓝牙耳机"}},
 "sort":[{"price":"asc"},{"_shard_doc":"asc"}],
 "pit":{"id":"<pit_id>","keep_alive":"1m"},
 "search_after":[199.0,12345]}

最小上手 ​

bash
curl -X PUT "localhost:9200/products" -H 'Content-Type: application/json' -d '
{"mappings":{"properties":{"title":{"type":"text"},"status":{"type":"keyword"},
  "price":{"type":"double"},"sales":{"type":"long"},"created_at":{"type":"date"}}}}'

curl -X POST "localhost:9200/products/_doc/1?refresh=true" \
  -H 'Content-Type: application/json' \
  -d '{"title":"蓝牙耳机","status":"published","price":299,"sales":100,"created_at":"2026-01-01"}'

curl -X POST "localhost:9200/products/_search" -H 'Content-Type: application/json' -d '
{"profile":true,"query":{"bool":{"must":{"match":{"title":"蓝牙耳机"}},
  "filter":{"term":{"status":"published"}}}}}'

预期:profile 中能看到 TermQuery/BooleanQuery 的耗时;filter 部分不参与评分。

生产动作 ​

  • filter 化:状态、时间、租户、权限等条件放 filter。
  • 减少返回字段:_source: ["title","price"];只要 ID 时用 stored_fields 或 _source:false。
  • 精确总数有代价:track_total_hits 默认 10000,超过后只给下限。
  • 深分页:列表页用 search_after + PIT,不要翻到 max_result_window 以外。
  • 慢查询:开慢日志的 query 和 fetch 两段,结合 profile 判断是查询还是取回慢。
  • 评分调参:function_score 的权重用评测集定。

边界 ​

  • BM25 不懂语义:同义词、错别字、口语表达要同义词词典或向量检索补。
  • term 查 text 字段会查不到:text 已分词,精确匹配用 keyword。
  • search_after 不能随机跳页:只能顺序往后翻。
  • PIT 会占资源:keep_alive 越长,保留的段和文件句柄越多。
  • function_score 会放大评分偏差:业务字段分布不均时先归一化或用 modifier。

讲清楚 ​

ES 查询先分两类:query 算相关性分,filter 只做筛选且可缓存。状态、价格、租户放 filter,文本匹配放 must,这是最基本的性能习惯。相关性默认用 BM25,词频饱和和长度归一化让它比 TF-IDF 更抗关键词堆砌;业务信号用 function_score 加进去,但权重要用评测集定。

分页成本不在 BM25,而在每个分片要返回多少候选。from+size 翻深页会让 coordinating node 归并大量文档,默认到 10000 就报错;正确做法是 search_after + PIT,排序必须确定,并用唯一字段或 _shard_doc 做 tiebreaker。scroll 只适合全量导出。

把一门技术讲透到既懂也会用