深度解析 Lucene 輕量級全文索引實現原理

vivo網際網路技術發表於2021-07-20

一、Lucene簡介

1.1 Lucene是什麼?

  • Lucene是Apache基金會jakarta專案組的一個子專案;

  • Lucene是一個開放原始碼的全文檢索引擎工具包,提供了完整的查詢引擎和索引引擎,部分語種文字分析引擎

  • Lucene並不是一個完整的全文檢索引擎,僅提供了全文檢索引擎架構,但仍可以作為一個工具包結合各類外掛為專案提供部分高效能的全文檢索功能

  • 現在常用的ElasticSearch、Solr等全文搜尋引擎均是基於Lucene實現的。

1.2 Lucene的使用場景

適用於需要資料索引量不大的場景,當索引量過大時需要使用ES、Solr等全文搜尋伺服器實現搜尋功能。

1.3 通過本文你能瞭解到哪些內容?

  • Lucene如此繁雜的索引如何生成並寫入,索引中的各個檔案又在起著什麼樣的作用?

  • Lucene全文索引如何進行高效搜尋?

  • Lucene如何優化搜尋結果,使使用者根據關鍵詞搜尋到想要的內容?

本文旨在分享Lucene搜尋引擎的原始碼閱讀和功能開發中的經驗,Lucene採用7.3.1版本。

二、Lucene基礎工作流程

索引的生成分為兩個部分:

1. 建立階段:

  • 新增文件階段,通過IndexWriter呼叫addDocument方法生成正向索引檔案;

  • 文件新增後,通過flush或merge操作生成倒排索引檔案。

2. 搜尋階段:

  • 使用者通過查詢語句向Lucene傳送查詢請求;

  • 通過IndexSearch下的IndexReader讀取索引庫內容,獲取文件索引;

  • 得到搜尋結果後,基於搜尋演算法對結果進行排序後返回。

索引建立及搜尋流程如下圖所示:

三、Lucene索引構成

3.1 正向索引

Lucene的基礎層次結構由索引、段、文件、域、詞五個部分組成。正向索引的生成即為基於Lucene的基礎層次結構一級一級處理文件並分解域儲存詞的過程。

索引檔案層級關係如圖1所示:

  • 索引:Lucene索引庫包含了搜尋文字的所有內容,可以通過檔案或檔案流的方式儲存在不同的資料庫或檔案目錄下。

  • :一個索引中包含多個段,段與段之間相互獨立。由於Lucene進行關鍵詞檢索時需要載入索引段進行下一步搜尋,如果索引段較多會增加較大的I/O開銷,減慢檢索速度,因此寫入時會通過段合併策略對不同的段進行合併。

  • 文件:Lucene會將文件寫入段中,一個段中包含多個文件。

  • :一篇文件會包含多種不同的欄位,不同的欄位儲存在不同的域中。

  • :Lucene會通過分詞器將域中的字串通過詞法分析和語言處理後拆分成詞,Lucene通過這些關鍵詞進行全文檢索。

3.2 倒排索引

Lucene全文索引的核心是基於倒排索引實現的快速索引機制。

倒排索引原理如圖2所示,倒排索引簡單來說就是基於分析器將文字內容進行分詞後,記錄每個詞出現在哪篇文章中,從而通過使用者輸入的搜尋詞查詢出包含該詞的文章。

問題:上述倒排索引使用時每次都需要將索引詞載入到記憶體中,當文章數量較多,篇幅較長時,索引詞可能會佔用大量的儲存空間,載入到記憶體後記憶體損耗較大。

解決方案:從Lucene4開始,Lucene採用了FST來減少索引詞帶來的空間消耗。

FST(Finite StateTransducers),中文名有限狀態機轉換器。其主要特點在於以下四點:

  • 查詢詞的時間複雜度為O(len(str));

  • 通過將字首和字尾分開儲存的方式,減少了存放詞所需的空間;

  • 載入時僅將字首放入記憶體索引,字尾詞在磁碟中進行存放,減少了記憶體索引使用空間的損耗;

  • FST結構在對PrefixQuery、FuzzyQuery、RegexpQuery等查詢條件查詢時,查詢效率高。

