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


一、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


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM