几日前接到一个需求,要对系统现有知识库中的一个模块添加全文检索功能,类似于语雀,要引入ElasticSearch:
由于整个系统是基于响应式webFlux的,所以在这里记录一下。
1. 目前的数据库表
文档实体类如下:
java
@Getter
@Setter
@Entity
@Table(name = "qa_files")
public class QAFile extends GenericEntity<String> {
@Column
private String questionId;
@Column
private String questionTitle;
@Column
private String questionDetail;
@Column
private String uploaderId;
@Column
private Date uploadTime;
@Column
private String content;
@Column
@JsonIgnore
private boolean delete;
@Column
private String editorId;
@Column
private Date updateTime;
@Column
private String typeId; // 文档类型
@Column
private Integer version; // 文档版本
}
现在要对questionTitle (文档标题,纯文本格式)、questionDetail (文档描述,纯文本格式)、content(文档内容,富文本格式)这三个字段支持全文检索,并且可以使用多关键词(由空格分隔)检索。
2. 本地安装ElasticSearch、es-head插件、Kibana、ik分词器
具体链接可以看这里,说的蛮详细
SpringBoot整合Elasticsearch(最新最全,高效安装到使用)
3. 引入依赖并修改配置文件
配置文件中添加:
ini
spring.elasticsearch.rest.uris=127.0.0.1:9200
spring.elasticsearch.rest.connection-timeout=1s
spring.elasticsearch.rest.read-timeout=30s
pom.xml:
xml
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>2.4.1</version>
<relativePath />
</parent>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-webflux</artifactId>
</dependency>
<dependency>
<groupId>io.projectreactor</groupId>
<artifactId>reactor-core</artifactId>
<version>3.4.17</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-r2dbc</artifactId>
<version>2.3.0.RELEASE</version>
</dependency>
<!-- ElasticSearch -->
<dependency>
<groupId>org.elasticsearch</groupId>
<artifactId>elasticsearch</artifactId>
<version>7.9.3</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-elasticsearch</artifactId>
</dependency>
</dependencies>
这里我使用的springboot版本为2.4.x,对应ES版本为7.9.3,其他版本对应如下:
此外spring-boot-starter-data-elasticsearch对reactor-core版本也有要求,如果引入后有冲突可以自行去Maven central排查
4. 修改实体类
java
import org.springframework.data.elasticsearch.annotations.Document;
import org.springframework.data.elasticsearch.annotations.Field;
import org.springframework.data.elasticsearch.annotations.FieldType;
@Getter
@Setter
@Entity
@Table(name = "qa_files")
@Document(indexName = "qa_file_index")// 稍后通过Kibana创建索引
public class QAFile extends GenericEntity<String> {
@Column
private String questionId;
@Column
@Field(type = FieldType.Text, analyzer = "ik_max_word") //指定字段类型和解析器,使用ik_max_word最细粒度拆分
private String questionTitle;
@Column
@Field(type = FieldType.Text, analyzer = "ik_max_word")
private String questionDetail;
@Column
private String uploaderId;
@Column
private Date uploadTime;
@Column
@Field(type = FieldType.Text, analyzer = "ik_max_word")
private String content;
@Column
@JsonIgnore
private boolean delete;
@Column
private String editorId;
@Column
private Date updateTime;
@Column
private String typeId; // 文档类型
@Column
private Integer version; // 文档版本
}
5. 创建qa_file_index索引
打开本地Kibana页面左侧选择开发工具Dev Tools - Elastic 在控制台创建索引:
ElasticsearchJSON
PUT /qa_file_index
{
"settings": {
"analysis": {
"analyzer": {
"ik_analyzer": {
"type": "custom",
"tokenizer": "ik_max_word"
}
}
}
},
"mappings": {
"properties": {
"questionTitle": {
"type": "text",
"analyzer": "ik_analyzer",
"search_analyzer": "ik_smart"
},
"questionDetail": {
"type": "text",
"analyzer": "ik_analyzer",
"search_analyzer": "ik_smart"
},
"content": {
"type": "text",
"analyzer": "ik_analyzer",
"search_analyzer": "ik_smart"
}
}
}
}
返回如下内容即创建成功:
然后此时可以通过es-head查看索引以及其中的数据:
6. 将数据从表中同步至ES
首先创建一个Repository接口继承ReactiveCrudRepository
Java
import com.clinic.HeartLine.domain.knowledge.QAFile;
import org.springframework.data.repository.reactive.ReactiveCrudRepository;
import org.springframework.stereotype.Repository;
@Repository
public interface QAFileESRepository extends ReactiveCrudRepository<QAFile, String> {
}
如果是非响应式可以继承ElasticsearchRepository或ElasticsearchTemplate,里面封装了很多基础操作,可以通过该接口操作ElasticSearch中的数据。
接着写一个数据同步接口,将符合条件的文档从表中同步至ES(这里由于content是富文本格式,因此用HtmlUtils.cleanHtmlTag将html标签去除):
Java
@Override
@Transactional
public Mono<Boolean> syncDataToES() {
return this.createQuery()
.where("delete", false)
.fetch()
.collectList()
.publishOn(Schedulers.boundedElastic())
.flatMap(qaFiles -> {
qaFiles.forEach(qaFile -> qaFile.setContent(HtmlUtils.cleanHtmlTag(qaFile.getContent())));
return qaFileESRepository.saveAll(qaFiles)
.collectList()
.thenReturn(true);
});
}
7. 全文检索接口
接下来就可以写接口了,这里最终通过HighlightBuilder将原本文档的文本替换为包含检索高亮关键词的文本摘要,在构建BoolQueryBuilder时为每个字段分配了匹配权重,ES会自动根据匹配度对结果进行排序:
Java
@Override
@Transactional
public Mono<List<QAFile>> fullTextSearch(String query) {
String[] queryTerms = query.split("\s+"); //按空格分隔多个查询条件
BoolQueryBuilder boolQuery = QueryBuilders.boolQuery();
for (String term : queryTerms) {
// 对每个查询词,内部使用should来进行多个字段的OR查询
BoolQueryBuilder termQuery = QueryBuilders.boolQuery()
.should(QueryBuilders.matchQuery("questionTitle", term).boost(2.0 f))
.should(QueryBuilders.matchQuery("questionDetail", term))
.should(QueryBuilders.matchQuery("content", term).boost(1.5 f));
// 将每个查询词的查询添加到总的查询中,使用must连接不同查询词(AND查询)
boolQuery.must(termQuery);
}
HighlightBuilder highlightBuilder = new HighlightBuilder()
.field("questionTitle") // 高亮questionTitle字段
.field("questionDetail") // 高亮questionDetail字段
.field("content") // 高亮content字段
.preTags("<span style="color: red;">") // 设置高亮标签
.postTags("</span>")
.fragmentSize(50); // 设置每个片段的最大长度,单位字符
// 构建查询
NativeSearchQuery searchQuery = new NativeSearchQueryBuilder()
.withQuery(boolQuery)
.withPageable(PageRequest.of(0, 10)) //默认返回10条
.withHighlightBuilder(highlightBuilder)
.build();
SearchHits<QAFile> searchHits = elasticsearchRestTemplate.search(searchQuery, QAFile.class);
List<QAFile> results = searchHits.stream()
.map(searchHit -> {
QAFile qaFile = searchHit.getContent();
Map<String, List<String>> highlightFields = searchHit.getHighlightFields();
List<String> highlightedContent = highlightFields.get("content");
if (highlightedContent != null && !highlightedContent.isEmpty()) {
// 取第一个高亮部分(如果有多个高亮部分)
qaFile.setContent(highlightedContent.get(0));
}
return qaFile;
})
.collect(Collectors.toList());
return Flux.fromIterable(results)
.flatMapSequential(this::fillUserInfo)
.collectList();
}
最终实现结果如下:
除此接口外还要对文档增删改操作接口添加同步至ES的代码,只需要调用Repository接口中的方法即可,这里不再赘述。