咱后端天天跟数据打交道,Elasticsearch(下文简称 ES)作为 "搜索引擎天花板",光会简单的增删改查可不够。今天咱就从 "高级搜索" 切入,把算分、分页、同义词这些实战痛点掰开揉碎,保证你看完能直接抄作业!
一、ES 高级搜索:不止于 "搜得到"
先聊聊基础 ------ES 高级搜索到底高级在哪儿?普通搜索是 "你要啥我找啥",高级搜索是 "你要啥我不仅找得到,还能按你的心思排好序、筛得准"。
比如你做个电商搜索,用户搜 "性价比手机",高级搜索能做到:
-
优先匹配 "手机" 关键词,再关联 "性价比" 描述;
-
销量高、评分高的商品往前排;
-
还能过滤掉已下架、库存为 0 的商品。
本质上,ES 高级搜索是通过bool
查询(must/should/must_not/filter)、算分机制、过滤条件组合实现的,而这一切的核心,绕不开 "算分"。
二、算分函数:让好结果 "C 位出道"
为啥搜索结果要算分?总不能把匹配的结果乱序扔给用户吧?就像外卖平台,肯定把评分高、距离近的先推给你,ES 的算分(_score)就是干这个的。
咱先盘两个最常用的算分函数:
1. TF-IDF:"提得多 = 更重要"
TF(词频):一个词在文档里出现的次数越多,分越高(比如 "手机" 在某商品描述里出现 5 次,比出现 1 次的分高);
IDF(逆文档频率):一个词在所有文档里出现得越少,分越高(比如 "曲面屏" 比 "手机" 稀有,含 "曲面屏" 的文档分更高)。
👉 通俗讲:你要找的词,在当前文档里越常见、在所有文档里越稀有,这篇文档就越匹配。
2. BM25:TF-IDF 的 "升级版"
TF-IDF 有个小毛病:词频越高分越高,没上限(比如某文档里 "手机" 重复 100 次,分能上天)。BM25 就聪明多了,它会给词频设个 "天花板",超过阈值后分数增长放缓,更符合实际场景。
现在 ES 默认用的就是 BM25,如果你想改回 TF-IDF,也能在索引设置里调整,不过一般没必要~
三、算分实战:DSL 和 Java 双管齐下
光懂理论没用,咱直接上代码!算分主要有两种玩法:DSL 手动调权重,Java 代码自定义算分。
1. DSL 算分:简单粗暴调权重
比如做博客搜索,想让 "标题" 的权重比 "正文" 高 3 倍(用户更可能在标题里找关键词),DSL 这么写:
json
json
GET /blog/_search
{
"query": {
"function_score": { // 自定义算分函数
"query": {
"multi_match": {
"query": "Elasticsearch", // 搜索关键词
"fields": ["title^3", "content"] // ^3表示标题权重×3
}
},
"boost_mode": "multiply" // 算分模式:权重×原始分数
}
}
}
效果立竿见影:标题含 "Elasticsearch" 的博客,会比正文含该词的博客排得更前。
2. Java 算分:复杂场景自定义
如果需要更灵活的算分(比如结合用户等级、文章发布时间),就得用 Java 代码了。这里以 Spring Data Elasticsearch 为例:
java
运行
less
// 1. 构建自定义算分查询
NativeSearchQueryBuilder queryBuilder = new NativeSearchQueryBuilder();
// 2. 基础查询:匹配关键词
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery()
.must(QueryBuilders.matchQuery("title", "ES").boost(3.0f)) // 标题权重×3
.must(QueryBuilders.matchQuery("content", "ES"));
// 3. 自定义算分:发布时间越近,分数越高
FunctionScoreQueryBuilder.FilterFunctionBuilder[] functions = {
new FunctionScoreQueryBuilder.FilterFunctionBuilder(
ScoreFunctionBuilders.fieldValueFactorFunction("publishTime")
.modifier(FieldValueFactorFunction.Modifier.LOG1P) // 对数增长,避免分数爆炸
.factor(0.1f) // 系数调整
)
};
// 4. 组合查询
queryBuilder.withQuery(QueryBuilders.functionScoreQuery(boolQuery, functions)
.boostMode(CombineFunction.MULTIPLY)); // 算分模式
// 5. 执行查询
SearchHits<Blog> hits = elasticsearchRestTemplate.search(
queryBuilder.build(), Blog.class);
这段代码实现了 "关键词匹配 + 发布时间加权",新发布的优质文章会更靠前,很适合博客、新闻类场景。
四、深度分页:后端 er 的 "翻页噩梦"?
聊完算分,咱来踩个大坑 ------ 深度分页。比如用户想翻到第 1000 页,你以为 ES 会直接返回第 1000 页的数据?错了!它的逻辑能让你 "卡到怀疑人生"。
1. 单节点分页:简单但有限
单节点下,ES 分页用的是from+size
(from 是起始位置,size 是每页条数),逻辑很直白:
-
从所有数据里筛选出匹配的结果;
-
把结果排序后,跳过前
from
条,取size
条。
比如from=1000,size=10
,ES 要先查 1010 条数据,再扔了前 1000 条,只返回 10 条。这时候问题来了:from
越大,ES 要处理的数据越多,性能越差。
2. 集群分页:坑更大!
集群环境下,ES 有多个分片(shard),分页逻辑更复杂:
-
假设集群有 3 个分片,你要查
from=1000,size=10
; -
每个分片都会查 1010 条数据(因为不知道其他分片的数据排序),然后返回给协调节点(coordinator);
-
协调节点把 3×1010=3030 条数据合并、排序,再跳过 1000 条,取 10 条。
你想想:from=10000
的时候,每个分片要返回 10010 条,3 个分片就是 30030 条,协调节点处理这么多数据,不崩才怪!这就是为啥 ES 官方不推荐from+size
用于深度分页(一般from
超过 1000 就会报警)。
五、深度分页救星:ES 给的方案 + 实战技巧
别慌!ES 早想到了这个问题,给了两种解决方案,咱再结合实际开发补两个技巧。
1. 方案一:Scroll(滚动查询)------ 适合批量导出
Scroll 就像给数据 "拍快照",第一次查询时生成一个快照 ID(scroll_id),后续用这个 ID 分批拉取数据,不用每次重新排序。
适用场景:批量导出数据(比如每天凌晨同步 ES 数据到 MySQL),不适合实时翻页(快照不会更新,新增数据查不到)。
Java 代码示例(Spring Data ES):
java
运行
scss
// 1. 初始化滚动查询,保存1分钟快照
NativeSearchQuery query = new NativeSearchQueryBuilder()
.withQuery(QueryBuilders.matchAllQuery())
.withScroll(TimeValue.timeValueMinutes(1)) // 快照有效期1分钟
.withPageable(PageRequest.of(0, 1000)) // 每次拉1000条
.build();
// 2. 第一次查询,获取scroll_id和第一页数据
SearchHits<Blog> scrollHits = elasticsearchRestTemplate.search(query, Blog.class);
String scrollId = scrollHits.getScrollId();
// 3. 循环拉取后续数据,直到没有结果
while (scrollHits.getTotalHits() > 0) {
// 处理当前页数据
List<Blog> blogs = scrollHits.stream()
.map(SearchHit::getContent)
.collect(Collectors.toList());
// 用scroll_id拉取下一页
NativeSearchQuery scrollQuery = new NativeSearchQueryBuilder()
.withScrollId(scrollId)
.withScroll(TimeValue.timeValueMinutes(1))
.build();
scrollHits = elasticsearchRestTemplate.search(scrollQuery, Blog.class);
scrollId = scrollHits.getScrollId();
}
// 4. 最后释放scroll(避免内存泄漏)
elasticsearchRestTemplate.clearScroll(scrollId);
2. 方案二:Search After------ 适合实时翻页
Search After 是 "接力式" 分页:用前一页最后一条数据的 "唯一排序字段"(比如id
+publishTime
)作为条件,拉取下一页,不用计算from
。
适用场景:实时翻页(比如电商商品列表),缺点是不能跳页(只能上一页 / 下一页)。
DSL 示例:
json
json
// 第一页:正常查询,返回最后一条数据的sort字段
GET /product/_search
{
"query": { "match": { "category": "手机" } },
"sort": [
{ "sales": "desc" }, // 按销量降序
{ "_id": "asc" } // _id唯一,避免排序冲突
],
"size": 10,
"from": 0
}
// 假设第一页最后一条数据的sort是 [1000, "123456"]
// 第二页:用search_after接力
GET /product/_search
{
"query": { "match": { "category": "手机" } },
"sort": [
{ "sales": "desc" },
{ "_id": "asc" }
],
"size": 10,
"search_after": [1000, "123456"] // 前一页最后一条的sort值
}
3. 实际开发技巧
- 限制分页深度:比如最多只允许翻 50 页,超过提示 "请缩小搜索范围"(用户真的会翻 100 页吗?大概率不会);
- 用 "跳转页" 代替 "连续翻页":比如让用户输入页码跳转时,先检查页码是否超过阈值,超过则用 Search After + 缓存处理;
- 冷热数据分离:把老数据(比如 3 个月前的订单)迁移到冷节点,减少热数据分片的查询压力。
六、同义词:让 ES "听懂" 你的潜台词
最后聊聊同义词 ------ 用户搜 "智能机",你想让 "手机" 也出现在结果里;搜 "电脑",想包含 "笔记本""台式机",这时候就得靠同义词。
1. 第一步:配置同义词文件
先在 ES 的config
目录下新建synonyms.txt
,写好同义词组(用英文逗号分隔):
txt
智能机,手机
电脑,笔记本,台式机
java,爪哇
2. 第二步:定义同义词分词器
创建索引时,自定义分词器,把同义词功能加进去:
json
json
PUT /product
{
"settings": {
"analysis": {
"analyzer": {
"my_synonym_analyzer": { // 自定义同义词分词器
"type": "custom",
"tokenizer": "ik_max_word", // 用IK分词(中文必备)
"filter": ["synonym"] // 启用同义词过滤
}
},
"filter": {
"synonym": { // 同义词过滤器配置
"type": "synonym",
"synonyms_path": "synonyms.txt" // 同义词文件路径
}
}
}
},
"mappings": {
"properties": {
"name": {
"type": "text",
"analyzer": "my_synonym_analyzer", // 字段使用同义词分词器
"search_analyzer": "my_synonym_analyzer"
}
}
}
}
3. 测试效果
现在搜 "智能机",ES 会自动把 "手机" 也当成关键词匹配:
json
bash
GET /product/_search
{
"query": {
"match": { "name": "智能机" }
}
}
返回结果里,含 "智能机" 和 "手机" 的商品都会出现,用户体验直接拉满!
结尾:ES 实战永无止境
今天咱把 ES 高级搜索、算分、分页、同义词这些核心知识点过了一遍,都是后端开发中高频用到的技能。其实 ES 还有很多玩法,比如聚合查询、索引优化、性能调优,后续咱再慢慢聊。
你在 ES 开发中还遇到过哪些坑?比如算分不准、分页超时、同义词不生效?评论区聊聊你的解决方案,咱一起抱团避坑!