拼写检查程序是指将输入的每个字词与存储器里的字典比较,检查其正确性并在屏幕上显示差异的计算机程序。如果在字典中没有这个单词,用户就会被警告可能拼写错误,同时经常提供几个纠正错误建议。拼写检查程序不能识别不常用的人或专用术语,但是它容许你生成自己的个人字典,把自己常用的词语加进去。
在使用搜索引擎时,当我们输入错误的关键词时,当然这里的错误是拼写错误,搜索引擎的下拉框中仍会显示以正确关键词为前前辍的提示,当你直接回车搜索错误的关键词时,搜索引擎的结果中仍包括正确关键词的结果。你有没有想过它是如何实现的呢?
这个的实现可以有多种方案,常见的方法就是我们前面学习过的最长公共子序列和今天要讲的莱文斯坦距离方式。
最长公共子序列可以查看我前面的博文程序员的算法课(6)-最长公共子序列(LCS)。
这两种方案都需要跟正确的词典进行对照,计算编辑距离或者最长公共子序列,将编辑距离最小或子序列最长的单词,作为纠正之后的单词,返回给用户。
【百度百科】莱文斯坦距离,又称Levenshtein距离,是编辑距离的一种。指两个字串之间,由一个转成另一个所需的最少编辑操作次数。允许的编辑操作包括将一个字符替换成另一个字符,插入一个字符,删除一个字符。
如单词three,我不小心手误打成了ethre,可以看到ethre只需要把首字母e移动到末尾即可变成正确的单词three。此时ethre相对于three的编辑距离就是1。
莱文斯坦距离允许增加、删除、替换字符这三个编辑操作,最长公共子串长度只允许增加、删除字符这两个编辑操作。
莱文斯坦距离和最长公共子串长度,从两个截然相反的角度,分析字符串的相似程度。莱文斯坦距离的大小,表示两个字符串差异的大小;而最长公共子串的大小,表示两个字符串相似程度的大小。
原理其实比较简单,无非就是通过算法进行计算得出一个最贴切的单词返回给用户,网上也有很多这样的案例讲解。既然是实战,那我们就分别来看看Java领域中鼎鼎大名的几个开源搜索框架对于拼写纠错是如何实现的吧。
源码中,定义了两个public的静态成员变量, DEFAULT_ACCURACY表示默认的最小分数,SpellCheck会对字典里的每个词与用户输入的搜索关键字进行一个相似度打分,默认该值是0.5,相似度分值范围是0到1之间,数字越大表示越相似,小于0.5会认为不是同一个结果。F_WORD是对于字典文件里每一行创建索引时使用的默认域名称,默认值为:word。
几个重要的API:
getAccuracy:
accuracy是精确度的意思,这里表示最小评分,评分越大表示与用户输入的关键字越相似看到了setStringDistance这个方法,想都不用想,Lucene肯定也是使用编辑距离的方式进行匹配的。
源码中还有两个私有属性,分别代表前缀和后缀的权重,前缀要比后缀大。
private float bStart = 2.0f;
private float bEnd = 1.0f;
几个重要的方法:
最终返回用户输入关键字和索引中当前Term的相似度,这个取决于你Distance实现,默认实现是LevenshteinDistance
(莱文斯坦距离)即计算编辑距离。采用三个一维数组代替了一个二维数组,一个数组为上一轮计算的值,一个数组保存本轮计算的值,最后一个数组用于交换两个数组的值。核心源码如下:
package org.apache.lucene.search.spell;
public class LevenshteinDistance implements StringDistance {
@Override
public float getDistance (String target, String other) {
char[] sa;
int n;
int p[]; //上一行计算的值
int d[]; //当前行计算的值
int _d[]; //用于交换p和d
sa = target.toCharArray();
n = sa.length;
p = new int[n+1];
d = new int[n+1];
final int m = other.length();
if (n == 0 || m == 0) {
if (n == m) {
return 1;
}
else {
return 0;
}
}
int i; // target的索引
int j; // other的索引
char t_j; //other的第j个字符
int cost;
//初始化,将空字符串转换为长度为i的target字符串的操作次数
for (i = 0; i<=n; i++) {
p[i] = i;
}
for (j = 1; j<=m; j++) {
t_j = other.charAt(j-1);
//左方的初始值为将长度为j的other字符串转换为空字符串的操作次数
d[0] = j;
//计算将长度为i的target字符串转换为长度为j的other字符串的操作次数
for (i=1; i<=n; i++) {
cost = sa[i-1]==t_j ? 0 : 1;
//d[i-1]左方、p[i]上方、p[i-1]左上
d[i] = Math.min(Math.min(d[i-1]+1, p[i]+1), p[i-1]+cost);
}
//交换p和d,用于下一轮计算
_d = p;
p = d;
d = _d;
}
//计算相似度,p中最后一个元素为LevensteinDistance
return 1.0f - ((float) p[n] / Math.max(other.length(), sa.length));
}
}
Lucene还内置了另外几种相似度实现,都是基于距离计算的:JaroWinklerDistance、LuceneLevenshteinDistance和NGramDistance。
源码中还有大量同步锁的使用,跟本次内容关系不大,暂时先不考虑。
solr的纠错依赖于lucene,主要通过插件的方式进行使用。solr通过配置文件的方式指定纠错的规则,里面一个非常重要的属性:
accuracy,这个值每下降0.1就可以纠错一个字母,下降0.2可以纠错一个汉字,例如:将其调整到0.8时可以搜索到数据但是仅仅只能出错一个汉字或者两个字母。
官方公式:https://www.elastic.co/guide/en/elasticsearch/reference/current/search-suggesters-phrase.html
es中使用phrase Suggester来进行拼写纠错。phrase suggester在term suggester之上添加了额外的逻辑以选择整体更正的phrase,而不是基于单个分词加权的ngram语言模型。在实际中phrase suggester能够根据单词的词频等信息作出更好的选择。
ES中常用的4种Suggester类型:Term、Phrase、Completion、Context。
Google搜索框的补全/纠错功能,如果用ES怎么实现呢?我能想到的一个的实现方式:
精准程度上(Precision)看: Completion > Phrase > term, 而召回率上(Recall)则反之。从性能上看,Completion Suggester是最快的,如果能满足业务需求,只用Completion Suggester做前缀匹配是最理想的。 Phrase和Term由于是做倒排索引的搜索,相比较而言性能应该要低不少,应尽量控制suggester用到的索引的数据量,最理想的状况是经过一定时间预热后,索引可以全量map到内存。
真正的搜索引擎的拼写纠错优化,肯定不止我讲的这么简单,但是万变不离其宗。掌握了核心原理,就是掌握了解决问题的方法,剩下就靠你自己的灵活运用和实战操练了。
我的微信公众号:架构真经(关注领取免费资源)
参考文章