具體儲存方式如圖3所示:

倒排索引相關檔案包含.tip、.tim和.doc這三個檔案,其中:

  • tip:用於儲存倒排索引Term的字首,來快速定位.tim檔案中屬於這個Field的Term的位置,即上圖中的aab、abd、bdc。

  • tim:儲存了不同字首對應的相應的Term及相應的倒排表資訊,倒排表通過跳錶實現快速查詢,通過跳錶能夠跳過一些元素的方式對多條件查詢交集、並集、差集之類的集合運算也提高了效能。

  • doc:包含了文件號及詞頻資訊,根據倒排表中的內容返回該檔案中儲存的文字資訊。

3.3 索引查詢及文件搜尋過程

Lucene利用倒排索引定位需要查詢的文件號,通過文件號搜尋出檔案後,再利用詞權重等資訊對文件排序後返回。

  • 記憶體載入tip檔案,根據FST匹配到字尾詞塊在tim檔案中的位置;

  • 根據查詢到的字尾詞塊位置查詢到字尾及倒排表的相關資訊;

  • 根據tim中查詢到的倒排表資訊從doc檔案中定位出文件號及詞頻資訊,完成搜尋;

  • 檔案定位完成後Lucene將去.fdx檔案目錄索引及.fdt中根據正向索引查詢出目標檔案。

檔案格式如圖4所示:

上文主要講解Lucene的工作原理,下文將闡述Java中Lucene執行索引、查詢等操作的相關程式碼。

四、Lucene的增刪改操作

Lucene專案中文字的解析,儲存等操作均由IndexWriter類實現,IndexWriter檔案主要由Directory和IndexWriterConfig兩個類構成,其中:

Directory:用於指定存放索引檔案的目錄型別。既然要對文字內容進行搜尋,自然需要先將這些文字內容及索引資訊寫入到目錄裡。Directory是一個抽象類,針對索引的儲存允許有多種不同的實現。常見的儲存方式一般包括儲存有本地(FSDirectory),記憶體(RAMDirectory)等。

IndexWriterConfig:用於指定IndexWriter在檔案內容寫入時的相關配置,包括OpenMode索引構建模式、Similarity相關性演算法等。

IndexWriter具體是如何操作索引的呢?讓我們來簡單分析一下IndexWriter索引操作的相關原始碼。

4.1. 文件的新增

a. Lucene會為每個文件建立ThreadState物件,物件持有DocumentWriterPerThread來執行檔案的增刪改操作;

ThreadState getAndLock(Thread requestingThread, DocumentsWriter documentsWriter) {
  ThreadState threadState = null;
  synchronized (this) {
    if (freeList.isEmpty()) {
      // 如果不存在已建立的空閒ThreadState,則新建立一個
      return newThreadState();
    } else {
      // freeList後進先出,僅使用有限的ThreadState操作索引
      threadState = freeList.remove(freeList.size()-1);

      // 優先使用已經初始化過DocumentWriterPerThread的ThreadState,並將其與當前
      // ThreadState換位,將其移到隊尾優先使用
      if (threadState.dwpt == null) {
        for(int i=0;i<freeList.size();i++) {
          ThreadState ts = freeList.get(i);
          if (ts.dwpt != null) {
            freeList.set(i, threadState);
            threadState = ts;
            break;
          }
        }
      }
    }
  }
  threadState.lock();
  
  return threadState;
}

b. 索引檔案的插入:DocumentWriterPerThread呼叫DefaultIndexChain下的processField來處理文件中的每個域,processField方法是索引鏈的核心執行邏輯。通過使用者對每個域設定的不同的FieldType進行相應的索引、分詞、儲存等操作。FieldType中比較重要的是indexOptions:

  • NONE:域資訊不會寫入倒排表,索引階段無法通過該域名進行搜尋;

  • DOCS:文件寫入倒排表,但由於不記錄詞頻資訊,因此出現多次也僅當一次處理;

  • DOCS_AND_FREQS:文件和詞頻寫入倒排表;

  • DOCS_AND_FREQS_AND_POSITIONS:文件、詞頻及位置寫入倒排表;

  • DOCS_AND_FREQS_AND_POSITIONS_AND_OFFSETS:文件、詞頻、位置及偏移寫入倒排表。

