Lucene查詢原理

宇珩發表於2018-04-16

前言

Lucene 是一個基於 Java 的全文資訊檢索工具包,目前主流的搜尋系統Elasticsearch和solr都是基於lucene的索引和搜尋能力進行。想要理解搜尋系統的實現原理,就需要深入lucene這一層,看看lucene是如何儲存需要檢索的資料,以及如何完成高效的資料檢索。

在資料庫中因為有索引的存在,也可以支援很多高效的查詢操作。不過對比lucene,資料庫的查詢能力還是會弱很多,本文就將探索下lucene支援哪些查詢,並會重點選取幾類查詢分析lucene內部是如何實現的。為了方便大家理解,我們會先簡單介紹下lucene裡面的一些基本概念,然後展開lucene中的幾種資料儲存結構,理解了他們的儲存原理後就可以方便知道如何基於這些儲存結構來實現高效的搜尋。本文重點關注是lucene如何做到傳統資料庫較難做到的查詢,對於分詞,打分等功能不會展開介紹。

本文具體會分以下幾部分:

  1. 介紹lucene的資料模型,細節可以參閱lucene資料模型一文。
  2. 介紹lucene中如何儲存需要搜尋的term。
  3. 介紹lucene的倒排鏈的如何儲存以及如何實現docid的快速查詢。
  4. 介紹lucene如何實現倒排鏈合併。
  5. 介紹lucene如何做範圍查詢和字首匹配。
  6. 介紹lucene如何優化數值類範圍查詢。

Lucene資料模型

Lucene中包含了四種基本資料型別,分別是:

Index:索引,由很多的Document組成。
Document:由很多的Field組成,是Index和Search的最小單位。
Field:由很多的Term組成,包括Field Name和Field Value。
Term:由很多的位元組組成。一般將Text型別的Field Value分詞之後的每個最小單元叫做Term。

在lucene中,讀寫路徑是分離的。寫入的時候建立一個IndexWriter,而讀的時候會建立一個IndexSearcher,
下面是一個簡單的程式碼示例,如何使用lucene的IndexWriter建索引以及如何使用indexSearch進行搜尋查詢。

    Analyzer analyzer = new StandardAnalyzer();
    // Store the index in memory:
    Directory directory = new RAMDirectory();
    // To store an index on disk, use this instead:
    //Directory directory = FSDirectory.open("/tmp/testindex");
    IndexWriterConfig config = new IndexWriterConfig(analyzer);
    IndexWriter iwriter = new IndexWriter(directory, config);
    Document doc = new Document();
    String text = "This is the text to be indexed.";
    doc.add(new Field("fieldname", text, TextField.TYPE_STORED));
    iwriter.addDocument(doc);
    iwriter.close();

    // Now search the index:
    DirectoryReader ireader = DirectoryReader.open(directory);
    IndexSearcher isearcher = new IndexSearcher(ireader);
    // Parse a simple query that searches for "text":
    QueryParser parser = new QueryParser("fieldname", analyzer);
    Query query = parser.parse("text");
    ScoreDoc[] hits = isearcher.search(query, 1000).scoreDocs;
    //assertEquals(1, hits.length);
    // Iterate through the results:
    for (int i = 0; i < hits.length; i++) {
         Document hitDoc = isearcher.doc(hits[i].doc);
         System.out.println(hitDoc.get("fieldname"));
    }
    ireader.close();
    directory.close();

從這個示例中可以看出,lucene的讀寫有各自的操作類。本文重點關注讀邏輯,在使用IndexSearcher類的時候,需要一個DirectoryReader和QueryParser,其中DirectoryReader需要對應寫入時候的Directory實現。QueryParser主要用來解析你的查詢語句,例如你想查 “A and B”,lucene內部會有機制解析出是term A和term B的交集查詢。在具體執行Search的時候指定一個最大返回的文件數目,因為可能會有過多命中,我們可以限制單詞返回的最大文件數,以及做分頁返回。

下面會詳細介紹一個索引查詢會經過幾步,每一步lucene分別做了哪些優化實現。

Lucene 查詢過程

在lucene中查詢是基於segment。每個segment可以看做是一個獨立的subindex,在建立索引的過程中,lucene會不斷的flush記憶體中的資料持久化形成新的segment。多個segment也會不斷的被merge成一個大的segment,在老的segment還有查詢在讀取的時候,不會被刪除,沒有被讀取且被merge的segement會被刪除。這個過程類似於LSM資料庫的merge過程。下面我們主要看在一個segment內部如何實現高效的查詢。

為了方便大家理解,我們以人名字,年齡,學號為例,如何實現查某個名字(有重名)的列表。

