前言:目前本身在作使用Lucene.net和PanGu分词实现全文检索的工做,不过本身是把别人作好的项目进行迁移。由于项目总体要迁移到ASP.NET Core 2.0版本,而Lucene使用的版本是3.6.0 ,PanGu分词也是对应Lucene3.6.0版本的。不过好在Lucene.net 已经有了Core 2.0版本(4.8.0 bate版),而PanGu分词,目前有人正在作,貌似已经作完,只是尚未测试~,Lucene升级的改变我都会加粗表示。html
Lucene.net 4.8.0 git
https://github.com/apache/lucenenetgithub
PanGu分词算法
https://github.com/LonghronShen/Lucene.Net.Analysis.PanGu/tree/netcore2.0apache
不过如今我已经抛弃了PanGu分词,取而代之的是JIEba分词,缓存
https://github.com/SilentCC/JIEba-netcore2.0服务器
如今已经支持直接在nuget中下载,包名:Lucene.JIEba.net数据结构
https://www.nuget.org/packages/Lucene.JIEba.net/1.0.4并发
详情能够参照上一篇。框架
这篇博文主要是想介绍Lucene的搜索过程在源码中怎样的。决定探究源码的缘由是由于我在使用Lucene的过程当中遇到性能瓶颈的问题,根本不知道在搜索过程当中哪里消耗的资源多,致使并发的时候服务器不堪重负。最后找到了缘由,虽然和这篇博文没什么大的关系,但仍是想把本身学习的过程记录下来。
在介绍Lucene的search以前,有必要对搜索引擎的索引系统作一个简单的了解。
索引通俗的说就是用来查找信息的信息,好比书的目录也是索引,能够帮助咱们快速的查找内容在哪一页。那么在搜索引擎中咱们须要储存的是文档和网页内容,就像是书中的一个一个章节同样。那么搜索引擎的索引其实就是查询的关键词,经过关键词,搜索引擎帮助你快速查找到文档在哪里。文档的量是十分巨大的,然而关键词在任何语言中都是固定的那么多,都是有限的。所以书本的目录能够是不多的几页。那么如何去建这个索引呢?这就是索引系统简历的关键。
咱们知道如今的全文检索的索引系统大都是基于倒排索引的,倒排索引能够快速经过关键词(索引)找到相应的文档,Lucene的索引系统天然也是基于倒排索引。
介绍倒排索引以前先介绍正排索引,由于正排索引是倒排索引建立的基础,两者结合起来就很好理解搜索引擎的索引系统。全文检索系统没法就是在大量的索引库中寻找命中搜索关键词的文档。因而在任何一个索引系统中应该有这么两个概念:关键词(索引),文档 (信息)。正排索引的储存很简单就是一个文档到关键词的映射,根据文档id 能够映射到这篇文档里面关键词信息:
上面就是正排表,它表示DocId 为D1 的文档 由三个词组成 W1, W2 和W3 。W1 在文档中出现了1次,起始位置为2。W2在文档中出现了2次,起始位置分别为5 和6。
这样能够经过文档快速的找到文档中的索引词的信息。它是站在文档的角度,以文档编号为索引结构。
正排索引是没法知足全文检索的须要,因而在正排索引的基础上创造了倒排索引。
倒排索引实际上是以关键词为索引结构,构造了从关键词到文档的一个映射。倒排索引由两部分组成,第一部分是关键词组成的字典,也就是索引结构。第二部分是文档集合。
上图就是一个倒排表,它表示的意思是:首先在第一部分(字典构成的索引)中,有个三个关键词W1,W2,W3. 其中包含W1的文档(nDocs)有3个,偏移位置(offset)为1 ,这个偏移位置就表示W1 映射在第二部分中的起始位置,因此能够看到,W1 命中了三篇文档(1,2,3)在第一篇文档中W1出现了2次,起始位置分别是1,2。以此类推第二篇和第三篇。 W2 命中了两篇文档(1 和 2),W3也是如此。
能够看到在倒排索引中,它是一个关键词映射到文档的集合。能够经过关键词,快速查找该关键词出如今哪里文档,而且在该文档中出现的次数和位置(这是创建在正排索引的基础上)
实际上这样一个简单的倒排索引结构仍是十分简陋的,没有考虑到记录表中的何种文档排序方式更有利于检索,以及这样一个倒排索引结构采用什么方式压缩更省空间。这些都不去细究了。接下来看Lucene的索引系统。
在 Lucene.net(4.8.0) 学习问题记录三: 索引的建立 IndexWriter 和索引速度的优化 中介绍了Lucene 索引结构的正向信息,所谓正向信息就是从文档的角度出发储存文档的域,词等信息:
那么Lucene索引结构中的反向信息也就是咱们所说的倒排索引:
Lucene的索引(这里就是指倒排索引第一部分也即词典索引)用的是FST数据结构,Lucene的记录表采用Frame of reference结构都不作细述。
从索引文件上来讲,Lucene的搜索过程:在IndexSearch 初始化的时候先就将.tip .tim文件的内容加载到内存中,在Search的过程当中,会从.tip .tim文件中查找到关键词(Terms),而后顺着这些Terms 去.doc文件中查找命中的文档,最后取出文档ID。这只是很笼统的一个大概的过程。实际上Lucene在Search的过程当中还有一个很重要同时也是很消耗时间的操做:评分。 接下来就看看Lucene的具体源码是怎么实现的,在这个过程当中只介绍重要的类和方法,由于整个搜索过程是很复杂的,而且在这个过程当中能够看看Lucene的搜索操做时间都消耗在了哪里?。PS:我这里的Lucene都是指Lucene.Net版本。
Lucene检索的时序图,大概以下所示,能够直观的看下整个流程:
咱们都知道Lucene的搜索是经过IndexSearch来完成的。IndexSearch的初始化分为三步,前两步是:
FSDriectory dir = FSDirectory.Open(storage.IndexDir);
IndexReader indexReader = DirectoryReader.Open(dir);
前面说到过Lucene须要加载词典索引到内存中,这步操做就是在 DirectoryReader.Open()的函数中完成的。而完成加载的类叫作 BlockTreeTermsReader ,还有一个与之对应的类叫作BlockTreeTermsWriter 很显然前者是历来读取索引,后者是用来写索引的,这两个类是操做词典索引的类。它们在Lucene.Net.Codecs包中
具体一点的加载方式:BlockTreeTermsReader 的内部类 FieldReader 它是前面的Term Directory 和Term Index的代码实现,只贴出一部分。
public sealed class FieldReader : Terms { private readonly BlockTreeTermsReader outerInstance; internal readonly long numTerms; internal readonly FieldInfo fieldInfo; internal readonly long sumTotalTermFreq; internal readonly long sumDocFreq; internal readonly int docCount; internal readonly long indexStartFP; internal readonly long rootBlockFP; internal readonly BytesRef rootCode; internal readonly int longsSize; internal readonly FST<BytesRef> index; //private boolean DEBUG; ..... }
能够看到FST<BytesRef> index 对应.tim中的FST 。FST.cs在Lucene.Net.Util包中 。
每次初始化IndexSearch,都会将.tim 和.tip中的内容加载到内存中,这些操做都是很耗时的。因此这就是为何用Lucene的人都说IndexSearch应该使用单例模式,或者把它缓存起来。
在初始化IndexSearch以后,便开始执行IndexSearch.Search 函数
public virtual TopDocs Search(Query query, Filter filter, int n) { Query q = WrapFilter(query, filter); Weight w = CreateNormalizedWeight(q); return Search(w, null, n); }
将Query 和Filter 组合成过滤查询FilteredQuery 就是上面代码块中的Query q = WrapFilter(query,filter);
IndexSearchr : WrapFilter
protected virtual Query WrapFilter(Query query, Filter filter) { Console.WriteLine("第二步:根据查询query,和过滤条件filter 组合成过滤查询FilteredQuery,执行函数WrapFilter"); return (filter == null) ? query : new FilteredQuery(query, filter); }
Weight 类简单的概念:
Lucene中生成Weight的源码:
public virtual Weight CreateNormalizedWeight(Query query) {
query = Rewrite(query);//重写查询 Weight weight = query.CreateWeight(this);//生成Weight float v = weight.GetValueForNormalization(); float norm = Similarity.QueryNorm(v); if (float.IsInfinity(norm) || float.IsNaN(norm)) { norm = 1.0f; } weight.Normalize(norm, 1.0f); return weight; }
首先是重写查询
Lucene 将Query 重写成一个个TermQuery组成的原始查询 ,调用的是Query的Rewrite 方法,好比一个PrefixQuery 则会被重写成由TermQuerys 组成的BooleanQuery 。全部继承Query的 好比BooleanQuery ,PhraseQuery,CustomQuery都会覆写这个方法以实现重写Query。
public virtual Query Rewrite(IndexReader reader) { return this; }
而后计算查询权重
计算查询权重,实际上这么一个操做:在获得重写查询以后的原始查询TermQuery ,先经过上文所说的 BlogTreeTermsReader 读取词典索引中符合TermQuery的Term ,而后经过Lucene本身TF/IDF 打分机制,算出Term的IDF值,以及QueryNorm的值(打分操做都是调用 Similarity 类),最后返回Weight。
计算Term IDF的源码,它位于 TFIDFSimilarity : Similarity 中
public override sealed SimWeight ComputeWeight(float queryBoost, CollectionStatistics collectionStats, params TermStatistics[] termStats) { Explanation idf = termStats.Length == 1 ? IdfExplain(collectionStats, termStats[0]) : IdfExplain(collectionStats, termStats); return new IDFStats(collectionStats.Field, idf, queryBoost); }
IDFStats 是包装Term IDF值的类,能够看到打分的过程还要考虑咱们在应用层设置的Query的Boost .
上面只是计算一个文档的分数的一小部分,实际上仍是比较复杂的,咱们能够简单了解介绍Lucene 的TFIDFSimilarity 的打分机制
TFIDFSimilarity的简单介绍:
TFIDFSimilarity 是Lucene中的评分类。这是官方文档的介绍:https://lucene.apache.org/core/4_8_0/core/org/apache/lucene/search/similarities/TFIDFSimilarity.html
它并不只仅是TFIDF那么简单的算法。实际上它是很大部分搜索引擎都在使用的打分机制,叫作空间向量模型。
作过天然语言处理的人都知道,对于文本都须要它们处理成向量,这样咱们就能够利用数学,统计学中的知识对文本进行分析了。这些向量叫作文本向量。向量的维度是文档中词的个数,向量中的值是文档中词的权重。算余弦值
cosine-similarity(q,d) = |
|
经过这些文本向量,咱们能够作一些颇有意思的事情,好比计算两个文本的文本向量的余弦值,就能够知道两篇文本的类似程度。而搜索引擎就是利用了这样的性质,将查询关键词和待查询的文档都转成空间向量,计算两者的余弦值,这样就能够知道哪些文档和查询关键词十分类似了。这些类似的文档得分就越高。这样的打分方式高效并且准确。
在Lucene中空间向量的值其实就是TF/IDF的值。Lucene的计算空间余弦值通过变换已经变成这样的形式
至于过程是怎么样的,有兴趣能够详细阅读上面的官方文档。(必定要注意颜色,这个很重要)
PS: 在这里我要提醒一点,由于Lucene提供了自定义打分机制(CustomSocre),和给Query设置Boost ,最终的得分是score(q,d)*customScore 我就吃过本身设置的自定义打分机制和Boost不当的亏,致使排序结果是那些IDF值很低(也即可有可无的词,例如“我”,“在”,“找不到”...)的词排名靠前,而明明有命中全部查询词的文档却排在后面。
能够猜到到这里Lucene只计算了 queryNorm(q) *idf(t in q) *t.getBoost() 值,最后的文档的分数 还要再正真的Search过程当中去完成剩余的部分。
生成Weight 以后,Lucene执行的源码以下:
protected virtual TopDocs Search(IList<AtomicReaderContext> leaves, Weight weight, ScoreDoc after, int nDocs) { // single thread int limit = reader.MaxDoc; if (limit == 0) { limit = 1; } nDocs = Math.Min(nDocs, limit); TopScoreDocCollector collector = TopScoreDocCollector.Create(nDocs, after, !weight.ScoresDocsOutOfOrder); Search(leaves, weight, collector); return collector.GetTopDocs(); }
TopSorceDocCollector 实际上一个文档收集器,它是装在查询结果文档的容器,collector.GetTopDocs() 获得就是你们都知道的TopDocs.
TopSorceDocCollector 生成函数
opScoreDocCollector collector = TopScoreDocCollector.Create(nDocs, after, !weight.ScoresDocsOutOfOrder);
Scorer 前面已经介绍过,它就是一个由TermQuery从索引库中查询出来的文档集合的迭代器,能够说生成Scorer的过程就是查找文档的过程。那么生成Scorer以后能够经过它的next 函数遍历咱们的结果文档集合,对它们一一打分结合前面计算的queryWeight
先来看源码:
protected virtual void Search(IList<AtomicReaderContext> leaves, Weight weight, ICollector collector) { // TODO: should we make this // threaded...? the Collector could be sync'd? // always use single thread: foreach (AtomicReaderContext ctx in leaves) // search each subreader { try { collector.SetNextReader(ctx); } catch (CollectionTerminatedException) { // there is no doc of interest in this reader context // continue with the following leaf continue; } BulkScorer scorer = weight.GetBulkScorer(ctx, !collector.AcceptsDocsOutOfOrder, ctx.AtomicReader.LiveDocs); if (scorer != null) { try { scorer.Score(collector); } catch (CollectionTerminatedException) { // collection was terminated prematurely // continue with the following leaf } } } }
经过Weight 生成scorer 的操做是:
BulkScorer scorer = weight.GetBulkScorer(ctx, !collector.AcceptsDocsOutOfOrder, ctx.AtomicReader.LiveDocs);
这应该是整个搜索过程当中最耗时的操做。它是若是获取Scorer的呢?上文说到Weight的一个做用是提供Search须要的Query, 其实生成Scorer的最终步骤是经过TermQuery(原始型查询) 的GetScorer函数,GetScorer函数:
public override Scorer GetScorer(AtomicReaderContext context, IBits acceptDocs) { Debug.Assert(termStates.TopReaderContext == ReaderUtil.GetTopLevelContext(context), "The top-reader used to create Weight (" + termStates.TopReaderContext + ") is not the same as the current reader's top-reader (" + ReaderUtil.GetTopLevelContext(context)); TermsEnum termsEnum = GetTermsEnum(context); if (termsEnum == null) { return null; } DocsEnum docs = termsEnum.Docs(acceptDocs, null); Debug.Assert(docs != null); return new TermScorer(this, docs, similarity.GetSimScorer(stats, context)); }
在这个函数里,已经体现了Lucene是怎么根据查找文档的,首先GetTermsEnum(context)函数 获取 TermsEnum , TermsEnum 是用来获取包含当前 Term 的 DocsEnum ,而DocsEnum 包含文档docs 和词频term frequency .
因而查询文档的过程就清晰了:
对于当前的TermQuery ,查找符合TermQuery的文档的步骤是 利用AtomicReader (经过AtomicReaderContext获取) 生成TermsEnum (TermsEnum中的当前Term 就是TermQuery咱们须要查询的那个Term)
TermsEnum termsEnum = context.AtomicReader.GetTerms(outerInstance.term.Field).GetIterator(null);
再经过TermsEnum 获取DocsEnum
DocsEnum docs = termsEnum.Docs(acceptDocs, null);
最后合成Scorer
return new TermScorer(this, docs, similarity.GetSimScorer(stats, context));
这一步直接体如今源码中就是:
scorer.Score(collector);
固然不多是这一行代码就能完成的。它最终调用的Weight类的ScoreAll()函数.
internal static void ScoreAll(ICollector collector, Scorer scorer) { System.Console.WriteLine("Weight类,ScoreAll ,将Scorer中的doc传给Collertor"); int doc; while ((doc = scorer.NextDoc()) != DocIdSetIterator.NO_MORE_DOCS) { collector.Collect(doc);//收集评分后的文档 } }
然而正真打分的函数也不是ScoreAll函数,它是scorer.NextDoc()函数,
scorer执行NextDoc函数会调用 TFIDFSimScorer 类,它是TFIDFSimilarity的内部类,计算分数的函数为:
public override float Score(int doc, float freq) { System.Console.WriteLine("开始计算文档的算分,根据TF/IDF方法"); float raw = outerInstance.Tf(freq) * weightValue; // compute tf(f)*weight return norms == null ? raw : raw * outerInstance.DecodeNormValue(norms.Get(doc)); // normalize for field }
这是Lucene评分公式中的部分得分,最终得分应该再乘以上文的查询得分queryWeight再乘以自定义的得分CustomScore.
没什么好说的了。
行文至此,终于将Lucene 的索引,搜索,打分机制说完了。实际上完整的过程不是一篇博文就能涵盖的,源码也远远不止我贴出来的那些。我只是大概了解这个过程,而且介绍了几个关键的类:IndexSearcher,Weight , Scorer , Similarity, TopScoreDocCollector,AtomicReader 等。Lucene之因此是搜索引擎开源框架的不二选择,是由于它的搜索效果和速度是真的不错。若是你的程序搜索效果不好,那么必定是你没有善用Lucene。
此外我想说一个问题,读懂Lucene的源码对于使用Lucene有没有帮助呢?你不懂Lucene的内部机制和底层原理,照样也能够用的很滑溜,还有Solr ElasticSearch 等现成的工具可使用。其实读懂源码对你的知识和代码认知能力提高不说,对于lucene,你能够在知道它内部原理的状况下本身修改它的源码已适应你的程序,好比 1. 你彻底能够将打分机制屏蔽,那么Lucene搜索的效率将成倍提升 2. 你也能够直接使用Lucene最底层的接口,好比AtomicReader 类,这个直接操做索引的类,从而达到更深层次的二次开发。这岂不是很酷炫?3. 能够直接修改lucene不合理的代码。
最后说一句勉励本身的话,其实写博客是一个很好的方式,由于你抱着写给别人看的态度,因此你要格外严谨,而且保证本身充分理解的状况下才能写博客。这个过程已经足够你对某个问题入木三分了。
最最后,我补充一下,我遇到的Lucene的性能问题,源于高亮。上述过程Lucene作的十分出色,而因为高亮的限制(其实是自动摘要)搜索引擎的并发性能很低,而如何解决这个问题也是很值得深究的问题。