// 構建倒排表

if (fieldType.indexOptions() != IndexOptions.NONE) {
    fp = getOrAddField(fieldName, fieldType, true);
    boolean first = fp.fieldGen != fieldGen;
    // field具體的索引、分詞操作
    fp.invert(field, first);

    if (first) {
      fields[fieldCount++] = fp;
      fp.fieldGen = fieldGen;
    }
} else {
  verifyUnIndexedFieldType(fieldName, fieldType);
}

// 儲存該field的storeField
if (fieldType.stored()) {
  if (fp == null) {
    fp = getOrAddField(fieldName, fieldType, false);
  }
  if (fieldType.stored()) {
    String value = field.stringValue();
    if (value != null && value.length() > IndexWriter.MAX_STORED_STRING_LENGTH) {
      throw new IllegalArgumentException("stored field \"" + field.name() + "\" is too large (" + value.length() + " characters) to store");
    }
    try {
      storedFieldsConsumer.writeField(fp.fieldInfo, field);
    } catch (Throwable th) {
      throw AbortingException.wrap(th);
    }
  }
}

// 建立DocValue(通過文件查詢文件下包含了哪些詞)
DocValuesType dvType = fieldType.docValuesType();
if (dvType == null) {
  throw new NullPointerException("docValuesType must not be null (field: \"" + fieldName + "\")");
}
if (dvType != DocValuesType.NONE) {
  if (fp == null) {
    fp = getOrAddField(fieldName, fieldType, false);
  }
  indexDocValue(fp, dvType, field);
}
if (fieldType.pointDimensionCount() != 0) {
  if (fp == null) {
    fp = getOrAddField(fieldName, fieldType, false);
  }
  indexPoint(fp, field);
}

c. 解析Field首先需要構造TokenStream類,用於產生和轉換token流,TokenStream有兩個重要的派生類Tokenizer和TokenFilter,其中Tokenizer用於通過java.io.Reader類讀取字元,產生Token流,然後通過任意數量的TokenFilter來處理這些輸入的Token流,具體原始碼如下:

// invert:對Field進行分詞處理首先需要將Field轉化為TokenStream
try (TokenStream stream = tokenStream = field.tokenStream(docState.analyzer, tokenStream))
// TokenStream在不同分詞器下實現不同,根據不同分詞器返回相應的TokenStream
if (tokenStream != null) {
  return tokenStream;
} else if (readerValue() != null) {
  return analyzer.tokenStream(name(), readerValue());
} else if (stringValue() != null) {
  return analyzer.tokenStream(name(), stringValue());
}

public final TokenStream tokenStream(final String fieldName, final Reader reader) {
  // 通過複用策略,如果TokenStreamComponents中已經存在Component則複用。
  TokenStreamComponents components = reuseStrategy.getReusableComponents(this, fieldName);
  final Reader r = initReader(fieldName, reader);
  // 如果Component不存在,則根據分詞器建立對應的Components。
  if (components == null) {
    components = createComponents(fieldName);
    reuseStrategy.setReusableComponents(this, fieldName, components);
  }
  // 將java.io.Reader輸入流傳入Component中。
  components.setReader(r);
  return components.getTokenStream();
}

d. 根據IndexWriterConfig中配置的分詞器,通過策略模式返回分詞器對應的分片語件,針對不同的語言及不同的分詞需求,分片語件存在很多不同的實現。

  • StopAnalyzer:停用詞分詞器,用於過濾詞彙中特定字串或單詞。

  • StandardAnalyzer:標準分詞器,能夠根據數字、字母等進行分詞,支援詞表過濾替代StopAnalyzer功能,支援中文簡單分詞。

  • CJKAnalyzer:能夠根據中文語言習慣對中文分詞提供了比較好的支援。

