Elasticsearch highlight 导致Html 语法异常分析

文章摘要:

本文深入分析了Elasticsearch高亮功能在处理HTML内容时出现的<em>标签乱序问题。问题表现为高亮标签<em>被错误地插入到HTML结构中,导致语法破坏(如<em>业务</span></em>)。文章通过分析Elasticsearch的Analyzer处理流程,特别是html_strip字符过滤器与end_offset偏移量的关系,揭示了问题的根本原因:即使HTML标签被剥离,分词器记录的end_offset仍基于原始文本的字符位置,导致高亮标签可能插入到错误的位置。

文章提供了完整的ES mapping配置和查询示例,并提出了两种解决方案:1)业务层面预处理HTML,保留纯文本数据到ES;2)在前端通过JavaScript清理高亮结果。最后,文章总结了Elasticsearch分析器的核心机制,并提供了详细的Kibana测试代码和完整的mapping配置供参考。

核心要点:

  • 问题现象 :ES高亮生成的<em>标签破坏HTML语法结构
  • 根本原因end_offset基于原始HTML文本位置,而非过滤后文本
  • 解决方案:前端清理高亮结果或业务层预处理纯文本
  • 技术深度:深入分析了Analyzer流程、分词偏移量机制

背景

前端同学反馈、使用Es 做一个搜索业务然后高亮后的搜索结果 <em>业务</span></em> 这种样子破坏了语法规则!!!

html 复制代码
<div><div><span class="text-weight-bold">业务</span>汪<br></div></div>

经过调研看到其他也有类似的问题: 解决思路就是业务里面在进行处理保留纯文本数据到ES做高亮。

这里想提两点:

1、为什么高亮的em 插入会乱序

2、不想改造的情况下怎么解决问题?

有兴趣的查看下面的文章

