
一、为什么不用 MySQL LIKE 做搜索
在平台中,用户发布的是一篇篇知文,内容包括标题、摘要、标签、Markdown 正文、图片等。
如果搜索只用 MySQL 的 LIKE '%关键词%',会有几个明显问题:
- 长文本检索性能差;
- 中文分词效果不好;
- 无法做相关性排序;
- 很难融合点赞数、浏览数等业务权重;
- 后续做联想建议、深分页也不方便。
所以项目中使用 Elasticsearch 构建搜索系统,整体能力包括:
text
关键词检索
标签过滤
BM25 相关性排序
点赞数 / 浏览数业务加权
高亮摘要
search_after 游标分页
completion suggester 前缀联想
这一篇先看最核心的搜索接口和排序逻辑。
二、搜索接口设计
搜索接口位于:
text
src/main/java/com/tongji/search/api/SearchController.java
核心代码如下:
java
@RestController
@RequestMapping("/api/v1/search")
@Validated
@RequiredArgsConstructor
public class SearchController {
private final SearchService searchService;
private final JwtService jwtService;
@GetMapping
public SearchResponse search(@RequestParam("q") @NotBlank String q,
@RequestParam(value = "size", required = false, defaultValue = "20") @Min(1) int size,
@RequestParam(value = "tags", required = false) String tagsCsv,
@RequestParam(value = "after", required = false) String after,
@AuthenticationPrincipal Jwt jwt) {
Long userId = (jwt == null) ? null : jwtService.extractUserId(jwt);
return searchService.search(q, size, tagsCsv, after, userId);
}
@GetMapping("/suggest")
public SuggestResponse suggest(@RequestParam("prefix") @NotBlank String prefix,
@RequestParam(value = "size", required = false, defaultValue = "10") @Min(1) int size) {
return searchService.suggest(prefix, size);
}
}
搜索接口主要参数是:
| 参数 | 含义 |
|---|---|
q |
搜索关键词 |
size |
每页返回数量,默认 20 |
tags |
标签过滤,逗号分隔 |
after |
下一页游标 |
jwt |
当前用户信息,用于补充点赞收藏状态 |
接口返回的是:
java
public record SearchResponse(
List<FeedItemResponse> items,
String nextAfter,
boolean hasMore
) {}
也就是说,搜索结果直接复用 Feed 卡片结构,让前端可以用同一套展示组件。
三、Service 层入口
搜索服务接口如下:
java
public interface SearchService {
SearchResponse search(String q,
int size,
String tagsCsv,
String after,
Long currentUserIdNullable);
SuggestResponse suggest(String prefix, int size);
}
这里把 tagsCsv 和 after 都交给 Service 处理。
原因很简单:
- Controller 只负责接参数;
- Service 负责搜索业务规则;
- ES 查询语句、游标解析、结果组装都属于搜索业务的一部分。
四、关键词召回:multi_match 检索 title 和 body
核心实现位于:
text
src/main/java/com/tongji/search/service/impl/SearchServiceImpl.java
搜索查询的核心部分是:
java
.query(qb -> qb.functionScore(fs -> fs
.query(qb2 -> qb2.bool(bq -> {
bq.must(m -> m.multiMatch(mm -> mm.query(q)
.fields("title^3", "body")));
bq.filter(f -> f.term(t -> t.field("status")
.value(v -> v.stringValue("published"))));
if (tags != null && !tags.isEmpty()) {
bq.filter(f -> f.terms(t -> t.field("tags")
.terms(tv -> tv.value(tags.stream().map(FieldValue::of).toList()))));
}
return bq;
}))
))
这里使用的是 ES 的 multi_match 查询。
java
.fields("title^3", "body")
表示同时搜索标题和正文,但标题权重更高。
为什么标题要加权?
因为在内容搜索场景中,标题命中通常比正文命中更能说明这篇文章和关键词相关。
例如用户搜索:
text
Redis 计数
如果一篇文章标题就是"Redis 计数系统设计",它应该比正文里偶然出现 Redis 的文章更靠前。
五、过滤条件:只搜索已发布内容
搜索查询中有一层过滤:
java
bq.filter(f -> f.term(t -> t.field("status")
.value(v -> v.stringValue("published"))));
也就是只搜索:
text
status = published
这和平台内容发布流程是一致的。
草稿、删除状态的内容不应该出现在搜索结果里。
这里使用 filter 而不是 must,原因是过滤条件不参与相关性算分,只负责缩小候选范围。
六、标签过滤:CSV 转 terms 查询
接口参数中的标签是:
text
tags=Redis,SpringBoot,Elasticsearch
后端先解析:
java
private List<String> parseCsv(String csv) {
if (csv == null || csv.isBlank()) {
return null;
}
String[] parts = csv.split(",");
List<String> out = new ArrayList<>();
for (String p : parts) {
String t = p.trim();
if (!t.isEmpty()) {
out.add(t);
}
}
return out;
}
然后在 ES 查询中追加:
java
bq.filter(f -> f.terms(t -> t.field("tags")
.terms(tv -> tv.value(tags.stream().map(FieldValue::of).toList()))));
tags 在 ES Mapping 中是 keyword 类型,适合做精确过滤。
这个设计比较适合内容平台:
text
关键词负责模糊召回
标签负责精确筛选
七、function_score:相关性 + 业务权重
如果只依赖 BM25 相关性,搜索结果只会考虑文本匹配程度。
但内容平台中,一篇高质量文章往往还会体现在:
- 点赞数;
- 浏览数;
- 收藏数;
- 发布时间。
项目中用 function_score 融合文本相关性和业务权重:
java
.functions(fn -> fn.fieldValueFactor(fvf -> fvf.field("like_count")
.modifier(FieldValueFactorModifier.Log1p))
.weight(2.0))
.functions(fn -> fn.fieldValueFactor(fvf -> fvf.field("view_count")
.modifier(FieldValueFactorModifier.Log1p))
.weight(1.0))
.boostMode(FunctionBoostMode.Sum)
这里做了两层处理。
第一层是 field_value_factor:
java
fvf.field("like_count")
fvf.field("view_count")
表示把点赞数、浏览数纳入评分。
第二层是 log1p:
java
modifier(FieldValueFactorModifier.Log1p)
log1p 的作用是压缩大数值影响。
比如点赞数从 10 到 100,确实说明热度提升;但从 10000 到 10100,就不应该让排序产生特别大的变化。
最后:
java
.boostMode(FunctionBoostMode.Sum)
表示把原始相关性分数和业务加权分数相加。
整体排序可以理解为:
text
最终分数 = BM25 文本相关性 + log(点赞数 + 1) * 2 + log(浏览数 + 1) * 1
这样既不会丢掉关键词相关性,也能让更受欢迎的内容适当前排。
八、多级排序保证结果稳定
除了 function_score,项目中还定义了多级排序:
java
List<SortOptions> sorts = new ArrayList<>();
sorts.add(SortOptions.of(s -> s.score(o -> o.order(SortOrder.Desc))));
sorts.add(SortOptions.of(s -> s.field(f -> f.field("publish_time").order(SortOrder.Desc))));
sorts.add(SortOptions.of(s -> s.field(f -> f.field("like_count").order(SortOrder.Desc))));
sorts.add(SortOptions.of(s -> s.field(f -> f.field("view_count").order(SortOrder.Desc))));
sorts.add(SortOptions.of(s -> s.field(f -> f.field("content_id").order(SortOrder.Desc))));
排序优先级是:
text
_score
publish_time
like_count
view_count
content_id
其中最后的 content_id 很关键。
当多个文档的得分、发布时间、点赞数都一样时,content_id 可以作为稳定排序字段。
这也是后续使用 search_after 游标分页的基础。
九、高亮片段合并为 snippet
ES 查询中还开启了高亮:
java
.highlight(h -> h
.fields(new NamedValue<>("title", new HighlightField.Builder().build()))
.fields(new NamedValue<>("body", new HighlightField.Builder().build()))
)
返回结果时,项目会把标题和正文高亮片段合并成摘要:
java
private String buildSnippet(Hit<Map<String, Object>> hit) {
StringBuilder sb = new StringBuilder();
if (hit.highlight() != null) {
List<String> ht = hit.highlight().get("title");
if (ht != null && !ht.isEmpty()) {
sb.append(String.join(" ", ht));
}
List<String> hb = hit.highlight().get("body");
if (hb != null && !hb.isEmpty()) {
if (!sb.isEmpty()) {
sb.append(" ");
}
sb.append(String.join(" ", hb));
}
}
return sb.isEmpty() ? null : sb.toString();
}
如果有高亮内容,就用高亮片段作为描述:
java
String snippet = buildSnippet(hit);
String description = (snippet != null && !snippet.isBlank()) ? snippet : descriptionFromDoc;
这样用户在搜索结果中看到的不是固定摘要,而是和搜索词相关的命中片段。
十、组装搜索结果
搜索结果最终被转换成 FeedItemResponse:
java
items.add(new FeedItemResponse(
id,
title,
description,
cover,
tagList,
authorAvatar,
authorNickname,
tagJson,
likeCount,
favoriteCount,
liked,
faved,
null
));
其中 liked 和 faved 会根据当前登录用户动态计算:
java
Boolean liked = currentUserIdNullable != null
&& counterService.isLiked("knowpost", id, currentUserIdNullable);
Boolean faved = currentUserIdNullable != null
&& counterService.isFaved("knowpost", id, currentUserIdNullable);
这个设计和 Feed 系统一样:
text
公共内容信息来自 ES
用户态点赞收藏状态来自计数系统
这样搜索结果可以直接展示:
- 标题;
- 摘要;
- 封面;
- 标签;
- 作者信息;
- 点赞数;
- 收藏数;
- 当前用户是否点赞 / 收藏。
十一、本篇小结
这一篇主要分析了搜索系统的核心查询能力。
整体流程是:
text
Controller 接收 q / size / tags / after
↓
Service 解析标签和游标
↓
ES multi_match 检索 title/body
↓
filter 限制 published 内容和标签
↓
function_score 融合点赞数、浏览数
↓
高亮 title/body
↓
组装 FeedItemResponse
它不是简单的关键词匹配,而是把文本相关性和业务价值一起纳入排序。
这套搜索的核心思路是:
text
BM25 负责相关性
function_score 负责业务权重
filter 负责筛选范围
highlight 负责搜索体验
CounterService 负责用户态补充
下一篇继续看搜索系统里的深分页:为什么不用 from + size,而是使用 search_after 和 Base64URL 游标。