以StandardAnalyzer(標準分詞器)為例:

// 標準分詞器建立Component過程,涵蓋了標準分詞處理器、Term轉化小寫、常用詞過濾三個功能
protected TokenStreamComponents createComponents(final String fieldName) {
  final StandardTokenizer src = new StandardTokenizer();
  src.setMaxTokenLength(maxTokenLength);
  TokenStream tok = new StandardFilter(src);
  tok = new LowerCaseFilter(tok);
  tok = new StopFilter(tok, stopwords);
  return new TokenStreamComponents(src, tok) {
    @Override
    protected void setReader(final Reader reader) {
      src.setMaxTokenLength(StandardAnalyzer.this.maxTokenLength);
      super.setReader(reader);
    }
  };
}

e. 在獲取TokenStream之後通過TokenStream中的incrementToken方法分析並獲取屬性,再通過TermsHashPerField下的add方法構建倒排表,最終將Field的相關資料儲存到型別為FreqProxPostingsArray的freqProxPostingsArray中,以及TermVectorsPostingsArray的termVectorsPostingsArray中,構成倒排表;

// 以LowerCaseFilter為例,通過其下的increamentToken將Token中的字元轉化為小寫
public final boolean incrementToken() throws IOException {
  if (input.incrementToken()) {
    CharacterUtils.toLowerCase(termAtt.buffer(), 0, termAtt.length());
    return true;
  } else
    return false;
}
  try (TokenStream stream = tokenStream = field.tokenStream(docState.analyzer, tokenStream)) {
    // reset TokenStream
    stream.reset();
    invertState.setAttributeSource(stream);
    termsHashPerField.start(field, first);
    // 分析並獲取Token屬性
    while (stream.incrementToken()) {
      ……
      try {
        // 構建倒排表
        termsHashPerField.add();
      } catch (MaxBytesLengthExceededException e) {
        ……
      } catch (Throwable th) {
        throw AbortingException.wrap(th);
      }
    }
    ……
}

4.2 文件的刪除

a. Lucene下文件的刪除,首先將要刪除的Term或Query新增到刪除佇列中;

synchronized long deleteTerms(final Term... terms) throws IOException {
  // TODO why is this synchronized?
  final DocumentsWriterDeleteQueue deleteQueue = this.deleteQueue;
  // 文件刪除操作是將刪除的詞資訊新增到刪除佇列中,根據flush策略進行刪除
  long seqNo = deleteQueue.addDelete(terms);
  flushControl.doOnDelete();
  lastSeqNo = Math.max(lastSeqNo, seqNo);
  if (applyAllDeletes(deleteQueue)) {
    seqNo = -seqNo;
  }
  return seqNo;
}

b. 根據Flush策略觸發刪除操作;

private boolean applyAllDeletes(DocumentsWriterDeleteQueue deleteQueue) throws IOException {
  // 判斷是否滿足刪除條件 --> onDelete
  if (flushControl.getAndResetApplyAllDeletes()) {
    if (deleteQueue != null) {
      ticketQueue.addDeletes(deleteQueue);
    }
    // 指定執行刪除操作的event
    putEvent(ApplyDeletesEvent.INSTANCE); // apply deletes event forces a purge
    return true;
  }
  return false;
}
public void onDelete(DocumentsWriterFlushControl control, ThreadState state) {
  // 判斷並設定是否滿足刪除條件
  if ((flushOnRAM() && control.getDeleteBytesUsed() > 1024*1024*indexWriterConfig.getRAMBufferSizeMB())) {
    control.setApplyAllDeletes();
    if (infoStream.isEnabled("FP")) {
      infoStream.message("FP", "force apply deletes bytesUsed=" + control.getDeleteBytesUsed() + " vs ramBufferMB=" + indexWriterConfig.getRAMBufferSizeMB());
    }
  }
}

4.3 文件的更新

文件的更新就是一個先刪除後插入的過程,本文就不再做更多贅述。

4.4 索引Flush

