【知识获取与分享社区项目 | 项目日记第 19 天】基于 Elasticsearch 实现关键词检索与业务权重排序

一、为什么不用 MySQL LIKE 做搜索

在平台中,用户发布的是一篇篇知文,内容包括标题、摘要、标签、Markdown 正文、图片等。

如果搜索只用 MySQL 的 LIKE '%关键词%',会有几个明显问题:

  1. 长文本检索性能差;
  2. 中文分词效果不好;
  3. 无法做相关性排序;
  4. 很难融合点赞数、浏览数等业务权重;
  5. 后续做联想建议、深分页也不方便。

所以项目中使用 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);
}

这里把 tagsCsvafter 都交给 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
));

其中 likedfaved 会根据当前登录用户动态计算:

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 游标。

相关推荐
Database_Cool_2 小时前
从 MySQL 迁移到阿里云 AnalyticDB MySQL:零改造百倍加速实战教程
数据库·mysql·阿里云
zzz_23682 小时前
【Spring】面试突击系列(一):IoC 与 DI 深度解析
java·spring·面试
于先生吖2 小时前
前后端分离体育服务项目,场馆计费+线下赛事排行小程序部署开发教程
java·小程序·uni-app
金融Tech趋势派2 小时前
2026企业微信SCRM与获客系统选型指南:功能矩阵、场景适配与避坑清单
大数据·人工智能·企业微信
RemainderTime2 小时前
Spring Boot脚手架集成 Spring Security实现生产级RBAC鉴权
spring boot·后端·spring
闪电悠米2 小时前
黑马点评-秒杀优化-01_async_seckill_idea
java·数据库·ide·redis·分布式·缓存·intellij-idea
摇滚侠2 小时前
IDEA 创建 Java 项目 lib 和 resources
java·ide·intellij-idea
宸津-代码粉碎机2 小时前
Spring AI企业级Agent实战|多工具自动规划+并行调度落地,彻底解决复杂业务AI任务编排问题
java·大数据·人工智能·spring boot·python·spring
lixia0417mul22 小时前
flink接入spring体系
java·spring·flink