springboot操作elasticsearch进行复杂搜索、地理位置查询
复杂搜索
最近在做一个搜索的项目,里面关于很多字段的搜索,一开始在网上查了一下,发现网上大部分都是一些简单搜索。正常情况我们通常都是很多字段的搜索,类似下图,其中省份又要精确到市级,县级。所以我分享一下我接触到的一些复杂搜索。
我的环境是 springboot2.6.13,elk集群是8.2。就一个依赖
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
看了一下依赖项,发现好像es只支持到7.15。经过我的测试,虽然我的es集群是8.2,但是也可以进行搜索,只是后面地理位置搜索的时候遇到了一个小问题。
项目配置文件
spring.elasticsearch.uris=http://xxxxx1:9200,http://xxxxx2:9200,http://xxxxx3:9200,http://xxxxx4:9200,http://xxxxx5:9200
spring.elasticsearch.username=xxxxxxx
spring.elasticsearch.password=xxxxxxx
es的索引结构,这里我就简化了搜索不需要的字段。我没有使用中文分词器,因为搜索的关键词不是一个词语的时候搜不出来。
{
"settings": {
"number_of_replicas": 1, // 一个副本
"number_of_shards": 5, // 5个分片
"index.store.type": "niofs",
"index.unassigned.node_left.delayed_timeout": "5m"
},
"mappings":{
"properties":{
"id":{
"type":"long",
"index": true
},
"company":{ // 公司名
"type":"text",
"index": true,
"fields":{
"keyword":{
"type":"keyword"
}
}
},
"intro":{ // 公司简介
"type":"text",
"index": true
},
"people":{ // 人名
"type":"text",
"index": true,
"fields":{
"keyword":{
"type":"keyword"
}
}
},
"addre":{ // 地址
"type":"text",
"index": true
},
"business_status":{ // 营业状态
"type":"keyword"
},
"province_id":{ // 省级编码
"type":"integer"
},
"province_name":{
"type":"keyword"
},
"city_id":{ // 市级编码
"type":"integer"
},
"city_name":{
"type":"keyword"
},
"county_id":{ // 县级编码
"type":"integer"
},
"county_name":{
"type":"keyword"
},
"register":{ // 注册金额
"type":"double"
},
"setup_time":{ // 成立时间
"type":"date",
"format":"yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
},
"is_mobile":{ // 是否有手机号 1有0无
"type": "keyword"
},
"is_else_mobile":{ // 是否有固话 1有0无
"type": "keyword"
},
"is_email":{ // 是否有邮箱 1有0无
"type": "keyword"
},
"location": { // 地理位置
"type": "geo_point"
},
"score_sort": { // 自定义排序规则,数据处理的时候给每条数据记录评分,给予数据权重
"type": "byte"
},
"update_time":{
"type":"date",
"format":"yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
},
"create_time":{
"type":"date",
"format":"yyyy-MM-dd HH:mm:ss||yyyy-MM-dd||epoch_millis"
}
}
}
}
- score_sort 这个字段,是我自定义排序规则的字段,很多时候单纯的ID倒序或者时间倒序满足不了复杂的排序需求。比如有些场景,svip的信息要放最前面,然后是vip的信息,然后是普通信息。又或者给了广告费之类的信息排前面。这时候就需要我们自定义排序规则,甚至定义多个用于排序的字段。
- 用于搜索的信息(非原始数据),通常都是通过数据处理,然后在通过logstash或者自己写的脚本或者别的方法把数据同步过去。在数据处理的时候根据需求自定义排序规则,定义排序字段。
- 这里我的规则的是(简化后的),搜索公司名的时候,我希望公司名里包含“有限公司”的排在前面,score_sort=3,然后是“公司”的score_sort=2,然后在是别的 店、商行之类的就score_sort=1。当然实际开发中这个排序规则要复杂很多,别的有重要意义的字段也要考虑进去排序规则中,具体根据需求来。
es索引实体类
import lombok.Data;
import lombok.AllArgsConstructor;
import lombok.NoArgsConstructor;
import org.elasticsearch.common.geo.GeoPoint;
import org.springframework.data.annotation.Id;
import org.springframework.stereotype.Component;
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
import java.util.HashMap;
@Component
@Data
@AllArgsConstructor
@NoArgsConstructor
@Document(indexName = "xxxxx_search")
public class CompanyInfoSearchEs {
@Id
private Integer id;
// @Field(type = FieldType.Text, analyzer = "ik_max_word", searchAnalyzer = "ik_smart")
@Field(type = FieldType.Text)
private String company;
@Field(type = FieldType.Text)
private String intro;
private String industry;
@Field(type = FieldType.Text)
private String people;
@Field(type = FieldType.Text)
private String addre;
private String business_status;
private Integer province_id;
private String province_name;
private Integer city_id;
private String city_name;
private Integer county_id;
private String county_name;
private Double register;
private String setup_time;
private String is_mobile;
private String is_else_mobile;
private String is_email;
private String img_path;
private Byte score_sort;
private GeoPoint location; // 地理位置经纬度信息
private String update_time;
private String create_time;
// 后面处理搜索结果的时候用到
public HashMap<String, Object> toHashMap() {
HashMap<String, Object> map = new HashMap<>();
map.put("id", id);
map.put("company", company);
map.put("intro", intro);
map.put("people", people);
map.put("addre", addre);
map.put("businessStatus", business_status);
map.put("register", register);
map.put("setupTime", setup_time);
map.put("isMobile", is_mobile);
map.put("isElseMobile", is_else_mobile);
map.put("isEmail", is_email);
map.put("imgPath", img_path);
map.put("location", location);
return map;
}
}
搜索接口
import com.xxxxx.dto.CompanyInfoDTO;
import com.xxxxx.service.impl.CompanyInfoSearchEsImp;
import com.xxxxx.utils.RetResponse;
import org.springframework.validation.BindingResult;
import org.springframework.web.bind.annotation.*;
import lombok.extern.slf4j.Slf4j;
import java.util.*;
import javax.annotation.Resource;
import javax.servlet.http.HttpServletRequest;
import javax.validation.Valid;
@Slf4j
@RestController
@RequestMapping(value = "/searchCompany")
public class SearchCompany {
@Resource
private CompanyInfoSearchEsImp companyInfoSearchEsImp;
/**
* 企业信息搜索
*/
@PostMapping(value = "/search")
public RetResponse<HashMap<String, Object>> searchPc(@RequestBody @Valid CompanyInfoDTO requestMap, BindingResult result) {
System.out.println("SearchCompanyInfo: " + requestMap.toString());
if (result.hasErrors()) {
log.warn(Objects.requireNonNull(result.getFieldError()).getDefaultMessage());
return RetResponse.newError(202, result.getFieldError().getDefaultMessage());
}
HashMap<String, Object> retCompanyInfo = companyInfoSearchEsImp.searchCompanyInfo(requestMap);
// 当精准搜索不到数据的时候切换模糊搜索在搜一次
if (Integer.parseInt(retCompanyInfo.get("total").toString()) == 0) {
requestMap.setPrecision(0);
retCompanyInfo = companyInfoSearchEsImp.searchCompanyInfo(requestMap);
}
return RetResponse.newSuccess(retCompanyInfo);
}
}
下面的调搜索接口时需要传的json参数,
{
"searchType": 0, // 搜索类型 0:全部,1:企业搜索,2:法人搜索,3:公司地址搜索 默认传 0
"precision": 1, // 搜索类型 1:精准搜索,0:模糊搜索 默认传 1
"keyword": "电脑", // 关键词 --默认不传
"province": [], // 省级编码 --默认不传
"city": [], // 市级编码 --默认不传
"county": [], // 县级编码 --默认不传
"setupTimeUp": "2021-06-11", // 开始时间 格式 “2024-06-11” ---默认不传 开始时间和结束时间要么都不传,要么都传
"setupTimeDown": "2024-09-01", // 结束时间 格式 “2024-06-11” ---默认不传
"amountUp": 1, // 注册金额 单位 万 开始 ---默认不传 开始和结束要么都不传,要么都传
"amountDown": 10, // 注册金额 单位 万 结束 ---默认不传
"status": "开业", // 开业 | 注销 | 吊销 | 迁出 | 停业 | 撤销 | 解散 | 个体转企业 | 其他 ---默认不传
"isMobile": "1", // 是否有手机号:1是,0否 ---默认不传
"isElseMobile": "0", // 是否有固话:1是 0否 ---默认不传
"isEmail": "0", // 是否有邮箱:1是 0否 ---默认不传
"page": 1 // 页数,一页50条数据,默认传 1
// geo_distance查询要加这3个字段
// "lon": 116.578756, // 经度 小数点控制在后6位数
// "lat": 39.902787, // 纬度 小数点控制在后6位数
// "distance": 10, // 距离 单位km 最大 30 km
// geo_bounding_bo查询要加这4个字段
// "upLon": 116.41088, // 左上角经度 小数点控制在后6位数
// "upLat": 39.979349, // 左上角纬度 小数点控制在后6位数
// "downLon": 116.578756, // 右下角经度 小数点控制在后6位数
// "downLat": 39.902787, // 右下角纬度 小数点控制在后6位数
}
数据校验,校验调用者传过来的搜索参数
import lombok.Data;
import org.hibernate.validator.constraints.Range;
import javax.validation.constraints.NotBlank;
import javax.validation.constraints.NotNull;
import javax.validation.constraints.Pattern;
import java.util.List;
@Data
public class CompanyInfoDTO {
@NotNull(message = "SearchType不能为空")
@Range(min = 0, max = 3, message = "SearchType只能为0/1/2/3")
private Integer searchType;
@NotNull(message = "precision不能为空")
@Range(min = 0, max = 1, message = "precision只能为0/1")
private Integer precision;
private String keyword;
private List<Integer> province;
private List<Integer> city;
private List<Integer> county;
@Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "time type error")
private String setupTimeUp;
@Pattern(regexp = "\\d{4}-\\d{2}-\\d{2}", message = "time1 type error")
private String setupTimeDown;
@Range(min = 0, max = 100000000, message = "amount type error")
private double amountUp;
@Range(min = 0, max = 100000000, message = "amount type error")
private double amountDown;
@Pattern(regexp = "(开业|注销|吊销|迁出|停业|撤销|解散|个体转企业|其他)", message = "status error")
private String status;
@Pattern(regexp = "[01]", message = "isMobile error")
private String isMobile;
@Pattern(regexp = "[01]", message = "isElseMobile error")
private String isElseMobile;
@Pattern(regexp = "[01]", message = "isEmail error")
private String isEmail;
@Range(min = 0, max = 200, message = "page不能超过200")
private Integer page;
// 下面这几个字段是地理搜索用到的
// geo_distance 用
private Double lon; // 经度
private Double lat; // 纬度
private Integer distance; // 方圆多少距离 km
// geo_bounding_box 用
private Double upLon; // 左上角经度
private Double upLat; // 左上角纬度
private Double downLon; // 右下角经度
private Double downLat; // 右下角纬度
}
mapper
package com.xxxx.mapper;
import com.meilianpc.entity.CompanyInfoSearchEs;
import org.springframework.data.elasticsearch.repository.ElasticsearchRepository;
public interface CompanyInfoSearchEsRep extends ElasticsearchRepository<CompanyInfoSearchEs, String> {
}
service
package com.xxxxx.service;
import com.xxxxx.dto.CompanyInfoDTO;
import java.util.HashMap;
public interface CompanyInfoSearchEsSer{
HashMap<String, Object> searchCompanyInfo(CompanyInfoDTO requestMap);
}
impl 业务核心代码,要完成复杂的搜索关键就在于拆分和组装搜索语句。
package com.xxxxx.service.impl;
import lombok.extern.slf4j.Slf4j;
import org.elasticsearch.common.geo.GeoDistance;
import org.elasticsearch.common.geo.GeoPoint;
import org.elasticsearch.common.unit.DistanceUnit;
import org.elasticsearch.index.query.*;
import org.elasticsearch.search.fetch.subphase.highlight.HighlightBuilder;
import org.elasticsearch.search.sort.FieldSortBuilder;
import org.elasticsearch.search.sort.GeoDistanceSortBuilder;
import org.elasticsearch.search.sort.SortOrder;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.elasticsearch.core.ElasticsearchRestTemplate;
import org.springframework.data.elasticsearch.core.SearchHit;
import org.springframework.data.elasticsearch.core.SearchHits;
import org.springframework.data.elasticsearch.core.query.FetchSourceFilter;
import org.springframework.data.elasticsearch.core.query.NativeSearchQuery;
import org.springframework.data.elasticsearch.core.query.NativeSearchQueryBuilder;
import org.springframework.data.elasticsearch.core.query.SourceFilter;
import org.springframework.stereotype.Service;
import com.xxxxx.entity.CompanyInfoSearchEs;
import com.xxxxx.converter.CompanyInfoCon;
import com.xxxxx.dto.CompanyInfoDTO;
import com.xxxxx.service.CompanyInfoSearchEsSer;
import javax.annotation.Resource;
import java.util.*;
@Slf4j
@Service
public class CompanyInfoSearchEsImp implements CompanyInfoSearchEsSer {
@Resource
private ElasticsearchRestTemplate elasticsearchRestTemplate;
@Override
public HashMap<String, Object> searchCompanyInfo(CompanyInfoDTO requestMap) {
// 开始构建查询条件
BoolQueryBuilder boolQueryBuilder = QueryBuilders.boolQuery();
// 如果有搜索关键词
if (requestMap.getKeyword() != null && !requestMap.getKeyword().isEmpty()) {
// 去除特殊符号
requestMap.setKeyword(requestMap.getKeyword().replaceAll("[()()#$%^&*_+{}\":;|,.<>'/?~!@¥…—【】、;‘’“”:,。《》?「」『』〔〕\\-=\\]\\[]", ""));
// searchType = 0 搜索全部字段 这里我搜索了 company, addre, people。可以更具实际情况搜索更多字段
// field("company", 3) 3是优先级
if (requestMap.getSearchType() == 0) {
if (requestMap.getPrecision() == 0) { // precision=0 模糊搜索
QueryStringQueryBuilder queryStringQueryBuilder = QueryBuilders.queryStringQuery(requestMap.getKeyword())
.defaultOperator(Operator.AND)
.field("company", 3).field("addre").field("people").escape(true);
boolQueryBuilder.must(queryStringQueryBuilder);
} else { // 精确搜索 在于 type=phrase
QueryStringQueryBuilder queryStringQueryBuilder = QueryBuilders.queryStringQuery(requestMap.getKeyword())
.defaultOperator(Operator.AND)
.type(MultiMatchQueryBuilder.Type.PHRASE)
.field("company", 3).field("addre").field("people").escape(true);
boolQueryBuilder.must(queryStringQueryBuilder);
}
} else {
// 搜索指定字段
String searchZd = "company";
if (requestMap.getSearchType() == 2) {
searchZd = "people";
} else if (requestMap.getSearchType() == 3) {
searchZd = "addre";
}
if (requestMap.getPrecision() == 0) { // precision=0 模糊搜索
QueryStringQueryBuilder queryStringQueryBuilder = QueryBuilders.queryStringQuery(requestMap.getKeyword())
.defaultOperator(Operator.AND)
.field(searchZd).escape(true);
boolQueryBuilder.must(queryStringQueryBuilder);
} else {
// 精确搜索
QueryStringQueryBuilder queryStringQueryBuilder = QueryBuilders.queryStringQuery(requestMap.getKeyword())
.defaultOperator(Operator.AND)
.type(MultiMatchQueryBuilder.Type.PHRASE)
.field(searchZd).escape(true);
boolQueryBuilder.must(queryStringQueryBuilder);
}
}
} else {
// 如果没有搜索关键词,则搜索全部
MatchAllQueryBuilder matchAllQueryBuilder = QueryBuilders.matchAllQuery();
boolQueryBuilder.must(matchAllQueryBuilder);
}
// 省级编码
if ((requestMap.getProvince() != null && !requestMap.getProvince().isEmpty())) {
TermsQueryBuilder termsProvince = QueryBuilders.termsQuery("province_id", requestMap.getProvince());
boolQueryBuilder.must(termsProvince);
}
// 市级编码
if (requestMap.getCity() != null && !requestMap.getCity().isEmpty()) {
TermsQueryBuilder termsCity = QueryBuilders.termsQuery("city_id", requestMap.getCity());
boolQueryBuilder.must(termsCity);
}
// 县级编码
if (requestMap.getCounty() != null && !requestMap.getCounty().isEmpty()) {
TermsQueryBuilder termsCounty = QueryBuilders.termsQuery("county_id", requestMap.getCounty());
boolQueryBuilder.must(termsCounty);
}
// 营业状态
if (requestMap.getStatus() != null && !requestMap.getStatus().isEmpty()) {
MatchPhraseQueryBuilder matchPhraseStatus = QueryBuilders.matchPhraseQuery("business_status", requestMap.getStatus());
boolQueryBuilder.must(matchPhraseStatus);
}
// 手机号
if (requestMap.getIsMobile() != null) {
MatchPhraseQueryBuilder matchPhraseMobile = QueryBuilders.matchPhraseQuery("is_mobile", requestMap.getIsMobile());
boolQueryBuilder.must(matchPhraseMobile);
}
// 其他号码
if (requestMap.getIsElseMobile() != null) {
MatchPhraseQueryBuilder matchPhraseElseMobile = QueryBuilders.matchPhraseQuery("is_else_mobile", requestMap.getIsElseMobile());
boolQueryBuilder.must(matchPhraseElseMobile);
}
// 邮箱
if (requestMap.getIsEmail() != null) {
MatchPhraseQueryBuilder matchPhraseEmail = QueryBuilders.matchPhraseQuery("is_email", requestMap.getIsEmail());
boolQueryBuilder.must(matchPhraseEmail);
}
// 成立时间
if (requestMap.getSetupTimeUp() != null && !requestMap.getSetupTimeUp().isEmpty() && requestMap.getSetupTimeDown() != null
&& !requestMap.getSetupTimeDown().isEmpty()) {
RangeQueryBuilder rangeTime = QueryBuilders.rangeQuery("setup_time").from(requestMap.getSetupTimeUp()).to(requestMap.getSetupTimeDown());
boolQueryBuilder.filter(rangeTime);
}
// 注册金额
if (requestMap.getAmountUp() >= 0 && requestMap.getAmountDown() > 1) {
RangeQueryBuilder rangeQueryBuilder = QueryBuilders.rangeQuery("register").from(requestMap.getAmountUp()).to(requestMap.getAmountDown());
boolQueryBuilder.filter(rangeQueryBuilder);
}
// 页数
int queryPage = requestMap.getPage();
if (queryPage == 0) {
requestMap.setPage(1);
}
// 对应es里的 from 和 size
PageRequest pageRequest = PageRequest.of(queryPage - 1, 50);
// 显示的字段,对应es _source
String[] includes = {"id", "company", "intro", "industry", "people", "addre", "business_status", "province_id",
"province_name", "city_id", "city_name", "county_id", "county_name", "register", "setup_time", "is_mobile",
"is_else_mobile", "is_email", "img_path", "score_sort", "location"};
SourceFilter sourceFilter = new FetchSourceFilter(includes, null);
FieldSortBuilder twoSort;
// 如果有搜索关键词,则根据搜索关键词的搜索得分排序
if (requestMap.getKeyword() != null && !requestMap.getKeyword().isEmpty()) {
twoSort = new FieldSortBuilder("_score").order(SortOrder.DESC);
} else {
// 如果没有搜索关键词,则根据更新时间排序
twoSort = new FieldSortBuilder("update_time").order(SortOrder.DESC);
}
// 如果需要使用es的高亮
// 自定义高亮html
// HighlightBuilder highlightBuilder = new HighlightBuilder().preTags("<span style='color:red;'>").postTags("</span>");
// 高亮字段
// HighlightBuilder.Field highlightCompany = new HighlightBuilder.Field("company");
// HighlightBuilder.Field highlightAddress = new HighlightBuilder.Field("address").fragmentSize(1);
// HighlightBuilder.Field highlightIntro = new HighlightBuilder.Field("intro").fragmentSize(1);
// 构建查询
NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
.withQuery(boolQueryBuilder)
.withSourceFilter(sourceFilter)
// 排序,这里我先自定义排序字段排序,然后再根据第二个排序规则排序
.withSorts(new FieldSortBuilder("score_sort").order(SortOrder.DESC), twoSort)
// 如果使用es了的高亮
// .withHighlightFields(highlightCompany, highlightAddress, highlightIntro) // 高亮字段
// .withHighlightBuilder(highlightBuilder) // 自定义高亮html
.withPageable(pageRequest)
.build();
// 执行查询
SearchHits<CompanyInfoSearchEs> searchHits = elasticsearchRestTemplate.search(searchQuery, CompanyInfoSearchEs.class);
List<HashMap<String, Object>> resultList = new ArrayList<>();
// 处理搜索结果
for (SearchHit<CompanyInfoSearchEs> searchHit : searchHits) {
HashMap<String, Object> companyInfo = searchHit.getContent().toHashMap(); // 实体类写的toHashMap方法,转换成HashMap
// 我没有使用es的高亮,自己处理高亮
if (requestMap.getKeyword() != null && !requestMap.getKeyword().isEmpty()) {
if (companyInfo.get("company") != null) {
companyInfo.replace("company", CompanyInfoCon.dispHighlight(companyInfo.get("company").toString(), requestMap.getKeyword()));
}
if (companyInfo.get("intro") != null) {
companyInfo.replace("intro", CompanyInfoCon.dispHighlight(companyInfo.get("intro").toString(), requestMap.getKeyword()));
}
if (companyInfo.get("people") != null) {
companyInfo.replace("people", CompanyInfoCon.dispHighlight(companyInfo.get("people").toString(), requestMap.getKeyword()));
}
if (companyInfo.get("addre") != null) {
companyInfo.replace("addre", CompanyInfoCon.dispHighlight(companyInfo.get("addre").toString(), requestMap.getKeyword()));
}
}
// 处理经纬度
if (searchHit.getContent().getLocation() != null) {
companyInfo.put("location", new HashMap<String, Double>() {{
put("lat", searchHit.getContent().getLocation().getLat());
put("lon", searchHit.getContent().getLocation().getLon());
}});
}
resultList.add(companyInfo);
}
// 构建返回结果
HashMap<String, Object> result = new HashMap<>();
result.put("data", resultList);
result.put("total", searchHits.getTotalHits());
return result;
}
}
- .escape(true) 表示对查询字符串中的特殊字符进行转义。这样可以避免这些特殊字符被误解析为查询语法的一部分,从而确保查询的准确性。例如,如果你想搜索包含"+“或”-"等符号的文本,如果不使用escape: true,这些符号可能会被解释为查询操作符(如AND、OR等)。而使用escape: true后,这些符号将被当作普通字符进行搜索。
上面一套下来其实执行了es的这个语句,要看es执行的语句,在项目配置文件中加上 logging.level.tracer=TRACE
{
"from": 0,
"size": 50,
"query": {
"bool": {
"must": [
{
"query_string": {
"query": "电脑",
"fields": [
"addre^1.0",
"company^3.0",
"people^1.0"
],
"type": "phrase",
"default_operator": "and",
"escape": true
}
},
{
"terms": {
"province_id": [
440000,
450000
]
}
},
{
"terms": {
"city_id": [
440100
]
}
},
{
"terms": {
"county_id": [
440106
]
}
},
{
"match_phrase": {
"business_status": {
"query": "开业"
}
}
},
{
"match_phrase": {
"is_mobile": {
"query": "1"
}
}
},
{
"match_phrase": {
"is_else_mobile": {
"query": "0"
}
}
},
{
"match_phrase": {
"is_email": {
"query": "0"
}
}
}
],
"filter": [
{
"range": {
"setup_time": {
"from": "2021-06-11",
"to": "2024-09-01",
"include_lower": true,
"include_upper": true,
"boost": 1
}
}
},
{
"range": {
"register": {
"from": 1,
"to": 10,
"include_lower": true,
"include_upper": true,
"boost": 1
}
}
}
],
"adjust_pure_negative": true,
"boost": 1
}
},
"version": true,
"explain": false,
"_source": {
"includes": [
"id",
"company",
"intro",
"industry",
"people",
"addre",
"business_status",
"province_id",
"province_name",
"city_id",
"city_name",
"county_id",
"county_name",
"register",
"setup_time",
"is_mobile",
"is_else_mobile",
"is_email",
"img_path",
"score_sort",
"location"
],
"excludes": []
},
"sort": [
{
"score_sort": {
"order": "desc"
}
},
{
"_score": {
"order": "desc"
}
}
]
}
自定义高亮处理类
import org.springframework.stereotype.Component;
import java.util.*;
import java.util.regex.Pattern;
import java.util.regex.Matcher;
@Component
public class CompanyInfoCon {
// 处理高亮
public static String dispHighlight(String higKeyword, String keyword) {
if (keyword == null || keyword.isEmpty() || higKeyword == null || higKeyword.isEmpty()) {
return higKeyword;
}
char[] keywordArr = keyword.toCharArray();
StringJoiner charString = new StringJoiner("|");
for (char c : keywordArr) {
charString.add(String.valueOf(c));
}
Pattern compile = Pattern.compile(charString.toString());
Matcher matcher = compile.matcher(higKeyword);
String retHigKeyword = matcher.replaceAll(matchResult -> "<span style='color:red;'>" + matchResult.group() + "</span>");
retHigKeyword = retHigKeyword.replaceAll("</span><span style='color:red;'>", "");
return retHigKeyword;
}
}
自定义处理高亮:比如我的关键词是 “电脑”,处理出来后会变成。
<span style='color:red;'>电</span><span style='color:red;'>脑</span>
如果用es处理高亮出来也要这样处理
所以有了最后一行代码
retHigKeyword = retHigKeyword.replaceAll("</span><span style='color:red;'>", "");
之所以自己处理高亮处理成单个字高亮在合并高亮,是因为如果模糊搜索,搜索电脑,可能出来的结果就是“电xxxx脑” 或者 “脑xxxx电”。
用整词处理就会有问题。
复杂搜索+地理位置
地理位置搜索我用到了 geo_distance
和 geo_bounding_box
官网地址
大白话
geo_distance: 在地图上点一个点,然后方圆多少距离内的信息
geo_bounding_box: 在地图上框一个四边形,这个矩形内的信息
结合 geo_distance
查询
调接口时多传几个json参数
geo_distance 查询
...
...
// 在 上面 impl 业务核心代码文件中, 构建查询前 加入以下代码
if (requestMap.getDistance() > 30) { // 以中心点扩散多少距离,这里我限制了最多30km
requestMap.setDistance(30);
}
boolQueryBuilder.filter(
new GeoDistanceQueryBuilder("location")
.point(requestMap.getLat(), requestMap.getLon()) // 经纬度 注意经纬度前后顺序
.distance(requestMap.getDistance(), // 方圆多少距离
DistanceUnit.KILOMETERS) // 单位 km
.geoDistance(GeoDistance.ARC) // 模式 arc 更精准,速度慢点。 plane 更快,精准略差,越靠近地球两级精准度越差,越靠近赤道精准度越高
);
// 以距离坐标中心点距离排序,单位米。到时候看查询结果sort字段就可以知道距离中心点多远
GeoDistanceSortBuilder locationSort = new GeoDistanceSortBuilder(
"location", requestMap.getLat(), requestMap.getLon()
).order(SortOrder.ASC).unit(DistanceUnit.METERS).geoDistance(GeoDistance.ARC);
// 构建查询
NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
.withQuery(boolQueryBuilder)
.withSourceFilter(sourceFilter)
// 先自定义排序字段排序,然后再根据第二个排序规则排序
.withSorts(new FieldSortBuilder("score_sort").order(SortOrder.DESC), locationSort)
// 如果使用es了的高亮
// .withHighlightFields(highlightCompany, highlightAddress, highlightIntro) // 高亮字段
// .withHighlightBuilder(highlightBuilder) // 自定义高亮html
.withPageable(pageRequest)
.build();
// 执行查询
SearchHits<CompanyInfoSearchEs> searchHits = elasticsearchRestTemplate.search(searchQuery, CompanyInfoSearchEs.class);
// 处理查询结果
...
...
地理位置查询就是在上面的那些查询中多加一个filter
结合 geo_bounding_box
查询
要多传几个参数,通常精确到小数点后6完全够用了
和上面结合geo_distance一样,多加一个filter。
...
...
// 在 上面 impl 业务核心代码文件中, 构建查询前 加入以下代码
GeoBoundingBoxQueryBuilder locationBox = QueryBuilders.geoBoundingBoxQuery("location")
.setCorners( // 注意经纬度顺序
new GeoPoint(requestMap.getUpLat(), requestMap.getUpLon()),
new GeoPoint(requestMap.getDownLat(), requestMap.getDownLon())
);
boolQueryBuilder.filter(locationBox);
FieldSortBuilder twoSort;
// 如果有搜索关键词,则根据搜索关键词的搜索得分排序
if (requestMap.getKeyword() != null && !requestMap.getKeyword().isEmpty()) {
twoSort = new FieldSortBuilder("_score").order(SortOrder.DESC);
} else {
// 如果没有搜索关键词,则根据更新时间排序
twoSort = new FieldSortBuilder("update_time").order(SortOrder.DESC);
}
// 构建查询
NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
.withQuery(boolQueryBuilder)
.withSourceFilter(sourceFilter)
// 先自定义排序字段排序,然后再根据第二个排序规则排序
.withSorts(new FieldSortBuilder("score_sort").order(SortOrder.DESC), twoSort)
// 如果使用es了的高亮
// .withHighlightFields(highlightCompany, highlightAddress, highlightIntro) // 高亮字段
// .withHighlightBuilder(highlightBuilder) // 自定义高亮html
.withPageable(pageRequest)
.build();
// 执行查询
SearchHits<CompanyInfoSearchEs> searchHits = elasticsearchRestTemplate.search(searchQuery, CompanyInfoSearchEs.class);
// 处理查询结果
...
...
问题
我原以为会顺顺利利,结果一查询,报错了 Elasticsearch exception [type=parsing_exception, reason=failed to parse [geo_bounding_box] query. unexpected field [type]]
Servlet.service() for servlet [dispatcherServlet] in context with path [/apipro] threw exception [Request processing failed; nested exception is RestStatusException{status=400}
org.springframework.data.elasticsearch.RestStatusException: Elasticsearch exception [type=x_content_parse_exception, reason=[1:598] [bool] failed to parse field [filter]]; nested exception is ElasticsearchStatusException[Elasticsearch exception [type=x_content_parse_exception, reason=[1:598] [bool] failed to parse field [filter]]]; nested: ElasticsearchException[Elasticsearch exception [type=parsing_exception, reason=failed to parse [geo_bounding_box] query. unexpected field [type]]];] with root cause
org.elasticsearch.ElasticsearchException: Elasticsearch exception [type=parsing_exception, reason=failed to parse [geo_bounding_box] query. unexpected field [type]]
原来是type这个字段在es7.14版本的时候就开始废弃了。但是我明明没有设置type,点击QueryBuilders.geoBoundingBoxQuery("location")
进去发现不设置type他会给我默认设置一个值。
网上查了很多质料和问AI之类的都无果,都说版本不兼容之类的。升级到springboot3.x,但那样项目大部分地方都要重写很是费时费力。
研究了一下发现不管怎么样他都会带上这个type字段。在kibana上发现只要把type字段设置为null就可以顺利执行语句。而看了QueryBuilders.geoBoundingBoxQuery源码又发现他根本不接受设置为null。
解决办法
我的解决办法就是 取代 GeoBoundingBoxQueryBuilder
类,他不支持null就取代他支持null
他继承了 AbstractQueryBuilder 。我们也写个类继承他。把GeoBoundingBoxQueryBuilder
里面的所有东西
复制过来,类名改个名字MyGeoBoundingBoxQueryBuilder
。然后把这个类里所有返回 GeoBoundingBoxQueryBuilder
的改为 返回MyGeoBoundingBoxQueryBuilder
并把private GeoExecType type = DEFAULT_TYPE;
改为 = null;
点击上面写好的 QueryBuilders.geoBoundingBoxQuery("location") 中的 geoBoundingBoxQuery
会点到这里 QueryBuilders 文件里的 geoBoundingBoxQuery 方法
public static GeoBoundingBoxQueryBuilder geoBoundingBoxQuery(String name) {
return new GeoBoundingBoxQueryBuilder(name);
}
把这个方法复制到我们写的 MyGeoBoundingBoxQueryBuilder 类里
改成
public static MyGeoBoundingBoxQueryBuilder geoBoundingBoxQuery(String name) {
return new MyGeoBoundingBoxQueryBuilder(name);
}
// 在 上面 impl 业务核心代码文件中, 构建查询前 加入以下代码
GeoBoundingBoxQueryBuilder locationBox = QueryBuilders.geoBoundingBoxQuery("location")
.setCorners( // 注意经纬度顺序
new GeoPoint(requestMap.getUpLat(), requestMap.getUpLon()),
new GeoPoint(requestMap.getDownLat(), requestMap.getDownLon())
);
boolQueryBuilder.filter(locationBox);
把上面这段改为
MyGeoBoundingBoxQueryBuilder locationBox = MyGeoBoundingBoxQueryBuilder.geoBoundingBoxQuery("location")
.setCorners(
new GeoPoint(requestMap.getUpLat(), requestMap.getUpLon()),
new GeoPoint(requestMap.getDownLat(), requestMap.getDownLon())
);
locationBox.type();
boolQueryBuilder.filter(locationBox);
在调接口就可以查询成功了,通过打印出来的日志可以看到type设置为了null
好了,以上就是我的分享。如果哪里有错误或者你有更好的方法欢迎评论区指正和讨论。