文件寫入到一定數量後,會由某一執行緒觸發IndexWriter的Flush操作,生成段並將記憶體中的Document資訊寫到硬碟上。Flush操作目前僅有一種策略:FlushByRamOrCountsPolicy。FlushByRamOrCountsPolicy主要基於兩種策略自動執行Flush操作:

  • maxBufferedDocs:文件收集到一定數量時觸發Flush操作。

  • ramBufferSizeMB:文件內容達到限定值時觸發Flush操作。

其中 activeBytes() 為dwpt收集的索引所佔的記憶體量,deleteByteUsed為刪除的索引量。

@Override
public void onInsert(DocumentsWriterFlushControl control, ThreadState state) {
  // 根據文件數進行Flush
  if (flushOnDocCount()
      && state.dwpt.getNumDocsInRAM() >= indexWriterConfig
          .getMaxBufferedDocs()) {
    // Flush this state by num docs
    control.setFlushPending(state);
  // 根據記憶體使用量進行Flush
  } else if (flushOnRAM()) {// flush by RAM
    final long limit = (long) (indexWriterConfig.getRAMBufferSizeMB() * 1024.d * 1024.d);
    final long totalRam = control.activeBytes() + control.getDeleteBytesUsed();
    if (totalRam >= limit) {
      if (infoStream.isEnabled("FP")) {
        infoStream.message("FP", "trigger flush: activeBytes=" + control.activeBytes() + " deleteBytes=" + control.getDeleteBytesUsed() + " vs limit=" + limit);
      }
      markLargestWriterPending(control, state, totalRam);
    }
  }
}

將記憶體資訊寫入索引庫。

索引的Flush分為主動Flush和自動Flush,根據策略觸發的Flush操作為自動Flush,主動Flush的執行與自動Flush有較大區別,關於主動Flush本文暫不多做贅述。需要了解的話可以跳轉連結

4.5 索引段Merge

索引Flush時每個dwpt會單獨生成一個segment,當segment過多時進行全文檢索可能會跨多個segment,產生多次載入的情況,因此需要對過多的segment進行合併。

段合併的執行通過MergeScheduler進行管理。mergeScheduler也包含了多種管理策略,包括NoMergeScheduler、SerialMergeScheduler和ConcurrentMergeScheduler。

  1. merge操作首先需要通過updatePendingMerges方法根據段的合併策略查詢需要合併的段。段合併策略分為很多種,本文僅介紹兩種Lucene預設使用的段合併策略:TieredMergePolicy和LogMergePolicy。
  • TieredMergePolicy:先通過OneMerge打分機制對IndexWriter提供的段集進行排序,然後在排序後的段集中選取部分(可能不連續)段來生成一個待合併段集,即非相鄰的段檔案(Non-adjacent Segment)。

  • LogMergePolicy:定長的合併方式,通過maxLevel、LEVEL_LOG_SPAN、levelBottom引數將連續的段分為不同的層級,再通過mergeFactor從每個層級中選取段進行合併。

private synchronized boolean updatePendingMerges(MergePolicy mergePolicy, MergeTrigger trigger, int maxNumSegments)
  throws IOException {

  final MergePolicy.MergeSpecification spec;
  // 查詢需要合併的段
  if (maxNumSegments != UNBOUNDED_MAX_MERGE_SEGMENTS) {
    assert trigger == MergeTrigger.EXPLICIT || trigger == MergeTrigger.MERGE_FINISHED :
    "Expected EXPLICT or MERGE_FINISHED as trigger even with maxNumSegments set but was: " + trigger.name();

    spec = mergePolicy.findForcedMerges(segmentInfos, maxNumSegments, Collections.unmodifiableMap(segmentsToMerge), this);
    newMergesFound = spec != null;
    if (newMergesFound) {
      final int numMerges = spec.merges.size();
      for(int i=0;i<numMerges;i++) {
        final MergePolicy.OneMerge merge = spec.merges.get(i);
        merge.maxNumSegments = maxNumSegments;
      }
    }
  } else {
    spec = mergePolicy.findMerges(trigger, segmentInfos, this);
  }
  // 註冊所有需要合併的段
  newMergesFound = spec != null;
  if (newMergesFound) {
    final int numMerges = spec.merges.size();
    for(int i=0;i<numMerges;i++) {
      registerMerge(spec.merges.get(i));
    }
  }
  return newMergesFound;
}

