搜索算法的核心实际是对搜索项之间相似度的打分策略,一个好的打分策略应该能够综合各种与搜索项内容相关并对搜索目的有帮助的所有因素,一般将这种策略叫做建模(modeling),由量化后的相关因素即特征(feature)构成检索(评分)模型,最后通过模型得到搜索项之间的相似度分数(similarity score)。
一般来讲相似度分数应该是与内容相关的程度成正比的,因此当计算过所有的搜索项的相似度分数后降序排列,将值最大的k个项作为搜索结果展示。
信息检索技术的发展也有些年头了,过程中诞生了不少优秀的检索模型,其中比较早的是VSM模型,虽然诞生的较早,但由于其原理简单直观并在检索性能(performance)和效率(efficiency)方面有较好的平衡,成为经典并直到今天依然被广泛使用,同时也是Lucene最先实现并被设为默认使用的检索模型,接下来我就对VSM检索模型的原理和在Lucene中的实现进行分析。
首先集中解释一下在下文中会使用到的一些概念和标识:
- d,文档(document);即检索结果的项,是最基本的内容对象。
- q,查询(query);即检索的需求,一般情况为用户提供的短文本描述,当然某些情况下也可以是某篇特定的文档d,或是其它。
- f,域(field);及文档中表示不同内容的片段或者属性,比如标题、作者或正文,是比文档粒度更小的内容对象,Lucene支持对特定域或多域的检索。
- t,词项(term);也被称为词元,原始状态下就是构成文档的词或字(word),但通常检索前都会对文本内容进行一定的处理,比如去停用词、词干化、分词等,使得词项不在是原始的文本形式,总之,是粒度最小的内容对象。需要特殊说明的是,由于Lucene是可多域检索的,所以词项是与域绑定的,每个词项都包含文本和所属域两个基本属性,也就是说两个词项(“百度”,“标题”)和(“百度”,“正文”)在Lucene内视为两个不同的词项。
好了,下面开始分析VSM,VSM是(Vector Space Model)的缩写,中译就是向量空间模型,其实就是把每个文档和查询映射为高维空间上的向量(如图1-1),每一维都对应了在整个文档集中出现过的词项,借助夹角越小余弦值越大的性质,最终通过计算向量间夹角的余弦值作为两个项之间的相似度(如公式1)。
图1-1
公式1-1
其中分子部分为两个向量的点乘积,分母部分则分别为两个向量的欧几里德范式。原始的VSM模型思想很直观,在这里就不过多做介绍了,还不太明白的朋友请参考《Introduction To Information Retrieval》的第6章,我将主要分析VSM在Lucene中的实现。
在公式1-1的分母中两个欧式长度项的意义在于将向量规范化为单位向量以消除冗余长度对相似度的影响,例如两篇文档“百度”和“百度百度百度”相比两者包含完全相同的信息,所以对同一个查询的相似度分数理应相同。但并不是所有的情况下的规范化都是合理的,例如两篇文档“百度”和“百度是个搜索引擎”相比前者包含的信息是后者的子集,因此如果查询为“百度”两篇文档的相似度分数应当相似,但如果依照公式1-1计算,则完全消除了文档长度的信息,就会有些问题。为此Lucene将分母中文档长度项进行一个替换,以求一个更平滑和平衡的模型(如公式1-2)。
公式1-2
其中doc_norm(d)为文档长度的规范化项,但并非一定是归一化,一般对文档长度进行平滑并包含越多信息量的文档其值越大,该值将在索引过程中计算;doc_boost(d)则是该篇文档的先验权重,一般情况下,视为所有文档都有相同的先验权重,但Lucene允许用户在索引过程中对文档设置先验权重。
同理对公式1-1中查询项欧式长度进行相似替换(如公式1-3)。
公式1-3
通常在一次检索中查询只有一个,因此对于该查询则无需设置先验的权重。同时,还需要说明的是,在一次检索过程中该项是一个常数,对检索结果没有影响可以忽略,Lucene保留该项的原因是在有些情况下需要使不同查询的检索结果之间有可比性,比如相关反馈或者计算文档间的相似度。
至此,相似度分数计算公式演变为公式1-4:
公式1-4
接下来就可以开始计算两个向量的点乘了,向量点乘是各维的乘积和,那么就需要知道向量各维的值是如何构成的,在经典的VSM模型实现中,使用TF-IDF公式来计算如下:
公式1-5
即tf和idf两项的乘积,分别解释一下:
- tf,词项频数(term frequency);即词项在文档中出现的频数(次数)。一般情况下不会直接使用频次,而是做一定形式的平滑或变换,但总的来说应与词项在文档中出现的频次成正比,后文会继续说明。
- df,文档频数(document frequency);即整个数据集中出现过该指定词项的文档的频数(数量)。同样一般也不会直接使用,后文会继续说明。
- idf,逆文档频数(invert document frequency);即与文档频数成反比的值。
针对查询q来说,每个词项一般最多出现一次,因此一般tf(t,q)为一个常数,但为了使得检索更加灵活,Lucene允许对查询中的词项设置不同的权重,所以各维为词项权重与逆文档频数的乘积(如公式1-6)。
公式1-6
如我前文所述的,Lucene支持多域检索且词项也与域绑定,因此文档的归一化长度其实也是与域绑定的,而非每篇文档一个值,而是每个域一个值:
公式1-7
另外,Lucene还支持逻辑查询和批量查询,因此查询有可能还有子查询,在有多个并列析取子查询的情况下,如果文档命中的子查询越多,即对这个查询的覆盖率越高,则理应获得越高的相似度得分,因此Lucene在相似度分值计算时加入了文档与查询的协调分数(coordination):
公式1-8
至此,就得到了Lucene相似度计算模型如(公式1-9):
公式1-9
有了以上的原理分析,下面我们进入Lucene的代码部分,所有实现相似度评分相关的代码都位于org.apache.lucene.search.similarities包下,由于对VSM改动较大,因此在命名上Lucene并没有延续VSM这个名字,而是将模型实现代码放在了TFIDFSimilarity类中,该类主要提供了以下的功能:
- public abstract float coord(int overlap, int maxOverlap)
抽象方法,用于为公式1-8部分的计算提供接口。 - public abstract float queryNorm(float sumOfSquaredWeights)
抽象方法,用于为公式1-3部分的计算提供接口。 - public abstract float tf(float freq)
抽象方法,用于为计算词项频数和相关平滑提供接口。 - public abstract float idf(long docFreq, long numDocs)
抽象方法,用于为计算你文档频率和相关平滑提供接口。 - public abstract void computeNorm(FieldInvertState state, Norm norm)
抽象方法,用于为公式1-7部分的计算提供接口。
如上所述,TFIDFSimilarity也只是个抽象类,实现了VSM模型计算的框架,但对于各构成元素并没有给出具体的计算,事实上具体的计算是有其派生类DefaultSimilarity实现的,通过类名也能看出该类为Lucene默认的相似度评分类,所以综合DefaultSimilarity、TFIDFSimilarity以及它们的基类Similarity的所有代码,才是实现了VSM模型的所有计算过程,下面就详细分析一下代码。
(1)我们先来看一下公式1-8部分的计算。
在DefaultSimilarity中重写的coord给出了具体的计算过程:
代码1-1
public float coord(int overlap, int maxOverlap) {
return overlap / (float)maxOverlap;
}
就是文档覆盖查询词项的数量除以查询中所有词项的数量。该方法会被查询对象xxxQuery调用,并实际由查询评分对象xxxScorer使用,例如在布尔逻辑查询过程中:
代码1-2 BooleanQuery代码片段
public float coord(int overlap, int maxOverlap) {
return similarity.coord(overlap, maxOverlap);
}
代码1-3 BooleanScorer代码片段
final class BooleanScorer extends Scorer {
…
BooleanScorer(BooleanWeight weight, boolean disableCoord, int minNrShouldMatch,
List<Scorer> optionalScorers, List<Scorer> prohibitedScorers, int maxCoord) throws IOException {
…
coordFactors = new float[optionalScorers.size() + 1];
for (int i = 0; i < coordFactors.length; i++) {
coordFactors[i] = disableCoord ? 1.0f : weight.coord(i, maxCoord);
}
}
…
public boolean score(Collector collector, int max, int firstDocID) throws IOException {
…
if (current.coord >= minNrShouldMatch) {
bs.score = current.score * coordFactors[current.coord];
bs.doc = current.doc;
bs.freq = current.coord;
collector.collect(current.doc);
}
…
}
}
从代码1-2和1-3中可以看到,在BooleanQuery对象的coord方法调用了Similarity类中的coord方法,也就是DefaultSimility类重写过的,BooleanScorer类在构造方法中建立了coord值的缓存,即将所有可能的coord值全部计算出来放入成员数组中,之后对于每篇文档的计算(score方法)直接通过栈寻址即可,比3层的方法调用加除法要高效许多,Lucene中类似这种缓存的运用随处可见。最后将取到的coord值与其它项计算得到的分数相乘,得到最后的文档相似度分数。
(2)公式1-3计算。
同样是在DefaultSimilarity中重写的queryNorm给出了具体的计算过程:
代码1-4
public float queryNorm(float sumOfSquaredWeights) {
return (float)(1.0 / Math.sqrt(sumOfSquaredWeights));
}
可以看出,该方法只是将一个中间值进行开方后取倒数,而这个中间值并不是在Similarity类中得到的,而是通过查询树的Weight对象通过getValueForNormalization方法递归计算得到的,具体可以参考BooleanWeight和TermWeight类中的代码:
代码1-5 BooleanWeight代码片段
public float getValueForNormalization() throws IOException {
float sum = 0.0f;
for (int i = 0 ; i < weights.size(); i++) {
// call sumOfSquaredWeights for all clauses in case of side effects
float s = weights.get(i).getValueForNormalization();
if (!clauses.get(i).isProhibited())
// only add to sum for non-prohibited clauses
sum += s;
}
sum *= getBoost() * getBoost(); // boost each sub-weight
return sum ;
}
代码1-6 TermWeight代码片段
public TermWeight(IndexSearcher searcher, TermContext termStates)
throws IOException {
assert termStates != null : "TermContext must not be null";
this.termStates = termStates;
this.similarity = searcher.getSimilarity();
this.stats = similarity.computeWeight(
getBoost(),
searcher.collectionStatistics(term.field()),
searcher.termStatistics(term, termStates));
}
…
public float getValueForNormalization() {
return stats.getValueForNormalization();
}
代码1-5中是将查询树中所有的叶节点也就是词项节点的getValueForNormalization值累加后再乘以查询树整体的权重平方,而代码1-6显示词项节点的getValueForNormalization计算直接调用了Similarity类中通过computeWeight方法得到的SimWeight类的同名方法如下:
代码1-7 TFIDFSimilarity代码片段
public final SimWeight computeWeight(float queryBoost, CollectionStatistics collectionStats, TermStatistics... termStats) {
final Explanation idf = termStats.length == 1
? idfExplain(collectionStats, termStats[0])
: idfExplain(collectionStats, termStats);
return new IDFStats(collectionStats.field(), idf, queryBoost);
}
…
public Explanation idfExplain(CollectionStatistics collectionStats, TermStatistics termStats) {
final long df = termStats.docFreq();
final long max = collectionStats.maxDoc();
final float idf = idf(df, max);
return new Explanation(idf, "idf(docFreq=" + df + ", maxDocs=" + max + ")");
}
…
private static class IDFStats extends SimWeight {
private final String field;
private final Explanation idf;
private float queryNorm;
private float queryWeight;
private final float queryBoost;
private float value;
public IDFStats(String field, Explanation idf, float queryBoost) {
this.field = field;
this.idf = idf;
this.queryBoost = queryBoost;
this.queryWeight = idf.getValue() * queryBoost; // compute query weight
}
@Override
public float getValueForNormalization() {
return queryWeight * queryWeight; // sum of squared weights
}
@Override
public void normalize(float queryNorm, float topLevelBoost) {
this.queryNorm = queryNorm * topLevelBoost;
queryWeight *= this.queryNorm; // normalize query weight
value = queryWeight * idf.getValue(); // idf for document
}
}
可以看出,TermWeight的getValueForNormalization方法调用IDFStats中的同名方法最终得到的是idf.getValue()* queryBoost的平方,而对于TermWeight来说,其queryBoost值即为查询中该词项的权重值,因此该值即为公式1-6的平方,至此我们可以得到query_norm(q)的具体计算公式如下:
公式1-10
(3)和式计算。
最后在公式1-9中只剩下和式部分没有计算,也就是向量点乘积和文档向量规范项的计算。这部分的计算主要由Similarity类的子类ExactSimScorer实现,在TFIDFSimilarity中即ExactTFIDFDocScorer,通过exactSimScorer方法生成(如代码1-8)。
代码1-8 TFIDFSimilarity代码片段
public final ExactSimScorer exactSimScorer(SimWeight stats, AtomicReaderContext context) throws IOException {
IDFStats idfstats = (IDFStats) stats;
return new ExactTFIDFDocScorer(idfstats, context.reader().normValues(idfstats.field));
}
…
private final class ExactTFIDFDocScorer extends ExactSimScorer {
private final IDFStats stats;
private final float weightValue;
private final byte[] norms;
private static final int SCORE_CACHE_SIZE = 32;
private float[] scoreCache = new float[SCORE_CACHE_SIZE];
ExactTFIDFDocScorer(IDFStats stats, DocValues norms) throws IOException {
this.stats = stats;
this.weightValue = stats.value;
this.norms = norms == null ? null : (byte[])norms.getSource().getArray();
for (int i = 0; i < SCORE_CACHE_SIZE; i++)
scoreCache[i] = tf(i) * weightValue;
}
@Override
public float score(int doc, int freq) {
final float raw = // compute tf(f)*weight
freq < SCORE_CACHE_SIZE // check cache
? scoreCache[freq] // cache hit
: tf(freq)*weightValue; // cache miss
return norms == null ? raw : raw * decodeNormValue(norms[doc]); // normalize for field
}
@Override
public Explanation explain(int doc, Explanation freq) {
return explainScore(doc, freq, stats, norms);
}
}
如代码所示,在ExactTFIDFDocScorer的构造方法中主要是建立了分值的缓存,在调用score方法进行分值计算将tf值与IDFStats对象中value字段的值相乘,而value值的计算如代码1-7的normalize方法,是将公式1-6计算得到的值乘以一个规范化后的查询权重(该值没有出现在官方文档的计算公式1-9中,笔者也无法理解,需要继续求证),之后再乘以idf,即的到了如下值:
公式1-11
然后再乘以从索引文件中解码读取到的文档(域)规范项decodeNormValue(norms[doc]),即得到了累加和式中的一次迭代,最后通过查询树进行累加完成和式的计算。而具体的tf’、idf’和norm计算实现都是在DefaultSimilarity类中(如代码1-9)。
代码1-9 DefaultSimilrity代码片段
public void computeNorm(FieldInvertState state, Norm norm) {
final int numTerms;
if (discountOverlaps)
numTerms = state.getLength() - state.getNumOverlap();
else
numTerms = state.getLength();
norm.setByte(encodeNormValue(state.getBoost() * ((float) (1.0 / Math.sqrt(numTerms)))));
}
…
public float tf(float freq) {
return (float)Math.sqrt(freq);
}
…
public float idf(long docFreq, long numDocs) {
return (float)(Math.log(numDocs/(double)(docFreq+1)) + 1.0);
}
代码中计算过程如下:
公式1-12
公式1-13
公式1-14
需要特别注意的是norm(t,d)的计算过程,首先该计算可通过设置选择在计算指定域的词项数量(长度)时是否包含位置相同的词项(同义词等);另外该计算是在索引阶段进行而不是检索阶段,是因为所需的所有信息在一篇文档被添加到索引时都为已知,将其计算一次并保存在索引中可以提高检索过程的性能,但同时也使得索引依赖相似度评分类的选择,这是4.0与之前版本的较大变化之一,建立索引的时候需要注意。
关于Lucene 4.0中使用的默认检索模型VSM的介绍就到此为止,由于Lucene是一个较为复杂的系统,本节介绍的部分概念设计其它模块,会在分析响应模块时再详细解释。
原文链接:http://kevinma.cn/technology/lucene-4-analysis-vsm/2012-09-05
作者:KeViNOne
主页:http://kevinma.cn
本文版权归作者所有,欢迎转载,但未经作者同意必须保留此段声明,且在文章页面明显位置给出原文连接及作者主页链接,否则保留追究法律责任的权利。