前一章讲了搜索中的拼写纠错功能,里面一个很重要的概念就是莱文斯坦距离。这章会讲解搜索中提升用户体验的另一项功能 - [自动补全]。本章直接介绍 ES 中的实现方式以及真正的搜索引擎对自动补全功能的优化。
大家对上面的这个应该都不陌生,搜索引擎会根据你输入的关键字进行一些提示,这样用户只需要输入部分内容就可以进行选择了。尤其在移动端会比较方便。淘宝、京东的搜索也有类似的功能,只不过行业不同,提示出来的内容也不同罢了。
InputIteratior 中几个方法的作用:
weight():此方法设置某个term的权重,设置的越高suggest的优先级越高;
payload():每个suggestion对应的元数据的二进制表示,我们在传输对象的时候需要转换对象或对象的某个属性为BytesRef类型,相应的suggester调用lookup的时候会返回payloads信息;
hasPayload():判断iterator是否有payloads;
contexts():获取某个term的contexts,用来过滤suggest的内容,如果suggest的列表为空,返回null
hasContexts():获取iterator是否有contexts;
Lucene 使用 AnalyzingInfixSuggester 类中的 lookup 方法去联想数据来源进行查询,其实就是一个普通的 search,所以我们的关键是要维护好这个联想数据来源,各行各业都应该有自己单独的语料库。
应该从这几个方面入手:怎么优化 Suggest 词库、提升 Suggest 词准确率、怎么提高响应速度
当使用 completion suggester 的时候, 不是用于完成 类似于 " 关键词 " 这样的模糊匹配场景,而是用于完成关键词前缀匹配的。 对于汉字的处理,无需使用 ik/ HanLP 一类的分词器,直接使用 keyword analyzer,配合去除一些不需要的 stop word 即可。
代码参考:
LinkedHashSet<String> returnSet = new LinkedHashSet<>();
Client client = elasticsearchTemplate.getClient();
SuggestRequestBuilder suggestRequestBuilder = client.prepareSuggest(elasticsearchTemplate.getPersistentEntityFor(SuggestEntity.class).getIndexName());
//全拼前缀匹配
CompletionSuggestionBuilder fullPinyinSuggest = new CompletionSuggestionBuilder("full_pinyin_suggest")
.field("full_pinyin").text(input).size(10);
//汉字前缀匹配
CompletionSuggestionBuilder suggestText = new CompletionSuggestionBuilder("suggestText")
.field("suggestText").text(input).size(size);
//拼音搜字母前缀匹配
CompletionSuggestionBuilder prefixPinyinSuggest = new CompletionSuggestionBuilder("prefix_pinyin_text")
.field("prefix_pinyin").text(input).size(size);
suggestRequestBuilder = suggestRequestBuilder.addSuggestion(fullPinyinSuggest).addSuggestion(suggestText).addSuggestion(prefixPinyinSuggest);
SuggestResponse suggestResponse = suggestRequestBuilder.execute().actionGet();
Suggest.Suggestion prefixPinyinSuggestion = suggestResponse.getSuggest().getSuggestion("prefix_pinyin_text");
Suggest.Suggestion fullPinyinSuggestion = suggestResponse.getSuggest().getSuggestion("full_pinyin_suggest");
Suggest.Suggestion suggestTextsuggestion = suggestResponse.getSuggest().getSuggestion("suggestText");
List<Suggest.Suggestion.Entry> entries = suggestTextsuggestion.getEntries();
//汉字前缀匹配
for (Suggest.Suggestion.Entry entry : entries) {
List<Suggest.Suggestion.Entry.Option> options = entry.getOptions();
for (Suggest.Suggestion.Entry.Option option : options) {
returnSet.add(option.getText().toString());
}
}
//全拼suggest补充
if (returnSet.size() < 10) {
List<Suggest.Suggestion.Entry> fullPinyinEntries = fullPinyinSuggestion.getEntries();
for (Suggest.Suggestion.Entry entry : fullPinyinEntries) {
List<Suggest.Suggestion.Entry.Option> options = entry.getOptions();
for (Suggest.Suggestion.Entry.Option option : options) {
if (returnSet.size() < 10) {
returnSet.add(option.getText().toString());
}
}
}
}
//首字母拼音suggest补充
if (returnSet.size() == 0) {
List<Suggest.Suggestion.Entry> prefixPinyinEntries = prefixPinyinSuggestion.getEntries();
for (Suggest.Suggestion.Entry entry : prefixPinyinEntries) {
List<Suggest.Suggestion.Entry.Option> options = entry.getOptions();
for (Suggest.Suggestion.Entry.Option option : options) {
returnSet.add(option.getText().toString());
}
}
}
return new ArrayList<>(returnSet);
搜索引擎的优化,需要更智能,每个人输入相同的关键字,提示出来的内容可能是完全不相同的,这就是所谓的 “千人千面”。这就用到了数据分析的知识,可以根据用户一段时间内的搜索历史,分析用户的搜索习惯,结合语料库实现对用户的精准提示。跟输入法的提升功能类似,会根据你过往的输入文本进行自动提示。所以,你付出了隐私,得到的是更大的便捷。这也是没有办法的事情。
如你所见,各大搜索引擎都提供了智能提示的 API 供广大用户调用,如果你司没有自研的能力,可以直接 js 中跨域调用,先把系统跑起来再说,给大家提供主流搜索引擎的调用地址,包含电商的哦。
提示:URL 中的 #content# 为搜索的 关键字
谷歌(Google)
http://suggestqueries.google.com/complete/search?client=youtube&q=#content#&jsonp=window.google.ac.h
callback:window.google.ac.h
window.google.ac.h([“关键字”,[[“关键字”,0],[“关键字 歌词”,0],[“关键字参数”,0],[“关键字 lyrics”,0],[“关键字过滤”,0],[“关键字排名”,0],[“关键字查询”,0],[“关键字提取算法”,0],[“关键字规划师可通过以下哪种方式帮助您制作新的搜索网络广告系列”,0],[“关键字优化”,0]],{“k”:1,“q”:“uhaB8ZMjzJay-BACee_C0eVdUCA”}])
必应(Bing)
http://api.bing.com/qsonhs.aspx?type=cb&q=#content#&cb=window.bing.sug
callback:window.bing.sug
if(typeof window.bing.sug == 'function') window.bing.sug({"AS":{"Query":"关键字","FullResults":0}} /* pageview_candidate */);
百度(Baidu)
http://suggestion.baidu.com/su?wd=#content#&cb=window.baidu.sug
callback:window.baidu.sug
window.baidu.sug({q:"关键字",p:false,s:["关键字搜索排名","关键字怎么优化","关键字查询工具","关键字推广","关键词优化","关键词排名","关键字 英文","关键词挖掘","关键词查询","关键词搜索"]});
好搜(So)
**callback:**window.so.sug
window.so.sug({"query":"关键字","result":[{"word":"关键字查询"},{"word":"关键字工具"},{"word":"关键字查询工具"},{"word":"关键字挖掘"},{"word":"关键字搜索"},{"word":"关键字英文"},{"word":"关键字是什么"},{"word":"关键字广告"},{"word":"关键字分析"},{"word":"关键字规划师"}],"version":"b","rec":""});
搜狗(Sogou)
https://www.sogou.com/suggnew/ajajjson?type=web&key=#content#
**callback:**window.sogou.sug
window.sogou.sug(["关键字",["关键字查询","关键字搜索","关键字优化","关键字规划师","关键字查询lol","关键字是什么意思","关键字搜索工具","关键字广告图片","关键字排名查询","关键字生成器"],["0;0;0;0","1;0;0;0","2;0;0;0","3;0;0;0","4;0;0;0","5;0;0;0","6;0;0;0","7;0;0;0","8;0;0;0","9;0;0;0"],["","","","","","","","","",""],["0"],"","suglabId_1"],-1);
淘宝(Taobao)
https://suggest.taobao.com/sug?code=utf-8&q=#content#&callback=window.taobao.sug
**callback:**window.taobao.sug
window.taobao.sug({"result":[["关键字推广","204"],["关键字seo","198"],["关键字 网站","182"],["关键字搜索","119"],["关键字软件","44"],["关键字首页","50"],["关键字收录","35"],["关键字采集","16"],["关键字采集器","10"],["网站关键字","180"]]})
搜索建议使用方式
以百度为例,API 返回的是 JSONP 数据,JSONP 是跨域访问的一种方式。由于服务器返回的 JavaScript 代码可以直接引用,通过回调函数的方式就可以间接的获取服务器的数据。
下面是一个回调搜索建议的例子,window.baidu.sug 返回的是一个 json 对象:
<script type="text/javascript">
window.onload = function() {
//组装查询地址
var sugurl = "http://suggestion.baidu.com/su?wd=#content#&cb=window.baidu.sug";
var content = "关键字";
sugurl = sugurl.replace("#content#", content);
//定义回调函数
window.baidu = {
sug: function(json) {
console.log(json)
}
}
//动态添加JS脚本
var script = document.createElement("script");
script.src = sugurl;
document.getElementsByTagName("head")[0].appendChild(script);
}
</script>
控制台打印的结果:如果要将结果保存在一个字符串数组中,只需要 var arr = json.s 即可。
在使用搜索引擎时,当我们输入错误的关键词时,当然这里的错误是拼写错误,搜索引擎的下拉框中仍会显示以正确关键词为前前辍的提示,当你直接回车搜索错误的关键词时,搜索引擎的结果中仍包括正确关键词的结果。你有没有想过它是如何实现的呢?
这个的实现可以有多种方案,常见的方法就是我们前面学习过的最长公共子序列和今天要讲的莱文斯坦距离方式。
最长公共子序列可以查看我前面的博文程序员的算法课(6)- 最长公共子序列(LCS)。
这两种方案都需要跟正确的词典进行对照,**计算编辑距离或者最长公共子序列,将编辑距离最小或子序列最长的单词,作为纠正之后的单词,**返回给用户。
莱文斯坦距离,又称 Levenshtein 距离,是[编辑距离]的一种。指两个[字串]之间,由一个转成另一个所需的最少编辑操作次数。允许的编辑操作包括将一个字符替换成另一个字符,插入一个[字符],删除一个字符。
如单词 three,我不小心手误打成了 ethre,可以看到 ethre 只需要把首字母 e 移动到末尾即可变成正确的单词 three。此时 ethre 相对于 three 的编辑距离就是 1。
莱文斯坦距离允许增加、删除、替换字符这三个编辑操作,最长公共子串长度只允许增加、删除字符这两个编辑操作。
莱文斯坦距离和最长公共子串长度,从两个截然相反的角度,分析字符串的相似程度。莱文斯坦距离的大小,表示两个字符串差异的大小;而最长公共子串的大小,表示两个字符串相似程度的大小。
源码中,定义了两个 public 的静态成员变量, DEFAULT_ACCURACY 表示默认的最小分数,SpellCheck 会对字典里的每个词与用户输入的搜索关键字进行一个相似度打分,默认该值是 0.5,相似度分值范围是 0 到 1 之间,数字越大表示越相似,小于 0.5 会认为不是同一个结果。F_WORD 是对于字典文件里每一行创建索引时使用的默认域名称,默认值为:word。
几个重要的 API:
**[getAccuracy](http://www.tuicool.com/admin/blogs/):**
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 到内存。
真正的搜索引擎的拼写纠错优化,肯定不止我讲的这么简单,但是万变不离其宗。掌握了核心原理,就是掌握了解决问题的方法,剩下就靠你自己的灵活运用和实战操练了。
参考文章
参考文章
https://blog.csdn.net/m0_37609579/article/details/101001985
https://blog.csdn.net/m0_37609579/article/details/101039881?utm_medium=distribute.pc_relevant.none-task-blog-2defaultbaidujs_baidulandingword~default-1-101039881-blog-130149985.235v40pc_relevant_anti_vip&spm=1001.2101.3001.4242.2&utm_relevant_index=4