docid name age id
1 Alice 18 101
2 Alice 20 102
3 Alice 21 103
4 Alan 21 104
5 Alan 18 105

在lucene中為了查詢name=XXX的這樣一個條件,會建立基於name的倒排鏈。以上面的資料為例,倒排鏈如下:
姓名

Alice | [1,2,3]
—- | — |
Alan | [4,5]
如果我們還希望按照年齡查詢,例如想查年齡=18的列表,我們還可以建立另一個倒排鏈:

18 | [1,5]
—| — |
20 | [2]
21 | [3,4]

在這裡,Alice,Alan,18,這些都是term。所以倒排本質上就是基於term的反向列表,方便進行屬性查詢。到這裡我們有個很自然的問題,如果term非常多,如何快速拿到這個倒排鏈呢?在lucene裡面就引入了term dictonary的概念,也就是term的字典。term字典裡我們可以按照term進行排序,那麼用一個二分查詢就可以定為這個term所在的地址。這樣的複雜度是logN,在term很多,記憶體放不下的時候,效率還是需要進一步提升。可以用一個hashmap,當有一個term進入,hash繼續查詢倒排鏈。這裡hashmap的方式可以看做是term dictionary的一個index。 從lucene4開始,為了方便實現rangequery或者字首,字尾等複雜的查詢語句,lucene使用FST資料結構來儲存term字典,下面就詳細介紹下FST的儲存結構。

FST

我們就用Alice和Alan這兩個單詞為例,來看下FST的構造過程。首先對所有的單詞做一下排序為“Alice”,“Alan”。

  1. 插入“Alan”
    fst1.png

  2. 插入“Alice”
    fst2.png

這樣你就得到了一個有向無環圖,有這樣一個資料結構,就可以很快查詢某個人名是否存在。FST在單term查詢上可能相比hashmap並沒有明顯優勢,甚至會慢一些。但是在範圍,字首搜尋以及壓縮率上都有明顯的優勢。

在通過FST定位到倒排鏈後,有一件事情需要做,就是倒排鏈的合併。因為查詢條件可能不止一個,例如上面我們想找name=”alan” and age=”18″的列表。lucene是如何實現倒排鏈的合併呢。這裡就需要看一下倒排鏈儲存的資料結構

SkipList

為了能夠快速查詢docid,lucene採用了SkipList這一資料結構。SkipList有以下幾個特徵:

  1. 元素排序的,對應到我們的倒排鏈,lucene是按照docid進行排序,從小到大。
  2. 跳躍有一個固定的間隔,這個是需要建立SkipList的時候指定好,例如下圖以間隔是3
  3. SkipList的層次,這個是指整個SkipList有幾層
    skiplist1.png

有了這個SkipList以後比如我們要查詢docid=12,原來可能需要一個個掃原始連結串列,1,2,3,5,7,8,10,12。有了SkipList以後先訪問第一層看到是然後大於12,進入第0層走到3,8,發現15大於12,然後進入原連結串列的8繼續向下經過10和12。
有了FST和SkipList的介紹以後,我們大體上可以畫一個下面的圖來說明lucene是如何實現整個倒排結構的:
luceneindex.png

有了這張圖,我們可以理解為什麼基於lucene可以快速進行倒排鏈的查詢和docid查詢,下面就來看一下有了這些後如何進行倒排鏈合併返回最後的結果。

倒排合併

假如我們的查詢條件是name = “Alice”,那麼按照之前的介紹,首先在term字典中定位是否存在這個term,如果存在的話進入這個term的倒排鏈,並根據引數設定返回分頁返回結果即可。這類查詢,在資料庫中使用二級索引也是可以滿足,那lucene的優勢在哪呢。假如我們有多個條件,例如我們需要按名字或者年齡單獨查詢,也需要進行組合 name = “Alice” and age = “18”的查詢,那麼使用傳統二級索引方案,你可能需要建立兩張索引表,然後分別查詢結果後進行合併,這樣如果age = 18的結果過多的話,查詢合併會很耗時。那麼在lucene這兩個倒排鏈是怎麼合併呢。
假如我們有下面三個倒排鏈需要進行合併。

mergepostinglist.png

在lucene中會採用下列順序進行合併:

  1. 在termA開始遍歷,得到第一個元素docId=1
  2. Set currentDocId=1
  3. 在termB中 search(currentDocId) = 1 (返回大於等於currentDocId的一個doc),

    1. 因為currentDocId ==1,繼續
    2. 如果currentDocId 和返回的不相等,執行2,然後繼續
  4. 到termC後依然符合,返回結果
  5. currentDocId = termC的nextItem
  6. 然後繼續步驟3 依次迴圈。直到某個倒排鏈到末尾。

