赞
踩
nested
字段多关键字搜索,高亮全部匹配关键字的处理ElasticSearch
版本号: 6.7.0
用户会传入多个关键字去ES查询ElasticSearch nested
字段 的多个字段,要求在返回的结果中被搜索的字段需要高亮所有匹配的关键字。例如同时通过上海
和策划
关键字,再 工作经历
的列表中的工作内容
和公司名称
中搜索。如果有人员的工作经历
中这两个关键字上海
和策划
都可以匹配到,那么返回的结果中同时高亮这上海
和策划
关键字
基础的ElasticSearch nested
字段的高亮实现搜可以参考https://blog.csdn.net/weixin_48990070/article/details/120342597 这篇笔记。
对于同一个nested
字段支持在一个nested Query
用不同的关键字来搜索,但对于should
查询只会高亮其中匹配的一个关键字,而不是全部。引入如果多关键字直接是任意满足的关系,则之后高亮匹配的其中的一个关键字,这个与不满足需求。
那就把关键字拆分为多个nested Query
,一个关键字对应一个nested Query
。但这个方法一样可以搜索,但对于同一个nested
字段的nested Query
默认的inner_hits
属性只能出现在一个nested Query
中,不允许同一个nested
字段的不同nested Query
都指定inner_hits
,如果一定要这么做,那么就会得到一个查询错误的提示,提示如下:
"reason": {
"type": "illegal_argument_exception",
"reason": "[inner_hits] already contains an entry for key [trackRecordList]"
}
如果只在一个关键字的nested Query
指定inner_hits
,那么最终的高亮结果只会有该nested Query
的高亮,还是不满足要求。
通过AI询问得知inner_hits
有个name
属性可以解决问题点2
的情况,可以通过设置inner_hits
不同的name
属性值来达到对同一个nested
字段用不同nested Query
来做多关键字的高亮效果,但是这样里又出现了两个新的问题。
1、inner_hits
有个name
属性值不能重复,否则一样出问题点2
的错误提示。
2、高亮 结果是按照inner_hits
有个name
属性值分组展示的,不像非nested
会给一个最终多个关键字都高亮的结果。
转换下问题就是:
1、要根据关键字自动生成不重复inner_hits
有个name
属性值
2、对于同字段的高亮结果,要做高亮内容的合并。
因此只要解决了上面两个问题,就可以完成业务的需求了。
在将查询参数转换为ES Query语句的处理中,用Map来缓存每个nested
字段的当前有几个nested Query
,通过累计数量,来自动生成每个nested Query
中的inner_hits
有个name
属性名,例如名称为 nested
字段名+“-”+自增序号
因此就不能再使用静态方法来构建查询语句了,得用构建器了,下面就是构建器的部分实现
public class EsQueryBuilder { // 存储嵌套字段及其累计值的映射 private Map<String, IntAccumulator> accNestedFieldMap = new HashMap<>(); // 无需嵌套高亮的字段集合 private Set<String> noNestedHighlightFields = new HashSet<>(); // 关键词分组列表 private List<PageSearchKeywordGroupParameter> keywordGroupList ; // 是否开启高亮显示 private boolean isHighlight = false; // 主查询构建器 private BoolQueryBuilder mainQueryBuilder; //存储嵌套字段及其高亮构建器 private Map<NestedQueryBuilder,InnerHitBuilder> nestedQueryBuilderHighlightMap = new HashMap<>(); /** * 构造方法 * @param keywordGroupList 搜索关键字组 * @param isHighlight 是否高亮 */ public EsQueryBuilder(List<PageSearchKeywordGroupParameter> keywordGroupList,boolean isHighlight) { this.keywordGroupList = keywordGroupList; this.isHighlight = isHighlight; this.mainQueryBuilder = new BoolQueryBuilder(); //补充嵌套字段初始累加器 EsQueryFieldEnum.getNestedFieldList().forEach(item->{ accNestedFieldMap.put(item.getFieldConfig().getMainField(),new IntAccumulator(0)); }); } /** * 向当前的查询构建器中添加条件。这个方法会遍历关键字组列表(keywordGroupList)中的每一个项目, * 并根据是否标记为排除条件,将关键字添加到查询的必须条件(must)或者必须不条件(mustNot)中。 * @return EsQueryBuilder 返回当前的查询构建器实例,允许链式调用。 */ public EsQueryBuilder addCondition() { keywordGroupList.forEach(item->{ // 只处理非空关键字的项目 if(StringUtils.isNotBlank(item.getKeyword())){ // 根据是否为排除条件,选择添加到must或mustNot中 if(BooleanUtils.isTrue(item.getIsExclude())){ mainQueryBuilder.mustNot(buildQueryBuilder(item)); }else{ mainQueryBuilder.must(buildQueryBuilder(item)); } } }); return this; } /** * 为所有内容添加高亮显示条件的查询构建器。 * 该方法遍历关键字组列表,对非排除条件的关键字进行全文搜索设置,并根据关键字是否为排除条件,添加相应的查询条件。 * @return EsQueryBuilder 当前查询构建器实例,支持链式调用。 */ public EsQueryBuilder addConditionForAllContentHighlight() { // 遍历关键字组列表,过滤掉设置为排除条件的关键字,对剩余的关键字进行全文搜索设置 keywordGroupList.stream() // 过滤掉设置为排除条件的关键字 .filter(item->BooleanUtils.isNotTrue(item.getIsExclude())) .peek(item->{ // 设置搜索类型为全文搜索,清空子类型设置 item.setSearchType(EsQueryTypeEnum.ALL.value()); item.setSearchSubType(null); }) .forEach(item->{ // 根据关键字是否为排除条件,添加相应的查询条件 if(StringUtils.isNotBlank(item.getKeyword())){ if(BooleanUtils.isTrue(item.getIsExclude())){ // 如果是排除条件,则添加到must not查询条件中 mainQueryBuilder.mustNot(buildQueryBuilder(item)); }else{ // 如果不是排除条件,则添加到must查询条件中 mainQueryBuilder.must(buildQueryBuilder(item)); } } }); return this; } /** * 嵌套字段高亮处理 **/ private void highlightNestedQuery() { if(!nestedQueryBuilderHighlightMap.isEmpty()){ nestedQueryBuilderHighlightMap.forEach(NestedQueryBuilder::innerHit); } } /** * 为查询添加过滤条件。 * 这个方法允许用户指定一个过滤条件,并将其应用到当前的查询构建器中。 * @param queryBuilder 过滤条件的查询构建器。这是一个已经构建好的查询条件,将作为过滤条件添加到主查询中。 * @return 返回当前的EsQueryBuilder实例,允许链式调用。 */ public EsQueryBuilder filterCondition(QueryBuilder queryBuilder) { // 为主查询添加过滤条件 mainQueryBuilder.filter(queryBuilder); return this; } /** * 构建查询条件 * @return org.elasticsearch.search.builder.SearchSourceBuilder */ public SearchSourceBuilder build() { SearchSourceBuilder searchSourceBuilder = new SearchSourceBuilder(); //查询条件 searchSourceBuilder.query(mainQueryBuilder); return searchSourceBuilder; } /** * 构建查询条件(支持高亮) * @return org.elasticsearch.search.builder.SearchSourceBuilder */ public SearchSourceBuilder buildWithHighlight() { SearchSourceBuilder searchSourceBuilder = build(); if(isHighlight){ //补充非嵌套字段的高亮 highlightNestedQuery(); //非嵌套高亮 searchSourceBuilder.highlighter(EsHighlightUtils.buildNotNestHighlightBuilder(noNestedHighlightFields)); } return searchSourceBuilder; } /** * 构建查询构建器。 * 该方法根据传入的参数生成一个对应的查询条件构建器,主要用于处理专家页面的搜索关键词分组参数。 * @param parameter 搜索参数,包含需要搜索的关键词和其他搜索条件。 * @return 返回构建好的查询条件构建器对象。 */ private QueryBuilder buildQueryBuilder(PageSearchKeywordGroupParameter parameter){ // 初始化一个布尔类型的查询条件构建器,用于后续添加各种查询条件 BoolQueryBuilder keywordQueryBuilder = new BoolQueryBuilder(); // 根据参数生成对应的查询类型枚举,用于确定如何构建查询条件 EsQueryTypeEnum queryTypeEnum = generateQueryTypeEnum(parameter); // 调用查询类型枚举中定义的添加条件处理器,处理当前搜索参数,并将其添加到查询条件构建器中 queryTypeEnum.getAddConditionHandler().handle(this,keywordQueryBuilder,parameter.getKeyword()); return keywordQueryBuilder; } /** * 根据关键词和字段枚举生成查询条件。 * @param keyword 关键词,用于构建查询条件。 * @param fieldEnum 字段枚举,包含字段配置信息,用于指定要查询的字段。 * @param highlight 是否高亮处理。 * @return org.elasticsearch.index.query.QueryBuilder 查询构建器,用于构建Elasticsearch的查询语句。 */ private QueryBuilder generateCondition(String keyword, EsQueryFieldEnum fieldEnum, boolean highlight){ EsQueryFieldConfigDTO fieldConfigDTO = fieldEnum.getFieldConfig(); // 构建基于关键词的基本查询条件 BoolQueryBuilder boolQueryBuilder = EsQueryBuilderUtils.generateFieldQueryBuilder(keyword, true, fieldConfigDTO.getSearchFieldList()); if(BooleanUtils.isTrue(fieldConfigDTO.getIsNested())){ // 如果是嵌套类型字段,则使用NestedQueryBuilder来处理 NestedQueryBuilder nestedQueryBuilder = new NestedQueryBuilder(fieldConfigDTO.getMainField(), boolQueryBuilder, ScoreMode.Avg); if(highlight && isHighlight){ // 如果需要高亮显示,则为嵌套类型字段设置高亮处理 String innerHitName = generateInnerHitName(fieldEnum); InnerHitBuilder innerHitBuilder = EsHighlightUtils.buildNestHighlightBuilder(innerHitName, fieldConfigDTO.getSearchFieldList()); nestedQueryBuilderHighlightMap.put(nestedQueryBuilder,innerHitBuilder); } return nestedQueryBuilder; }else{ // 对于非嵌套类型字段,处理高亮显示的逻辑 if(highlight && isHighlight){ // 收集非嵌套类型的高亮字段 noNestedHighlightFields.addAll(fieldConfigDTO.getSearchFieldList()); } return boolQueryBuilder; } } /** * 生成嵌套查询的innerHit名称 * @param fieldEnum * @return java.lang.String **/ private String generateInnerHitName(EsQueryFieldEnum fieldEnum){ IntAccumulator accumulator = accNestedFieldMap.get(fieldEnum.getFieldConfig().getMainField()); accumulator.accumulate(1); return fieldEnum.getFieldConfig().getMainField()+"-"+accumulator.getValue(); } /** * 向查询构建器中添加公司名称条件。 * @param esQueryBuilder ES查询构建器,用于生成特定的ES查询条件。 * @param keywordQueryBuilder 关键词查询构建器,用于组合不同的查询条件。 * @param keyword 用户输入的关键词,用于匹配公司名称。 */ public static void addCompanyNameCondition(EsQueryBuilder esQueryBuilder,BoolQueryBuilder keywordQueryBuilder, String keyword) { // 根据关键词和字段类型(当前公司名称),生成查询条件,并添加到关键词查询构建器中 keywordQueryBuilder.should(esQueryBuilder.generateCondition(keyword,EsQueryFieldEnum.CURRENT_COMPANY,true)); // 根据关键词和字段类型(履历中的公司名称),生成查询条件,并添加到关键词查询构建器中 keywordQueryBuilder.should(esQueryBuilder.generateCondition(keyword,EsQueryFieldEnum.TRACK_RECORD_COMPANY,true)); } }
其他相关代码:
定义一个适用Lambda表达式的接口
/** * Es 搜索条件处理器 */ @FunctionalInterface public interface IEsQueryConditionHandler { /** * 处理Es搜索条件 * @param esQueryBuilder * @param keywordQueryBuilder * @param keyword * @return void */ void handle(EsQueryBuilder esQueryBuilder, BoolQueryBuilder keywordQueryBuilder, String keyword); }
定义搜索字段的枚举
/** * 专家库ES查询字段枚举 */ public enum EsQueryFieldEnum { /** * 当前公司 */ CURRENT_COMPANY(10,"当前公司", EsQueryFieldConfigDTO.builder() .mainField("companyInfo") .isNested(false) .searchFieldList(List.of("companyInfo.companyName")) .build()), /** * 工作经历公司 */ TRACK_RECORD_COMPANY(20,"工作经历公司", EsQueryFieldConfigDTO.builder() .mainField("trackRecordList") .isNested(true) .searchFieldList(List.of("trackRecordList.companyName","trackRecordList.companyOtherName")) .build()), ; /** * 嵌套字段列表 */ private static final List<EsQueryFieldEnum> NESTED_FIELD_LIST = Stream.of(EsQueryFieldEnum.values()) .filter(item->item.fieldConfig.getIsNested()).collect(Collectors.toList()); EsQueryFieldEnum(Integer value, String description, EsQueryFieldConfigDTO fieldConfig){ this.value =value; this.description = description; this.fieldConfig = fieldConfig; } private final Integer value; private final String description; private final EsQueryFieldConfigDTO fieldConfig; public Integer value() { return this.value; } public String getDescription() { return this.description; } public EsQueryFieldConfigDTO getFieldConfig() { return fieldConfig; } /** * 获取嵌套字段列表 */ public static List<EsQueryFieldEnum> getNestedFieldList() { return NESTED_FIELD_LIST; } }
定义搜索类型的枚举
/** * ES查询类型枚举 **/ public enum EsQueryTypeEnum { /** * 公司 */ COMPANY(20,"公司", EsQueryBuilder::addCompanyNameCondition), ; EsQueryTypeEnum(Integer value, String description, IEsQueryConditionHandler addConditionHandler){ this.value =value; this.description = description; this.addConditionHandler = addConditionHandler; } private final Integer value; private final String description; private final IEsQueryConditionHandler addConditionHandler; public Integer value() { return this.value; } public String getDescription() { return this.description; } public IEsQueryConditionHandler getAddConditionHandler() { return addConditionHandler; } public static EsQueryTypeEnum resolve(Integer statusCode) { for (EsQueryTypeEnum status : values()) { if (status.value.equals(statusCode)) { return status; } } return null; } }
使用方法
SearchSourceBuilder searchSourceBuilder = queryBuilder // 增加关键字查询条件条件 .addCondition() // 组合条件过滤 .filterCondition(EsQueryHandler.getAdvancedSearchQueryBuilder(searchParameter)) //生成查询语句 .build(); // 获取总条数 Integer total = EsService.countBySearch(searchSourceBuilder); //重新生成高亮查询语句 searchSourceBuilder = queryBuilder.buildWithHighlight(); //补充排序规则 EsQueryHandler.setSearchSortRule(searchSourceBuilder,searchParameter.getSortType()); // 从第几页开始 searchSourceBuilder.from(searchParameter.getOffset()); // 每页显示多少条 searchSourceBuilder.size(searchParameter.getLimit()); //分页搜索 List<EsAllInfoDTO> allInfoList = EsService.listByPageSearch(searchSourceBuilder);
合并高亮的处理,这个问题实际就是:对于一个字符串a
,存在多个字符串a1
,a2
,a3
,并且a1
,a2
,a3
再过滤掉<em>
和</em>
字符后是相同的字符串。现在需要将字符串a
,a1
,a2
,a3
合并为一个字符串fa
。合并后的字符串需要满足:
1、fa
过滤掉<em>
和</em>
字符后同a
相同
2、所有在a1
,a2
,a3
被<em>
和</em>
包围的子字符串,在fa
同样被<em>
和</em>
包围
另外要保证一个点是原始的字符串a
不能本身就有<em>
或</em>
这些字符串,这个可以通过对数据源头进行过滤就可以了。比如使用Jsonp
过滤。
合并高亮字符串的具体的实现算法如下:
/** * Es高亮工具类 */ public class EsHighlightUtils { public static final String emBegin = "<em>"; public static final String emEnd = "</em>"; private static final String emRegex = "(?i)<em>|</em>"; private static final int emBeginLen = emBegin.length(); private static final int emEndLen = emEnd.length(); /** * 将字符串数组中的字符串合并,并在特定位置添加增强标签(<em></em>)。 * @param stringList 字符串数组,数组中所有字符串如果去除"<em>" 和"</em>"后必定是相同的字符串。 * @return 合并后的字符串,增强了指定的字符串片段。 */ public static String mergeStrWithEmTags(List<String> stringList) { // 移除原始字符串中的所有em标签,获取干净的源字符串 String sourceStr = stringList.get(0).replaceAll(emRegex, ""); // 使用StringBuilder来操作源字符串,以便高效地添加em标签 StringBuilder sourceBuilder = new StringBuilder(sourceStr); // 初始化一个布尔数组,用于标记哪些字符需要增强 boolean[] emFlags = new boolean[sourceStr.length()]; // 填充布尔数组,标记需要增强的字符位置 fillEmFlags(stringList, emFlags); // 根据标记,在相应位置添加em标签 addEmFlags(sourceBuilder, emFlags); return sourceBuilder.toString(); } /** * 为给定的字符串数组中的每个字符串设置强调标志数组。 * 该方法会查找每个字符串中所有"<em>"开头和"</em>"结尾的包围结构, * 并将这些包围结构在原字符串中的对应部分在标志数组中设置为true。 * @param stringList 字符串数组,包含需要处理的字符串。 * @param emFlags 增强标志数组,与字符串数组对应,用于标记特定部分。 */ private static void fillEmFlags(List<String> stringList, boolean[] emFlags) { // 遍历字符串数组,为每个字符串设置强调标志 for(int j = 0; j< stringList.size(); j++){ String str = stringList.get(j); // 查找每个字符串中"<em>"的起始位置 int beginIndex = str.indexOf(emBegin); int cumulativeOffset = 0; int noEmLen = 0; int endIndex = 0; while(beginIndex != -1){ //计算没有增强的字符串长度 noEmLen = endIndex>0?Math.max(beginIndex - (endIndex + emEndLen),0):beginIndex; // 查找"<em>"后的"</em>"位置 endIndex = str.indexOf(emEnd,beginIndex+emBeginLen); if(endIndex==-1){ // 如果找不到结束标签,则跳出循环 break; } // 计算被包围的子字符串长度 int emSubLength = endIndex - beginIndex - emBeginLen; // 更新累计偏移量,跳过未增强的字符串 cumulativeOffset = cumulativeOffset+ noEmLen; // 将被包围的子字符串在标志数组中对应的元素设置为true for(int i=0;i<emSubLength;i++){ emFlags[cumulativeOffset + i] = true; } // 更新累计偏移量,为处理下一个"<em>"做准备 cumulativeOffset = cumulativeOffset + emSubLength; // 计算下一个"<em>"标签的起始位置 beginIndex = endIndex + emEndLen; // 继续查找下一个"<em>" beginIndex = str.indexOf(emBegin,beginIndex); } } } /** * 向源字符串中插入增强标签。 * 根据给定的增强标志数组(emFlags),在源字符串(sourceBuilder)中插入开始(emBegin)和结束(emEnd)标签。 * 当emFlags中的元素为true时,表示字符串的这个位置需要被增强 * @param sourceBuilder 被插入标签的源字符串的StringBuilder对象。 * @param emFlags 增强标志数组,true表示字符串的这个位置需要被增强。 */ private static void addEmFlags(StringBuilder sourceBuilder, boolean[] emFlags) { // 初始化是否开始插入标签的标志和累计偏移量 boolean startEm = false; int cumulativeOffset = 0 ; // 遍历增强标志数组,根据标志插入相应的标签 for (boolean emFlag : emFlags) { if (emFlag) { // 当前位置需要插入开始标签 if (!startEm) { // 第一次需要插入开始标签,进行插入操作并更新累计偏移量 startEm = true; sourceBuilder.insert(cumulativeOffset, emBegin); cumulativeOffset += emBeginLen; } // 无论是否第一次,只要需要插入开始标签,累计偏移量就需要增加 cumulativeOffset++; } else { // 当前位置需要插入结束标签 if (startEm) { // 已经开始插入标签,进行插入操作并更新累计偏移量 sourceBuilder.insert(cumulativeOffset, emEnd); cumulativeOffset += emEndLen; } // 标记不再插入开始标签 startEm = false; // 累计偏移量增加 cumulativeOffset++; } } // 如果遍历结束时正在插入开始标签,插入结束标签 if(startEm){ sourceBuilder.insert(cumulativeOffset,emEnd); } } /** * 构建嵌套的高亮 InnerHitBuilder * @param name * @param fields * @return org.elasticsearch.index.query.InnerHitBuilder */ public static InnerHitBuilder buildNestHighlightBuilder(String name, Collection<String> fields) { if(CollectionUtils.isEmpty(fields)){ return null; } InnerHitBuilder innerHitBuilder = StringUtils.isBlank(name)?new InnerHitBuilder():new InnerHitBuilder(name); HighlightBuilder highlightBuilder = new HighlightBuilder(); highlightBuilder.preTags(emBegin).postTags(emEnd); //设置高亮的方法 highlightBuilder.highlighterType("plain"); //设置分段的数量不做限制 highlightBuilder.numOfFragments(0); for(String field:fields){ highlightBuilder.field(field); } innerHitBuilder.setHighlightBuilder(highlightBuilder); return innerHitBuilder; } /** * 构建非嵌套的高亮 HighlightBuilder * @param fields * @return org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder */ public static HighlightBuilder buildNotNestHighlightBuilder(Collection<String> fields) { HighlightBuilder highlightBuilder = new HighlightBuilder(); highlightBuilder.preTags(emBegin).postTags(emEnd); //设置高亮的方法 highlightBuilder.highlighterType("plain"); //设置分段的数量不做限制 highlightBuilder.numOfFragments(0); for(String field:fields){ highlightBuilder.field(field); } return highlightBuilder; } }
修改https://blog.csdn.net/weixin_48990070/article/details/120342597 这篇笔记中的替换高亮处理的代码,思路为每次只合并找到的第一个高亮内容,将它和当前的原始内容合并,并将合并后的内容替换掉原始内容。重复这个动作知道所有高亮的内容都被合并到当前的原始内容中。
/** * 替换嵌套高亮的值 * @param sourceObj * @param nestedEle * @param highlightEle * @return void */ private void replaceInnerHighlightValue(JsonObject sourceObj, JsonElement nestedEle, JsonElement highlightEle){ if(nestedEle==null || highlightEle==null){ return ; } //获取源对象中的嵌套字段名称 JsonObject nestedObj= nestedEle.getAsJsonObject(); String innerFieldName = nestedObj.get("field").getAsString(); //获取当前对象匹配的源对象中的偏移位置 int innerFieldOffset = nestedObj.get("offset").getAsInt(); //获取源对象 JsonObject findSourceObj = GsonUtils.getJsonObjectForArray(sourceObj,innerFieldName,innerFieldOffset); if(findSourceObj==null){ return ; } //替换高亮的部分 log.debug("高亮的部分:{}",highlightEle); JsonObject highlightObj = highlightEle.getAsJsonObject(); highlightObj.entrySet().forEach((h)->{ //合并高亮字段对应的原值 String highlightValue = h.getValue().getAsString(); JsonObject currentSourceObj = findSourceObj; String[] keyNames = StringUtils.split(h.getKey(),"."); //循环到倒数第二层,获取待替换字段值对象 for(int i=0;i<keyNames.length-2;i++){ String keyName = keyNames[i+1]; currentSourceObj = currentSourceObj.get(keyName).getAsJsonObject(); } //获取最后一层的字段名称 String lastFieldName = keyNames[keyNames.length-1]; //获取高亮字段对应的原值 String sourceValue = currentSourceObj.get(lastFieldName).getAsString(); //合并原值和高亮增强的值 String mergedValue = EsHighlightUtils.mergeStrWithEmTags(List.of(sourceValue, highlightValue)); //替换最后一层对象的指定字段的值 GsonUtils.replaceFieldValue(currentSourceObj, lastFieldName, mergedValue); }); log.debug("替换后的高亮的部分{}",findSourceObj); }
Copyright © 2003-2013 www.wpsshop.cn 版权所有,并保留所有权利。