从零开始搭建.NET Core版搜索引擎(六)--Lucene工作原理及流程

19 篇文章 7 订阅
11 篇文章 4 订阅

在前面几篇中对于关键词索引的创建和检索已经基本实现,但如果想要再继续深入就有必要理解Lucene.NET的工作原理和工作流程。

1.工作原理

当我们想从大量的信息查询某个特定的信息时,不仅需要知道这个特定信息是否存在,而且需要知道这个特定信息在什么位置。这种情况下遍历所有信息去查找这个特定的信息并不是一个明智选择,查找的成本会极其巨大。建立索引则是一种很好的解决思路,索引类似目录、标签。就好比字典、书籍的目录,我们可以通过目录快速定位到相关的章节,而不必翻看每一页,这样能极大的缩短查找时间,毕竟查询的核心诉求就是要快。

Lucene.NET的核心原理是倒排索引。倒排索引是根据属性值反向查找记录,也即是根据根据关键词反向查找其所在的文档。

1.1.文本分割

这里的文本指广义上的数据,可以是一篇文章、一段字符。文本的分割有两个方面,一方面是查询对象文本的分割,一方面是查询条件文本的分割。

查询对象文本的分割,比如一段字符串“有人说:“首长要爱护士兵””,对其中的关键词keyword进行分词提取,大致会进行以下处理过程:

  • 根据语义分词。“首长”、“爱护”、“士兵”这些在中文中都是词,如果分成“首”、“长”这样就不是原来的意思了。
  • 移除非关键词。比如常见的语气词、介词“啊”、“与”、“的”、“是”等,没有具体的含义。
  • 移除标点符号、空格等。比如?、;等不表示某种概念,也可以移除掉。

比如” 有人说:“首长要爱护士兵” “这句话分词后就会是”有人“、”首长“、”爱护“、”士兵“这几个关键词。

由于查询对象文本已经按照关键词分割,输入查询条件是也要分割关键词,这样才能更好的匹配查询对象,进而得到更准确的匹配结果。

1.2.建立索引

有了关键词后,就可以建立索引了。索引有正排索引和倒排索引之分。

举个例子,文档1是“南京市玄武区北京东路12号”,分词为“南京”、“南京市”、“玄武”、“玄武区”、“北京”、“北京东路”、“东路”、“12”、“号”。文档2是“南京市鼓楼区上海路7号”,分词为“南京”、“南京市”、“鼓楼”、“鼓楼区”、“上海”、“上海路”、“7”、“号”。

正排索引就是从文档的角度检索。

文档号关键词出现频率(命中次数)位置(偏移量)
1南京11
玄武13
北京17
2南京11
鼓楼13

假如要查询关键字“鼓楼”,则需要按顺序先查文档1、再查文档2,即使文档1中没有“鼓楼”还是要查一遍,毕竟没有查之前是不知道有没有的,这样显然查询成本有点高。

倒排索引则是从关键词的角度检索。

关键词文档号【出现频率】出现位置(偏移量)
南京1【1】1
2【1】1
玄武1【1】3
鼓楼2【1】3

如果要查“鼓楼”,则直接可以在倒排索引检索到“鼓楼”,这是因为倒排索引是类似字典的结构(虽然实际上并不是字典)。之后就可以查询关键词出现在哪个文档里、在文档中出现的位置。关键字是按字符顺序排列的(lucene没有使用B树结构),因此lucene可以用二元搜索算法快速定位关键词。
倒排索引通常包含的数据有关键词在文章中出现次数和出现的位置:

  • 字符位置,即记录该词是文章中第几个字符(优点是关键词亮显时定位快);
  • 关键词位置,即记录该词是文章中第几个关键词(优点是节约索引空间、词组(phase)查询快);

倒排索引是Lucene索引结构中的最核心部分,也是理解Lucene的原理的关键。

1.3.具体实现

在具体实现上,Lucene将倒排索引表中的关键词、文档号【出现频率】、关键词位置分别存储在词典文件(Term Dictionary)、频率文件(frequencies)、位置文件 (positions)保存。其中词典文件不仅保存有每个关键词,还保留了指向频率文件和位置文件的指针,通过指针可以找到该关键字的频率信息和位置信息。   
Lucene中使用了field的概念,用于表达信息所在位置(如标题中,文章中,url中),在建立索引过程中,该field信息也记录在词典文件中,每个关键词都有一个field信息(因为每个关键字一定属于一个或多个field)。
此外为了减小索引文件的大小,Lucene对索引还使用了压缩技术:

  • 首先,对词典文件中的关键词进行了压缩,关键词压缩为<前缀长度,后缀>,例如:当前词为“北京东路”,上一个词为“北京”,那么“北京东路”压缩为<2,东路>。
  • 其次大量用到的是对数字的压缩,数字只保存与上一个值的差值(这样可以减小数字的长度,进而减少保存该数字需要的字节数)。