整個合併步驟我可以發現,如果某個鏈很短,會大幅減少比對次數,並且由於SkipList結構的存在,在某個倒排中定位某個docid的速度會比較快不需要一個個遍歷。可以很快的返回最終的結果。從倒排的定位,查詢,合併整個流程組成了lucene的查詢過程,和傳統資料庫的索引相比,lucene合併過程中的優化減少了讀取資料的IO,倒排合併的靈活性也解決了傳統索引較難支援多條件查詢的問題。

BKDTree

在lucene中如果想做範圍查詢,根據上面的FST模型可以看出來,需要遍歷FST找到包含這個range的一個點然後進入對應的倒排鏈,然後進行求並集操作。但是如果是數值型別,比如是浮點數,那麼潛在的term可能會非常多,這樣查詢起來效率會很低。所以為了支援高效的數值類或者多維度查詢,lucene引入類BKDTree。BKDTree是基於KDTree,對資料進行按照維度劃分建立一棵二叉樹確保樹兩邊節點數目平衡。在一維的場景下,KDTree就會退化成一個二叉搜尋樹,在二叉搜尋樹中如果我們想查詢一個區間,logN的複雜度就會訪問到葉子結點得到對應的倒排鏈。如下圖所示:
1kdtree.png

如果是多維,kdtree的建立流程會發生一些變化。
比如我們以二維為例,建立過程如下:

  1. 確定切分維度,這裡維度的選取順序是資料在這個維度方法最大的維度優先。一個直接的理解就是,資料分散越開的維度,我們優先切分。
  2. 切分點的選這個維度最中間的點。
  3. 遞迴進行步驟1,2,我們可以設定一個閾值,點的數目少於多少後就不再切分,直到所有的點都切分好停止。

下圖是一個建立例子:
kdtree.png

BKDTree是KDTree的變種,因為可以看出來,KDTree如果有新的節點加入,或者節點修改起來,消耗還是比較大。類似於LSM的merge思路,BKD也是多個KDTREE,然後持續merge最終合併成一個。不過我們可以看到如果你某個term型別使用了BKDTree的索引型別,那麼在和普通倒排鏈merge的時候就沒那麼高效了所以這裡要做一個平衡,一種思路是把另一類term也作為一個維度加入BKDTree索引中。

如何實現返回結果進行排序聚合

通過之前介紹可以看出lucene通過倒排的儲存模型實現term的搜尋,那對於有時候我們需要拿到另一個屬性的值進行聚合,或者希望返回結果按照另一個屬性進行排序。在lucene4之前需要把結果全部拿到再讀取原文進行排序,這樣效率較低,還比較佔用記憶體,為了加速lucene實現了fieldcache,把讀過的field放進記憶體中。這樣可以減少重複的IO,但是也會帶來新的問題,就是佔用較多記憶體。新版本的lucene中引入了DocValues,DocValues是一個基於docid的列式儲存。當我們拿到一系列的docid後,進行排序就可以使用這個列式儲存,結合一個堆排序進行。當然額外的列式儲存會佔用額外的空間,lucene在建索引的時候可以自行選擇是否需要DocValue儲存和哪些欄位需要儲存。

Lucene的程式碼目錄結構

介紹了lucene中幾個主要的資料結構和查詢原理後,我們在來看下lucene的程式碼結構,後續可以深入程式碼理解細節。lucene的主要有下面幾個目錄:

  1. analysis模組主要負責詞法分析及語言處理而形成Term。
  2. codecs模組主要負責之前提到的一些資料結構的實現,和一些編碼壓縮演算法。包括skiplist,docvalue等。
  3. document模組主要包括了lucene各類資料型別的定義實現。
  4. index模組主要負責索引的建立,裡面有IndexWriter。
  5. store模組主要負責索引的讀寫。
  6. search模組主要負責對索引的搜尋。
  7. geo模組主要為geo查詢相關的類實現
  8. util模組是bkd,fst等資料結構實現。

最後

本文介紹了lucene中的一些主要資料結構,以及如何利用這些資料結構實現高效的查詢。我們希望通過這些介紹可以加深理解倒排索引和傳統資料庫索引的區別,資料庫有時候也可以藉助於搜尋引擎實現更豐富的查詢語意。除此之外,做為一個搜尋庫,如何進行打分,query語句如何進行parse這些我們沒有展開介紹,有興趣的同學可以深入lucene的原始碼進一步瞭解。


相關文章