Springboot非侵入性实现全文检索(兼容PG与Oracle)
前言:我把需求分析、实现分析、实现过程都记录下来了,需要改进的地方,敬请不吝赐教。
需求:
迎新系统需要实现一个聊天机器人的功能,机器人根据用户发送的信息(搜索内容)在数据库内搜索对应的应答内容进行回复(响应)。
需求与实现分析:
1、聊天机器人本质上就是根据关键词在数据表内进行搜索
2、在数据表内要怎样进行搜索呢?用Like关键字进行搜索?行不通!如:
用户的搜索内容为:学校的电话号码是多少?
搜索的Where语句为 ... WHERE question LIKE "%学校的电话号码是多少?%"
在问题数据表内有以下数据
question | answer |
---|---|
学校固定电话 | 0123-88888888 |
学校官网 | www.123.com |
显然是搜索不到任何数据的。
3、用户是不会根据问题库里面的问题来提问的。那么,要怎样才能搜索到这条数据呢?
最简单粗暴的方法就是将用户搜索的内容拆分为一个一个字符,然后每个字符都用LIKE子句来匹配。
这样确实能达到预期,但是太粗暴了,不够科学,性能消耗大。
这时候就可以使用全文检索技术了。
4、什么是全文检索?
笼统来说全文检索就是将搜索内容进行分词,然后根据分词结果进行搜索匹配。
5、要怎样才能进行全文检索?
一般需要数据库支持全文检索功能的话,需要为数据库安装插件,然后在SQL语句中执行配套的全文检索函数。
这样做侵入性太大了,而且每次部署项目都要为数据库安装插件,还要为不同的数据库写兼容的SQL,太麻烦了。
6、结合需求进行分析,问题数据表的数据量一般来说不会很大,可以考虑使用程序的方法非侵入性实现全文检索功能。
7、程序要实现全文检索功能,第一步就是对用户的搜索内容进行分词。
分词需要根据自然语义进行分词,而不是把内容拆分为一个一个字符这样的暴力分词,这时候就需要分词器的支持了。
根据开源协议、依赖安全性、分词效果等方面从11大Java开源中文分词器中筛选出了最适合当前需求的分词器“Jcseg”
8、分词分好了,那要怎样根据分词结果在数据表中进行搜索呢?
使用LIKE关键字搜索效率太低了,建议使用position(PG)、instr(Oracle)
9、内容也搜索出来了,但结果没有达到预期
因为分词结果为["学校","电话","号码","是","多少","?"]
,而两条记录都包含“学校”,所以都被查出来了。
但是我们只需要“学校固定电话”这一条数据,那么要怎么让程序知道“学校固定电话”是相对最标准的问题数据呢?
这个就涉及到了文本的匹配度,需要将用户搜索的内容与搜索结果集的问题进行匹配度计算,并让结果集根据匹配度进行排序,最后取结果集第一项回复即可。
question | answer |
---|---|
学校固定电话 | 0123-88888888 |
学校官网 | www.123.com |
10、文本匹配度要怎么计算呢?
这里基于编辑距离算法结合需求来计算文本的匹配度。
代码实现:
1、引入Jcseg中文分词
<!--中文分词器-->
<dependency>
<groupId>org.lionsoul</groupId>
<artifactId>jcseg-core</artifactId>
<version>2.6.2</version>
</dependency>
2、编写Jcseg的业务方法
/**
* Description: jcseg分词器业务类
*
* @Author: NonNullPointer
*/
@Service
public class JcsegService {
//创建SegmenterConfig分词配置实例,自动查找加载jcseg.properties配置项来初始化
private SegmenterConfig config = new SegmenterConfig(true);
//创建默认单例词库实现,并且按照config配置加载词库
private ADictionary dic = DictionaryFactory.createSingletonDictionary(config);
//依据给定的ADictionary和SegmenterConfig来创建ISegment
//NLP模式
private ISegment iSegment = ISegment.NLP.factory.create(config, dic);
public String[] seg(String str) {
HashSet<String> segHashSet = new HashSet<>();
try {
iSegment.reset(new StringReader(str));
//获取分词结果
IWord word = null;
//去重
while ((word = iSegment.next()) != null) {
String wordStr = word.getValue();
//去除空白符
wordStr = NNPStringUtil.clearWhite(wordStr);
//去除符号
wordStr = NNPStringUtil.removeSymbol(wordStr);
if(wordStr.length()>0){
segHashSet.add(wordStr);
}
}
} catch (IOException e) {
e.printStackTrace();
return null;
}
if (segHashSet.size() == 0) {
return null;
}
return NNPCollectionUtil.hashSet2Arr(segHashSet);
}
}
3、编写适配并生成全文检索SQL的工具方法
/**
* Description: 创建伪全文搜索的where条件
*
* @param: [words, field]
* @Author: NonNullPointer
* @return: java.lang.String
*/
public static String createWhereByFullText(String[] words, String field) {
//对大小写不敏感
final String pgTemp = "position('%s' in lower(%s))>0";
final String oracleTemp = "instr(lower(%s),'%s')>0";
StringBuilder whereBuilder = new StringBuilder();
if (DbUtil.dbIsOracle()) {
for (int i = 0; i < words.length; i++) {
String word = words[i];
if (i > 0) {
whereBuilder.append(" OR ");
}
whereBuilder.append(String.format(oracleTemp, field, word));
}
} else {
for (int i = 0; i < words.length; i++) {
String word = words[i];
if (i > 0) {
whereBuilder.append(" OR ");
}
whereBuilder.append(String.format(pgTemp, word, field));
}
}
return "(" + whereBuilder.toString() + ")";
}
4、编写文本匹配度计算算法工具类
/**
* Description: 计算文本相似度工具类(基于字符串编辑距离)
*
* @Cover: NonNullPointer
*/
public class NNPCalcLDSimUtil {
//计算文本相似度
public static double calcSim(String str1, String str2) {
//去除符号与空白符,忽略大小写
str1 = NNPStringUtil.clearWhite(NNPStringUtil.removeSymbol(str1)).toLowerCase();
str2 = NNPStringUtil.clearWhite(NNPStringUtil.removeSymbol(str2)).toLowerCase();
//全等返回1.1最高值
if(str1.equalsIgnoreCase(str2)){
return 1.1;
}
//相互包含直接返回1(100%)
if (str1.indexOf(str2) > -1 || str2.indexOf(str1) > -1) {
return 1;
}
int ld = getLD(str1, str2);
return 1 - (double) ld / Math.max(str1.length(), str2.length());
}
private static int min(int one, int two, int three) {
int min = one;
if (two < min) {
min = two;
}
if (three < min) {
min = three;
}
return min;
}
//获取字符串编辑距离
public static int getLD(String str1, String str2) {
int d[][]; // 矩阵
int n = str1.length();
int m = str2.length();
int i; // 遍历str1的
int j; // 遍历str2的
char ch1; // str1的
char ch2; // str2的
int temp; // 记录相同字符,在某个矩阵位置值的增量,不是0就是1
if (n == 0) {
return m;
}
if (m == 0) {
return n;
}
d = new int[n + 1][m + 1];
for (i = 0; i <= n; i++) { // 初始化第一列
d[i][0] = i;
}
for (j = 0; j <= m; j++) { // 初始化第一行
d[0][j] = j;
}
for (i = 1; i <= n; i++) { // 遍历str1
ch1 = str1.charAt(i - 1);
// 去匹配str2
for (j = 1; j <= m; j++) {
ch2 = str2.charAt(j - 1);
if (ch1 == ch2) {
temp = 0;
} else {
temp = 1;
}
// 左边+1,上边+1, 左上角+temp取最小
d[i][j] = min(d[i - 1][j] + 1, d[i][j - 1] + 1, d[i - 1][j - 1] + temp);
}
}
return d[n][m];
}
}
文章不足之处还请斧正!
本文By:NonNullPointer --2024/01/08