2.工作流程

Lucene.NET的工作流程有创建索引和检索查询两部分组成,其中创建索引主要是由IndexWriter实现的,而检索查询主要是由IndexSearch实现的。

2.1.创建索引

首先是IndexWriter的初始化。
IndexWriter的初始化需要三个前置条件,分别是Analyzer、Directory和IndexWriterConfig的初始化。

Lucene.Net.Analysis.Analyzer analyzer = new StandardAnalyzer(LuceneVersion.LUCENE_48);
Lucene.Net.Store.Directory directory = FSDirectory.Open(AppDomain.CurrentDomain.BaseDirectory + "/Lucene");
Lucene.Net.Index.IndexWriterConfig config = new IndexWriterConfig(LuceneVersion.LUCENE_48, analyzer);
using (Lucene.Net.Index.IndexWriter writer=new IndexWriter(directory,config))
{
 
}

其中Analyzer的实例又用于IndexWriterConfig的实例化。

在这里插入图片描述

从IndexWriter类的代码中可以看到,在IndexWriter初始化时IndexWriterConfig将自己与当前的IndexWriter实例绑定,避免config配置被其他IndexWriter实例使用。同时实例化LiveIndexWriterConfig,这是一个内部类,IndexWriterConfig就是派生于它,目的是创建一个新的配置用于控制当前活动的IndexWriter的配置。也就是说,一个配置IndexWriterConfig只能给一个索引操作器IndexWriter使用。

另外对于Directory添加了文件写入锁,又由于同时创建了IndexReader池,所以对于一个索引文件在写入的同时也可以读取,但是不能被多个IndexWriter同时写入。

在这里插入图片描述

IndexWriter的初始化过程还没结束。这部分是读取索引文件中的分片信息SegmentInfos,载入内存中,同时创建一份用于回滚备份的SegmentCommitInfo集合(segmentInfos.CreateBackupSegmentInfos())。与此同时实例化一个文档操作器DocumentWriter,参数是当前的IndexWriter实例、IndexWriterConfig实例和Directory实例,为后续的Document操作做准备。

IndexWriter实例化的源码比较长,不再一一列出。在这个过程中还同时完成了索引文件归并策略的配置、索引文件删除类的实例化。

之后是Document和Field的创建。

Document是创建索引和检索的单元。一个Document文档就是多个Field域的集合。每个Field域都有一个名称和文本值。为了便于一个Filed域随同检索命中返回,Document中的Field可以被存储。因此每个Document形式上应该至少有一个可存储的Filed用于标识Document的唯一性。说白了就需要手动为Document创建一个标识,一方你不知道检索结果中的Document是哪一个。Document类中的方法几乎都是关于Filed操作的。
Field是Document的一段。每个Field有三部分:名称、类型、值,值可以是文本string、预处理过的TokenStream、二进制或数字。Field域可以选择性的被存储在索引文件中(设置Stored属性),这样的话可以在随着检索结果返回。Lucene.NET中有多种不同类型的Filed,如StringField、TextField、Int32Field等,但核心功能都是在基类Field类中实现。Field类中的方法TokenStream GetTokenStream(Analyzer analyzer)实现了根据数据类型将FieldValue分割成Token流,其本质上还是由Analyzer实现的。

在这里插入图片描述

Analyzer中的方法TokenStream GetTokenStream(string fieldName, string text)、TokenStream GetTokenStream(string fieldName, TextReader reader)用于输出TokenStream。

Analyzer类中有两个抽象类ReuseStrategy、GlobalReuseStrategy配合实现上述功能。ResuseStrategy类中有成员函数GetReusableComponents 和SetReusableComponents 是设置TokenStream和Tokenizer的。这两个类实现了设置Analyzer中的TokenStreamComponents和获取TokenStreamComponents 。

在Analyzer 中,同一个线程上的所有Analyzer实例都是共用一个TokenStream,而实现如此都是因为Analyzer类中 storedValue 是全局共用的,获取TokenStream的方法是由reuseStrategy 类提供的,TokenStream 继承自AttributeSource。TokenStream 实际上是由一系列Token(分词)组合起来的序列。

