我们对搜索引擎进行查询时候,很少会有人进行翻页操作。这就要求我们对索引的内容提取具有高度的匹配性,这就搜索引擎文档的相似性计算,如何准确的选出最符合查询条件的文档。
《这就是搜索引擎》里面对相似性计算进行了简单的介绍。
内容的相似性计算由搜索引擎的检索模型建模,它是搜索引擎的理论基础,为量化相关性提供了一种数学模型,否则没法计算。当然检索模型理论研究存在理想化的隐含假设,即假设用户需求已经通过查询非常清晰明确地表达出来了,所以检索模型的任务不牵扯到对用户需求建模,但实际上这个和实际相差较远,即使相同的查询词,不同用户的需求目的可能差异很大,而检索模型对此无能为力。几种常见的检索模型有:
简述中介绍了好多种相似性计算方法,Solr采用了最基本的向量空间模型,本节就主要介绍下向量空间模型。其他的向量空间模型以后有时间进行学习吧。
Solr的索引文件中有.tvx,.tvd,tvf存储了term vector的信息,首先我们学习如何利用term vector来反映相似性程度。
用v(d1)表示了term d1的term向量,向量空间模型中,两个term的相似程度是计算向量空间坐标系中两个term向量的夹角,如果夹角越小,说明相似程度越大,而角度的计算可以使用余弦定理计算。
给定一个查询以及一个文档,如何计算他们的相似值呢,请看以下公式,它使用了以下概念:term frequency (tf), inverse document frequency (idf), term boosts (t.getBoost), field normalization (norm), coordination factor (coord), and query normalization (queryNorm).
tf(t in d) = numTermOccurrencesInDocument 1/2
idf(t) = 1 + log (numDocs / (docFreq +1))
norm(t,d) = d.getBoost() • lengthNorm(f) • f.getBoost()
1 Document doc = new Document(); 2 for (String author : authors){ 3 doc.add(new Field("author",author,Field.Store.YES,Field.Index.ANALYZED)); 4 }
1 //首先对多值域建立索引 2 Directory dir = FSDirectory.open(new File("/Users/rcf/workspace/java/solr/Lucene")); 3 IndexWriterConfig indexWriterCOnfig= new IndexWriterConfig(Version.LUCENE_48,new WhitespaceAnalyzer(Version.LUCENE_48)); 4 @SuppressWarnings("resource") 5 IndexWriter writer = new IndexWriter(dir,indexWriterConfig); 6 Document doc = new Document(); 7 doc.add(new Field("author","lucene",Field.Store.YES,Field.Index.ANALYZED)); 8 doc.add(new Field("author","solr",Field.Store.YES,Field.Index.ANALYZED)); 9 doc.add(new Field("text","helloworld",Field.Store.YES,Field.Index.ANALYZED)); 10 writer.addDocument(doc); 11 writer.commit(); 12 //对多值域进行查询 13 IndexReader reader = IndexReader.open(dir); 14 IndexSearcher search = new IndexSearcher(reader); 15 Query query = new TermQuery(new Term("author","lucene")); 16 TopDocs docs = search.search(query, 1); 17 Document doc = search.doc(docs.scoreDocs[0].doc); 18 for(IndexableField field : doc.getFields()){ 19 System.out.println(field.name()+":"+field.stringValue()); 20 } 21 System.out.print(docs.totalHits); 22 //运行结果 23 author:lucene 24 author:solr 25 text:helloworld 26 2
当对多值域设置boost的时候,那么该field的boost最后怎么算呢?即为每一个值域的boost相乘。比如title这个field,第一次boost是3.0,第二次1,第三次0.5,那么结果就是3*1*0.5.
Boost: (3) · (1) · (0.5) = 1.5
queryNorm(q) = 1 / (sumOfSquaredWeights )
sumOfSquaredWeights = q.getBoost()2 • ∑ ( idf(t) • t.getBoost() )2
coord(q,d),表示文档中符合查询的term的个数,如果在文档中查询的term个数越多,那么这个文档的score就会更高。
numTermsInDocumentFromQuery / numTermsInQuery
比如Query:AccountantAND("SanFrancisco"OR"NewYork"OR"Paris")
文档A包含了上面的3个term,那么coord就是3/4,如果包含了1个,则coord就是4/4
上面介绍了相似值计算的公式,那么现在就来查看Solr实现的代码,这部分实现是在DefaultSimilarity类中。
1 @Override 2 public float coord(int overlap, int maxOverlap) { 3 return overlap / (float)maxOverlap; 4 } 5 6 @Override 7 public float queryNorm(float sumOfSquaredWeights) { 8 return (float)(1.0 / Math.sqrt(sumOfSquaredWeights)); 9 } 10 11 @Override 12 public float lengthNorm(FieldInvertState state) { 13 final int numTerms; 14 if (discountOverlaps) 15 numTerms = state.getLength() - state.getNumOverlap(); 16 else 17 numTerms = state.getLength(); 18 return state.getBoost() * ((float) (1.0 / Math.sqrt(numTerms))); 19 } 20 21 @Override 22 public float tf(float freq) { 23 return (float)Math.sqrt(freq); 24 } 25 26 @Override 27 public float idf(long docFreq, long numDocs) { 28 return (float)(Math.log(numDocs/(double)(docFreq+1)) + 1.0); 29 }
Solr计算score(q,d)的过程如下:
1:调用IndexSearcher.createNormalizedWeight()计算queryNorm();
1 public Weight createNormalizedWeight(Query query) throws IOException { 2 query = rewrite(query); 3 Weight weight = query.createWeight(this); 4 float v = weight.getValueForNormalization(); 5 float norm = getSimilarity().queryNorm(v); 6 if (Float.isInfinite(norm) || Float.isNaN(norm)) { 7 norm = 1.0f; 8 } 9 weight.normalize(norm, 1.0f); 10 return weight; 11 }
具体实现步骤如下:
Weight weight = query.createWeight(this);
1 public IDFStats(String field, Explanation idf, float queryBoost) { 2 // TODO: Validate? 3 this.field = field; 4 this.idf = idf; 5 this.queryBoost = queryBoost; 6 this.queryWeight = idf.getValue() * queryBoost; // compute query weight 7 }
计算sumOfSquaredWeights
s = weights.get(i).getValueForNormalization()计算( idf(t) • t.getBoost() )2 如以下代码所示,queryWeight在上一部中计算出
1 public float getValueForNormalization() { 2 // TODO: (sorta LUCENE-1907) make non-static class and expose this squaring via a nice method to subclasses? 3 return queryWeight * queryWeight; // sum of squared weights 4 }
1 public float getValueForNormalization() throws IOException { 2 float sum = 0.0f; 3 for (int i = 0 ; i) { 4 // call sumOfSquaredWeights for all clauses in case of side effects 5 float s = weights.get(i).getValueForNormalization(); // sum sub weights 6 if (!clauses.get(i).isProhibited()) { 7 // only add to sum for non-prohibited clauses 8 sum += s; 9 } 10 } 11 12 sum *= getBoost() * getBoost(); // boost each sub-weight 13 14 return sum ; 15 }
计算完整的querynorm() = 1 / Math.sqrt(sumOfSquaredWeights));
1 public float queryNorm(float sumOfSquaredWeights) { 2 return (float)(1.0 / Math.sqrt(sumOfSquaredWeights)); 3 }
weight.normalize(norm, 1.0f) 计算norm()
topLevelBoost *= getBoost();
1 public void normalize(float queryNorm, float topLevelBoost) { 2 this.queryNorm = queryNorm * topLevelBoost; 3 queryWeight *= this.queryNorm; // normalize query weight 4 value = queryWeight * idf.getValue(); // idf for document 5 }
2:调用IndexSearch.weight.bulkScorer()计算coord(q,d),并获取每一个term的docFreq,并将docFreq按td从小到大排序。
1 if (optional.size() == 0 && prohibited.size() == 0) { 2 float coord = disableCoord ? 1.0f : coord(required.size(), maxCoord); 3 return new ConjunctionScorer(this, required.toArray(new Scorer[required.size()]), coord); 4 }
3:score.score()进行评分计算,获取相似值,并放入优先级队列中获取评分最高的doc id。
1 public float score(int doc, float freq) { 2 final float raw = tf(freq) * weightValue; // compute tf(f)*weight 3 4 return norms == null ? raw : raw * decodeNormValue(norms.get(doc)); // normalize for field 5 }
1 public float score() throws IOException { 2 // TODO: sum into a double and cast to float if we ever send required clauses to BS1 3 float sum = 0.0f; 4 for (DocsAndFreqs docs : docsAndFreqs) { 5 sum += docs.scorer.score(); 6 } 7 return sum * coord; 8 }
1 public void collect(int doc) throws IOException { 2 float score = scorer.score(); 3 4 // This collector cannot handle these scores: 5 assert score != Float.NEGATIVE_INFINITY; 6 assert !Float.isNaN(score); 7 8 totalHits++; 9 if (score <= pqTop.score) { 10 // Since docs are returned in-order (i.e., increasing doc Id), a document 11 // with equal score to pqTop.score cannot compete since HitQueue favors 12 // documents with lower doc Ids. Therefore reject those docs too. 13 return; 14 } 15 pqTop.doc = doc + docBase; 16 pqTop.score = score; 17 pqTop = pq.updateTop(); 18 }
关于公式的推导觉先的《Lucene学习总结之六:Lucene打分公式的数学推导》可以查看这部分内容。
我们把文档看作一系列词(Term),每一个词(Term)都有一个权重(Term weight),不同的词(Term)根据自己在文档中的权重来影响文档相关性的打分计算。
于是我们把所有此文档中词(term)的权重(term weight) 看作一个向量。
Document = {term1, term2, …… ,term N}
Document Vector = {weight1, weight2, …… ,weight N}
同样我们把查询语句看作一个简单的文档,也用向量来表示。
Query = {term1, term 2, …… , term N}
Query Vector = {weight1, weight2, …… , weight N}
我们把所有搜索出的文档向量及查询向量放到一个N维空间中,每个词(term)是一维。
我们认为两个向量之间的夹角越小,相关性越大。
所以我们计算夹角的余弦值作为相关性的打分,夹角越小,余弦值越大,打分越高,相关性越大。
余弦公式如下:
下面我们假设:
查询向量为Vq =
文档向量为Vd =
向量空间维数为n,是查询语句和文档的并集的长度,当某个Term不在查询语句中出现的时候,w(t, q)为零,当某个Term不在文档中出现的时候,w(t, d)为零。
w代表weight,计算公式一般为tf*idf。
我们首先计算余弦公式的分子部分,也即两个向量的点积:
Vq*Vd = w(t1, q)*w(t1, d) + w(t2, q)*w(t2, d) + …… + w(tn ,q)*w(tn, d)
把w的公式代入,则为
Vq*Vd = tf(t1, q)*idf(t1, q)*tf(t1, d)*idf(t1, d) + tf(t2, q)*idf(t2, q)*tf(t2, d)*idf(t2, d) + …… + tf(tn ,q)*idf(tn, q)*tf(tn, d)*idf(tn, d)
在这里有三点需要指出:
基于上述三点,点积公式为:
Vq*Vd = tf(t1, d) * idf(t1) * idf(t1) + tf(t2, d) * idf(t2) * idf(t2) + …… + tf(tn, d) * idf(tn) * idf(tn)
所以余弦公式变为:
下面要推导的就是查询语句的长度了。
由上面的讨论,查询语句中tf都为1,idf都忽略查询语句这篇小文档,得到如下公式
所以余弦公式变为:
下面推导的就是文档的长度了,本来文档长度的公式应该如下:
这里需要讨论的是,为什么在打分过程中,需要除以文档的长度呢?
因为在索引中,不同的文档长度不一样,很显然,对于任意一个term,在长的文档中的tf要大的多,因而分数也越高,这样对小的文档不公平,举一个极端的例子,在一篇1000万个词的鸿篇巨著中,"lucene"这个词出现了11次,而在一篇12个词的短小文档中,"lucene"这个词出现了10次,如果不考虑长度在内,当然鸿篇巨著应该分数更高,然而显然这篇小文档才是真正关注"lucene"的。
然而如果按照标准的余弦计算公式,完全消除文档长度的影响,则又对长文档不公平(毕竟它是包含了更多的信息),偏向于首先返回短小的文档的,这样在实际应用中使得搜索结果很难看。
所以在Lucene中,Similarity的lengthNorm接口是开放出来,用户可以根据自己应用的需要,改写lengthNorm的计算公式。比如我想做一个经济学论文的搜索系统,经过一定时间的调研,发现大多数的经济学论文的长度在8000到10000词,因而lengthNorm的公式应该是一个倒抛物线型的,8000到 10000词的论文分数最高,更短或更长的分数都应该偏低,方能够返回给用户最好的数据。
在默认状况下,Lucene采用DefaultSimilarity,认为在计算文档的向量长度的时候,每个Term的权重就不再考虑在内了,而是全部为一。
而从Term的定义我们可以知道,Term是包含域信息的,也即title:hello和content:hello是不同的Term,也即一个Term只可能在文档中的一个域中出现。
所以文档长度的公式为:
代入余弦公式:
再加上各种boost和coord,则可得出Lucene的打分计算公式。
前面学习了Solr的评分机制,虽然对理论的推导以及公式有了一些了解,但是在Solr具体实现上我却产生了不少疑惑:
1. BooleanQuery查询,为什么没有用到LengthNorm。
2. BooleanQuery 多条件查询时候,Not And Or 对文档进行打分时候是否具有影响。
3. PharseQuery查询时候,打分又是怎么进行的。
4. 怎么样对这个进行打分进行定制。
这些都是接下来需要去理解的。
我们把文档看作一系列词(Term),每一个词(Term)都有一个权重(Term weight),不同的词(Term)根据自己在文档中的权重来影响文档相关性的打分计算。
于是我们把所有此文档中词(term)的权重(term weight) 看作一个向量。
Document = {term1, term2, …… ,term N}
Document Vector = {weight1, weight2, …… ,weight N}
同样我们把查询语句看作一个简单的文档,也用向量来表示。
Query = {term1, term 2, …… , term N}
Query Vector = {weight1, weight2, …… , weight N}
我们把所有搜索出的文档向量及查询向量放到一个N维空间中,每个词(term)是一维。
我们认为两个向量之间的夹角越小,相关性越大。
所以我们计算夹角的余弦值作为相关性的打分,夹角越小,余弦值越大,打分越高,相关性越大。
余弦公式如下:
下面我们假设:
查询向量为Vq =
文档向量为Vd =
向量空间维数为n,是查询语句和文档的并集的长度,当某个Term不在查询语句中出现的时候,w(t, q)为零,当某个Term不在文档中出现的时候,w(t, d)为零。
w代表weight,计算公式一般为tf*idf。
我们首先计算余弦公式的分子部分,也即两个向量的点积:
Vq*Vd = w(t1, q)*w(t1, d) + w(t2, q)*w(t2, d) + …… + w(tn ,q)*w(tn, d)
把w的公式代入,则为
Vq*Vd = tf(t1, q)*idf(t1, q)*tf(t1, d)*idf(t1, d) + tf(t2, q)*idf(t2, q)*tf(t2, d)*idf(t2, d) + …… + tf(tn ,q)*idf(tn, q)*tf(tn, d)*idf(tn, d)
在这里有三点需要指出:
基于上述三点,点积公式为:
Vq*Vd = tf(t1, d) * idf(t1) * idf(t1) + tf(t2, d) * idf(t2) * idf(t2) + …… + tf(tn, d) * idf(tn) * idf(tn)
所以余弦公式变为:
下面要推导的就是查询语句的长度了。
由上面的讨论,查询语句中tf都为1,idf都忽略查询语句这篇小文档,得到如下公式
所以余弦公式变为:
下面推导的就是文档的长度了,本来文档长度的公式应该如下:
这里需要讨论的是,为什么在打分过程中,需要除以文档的长度呢?
因为在索引中,不同的文档长度不一样,很显然,对于任意一个term,在长的文档中的tf要大的多,因而分数也越高,这样对小的文档不公平,举一个极端的例子,在一篇1000万个词的鸿篇巨著中,"lucene"这个词出现了11次,而在一篇12个词的短小文档中,"lucene"这个词出现了10次,如果不考虑长度在内,当然鸿篇巨著应该分数更高,然而显然这篇小文档才是真正关注"lucene"的。
然而如果按照标准的余弦计算公式,完全消除文档长度的影响,则又对长文档不公平(毕竟它是包含了更多的信息),偏向于首先返回短小的文档的,这样在实际应用中使得搜索结果很难看。
所以在Lucene中,Similarity的lengthNorm接口是开放出来,用户可以根据自己应用的需要,改写lengthNorm的计算公式。比如我想做一个经济学论文的搜索系统,经过一定时间的调研,发现大多数的经济学论文的长度在8000到10000词,因而lengthNorm的公式应该是一个倒抛物线型的,8000到 10000词的论文分数最高,更短或更长的分数都应该偏低,方能够返回给用户最好的数据。
在默认状况下,Lucene采用DefaultSimilarity,认为在计算文档的向量长度的时候,每个Term的权重就不再考虑在内了,而是全部为一。
而从Term的定义我们可以知道,Term是包含域信息的,也即title:hello和content:hello是不同的Term,也即一个Term只可能在文档中的一个域中出现。
所以文档长度的公式为:
代入余弦公式:
再加上各种boost和coord,则可得出Lucene的打分计算公式。
前面学习了Solr的评分机制,虽然对理论的推导以及公式有了一些了解,但是在Solr具体实现上我却产生了不少疑惑:
1. BooleanQuery查询,为什么没有用到LengthNorm。
2. BooleanQuery 多条件查询时候,Not And Or 对文档进行打分时候是否具有影响。
3. PharseQuery查询时候,打分又是怎么进行的。
4. 怎么样对这个进行打分进行定制。
这些都是接下来需要去理解的。