← 全部文章

ElasticSearch搜索实战

Ezzi's Blog引入ES作文章搜索的实战记录

更新于 2026.08.07

本文目录 8 节
ElasticSearch搜索实战封面

最近在学习ElasticSearch,学完后想着先给博客接上,支持按照标题、作者、摘要、正文内容进行语义模糊搜索。

ElasticSearch支持语义搜索,对搜索的关键词进行分词后,去倒排索引中查询相关的文档,并按照相关性打分,最后返回结果。

这里说一个踩坑。从ES8开始,官方默认开启SSL访问,不支持普通的HTTP请求。如果在本地/测试环境中使用普通的HTTP访问,要在启动时加上启动参数xpack.security.enabled=false。

要引入ES,要从很多维度考虑。

多字段相关性搜索

这是ElasticSearch带来的最显著的提升,其功能强大,所以需要开发者也做好相应的工作。 为了让用户觉得搜索“聪明”,需要对不同字段设置不同的权重(Boosting)。

权重分配: 标题>摘要>正文>作者。

这里使用 multi_match 查询,配合 most_fields 模式。

{
  "query": {
    "multi_match": {
      "query": "搜索词",
      "fields": ["title^10", "summary^5", "content^1", "author^2"]
    }
  }
}

命中标题的文章通常比命中正文的更符合预期。将标题权重设高,能确保最相关的结果排在第一页.

高亮显示

搜索结果中的.高亮不仅仅是为了好看,更是为了告诉用户:“为什么这篇文章会被搜出来”。但当多个字段(如标题和正文)同时命中时,处理逻辑就变得至关重要。

独立高亮策略

ElasticSearch搜索实战配图

为了避免页面显得杂乱,我采用了独立高亮策略。

  • 标题: 只要命中,必须完整显示并包裹高亮标签。
  • 正文: 只显示命中关键词所在的片段。

在前端呈现上,标题的高亮是主要的,而正文片段的高亮是辅助。通过 ES 的 require_field_match: false 设置,即便搜索词在某个字段权重较低,也能确保所有命中所见即所得。

关于require_field_match: false

控制 “搜索词来源字段” 与 “高亮显示字段” 之间关联性的一个开关。 在 Elasticsearch的高亮(Highlighting)逻辑中,它的默认值是 true。

在默认情况下require_field_match: true,ES会要求**只有当搜索词在该字段中被搜到时,才会在该字段进行高亮。**当设置为false时,ES的逻辑变为:只要文档命中了搜索词(不管是在哪个字段命中的),所有请求高亮的字段只要包含这个词,通通高亮。

高亮偏移处理

image-20260511103823084

在搜索视图中,除了文章标题的展示外,其余内容通常受限于显示区域的大小,无法完全展示。如果只展示摘要或者文章正文的开头,而被检索到的地方在后面,就无法高亮显示,导致用户疑惑:“为什么搜到了这篇?”。因此,要把高亮的部分单独拿出来,也就是偏移处理。

为了优化体验,我引入了 fragmenter 机制:

  • **分片定位(Fragmenter):**设置为 span 或 sentence。ES 会自动寻找包含关键词的那一段话,而不是死板地从头截取。
  • **片段长度(Fragment Size):**每个高亮片段长度控制在 50 字左右,确保上下文语义完整。

Markdown纯文本化

image-20260511103550285

image-20260511103558655

对于HTML和Mardown格式的文本,文本中存在很多样式需要的符号。例如##、<p>等等。这些不仅影响ES的分词、搜索,还会影响前端的渲染。所以我的做法是,从数据库拉取到原始文本后,将其纯文本化。

要实现这个功能,使用Regex是可以实现的,大致代码如下。

public static String removeMarkdown(String markdown) {
    if (markdown == null || markdown.isBlank()) {
        return markdown;
    }
    // 代码块
    markdown = markdown.replaceAll("(?s)```.*?```", "");
    // 行内代码
    markdown = markdown.replaceAll("`([^`]*)`", "$1");
    // 图片
    markdown = markdown.replaceAll("!\\[(.*?)\\]\\(.*?\\)", "$1");
    // 链接
    markdown = markdown.replaceAll("\\[(.*?)\\]\\(.*?\\)", "$1");
    // 标题
    markdown = markdown.replaceAll("(?m)^#{1,6}\\s+", "");
    // 粗体 / 斜体
    markdown = markdown.replaceAll("(\\*\\*|__)(.*?)\\1", "$2");
    markdown = markdown.replaceAll("(\\*|_)(.*?)\\1", "$2");
    // 删除线
    markdown = markdown.replaceAll("~~(.*?)~~", "$1");
    // 引用
    markdown = markdown.replaceAll("(?m)^>+\\s*", "");
    // 无序列表
    markdown = markdown.replaceAll("(?m)^(\\s*)[-*+]\\s+", "$1");
    // 有序列表
    markdown = markdown.replaceAll("(?m)^(\\s*)\\d+\\.\\s+", "$1");
    // 分割线
    markdown = markdown.replaceAll("(?m)^(-{3,}|\\*{3,}|_{3,})$", "");
    // 表格分隔行
    markdown = markdown.replaceAll("(?m)^\\|?\\s*[-:]+[-| :]*\\|?$", "");
    // 去掉表格竖线
    markdown = markdown.replace("|", " ");
    // HTML 标签
    markdown = markdown.replaceAll("<[^>]+>", "");
    // 多余空行
    markdown = markdown.replaceAll("\\n{3,}", "\n\n");
    return markdown.trim();
}

在生产环境中,不建议自己维护Regex。可以使用flexmark-java。然后

Parser parser = Parser.builder().build();
Node document = parser.parse(markdown);
TextCollectingVisitor visitor = new TextCollectingVisitor();
String text = visitor.collectAndGetText(document);

性能权衡

不引入copy_to提升查询性能

copy_to虽然能把多个字段合并成一个,但它会带来一个严重的后果:字段边界丢失。用copy_to的代价:当你把标题、摘要、正文都塞进一个all_text字段时,ES 会把它们视为一整块文本。你很难精准地告诉 ES:“如果关键词出现在标题,给 10 分;如果出现在正文,给 1 分”。不用的好处:保持字段独立,你可以利用multi_match的fields权重调节(如 title^10)。这种精细化调优对于博客搜索这种极其依赖标题权重的场景来说,远比节省那点查询开销重要。

不使用term_vectors(项向量)

用 **term_vectors** 的代价: 它会记录词频、位置、偏移量等大量元数据。开启后,索引文件的大小可能会膨胀 2 到 3 倍,且降低写入时的性能。

现在,本博客的ES引入完成,搜索功能可以体验。

评论