一个TokenStream实例的生命周期是Reset()、IncrementToken()、End()、Dispose()。TokenStream 继承自AttributeSource , finalStream.AddAttribute 真是调用了父类AttributeSource的方法AddAttribute() ,所以AttributeSoucre是用来给TokenStream添加一系列属性的。可以给TokenStream添加的属性有IFlasAttribute,IKeywordAttribute,IPayloadAttribute,IPositionIncrementAttribute,IPositionLengthAttribute,ITermToBytesRefAttribute,ITypeAttribute。这其中 ICharTermAttribute 继承自CharTermAttribute 表示的是分词内容,IOffsetAttribute 继承自 OffsetAttribute 表示的是分词起始位置和结束位置。初始化TokenStream 和添加完属性之后,必须执行TokenStream的Reset(),才可继续执行TokenStream.IncrementToken()。
Reset()函数实际上在TokenStream创建和使用之后进行重置,因为在Analyzer中所有实例是共用一个TokenStream,所以在TokenStream被使用过一次后,需要Reset() 以清除上次使用的信息,重新给下一个需要分词的文本使用。而IncrementToken实际的作用则是在遍历TokenStream 中的Token,类似于一个迭代器。

直到返回的false ,表示分词已经遍历完了,这个时候调用End() 和Dispose() 来注销这个TokenStream。在这个过程中,TokenStream是可以被使用多次的。

分割后的分词集合被放入索引,每个Field的值都会经过上述一个过程。

2.2.检索查询

首先是IndexSearch的初始化。

Lucene.Net.Store.Directory directory = FSDirectory.Open(AppDomain.CurrentDomain.BaseDirectory + "/Lucene");
using (Lucene.Net.Index.IndexReader reader=DirectoryReader.Open(directory))
{
       Lucene.Net.Search.IndexSearcher searcher = new IndexSearcher(reader);
}
DirectoryReader.Open()方法将索引加载到内存中。默认是由StandardDirectoryReader中的私有类FindSegmentsFileAnonymousClass实现的。
internal static DirectoryReader Open(Directory directory, IndexCommit commit, int termInfosIndexDivisor)
{
      return (DirectoryReader)new FindSegmentsFileAnonymousClass(directory, termInfosIndexDivisor).Run(commit);
}

追溯到源头则是由抽象类FindSegmentsFile的Run()方法读取索引文件的分片信息得到的。
IndexReader初始化完成后,索引信息加载到内存,然后初始化IndexSearcher,准备开始查询。

IndexSearcher组合Query。将Query和Filter组合成查询FilteredQuery

protected virtual Query WrapFilter(Query query, Filter filter)
{
      return (filter == null) ? query : new FilteredQuery(query, filter);
 }

之后是在FilterQuery中根据Query生成Weight。

Weight 类负责生成Scorer (一个命中Query的文档集合的迭代器,文档打分调用Similarity 类就是Lucene自己的TF/IDF打分机制) 。Weight类实际上是包装Query 它先通过Query生成,之后IndexSearch所需要提供的Query便都由Weight提供。
Weight 生成Scorer 是通过AtomicReaderContext (由IndexReaderContext而来)构造而得。所以搜索过程的AtomicReader(提供对索引进行读取操作的类) 驻留在Scorer中。

public virtual Weight CreateNormalizedWeight(Query query)
{
     query = Rewrite(query);
     Weight weight = query.CreateWeight(this);
     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;
}

在根据Query生成Weight的过程中会将Query重写,第一步就是调用Query的ReWrite方法麻将Query重组成由TermQuery组成的原始查询集合。所有继承Query的派生类,如BooleanQuery ,PhraseQuery,CustomQuery都会覆写这个方法以实现重写Query。Query里面包含了自定义的权重Boosts。
重写完Query就要计算权重了。计算权重的过程是在得到重写查询之后的原始查询TermQuery ,读取词典索引中符合TermQuery的Term ,然后通过TF/IDF 打分机制,算出Term的IDF值,以及QueryNorm的值(打分操作都是调用 Similarity 类),最后返回Weight。

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);
}

Lucene默认的评分机制由由派生至Similarity类的 TFIDFSimilarity类实现。

TFIDF是一种利用空间向量模型的算法,也是很多搜索引擎都在使用的算法,其大致原理是将查询输入文本和待查询对象文本都转成空间向量(向量的维度是文本中词出现的频率,向量的值是文本中词的权重),计算二者余弦值。得到的余弦值越接近于1,说明查询输入文本和待查询对象文本越相似。

public IDFStats(string field, Explanation idf, float queryBoost)
{
    // TODO: Validate?
    this.Field = field;
    this.Idf = idf;
    this.QueryBoost = queryBoost;
    this.QueryWeight = idf.Value * queryBoost; // compute query weight
}

另外由于Lucene提供了自定义评分机制CustomScore,也可以给Query设置Boosts,所以最终结果是ScoreBoosts或CustomerScoreBoosts。