ES mapping 设置
js 复制代码
{
  "settings": {
    "refresh_interval": "1s",
    "analysis": {
      "tokenizer": {
        "1_ngram": {
          "type": "ngram",
          "min_gram": 1,
          "max_gram": 1,
          "token_chars": [
            "letter",
            "digit",
            "symbol",
            "punctuation",
            "whitespace"
          ]
        },
        "pinyin_tokenizer": {
          "type": "pinyin",
          "keep_first_letter": false,
          "keep_full_pinyin": true,
          "keep_original": false,
          "limit_first_letter_length": 5,
          "lowercase": true,
          "trim_whitespace": true,
          "keep_none_chinese_in_first_letter": false,
          "ignore_pinyin_offset": false
        }
      },
      "analyzer": {
        "1_ngram_analyzer": {
          "char_filter": [
            "html_strip"
          ],
          "tokenizer": "1_ngram",
          "filter": [
            "lowercase"
          ]
        },
        "pinyin_analyzer": {
          "char_filter": [
            "html_strip"
          ],
          "tokenizer": "pinyin_tokenizer"
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "rawText": {
        "type": "text",
        "term_vector": "with_positions_offsets",
        "analyzer": "1_ngram_analyzer",
        "fields": {
          "pinyin": {
            "type": "text",
            "term_vector": "with_positions_offsets",
            "analyzer": "pinyin_analyzer"
          }
        }
      }
    }
  }
}
Es 查询Query
  • https://www.elastic.co/guide/en/elasticsearch/reference/8.18/highlighting.html

    {
    "query":{
    "bool" : {
    "must" : [
    {
    "dis_max" : {
    "tie_breaker" : 0.2,
    "queries" : [
    {
    "match_phrase" : {
    "rawText" : {
    "query" : "业务",
    "slop" : 1,
    "zero_terms_query" : "NONE",
    "boost" : 6.0
    }
    }
    },
    {
    "bool" : {
    "should" : [
    {
    "match_phrase" : {
    "rawText" : {
    "query" : "业务",
    "slop" : 0,
    "zero_terms_query" : "NONE",
    "boost" : 1.0
    }
    }
    }
    ],
    "adjust_pure_negative" : true,
    "boost" : 3.0
    }
    }
    ],
    "boost" : 1.0
    }
    }
    ],
    "adjust_pure_negative" : true,
    "boost" : 1.0
    }
    },"highlight":{
    "order" : "score",
    "fields" : {
    "rawText" : {
    "pre_tags" : [
    ""
    ],
    "post_tags" : [
    "
    "
    ],
    "fragment_size" : 500,
    "number_of_fragments" : 0,
    "type" : "fvh",
    "boundary_scanner" : "WORD",
    "require_field_match" : true,
    "matched_fields" : [
    "rawText",
    "rawText.pinyin"
    ]
    }
    }
    }}

为什么高亮的em 插入会乱序

Analyzer 基本流程

在 Elasticsearch 中,​​分析器(Analyzer)的执行顺序是固定的​​,遵循以下流程:

  • Character Filters(字符过滤器)
    首先对原始文本进行预处理,例如去除 HTML 标签、转换特殊字符(如将 & 转为 and),或处理自定义字符逻辑。字符过滤器可以有多个,按配置顺序依次执行。
  • Tokenizer(分词器)
    将经过字符过滤的文本拆分为独立的词项(Token)。例如,标准分词器(Standard Tokenizer)根据空格和标点分割单词,而中文分词器(如 IK)则按语义切分。每个分析器必须且仅有一个分词器。
  • Token Filters(词项过滤器)
    对分词后的词项进一步处理
    例如:
    转小写(Lowercase Filter)
    去除停用词(Stop Filter)
    添加同义词(Synonym Filter)
    词干提取(Stemmer Filter)
    多个过滤器按配置顺序依次应用
项目Analyze 分析

html_strip-> 1_ngram -> lowercase

通过手动kibana分词一下分词的结果

js 复制代码
POST /cloud-note-index/_analyze
{
  "analyzer": "1_ngram_analyzer",  
  "text": ["<div><div><span class=\"text-weight-bold\">业务</span>汪<br></div></div>"]
}

回过头在来看一下问题 <em>业务</span></em> 这个em添加到了span的后面去了?为什么?

的end_offset 恰好是 前面的位置,这个也是为什么 em 高亮标签会把后面的包裹进去的原因。

这里的start_offset end_offset是原文中的位置信息。

json 复制代码
{
  "tokens" : [
    ...
    {
      "token" : "业",
      "start_offset" : 41,
      "end_offset" : 42,
      "type" : "word",
      "position" : 2
    },
    {
      "token" : "务",
      "start_offset" : 42,
      "end_offset" : 50,
      "type" : "word",
      "position" : 3
    },
    {
      "token" : "汪",
      "start_offset" : 50,
      "end_offset" : 51,
      "type" : "word",
      "position" : 4
    }
    ....
}
Elasticsearch end_offset 含义

在Elasticsearch中,end_offset表示分词后某个词项(Token)​​在原始文本中的结束位置的下一个字符索引​​,遵循左闭右开([start_offset, end_offset))的区间规则。

偏移量的本质是原始文本的物理位置​

start_offset和end_offset的定位始终基于​​原始文本的字符序列​​,而非过滤后的文本。

即使HTML标签被剥离,偏移量仍记录其在原始文本中的实际位置,确保后续搜索高亮、词项定位等功能能正确映射到原始内容。

示例:

原始文本:test (总长度9字符)

过滤后文本:test

test的start_offset为3(对应标签后的第一个字符位置),end_offset为9(对应 标签后的位置)

看看下面这个例子,test的 start_offset end_offset ,如果高亮也是在</b> 之后的 !!!

js 复制代码
POST _analyze
{
  "tokenizer": "standard",
  "char_filter": ["html_strip"],
  "text": "<b>test</b> 1"
}

分词后的结果

复制代码
{
  "tokens" : [
    {
      "token" : "test",
      "start_offset" : 3,
      "end_offset" : 11,
      "type" : "<ALPHANUM>",
      "position" : 0
    },
    {
      "token" : "1",
      "start_offset" : 12,
      "end_offset" : 13,
      "type" : "<NUM>",
      "position" : 1
    }
  ]
}

从上面的例子看出来,为什么建议提前处理html信息,因为不管怎么处理end_offset的位置都可能导致错误,导致高亮语法错误,这个也是根本原因。

好了,知道原因之后,解决起来就容易了

不想改造的情况下怎么解决问题

从上面的内容分析,ES实现的原理end_offset 目前啃不动,获取纯文本,然后自己映射处理比较麻烦...

其实从上面的文本分析,这种高亮的异常其实比较好处理,可以通过代码去解决,直接下放到前端处理。

如下是解决方案:遍历所有em标签,替换一下

js 复制代码
function cleanHtmlHighlight(htmlString) {
  var parser = new DOMParser()
  var doc = parser.parseFromString(htmlString, 'text/html')
  var hiTags = doc.querySelectorAll('em')
  hiTags.forEach(function(tag) {
    if (tag.childNodes.length > 0) {
      var textContent = tag.textContent
      tag.textContent = ''
      tag.appendChild(doc.createTextNode(textContent))
    }
    if (tag.textContent.trim() === '') {
      tag.parentNode.removeChild(tag)
    }
  })
  return doc.body.innerHTML
}

总结

通过这个问题,加深了对应ES的一些认识 Analyzer 基本流程、Analyzer 分析等等

以下是分析过程用到的一些kibana的测试

js 复制代码
## 分析字段分词
GET /cloud-note-index/_termvectors/9e895bcb-d1f0-4579-90a4-2da9b6127937?fields=rawText
POST /cloud-note-index/_analyze
{
  "analyzer": "1_ngram_analyzer",  
  "text": ["<div><div><span class=\"text-weight-bold\">业务</span>汪<br></div></div>"]
}
POST /cloud-note-index/_analyze
{
  "tokenizer": "standard",
  "char_filter": ["html_strip"],  
  "text": ["<div><div><span class=\"text-weight-bold\">业务</span>汪</div></div>"]
}
POST /cloud-note-index/_analyze
{
  "tokenizer": "1_ngram",  
  "text": ["<div><div><span class=\"text-weight-bold\">业务</span>汪<br></div></div>"]
}
POST _analyze
{
  "tokenizer": "standard",
  "char_filter": ["html_strip"],
  "text": "<b>test</b> 1"
}

完整的mapping

json 复制代码
{
  "settings": {
    "refresh_interval": "1s",
    "number_of_replicas": 1,
    "number_of_shards": 3,
    "analysis": {
      "tokenizer": {
        "1_ngram": {
          "type": "ngram",
          "min_gram": 1,
          "max_gram": 1,
          "token_chars": [
            "letter",
            "digit",
            "symbol",
            "punctuation",
            "whitespace"
          ]
        },
        "pinyin_tokenizer": {
          "type": "pinyin",
          "keep_first_letter": false,
          "keep_full_pinyin": true,
          "keep_original": false,
          "limit_first_letter_length": 5,
          "lowercase": true,
          "trim_whitespace": true,
          "keep_none_chinese": true,
          "keep_none_chinese_in_first_letter": false,
          "ignore_pinyin_offset": false,
          "none_chinese_pinyin_tokenize": false
        },
        "pinyin_search_tokenizer": {
          "type": "pinyin",
          "keep_first_letter": false,
          "keep_full_pinyin": true,
          "keep_original": false,
          "limit_first_letter_length": 5,
          "lowercase": true,
          "trim_whitespace": true,
          "keep_none_chinese": true,
          "keep_none_chinese_in_first_letter": false,
          "ignore_pinyin_offset": false,
           //搜索针对如果判断是拼音字符的进行分词
          "none_chinese_pinyin_tokenize": true
        }
      },
      "analyzer": {
        "1_ngram_analyzer": {
          "char_filter": [
            "html_strip"
          ],
          "tokenizer": "1_ngram",
          "filter": [
            "lowercase"
          ]
        },
        "pinyin_analyzer": {
          "char_filter": [
            "html_strip"
          ],
          "tokenizer": "pinyin_tokenizer"
        },
        "pinyin_search_analyzer": {
          "tokenizer": "pinyin_search_tokenizer"
        }
      }
    }
  },
  "mappings": {
    "properties": {
      "rawText": {
        "type": "text",
        "term_vector": "with_positions_offsets",
        "analyzer": "1_ngram_analyzer",
        "fields": {
          "pinyin": {
            "type": "text",
            "term_vector": "with_positions_offsets",
            "analyzer": "pinyin_analyzer"
          }
        }
      },
      "userId": {
        "type": "keyword"
      },
      "extra": {
        "type": "text",
        "index": false
      }
    }
  }
}
相关推荐
可乐ea2 小时前
【知识获取与分享社区项目 | 项目日记第 24 天】终章总结:从认证、发布、计数、Feed、搜索到 RAG:完整复盘一个知识社区后端系统
java·spring boot·redis·mysql·elasticsearch·ai·kafka
汪小哥3 小时前
Elasticsearch Preference + Slice 加速查询实战案例
elasticsearch
金融支付架构实战指南14 小时前
支付系统 ES 实战案例:从索引创建到真实业务查询
大数据·elasticsearch·搜索引擎·支付
Elastic 中国社区官方博客19 小时前
13.7万人,零人工决策:使用 Elasticsearch 实现智能体驱动的灾害响应系统
大数据·数据库·人工智能·elasticsearch·搜索引擎·ai·全文检索
可乐ea20 小时前
【知识获取与分享社区项目 | 项目日记第 19 天】基于 Elasticsearch 实现关键词检索与业务权重排序
java·大数据·spring boot·mysql·elasticsearch·搜索引擎·全文检索
查拉图斯特拉面条1 天前
Git操作指南:克隆、提交、推送与避坑大全
大数据·git·elasticsearch
Zhu7581 天前
在k8s环境部署elasticsearch+kibana
elasticsearch·kubernetes·jenkins
为爱停留1 天前
让智能体「记住」对话:Checkpoint 功能、持久化数据接口与 thread_id 详解
java·数据库·elasticsearch
可乐ea1 天前
【知识获取与分享社区项目 | 项目日记第 23 天】项目梳理下篇:高并发与最终一致性复盘:Redis、Kafka、Outbox、ES 与 RAG 如何协同
java·redis·mysql·elasticsearch·缓存·ai·kafka