2)通過ConcurrentMergeScheduler類中的merge方法建立使用者合併的執行緒MergeThread並啟動。

@Override
public synchronized void merge(IndexWriter writer, MergeTrigger trigger, boolean newMergesFound) throws IOException {
  ……
  while (true) {
    ……
    // 取出註冊的後選段
    OneMerge merge = writer.getNextMerge();
    boolean success = false;
    try {
      // 構建用於合併的執行緒MergeThread 
      final MergeThread newMergeThread = getMergeThread(writer, merge);
      mergeThreads.add(newMergeThread);

      updateIOThrottle(newMergeThread.merge, newMergeThread.rateLimiter);

      if (verbose()) {
        message("    launch new thread [" + newMergeThread.getName() + "]");
      }
      // 啟用執行緒
      newMergeThread.start();
      updateMergeThreads();

      success = true;
    } finally {
      if (!success) {
        writer.mergeFinish(merge);
      }
    }
  }
}

3)通過doMerge方法執行merge操作;

public void merge(MergePolicy.OneMerge merge) throws IOException {
  ……
      try {
        // 用於處理merge前快取任務及新段相關資訊生成
        mergeInit(merge);
        // 執行段之間的merge操作
        mergeMiddle(merge, mergePolicy);
        mergeSuccess(merge);
        success = true;
      } catch (Throwable t) {
        handleMergeException(t, merge);
      } finally {
        // merge完成後的收尾工作
        mergeFinish(merge)
      }
……
}

五、Lucene搜尋功能實現

5.1 載入索引庫

Lucene想要執行搜尋首先需要將索引段載入到記憶體中,由於載入索引庫的操作非常耗時,因此僅有當索引庫產生變化時需要重新載入索引庫。

載入索引庫分為載入段資訊和載入文件資訊兩個部分:

1)載入段資訊:

  • 通過segments.gen檔案獲取段中最大的generation,獲取段整體資訊;

  • 讀取.si檔案,構造SegmentInfo物件,最後彙總得到SegmentInfos物件。

2)載入文件資訊:

  • 讀取段資訊,並從.fnm檔案中獲取相應的FieldInfo,構造FieldInfos;

  • 開啟倒排表的相關檔案和詞典檔案;

  • 讀取索引的統計資訊和相關norms資訊;

  • 讀取文件檔案。

5.2 封裝

索引庫載入完成後需要IndexReader封裝進IndexSearch,IndexSearch通過使用者構造的Query語句和指定的Similarity文字相似度演算法(預設BM25)返回使用者需要的結果。通過IndexSearch.search方法實現搜尋功能。

搜尋:Query包含多種實現,包括BooleanQuery、PhraseQuery、TermQuery、PrefixQuery等多種查詢方法,使用者可根據專案需求構造查詢語句

排序:IndexSearch除了通過Similarity計算文件相關性分值排序外,也提供了BoostQuery的方式讓使用者指定關鍵詞分值,定製排序。Similarity相關性演算法也包含很多種不同的相關性分值計算實現,此處暫不做贅述,讀者有需要可自行網上查閱。

六、總結

Lucene作為全文索引工具包,為中小型專案提供了強大的全文檢索功能支援,但Lucene在使用的過程中存在諸多問題:

  • 由於Lucene需要將檢索的索引庫通過IndexReader讀取索引資訊並載入到記憶體中以實現其檢索能力,當索引量過大時,會消耗服務部署機器的過多記憶體。

  • 搜尋實現比較複雜,需要對每個Field的索引、分詞、儲存等資訊一一設定,使用複雜。

  • Lucene不支援叢集。

Lucene使用時存在諸多限制,使用起來也不那麼方便,當資料量增大時還是儘量選擇ElasticSearch等分散式搜尋伺服器作為搜尋功能的實現方案。

作者:vivo網際網路伺服器團隊-Qian Yulun

相關文章