protected virtual TopFieldDocs Search(Weight weight, FieldDoc after, int nDocs, Sort sort, bool fillFields, bool doDocScores, bool doMaxScore)
{
    if (sort is null)
    {
        throw new ArgumentNullException("Sort must not be null"); // LUCENENET specific - changed from IllegalArgumentException to ArgumentNullException (.NET convention)
    }
 
    int limit = reader.MaxDoc;
    if (limit == 0)
    {
        limit = 1;
    }
    nDocs = Math.Min(nDocs, limit);
 
    if (executor == null)
    {
        // use all leaves here!
        return Search(m_leafContexts, weight, after, nDocs, sort, fillFields, doDocScores, doMaxScore);
    }
    else
    {
        TopFieldCollector topCollector = TopFieldCollector.Create(sort, nDocs, after, fillFields, doDocScores, doMaxScore, false);
 
        ReentrantLock @lock = new ReentrantLock();
        ExecutionHelper<TopFieldDocs> runner = new ExecutionHelper<TopFieldDocs>(executor);
        for (int i = 0; i < m_leafSlices.Length; i++) // search each leaf slice
        {
            runner.Submit(new SearcherCallableWithSort(@lock, this, m_leafSlices[i], weight, after, nDocs, topCollector, sort, doDocScores, doMaxScore));
        }
        int totalHits = 0;
        float maxScore = float.NegativeInfinity;
        foreach (TopFieldDocs topFieldDocs in runner)
        {
            if (topFieldDocs.TotalHits != 0)
            {
                totalHits += topFieldDocs.TotalHits;
                maxScore = Math.Max(maxScore, topFieldDocs.MaxScore);
            }
        }
 
        TopFieldDocs topDocs = (TopFieldDocs)topCollector.GetTopDocs();
 
        return new TopFieldDocs(totalHits, topDocs.ScoreDocs, topDocs.Fields, topDocs.MaxScore);
    }
}

生成权重Weight后,又创建TopScoreDocCollector用来盛装查询结果文档。

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
            }
        }
    }
}

Scorer 前面已经介绍过,它就是一个由TermQuery从索引库中查询出来的文档集合的迭代器,可以说生成Scorer的过程就是查找文档的过程。那么生成Scorer之后可以通过它的next 函数遍历我们的结果文档集合,对它们一一打分结合前面计算的queryWeight。

public override Scorer GetScorer(AtomicReaderContext context, IBits acceptDocs)
{
    if (Debugging.AssertsEnabled) Debugging.Assert(termStates.TopReaderContext == ReaderUtil.GetTopLevelContext(context),"The top-reader used to create Weight ({0}) is not the same as the current reader's top-reader ({1})", termStates.TopReaderContext, ReaderUtil.GetTopLevelContext(context));
    TermsEnum termsEnum = GetTermsEnum(context);
    if (termsEnum == null)
    {
        return null;
    }
    DocsEnum docs = termsEnum.Docs(acceptDocs, null);
    if (Debugging.AssertsEnabled) Debugging.Assert(docs != null);
    return new TermScorer(this, docs, similarity.GetSimScorer(stats, context));
}

其中生成Scorer的最终步骤是通过TermQuery(原始型查询) 的GetScorer函数实现的。在这个函数中首先GetTermsEnum(context)函数 获取 TermsEnum , TermsEnum 是用来获取包含当前 Term 的 DocsEnum ,而DocsEnum 包含文档docs 和词频term frequency。

对于当前的TermQuery ,查找符合TermQuery的文档的步骤是 利用AtomicReader (通过AtomicReaderContext获取) 生成TermsEnum (TermsEnum中的当前Term 就是TermQuery我们需要查询的那个Term)。再通过TermsEnum 获取DocsEnum。最后合成Scorer。

internal static void ScoreAll(ICollector collector, Scorer scorer)
{
    int doc;
    while ((doc = scorer.NextDoc()) != DocIdSetIterator.NO_MORE_DOCS)
    {
        collector.Collect(doc);
    }
}

通过Weight的ScoreAll方法将评分添加至TopDocCollector集合中。

public override float Score(int doc, float freq)
{
    float raw = outerInstance.Tf(freq) * weightValue; // compute tf(f)*weight
 
    return norms == null ? raw : raw * outerInstance.DecodeNormValue(norms.Get(doc)); // normalize for field
}

而这个过程中真正打分的函数也不是ScoreAll函数,它是scorer.NextDoc()函数。scorer执行NextDoc函数会调用 TFIDFSimScorer 类,它是TFIDFSimilarity的内部类,通过Score方法实现。
最后是将评分更新至TopScoreDocCollector并返回。

从TopScoreDocCo llector中获取TopDocs就是最终Lucene对外输出的查询结果。

至此检索查询过程结束。


项目地址:https://github.com/ludewig/Muyan.Search

Lucene.NET的核心原理并不难理解,但在具体实现上还是有不少细节的,看了源码还是有些许收获的,后面还要继续深入理解。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值