主题
查询与相关性评分
一句话:ES 查询先分「算分的 query」和「不算分的 filter」,再用 BM25 把匹配程度变成分数;分页的成本不在公式,而在每个分片要返回多少候选文档。
适用版本:Elasticsearch 8.x;BM25 自 5.0 起为默认相似度。
为什么需要
全文检索不只要「匹配到」,还要「排得对」:标题完全匹配的应排在正文偶然出现的前面;状态、价格、租户只负责过滤,不应影响相关性。ES 用 query/filter 分工和 BM25 解决这两件事。但评分和分页都有成本:query 要算分,filter 可缓存;深分页会让每个分片返回大量候选,coordinating node 再归并。
核心机制
Query 与 Filter
| Query | Filter | |
|---|---|---|
| 目的 | 这个文档有多匹配 | 这个文档符不符合条件 |
| 算分 | 是,影响 _score | 否 |
| 缓存 | 不缓存 | 结果可缓存为 bitset |
| 典型 | match、multi_match | term、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 只适合全量导出。