文章摘要:
本文深入分析了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、不想改造的情况下怎么解决问题?
有兴趣的查看下面的文章
- Highlighting leads to html tags overlap
- Elasticsearch highlight matches in HTML without breaking syntax
如下是ES 的一些配置信息
ES mapping 设置
-
https://www.elastic.co/docs/reference/text-analysis/analysis-htmlstrip-charfilter
- https://github.com/infinilabs/analysis-pinyin
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
}
}
}
}