垂直搜索结果的优化包括对搜索结果的控制和排序优化两方面,其中排序又是重中之重。本文将全面深入探讨垂直搜索的排序模型的演化过程,最后推导出BM25模型的排序。然后将演示如何修改lucene的排序源代码,下一篇将深入解读目前比较火热的机器学习排序在垂直搜索中的应用。文章的结构如下:
一、VSM模型简单介绍;
二、lucene默认的评分公式介绍;
三、概率语言模型中的二元独立模型BIM介绍;
四、BM25介绍;
五、lucene中的edismax解析器介绍以及评分公式源代码介绍;
六、修改排序源代码;
七、机器学习排序:①为什么需要机器学习排序②机器学习排序相关算法介绍③关于ListNet算法的英语原版学术论文的解读④机器学习排序实施思路
第一部分:VSM
VSM简称向量空间模型,主要用于计算文档的相似度。计算文档相似度时,需要提取重要特征。特征提取一般用最通用常规的方法:TF-IDF算法。这个方法非常简单但是却非常实用。给你一篇文章,用中文分词工具(目前最好的是opennlp社区中的开源源码包HanLP)对文档进行切分,处理成词向量(去除停词后的结果),然后计算TF-IDF,按降序排列,排在前几位的就是重要特征。这里不论述TF-IDF,因为太简单了。那么对于一个查询q来说,经过分词处理后形成查询向量T[t1,t2……],给每个t赋予权重值,假设总共查询到n个文档,把每个文档处理成向量(按t处理),计算每个t在各自文档中的TF-IDF。然后分别计算与T向量的余弦相似度,得出的分数按降序排列。
VSM的本质是:计算查询和文档内容的相似度。没有考虑到相关性。因为用户输入一个查询,最想得到的是相关度大的文档,而不只是这个文档中出现了查询词。因为某篇文档出现了查询词,也不一定是相关性的,所以需要引入概率模型。后面要将的BIM还有BM25本质是:计算查询和用户需求的相似度。所以BM25会有很好的表现。而lucen底层默认的评分扩展了VSM。下面进入第二部分,lucent的默认评分公式:
二、lucene默认的评分公式介绍
Lucene 评分体系/机制(lucene scoring)是 Lucene 出名的一核心部分。它对用户来说隐藏了很多复杂的细节,致使用户可以简单地使用 lucene。但个人觉得:如果要根据自己的应用调节评分(或结构排序),十分有必须深入了解 lucene 的评分机制。
Lucene scoring 组合使用了 信息检索的向量空间模型 和 布尔模型 。
首先来看下 lucene 的评分公式(在 Similarity 类里的说明)
|
其中:
- tf(t in d) 关联到项频率,项频率是指 项 t 在 文档 d 中出现的次数 frequency。默认的实现是:
tf(t in d) = frequency? - idf(t) 关联到反转文档频率,文档频率指出现 项 t 的文档数 docFreq。docFreq 越少 idf 就越高(物以稀为贵),但在同一个查询下些值是相同的。默认实现:
idf(t) = 1 + log ( numDocs ––––––––– docFreq+1 ) - 关于idf(t)应该这样认识:一个词语在文档集合中出现了n次,文档集合总数为N。idf(t)来源于信息论。那么每篇文档出现这个词语的概率为:n/N,所以这篇文档出现这个词语的信息量为:-log(n/N)。这个和信息熵有些类似(-P(x)logP(x)),在数据挖掘的过滤法进行特征选择时,需要用到互信息,其实是计算信息增益,还有决策树。把-log(n/N)变换一下,log(N/n),为了避免0的出现,进行平滑处理,就是上面的公式(就像朴素贝叶斯需要拉普拉斯平滑处理一样)。
- coord(q,d) 评分因子,是基于文档中出现查询项的个数。越多的查询项在一个文档中,说明些文档的匹配程序越高。默认是出现查询项的百分比。
- queryNorm(q)查询的标准查询,使不同查询之间可以比较。此因子不影响文档的排序,因为所有有文档 都会使用此因子。默认值:
queryNorm(q) = queryNorm(sumOfSquaredWeights) = 1 –––––––––––––– sumOfSquaredWeights? 每个查询项权重的平分方和(sumOfSquaredWeights)由 Weight 类完成。例如 BooleanQuery 地计算:
sumOfSquaredWeights = q.getBoost() 2 · ∑ ( idf(t) · t.getBoost() ) 2
t in q - t.getBoost()查询时期的 项 t 加权(如:java^1.2),或者由程序使用 setBoost()。
- norm(t,d)压缩几个索引期间的加权和长度因子:
- Document boost - 文档加权,在索引之前使用 doc.setBoost()
- Field boost - 字段加权,也在索引之前调用 field.setBoost()
- lengthNorm(field) - 由字段内的 Token 的个数来计算此值,字段越短,评分越高,在做索引的时候由 Similarity.lengthNorm 计算。
以上所有因子相乘得出 norm 值,如果文档中有相同的字段,它们的加权也会相乘:
norm(t,d) = doc.getBoost() · lengthNorm(field) · ∏ f.getBoost()
field f in d named as t
索引的时候,把 norm 值压缩(encode)成一个 byte 保存在索引中。搜索的时候再把索引中 norm 值解压(decode)成一个 float 值,这个 encode/decode 由 Similarity 提供。官方说:这个过程由于精度问题,以至不是可逆的,如:decode(encode(0.89)) = 0.75。
总体来说,这个评分公式仍然是基于查询与文档内容的相似度计算分数。而且,lengthNorm(field) = 1/sqrt(numTerms),即文档的索引列越长,分值越低。这个显然是不合理的,需要改进。而且这个评分公式仅仅考虑了查询词在文档向量中的TF,并没有考虑在T(查询向量)中的TF,而且,如果一篇文档越长,它的TF一般会越高,会成一定的正相关性。这对于短文档来说计算TF是不公平的。在用这个公式打分的时候,需要对文档向量归一化处理,其中的lengthNorm如何处理是个问题。举个例子,在用球拍打羽毛球的时候,球拍会有一个最佳击球和回球的区域,被成为"甜区"。在处理文档向量的长度时候,我们同样可以规定一个"甜区",比如min/max,超过这个范围的,lengthNorm设置为1。基于以上缺点,需要改进排序模型,让查询和用户的需求更加相关,所以提出了概率模型,下面进入第三部分:
三、概率语言模型中的二元独立模型BIM介绍
概率检索模型是从概率排序原理推导出来的,所以理解这一原理对于理解概率检索模型非常重要。概率排序模型的思想是:给定一个查询,返回的文档能够按照查询和用户需求的相关性得分高低排序。这是一种对用户需求相关性建模的方法。按照如下思路进行思考:首先,我们可以对查询后得到的文档进行分类:相关文档和非相关文档。这个可以按照朴素贝叶斯的生成学习模型进行考虑。如果这个文档属于相关性的概率大于非相关性的,那么它就是相关性文档,反之属于非相关性文档。所以,引入概率模型:P(R|D)是一个文档相关性的概率,P(NR|D)是一个文档非相关性的概率。如果P(R|D) > P(NR|D),说明它与查询相关,是用户想要的。按照这个思路继续,怎样才能计算这个概率呢?如果你熟悉朴素贝叶斯的话,就容易了。P(R|D) = P(D|R)P(R)/P(D),P(NR|D) = P(D|NR)P(NR)/P(D)。用概率模型计算相关性的目的就是判断一个文档是否P(R|D) > P(NR|D),即P(D|R)P(R)/P(D) > P(D|NR)P(NR)/P(D) <=> P(D|R)P(R) > P(D|NR)P(NR) <=> P(D|R)/P(D|NR) > P(NR)/P(R)。对于搜索来说,并不需要真的进行分类,只需计算P(D|R)/P(D|NR)然后按降序排列即可。于是引入二元独立模型(Binary Independent Model) 假设=>
①二元假设:在对文档向量进行数据建模时,假设特征的值属于Bernoulli分布,其值为0或者1(朴素贝叶斯就适用于特整值和分类值都属于Bernoulli分布的情况,而loggistic Regression适用于分类值为Bernoulli分布)。在文本处理领域,就是这个特征在文档中出现或者不出现,不考虑词频。
②词汇独立性假设:假设构成每个特征的词是相互独立的,不存在关联性。在机器学习领域里,进行联合似然估计或者条件似然估计时,都是假设数据遵循iid分布。事实上,词汇独立假设是非常不合理的。比如"乔布斯"和"ipad"和"苹果"是存在关联的。
有了上面的假设,就可以计算概率了。比如,有一篇文档D,查询向量由5个Term组成,在D中的分布情况如下:[1,0,1,01]。那么,P(D|R) = P1*(1-P2)*P3*(1-P4)*P5。Pi为特征在D中出现的概率,第二个和第四个词汇没有出现,所以用(1-P2)和(1-P4)。这是文档属于相关性的概率,生成模型还需要计算非相关性的概率情况。用Si表示特征在非相关性文档中出现的概率,那么P(D|NR)=S1*(1-S2)*S3*(1-S4)*S5。=>
,这个公式中第一项代表在D中出现的各个特征概率乘积,第二项表示没有在D中出现的概率乘积。进一步变换得到:
这个公式里,第一部分是文档里出现的特征概率乘积,第二项是所有特征的概率乘积,是从全局计算得出。对于特定的文档,第二项对排序没有影响,计算结果都是一样的,所以去掉。于是,得出最终结果:。为了计算方便,对这个公式取对数:。进一步求解这个公式:,。其中,N表示文档集合总数,R表示相关文档总数,那么N-R就是非相关文档数目,ni表示包含特征di的文档数目,在这其中属于相关文档的数目是ri。于是,。当出现一个查询q和返回文档时,只需计算出现的特征的概率乘积,和朴素贝叶斯的predict原理是一样的。这个公式,在特定情况下可以转化为IDF模型。上述公式就是BM25模型的基础。下面来讲述第四部分。
四、BM25模型
BIM模型基于二元独立假设推导出,只考虑特征是否出现,不考虑TF因素。那么,如果在这个基础之上再考虑Tf因素的话,会更加完美,于是,有人提出了BM25模型。加入了词汇再查询向量中的权值以及在文档中的权值还有一系列经验因子。公式如下:
第一项就是BIM模型推导出的公式,因为在搜索的时候,我们不知道哪些是相关的哪些不是相关的,所以把ri和R设置为0,于是,第一项退化成了
就是IDF!非常神奇!,fi是特整在文档D中的TF权值,qfi是特征在查询向量中的TF权值。一般情况下,k1=1.2,b=0.75,k2=200.当查询向量比较短的时候,qfi通常取值为1。分析来看,当K1=0时,第二项不起作用,也就是不考虑特征在文档中的TF权值,当k2=0时,第三项也失效。从中可以看出,k1和k2值是对特征在文档或者查询向量中TF权值的惩罚因子。综合来看,BM25考虑了4个因素:IDF因子,文档长度因子,文档词频因子和查询词频因子。有了以上4个部分,相信大部分人会对lucene的评分公式有了很深入的了解,下面进入源代码解读和修改阶段,主要是为了能够满足根据时间业务场景自定义排序。进入第五部分:
五、edismax解析器介绍:
之所以介绍这个查询解析器,是因为特殊的业务场景需要。lucene的源码包中,两大核心包,org.apache.lucene.index和org.apache.lucene.search。其中第一个包会调用store、util和document子包,第二个会和queryParser和analysis、message子包交互。在查询中,最重要的就是queryParser。当用户输入查询字符串后,调用lucene的查询服务,要调用QueryParser类,第一步是调用analyzer(分词)形成查询向量T[t1,t2……tn],这一步是词法分析,接下来是句法分析,形成查询语法,即先形成QueryNode--->QueryTree .t1和t2之间是逻辑与的关系,用Boolean查询。这样lucene就能理解查询语法了。为了加深理解,先看一段代码:
package com.txq.lucene.queryParser;
import java.io.IOException;
import java.io.StringReader;
import java.util.ArrayList;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.analysis.tokenattributes.OffsetAttribute;
import org.apache.lucene.analysis.tokenattributes.TypeAttribute;
import org.apache.lucene.queryparser.classic.ParseException;
import org.apache.lucene.queryparser.classic.QueryParser;
import org.apache.lucene.util.Version;
import org.apache.lucene.search.BooleanQuery;
import org.apache.lucene.search.PhraseQuery;
import org.apache.lucene.search.Query;
import org.apache.lucene.search.TermQuery;
import org.apache.lucene.search.BooleanClause.Occur;
import org.apache.lucene.index.Term;
/**
* 自定义一个查询解析器,BooleanQuery
* @author XueQiang Tong
*
*/
public class BlankAndQueryParser extends QueryParser {
// analyzer = new IKAnalyzer(false);
public BlankAndQueryParser(Version matchVersion, String field, Analyzer analyzer) {
super(matchVersion, field, analyzer);
}
protected Query getFieldQuery(String field,String queryText,int slop) throws ParseException{
try {
TokenStream ts = this.getAnalyzer().tokenStream(field, new StringReader(queryText));
OffsetAttribute offset = (OffsetAttribute) ts.addAttribute(OffsetAttribute.class);
CharTermAttribute term = (CharTermAttribute) ts.addAttribute(CharTermAttribute.class);
TypeAttribute type = (TypeAttribute) ts.addAttribute(TypeAttribute.class);
ts.reset();
ArrayList<CharTermAttribute> v = new ArrayList<CharTermAttribute>();
while (ts.incrementToken()) {
// System.out.println(offset.startOffset() + " - "
// + offset.endOffset() + " : " + term.toString() + " | "
// + type.type());
if(term.toString() == null){
break;
}
v.add(term);
}
ts.end();
ts.close();
if(v.size() == 0){
return null;
} else if (v.size() == 1){
return new TermQuery(new Term(field,v.get(0).toString()));
} else {
PhraseQuery q = new PhraseQuery();
BooleanQuery b = new BooleanQuery();
q.setBoost(2048.0f);
b.setBoost(0.001f);
for(int i = 0;i < v.size();i++){
CharTermAttribute t = v.get(i);
//q.add(new Term(field,t.toString()));
TermQuery tmp = new TermQuery(new Term(field,t.toString()));
tmp.setBoost(0.01f);
b.add(tmp, Occur.MUST);
}
return b;
}
} catch (IOException e) {
e.printStackTrace();
}
return null;
}
protected Query getFieldQuery(String field,String queryText) throws ParseException{
return getFieldQuery(field,queryText,0);
}
}
上面这段代码,展示了QueryParser的基本步骤,需要分词器,我用的是去年自己写的基于逆向最大匹配算法的分词器(由IK分词改造而来)。从上面能够基本了解BooleanQuery的工作原理了。去年写的两个数组取交集的算法,就是布尔查询为AND的逻辑问题抽象。短语查询精确度最高,在倒排索引项中存储有词元的位置信息,就是提供短语查询功能支持的。现在回归到第五部分的内容,现在有一个排序业务场景:有一个电商平台,交易量非常火,点击量比较大,需要自定义一个更加合理的符合自己公司的排序需求。先提出如下需求:要求按照入住商家的时间,商家是否为VIP以及商品的点击率(point)综合考虑三者因素得出最后的评分。为了完成这样一项排序任务,先梳理以下思路:这个排序要求,按照lucene现有的score公式肯定满足不了,这是属于用户在外部自定义的排序规则,与底层的排序规则不相干。能够满足这样需求的只有solr的edismax解析器。所以按照如下思路:先了解一下edismax怎么使用(比如可以在外部定义linear函数实现规则),然后还需要解析器的内部原理(看源代码),看看它与底层的score有何种关系(不可能没有关系,所以需要深入研读源代码,看看有没有必要修改lucene底层的score源代码)。按照上面的思路开展工作,查看源代码后发现,最中得分是外部传递的评分函数与底层score的乘积,dismax解析器是相加。如果用dismax解析器的话,相加不能突出上述规则的作用,所以最好用edismax解析器。从理论上分析,如果底层使用VSM模型或者是BM25模型的话,score打分会对业务排序规则产生影响,比如有的商家是VIP,点击率很高,但是底层的score可能很低,这样一相乘的话,最后得分就不准确了,所以需要把底层的score写死,改为1,消除影响。所以,按照这个规则,评分函数,点击率高的排在前面(在point前设置比较高的权重)是比较合理的。为了验证以上想法的正确性,可以先定义评分函数,不修改底层的score,看看排序效果,排序是混乱的 。所以,根据上面的分析,需要从基本的lucene底层的score打分源代码开始研究,然后edismax源代码。在修改lucene的score源代码的时候,最好不要用jd-gui反编译工具,最开始用的时候,得到的代码只有部分是正确的。用maven构建项目时,直接下载以来的包,包括lucene-core-4.9.0-sources.jar,修改源码包,然后重新编译打包,替换掉原来的包。这是一项繁琐的工程,包括后面的博客中介绍的机器学习排序,构建文档数据特征时,需要获取BM25信息,同样需要lucene源代码,构建训练系统和预测系统。由于时间关系,明天继续写,今天晚上太晚了……