1. 程式人生 > >Lucene:基於Java的全文檢索引擎簡介

Lucene:基於Java的全文檢索引擎簡介

     轉載自:http://www.chedong.com/tech/lucene.html

     Lucene是一個基於Java的全文索引工具包。

另外,如果是在選擇全文引擎,現在也許是試試Sphinx的時候了:相比Lucene速度更快,有中文分詞的支援,而且內建了對簡單的分散式檢索的支援;

基於Java的全文索引/檢索引擎——Lucene

Lucene不是一個完整的全文索引應用,而是是一個用Java寫的全文索引引擎工具包,它可以方便的嵌入到各種應用中實現針對應用的全文索引/檢索功能。

Lucene的作者:Lucene的貢獻者Doug Cutting是一位資深全文索引/檢索專家,曾經是V-Twin搜尋引擎(Apple的Copland作業系統的成就之一)的主要開發者,後在Excite擔任高階系統架構設計師,目前從事於一些INTERNET底層架構的研究。他貢獻出的Lucene的目標是為各種中小型應用程式加入全文檢索功能。

已經有很多Java專案都使用了Lucene作為其後臺的全文索引引擎,比較著名的有:

  • Jive:WEB論壇系統;
  • Eyebrows:郵件列表HTML歸檔/瀏覽/查詢系統,本文的主要參考文件“TheLucene search engine: Powerful, flexible, and free”作者就是EyeBrows系統的主要開發者之一,而EyeBrows已經成為目前APACHE專案的主要郵件列表歸檔系統。
  • Cocoon:基於XML的web釋出框架,全文檢索部分使用了Lucene
  • Eclipse:基於Java的開放開發平臺,幫助部分的全文索引使用了Lucene

對於中文使用者來說,最關心的問題是其是否支援中文的全文檢索。但通過後面對於Lucene的結構的介紹,你會了解到由於Lucene良好架構設計,對中文的支援只需對其語言詞法分析介面進行擴充套件就能實現對中文檢索的支援。

全文檢索的實現機制

Lucene的API介面設計的比較通用,輸入輸出結構都很像資料庫的表==>記錄==>欄位,所以很多傳統的應用的檔案、資料庫等都可以比較方便的對映到Lucene的儲存結構/介面中。總體上看:可以先把Lucene當成一個支援全文索引的資料庫系統

比較一下Lucene和資料庫:

Lucene 資料庫
索引資料來源:doc(field1,field2...) doc(field1,field2...)
                  \  indexer /
                 _____________
                | Lucene Index|
                --------------
                 / searcher \
 結果輸出:Hits(doc(field1,field2) doc(field1...))
 索引資料來源:record(field1,field2...) record(field1..)
              \  SQL: insert/
               _____________
              | DB  Index   |
               -------------
              / SQL: select \
結果輸出:results(record(field1,field2..) record(field1...))
Document:一個需要進行索引的“單元”
一個Document由多個欄位組成
Record:記錄,包含多個欄位
Field:欄位 Field:欄位
Hits:查詢結果集,由匹配的Document組成 RecordSet:查詢結果集,由多個Record組成

全文檢索 ≠ like "%keyword%"

通常比較厚的書籍後面常常附關鍵詞索引表(比如:北京:12, 34頁, 上海:3,77頁……),它能夠幫助讀者比較快地找到相關內容的頁碼。而資料庫索引能夠大大提高查詢的速度原理也是一樣,想像一下通過書後面的索引查詢的速度要比一頁一頁地翻內容高多少倍……而索引之所以效率高,另外一個原因是它是排好序的。對於檢索系統來說核心是一個排序問題

由於資料庫索引不是為全文索引設計的,因此,使用like "%keyword%"時,資料庫索引是不起作用的,在使用like查詢時,搜尋過程又變成類似於一頁頁翻書的遍歷過程了,所以對於含有模糊查詢的資料庫服務來說,LIKE對效能的危害是極大的。如果是需要對多個關鍵詞進行模糊匹配:like"%keyword1%" and like "%keyword2%" ...其效率也就可想而知了。

所以建立一個高效檢索系統的關鍵是建立一個類似於科技索引一樣的反向索引機制,將資料來源(比如多篇文章)排序順序儲存的同時,有另外一個排好序的關鍵詞列表,用於儲存關鍵詞==>文章對映關係,利用這樣的對映關係索引:[關鍵詞==>出現關鍵詞的文章編號,出現次數(甚至包括位置:起始偏移量,結束偏移量),出現頻率],檢索過程就是把模糊查詢變成多個可以利用索引的精確查詢的邏輯組合的過程。從而大大提高了多關鍵詞查詢的效率,所以,全文檢索問題歸結到最後是一個排序問題。

由此可以看出模糊查詢相對資料庫的精確查詢是一個非常不確定的問題,這也是大部分資料庫對全文檢索支援有限的原因。Lucene最核心的特徵是通過特殊的索引結構實現了傳統資料庫不擅長的全文索引機制,並提供了擴充套件介面,以方便針對不同應用的定製。

可以通過一下表格對比一下資料庫的模糊查詢:

Lucene全文索引引擎 資料庫
索引 將資料來源中的資料都通過全文索引一一建立反向索引 對於LIKE查詢來說,資料傳統的索引是根本用不上的。資料需要逐個便利記錄進行GREP式的模糊匹配,比有索引的搜尋速度要有多個數量級的下降。
匹配效果 通過詞元(term)進行匹配,通過語言分析介面的實現,可以實現對中文等非英語的支援。 使用:like "%net%" 會把netherlands也匹配出來,
多個關鍵詞的模糊匹配:使用like "%com%net%":就不能匹配詞序顛倒的xxx.net..xxx.com
匹配度 有匹配度演算法,將匹配程度(相似度)比較高的結果排在前面。 沒有匹配程度的控制:比如有記錄中net出現5詞和出現1次的,結果是一樣的。
結果輸出 通過特別的演算法,將最匹配度最高的頭100條結果輸出,結果集是緩衝式的小批量讀取的。 返回所有的結果集,在匹配條目非常多的時候(比如上萬條)需要大量的記憶體存放這些臨時結果集。
可定製性 通過不同的語言分析介面實現,可以方便的定製出符合應用需要的索引規則(包括對中文的支援) 沒有介面或介面複雜,無法定製
結論 高負載的模糊查詢應用,需要負責的模糊查詢的規則,索引的資料量比較大 使用率低,模糊匹配規則簡單或者需要模糊查詢的資料量少

全文檢索和資料庫應用最大的不同在於:讓最相關的頭100條結果滿足98%以上使用者的需求

Lucene的創新之處:

大部分的搜尋(資料庫)引擎都是用B樹結構來維護索引,索引的更新會導致大量的IO操作,Lucene在實現中,對此稍微有所改進:不是維護一個索引檔案,而是在擴充套件索引的時候不斷建立新的索引檔案,然後定期的把這些新的小索引檔案合併到原先的大索引中(針對不同的更新策略,批次的大小可以調整),這樣在不影響檢索的效率的前提下,提高了索引的效率。

Lucene和其他一些全文檢索系統/應用的比較:

Lucene 其他開源全文檢索系統
增量索引和批量索引 可以進行增量的索引(Append),可以對於大量資料進行批量索引,並且介面設計用於優化批量索引和小批量的增量索引。 很多系統只支援批量的索引,有時資料來源有一點增加也需要重建索引。
資料來源 Lucene沒有定義具體的資料來源,而是一個文件的結構,因此可以非常靈活的適應各種應用(只要前端有合適的轉換器把資料來源轉換成相應結構), 很多系統只針對網頁,缺乏其他格式文件的靈活性。
索引內容抓取 Lucene的文件是由多個欄位組成的,甚至可以控制那些欄位需要進行索引,那些欄位不需要索引,近一步索引的欄位也分為需要分詞和不需要分詞的型別:
   需要進行分詞的索引,比如:標題,文章內容欄位
   不需要進行分詞的索引,比如:作者/日期欄位
缺乏通用性,往往將文件整個索引了
語言分析 通過語言分析器的不同擴充套件實現:
可以過濾掉不需要的詞:an the of 等,
西文語法分析:將jumps jumped jumper都歸結成jump進行索引/檢索
非英文支援:對亞洲語言,阿拉伯語言的索引支援
缺乏通用介面實現
查詢分析 通過查詢分析介面的實現,可以定製自己的查詢語法規則:
比如: 多個關鍵詞之間的 + - and or關係等
併發訪問 能夠支援多使用者的使用

關於亞洲語言的的切分詞問題(Word Segment)

對於中文來說,全文索引首先還要解決一個語言分析的問題,對於英文來說,語句中單詞之間是天然通過空格分開的,但亞洲語言的中日韓文語句中的字是一個字挨一個,所有,首先要把語句中按“詞”進行索引的話,這個詞如何切分出來就是一個很大的問題。

首先,肯定不能用單個字元作(si-gram)為索引單元,否則查“上海”時,不能讓含有“海上”也匹配。

但一句話:“北京天安門”,計算機如何按照中文的語言習慣進行切分呢?
“北京 天安門” 還是“北 京 天安門”?讓計算機能夠按照語言習慣進行切分,往往需要機器有一個比較豐富的詞庫才能夠比較準確的識別出語句中的單詞。

另外一個解決的辦法是採用自動切分演算法:將單詞按照2元語法(bigram)方式切分出來,比如:
"北京天安門" ==> "北京 京天 天安 安門"。

這樣,在查詢的時候,無論是查詢"北京" 還是查詢"天安門",將查詢片語按同樣的規則進行切分:"北京","天安安門",多個關鍵詞之間按與"and"的關係組合,同樣能夠正確地對映到相應的索引中。這種方式對於其他亞洲語言:韓文,日文都是通用的。

基於自動切分的最大優點是沒有詞表維護成本,實現簡單,缺點是索引效率低,但對於中小型應用來說,基於2元語法的切分還是夠用的。基於2元切分後的索引一般大小和原始檔差不多,而對於英文,索引檔案一般只有原檔案的30%-40%不同,

自動切分 詞表切分
實現 實現非常簡單 實現複雜
查詢 增加了查詢分析的複雜程度, 適於實現比較複雜的查詢語法規則
儲存效率 索引冗餘大,索引幾乎和原文一樣大 索引效率高,為原文大小的30%左右
維護成本 無詞表維護成本 詞表維護成本非常高:中日韓等語言需要分別維護。
還需要包括詞頻統計等內容
適用領域 嵌入式系統:執行環境資源有限
分散式系統:無詞表同步問題
多語言環境:無詞表維護成本
對查詢和儲存效率要求高的專業搜尋引擎

目前比較大的搜尋引擎的語言分析演算法一般是基於以上2個機制的結合。關於中文的語言分析演算法,大家可以在Google查關鍵詞"wordsegment search"能找到更多相關的資料。

安裝和使用

注意:Lucene中的一些比較複雜的詞法分析是用JavaCC生成的(JavaCC:JavaCompilerCompiler,純Java的詞法分析生成器),所以如果從原始碼編譯或需要修改其中的QueryParser、定製自己的詞法分析器,還需要從https://javacc.dev.java.net/下載javacc。

lucene的組成結構:對於外部應用來說索引模組(index)和檢索模組(search)是主要的外部應用入口

org.apache.Lucene.search/ 搜尋入口
org.apache.Lucene.index/ 索引入口
org.apache.Lucene.analysis/ 語言分析器
org.apache.Lucene.queryParser/ 查詢分析器
org.apache.Lucene.document/ 儲存結構
org.apache.Lucene.store/  底層IO/儲存結構
org.apache.Lucene.util/ 一些公用的資料結構

簡單的例子演示一下Lucene的使用方法:

索引過程:從命令列讀取檔名(多個),將檔案分路徑(path欄位)和內容(body欄位)2個欄位進行儲存,並對內容進行全文索引:索引的單位是Document物件,每個Document物件包含多個欄位Field物件,針對不同的欄位屬性和資料輸出的需求,對欄位還可以選擇不同的索引/儲存欄位規則,列表如下:
方法 切詞 索引 儲存 用途
Field.Text(String name, String value) Yes Yes Yes 切分詞索引並存儲,比如:標題,內容欄位
Field.Text(String name, Reader value) Yes Yes No 切分詞索引不儲存,比如:META資訊,
不用於返回顯示,但需要進行檢索內容
Field.Keyword(String name, String value) No Yes Yes 不切分索引並存儲,比如:日期欄位
Field.UnIndexed(String name, String value) No No Yes 不索引,只儲存,比如:檔案路徑
Field.UnStored(String name, String value) Yes Yes No 只全文索引,不儲存
public class IndexFiles { 
  //使用方法:: IndexFiles [索引輸出目錄] [索引的檔案列表] ... 
  public static void main(String[] args) throws Exception {
    String indexPath = args[0];
    IndexWriter writer;
    //用指定的語言分析器構造一個新的寫索引器(第3個引數表示是否為追加索引)
    writer = new IndexWriter(indexPath, new SimpleAnalyzer(), false);

    for (int i=1; i<args.length; i++) {
      System.out.println("Indexing file " + args[i]);
      InputStream is = new FileInputStream(args[i]);

      //構造包含2個欄位Field的Document物件
      //一個是路徑path欄位,不索引,只儲存
      //一個是內容body欄位,進行全文索引,並存儲
      Document doc = new Document();
      doc.add(Field.UnIndexed("path", args[i]));
      doc.add(Field.Text("body", (Reader) new InputStreamReader(is)));
      //將文件寫入索引
      writer.addDocument(doc);
      is.close();
    };
    //關閉寫索引器
    writer.close();
  }
}
 

索引過程中可以看到:

  • 語言分析器提供了抽象的介面,因此語言分析(Analyser)是可以定製的,雖然lucene預設提供了2個比較通用的分析器SimpleAnalyser和StandardAnalyser,這2個分析器預設都不支援中文,所以要加入對中文語言的切分規則,需要修改這2個分析器。
  • Lucene並沒有規定資料來源的格式,而只提供了一個通用的結構(Document物件)來接受索引的輸入,因此輸入的資料來源可以是:資料庫,WORD文件,PDF文件,HTML文件……只要能夠設計相應的解析轉換器將資料來源構造成成Docuement物件即可進行索引。
  • 對於大批量的資料索引,還可以通過調整IndexerWrite的檔案合併頻率屬性(mergeFactor)來提高批量索引的效率。

檢索過程和結果顯示:

搜尋結果返回的是Hits物件,可以通過它再訪問Document==>Field中的內容。

假設根據body欄位進行全文檢索,可以將查詢結果的path欄位和相應查詢的匹配度(score)打印出來,

public class Search { 
  public static void main(String[] args) throws Exception {
    String indexPath = args[0], queryString = args[1];
    //指向索引目錄的搜尋器
    Searcher searcher = new IndexSearcher(indexPath);
    //查詢解析器:使用和索引同樣的語言分析器
    Query query = QueryParser.parse(queryString, "body", 
                              new SimpleAnalyzer());
    //搜尋結果使用Hits儲存
    Hits hits = searcher.search(query);
    //通過hits可以訪問到相應欄位的資料和查詢的匹配度
    for (int i=0; i<hits.length(); i++) {
      System.out.println(hits.doc(i).get("path") + "; Score: " + 
                         hits.score(i));
    };
  }
}
在整個檢索過程中,語言分析器,查詢分析器,甚至搜尋器(Searcher)都是提供了抽象的介面,可以根據需要進行定製。

Hacking Lucene

簡化的查詢分析器

個人感覺lucene成為JAKARTA專案後,畫在了太多的時間用於除錯日趨複雜QueryParser,而其中大部分是大多數使用者並不很熟悉的,目前LUCENE支援的語法:

Query ::= ( Clause )*
Clause ::= ["+", "-"] [<TERM> ":"] ( <TERM> | "(" Query ")")

中間的邏輯包括:and or + - &&||等符號,而且還有"短語查詢"和針對西文的字首/模糊查詢等,個人感覺對於一般應用來說,這些功能有一些華而不實,其實能夠實現目前類似於Google的查詢語句分析功能其實對於大多數使用者來說已經夠了。所以,Lucene早期版本的QueryParser仍是比較好的選擇。

新增修改刪除指定記錄(Document)

Lucene提供了索引的擴充套件機制,因此索引的動態擴充套件應該是沒有問題的,而指定記錄的修改也似乎只能通過記錄的刪除,然後重新加入實現。如何刪除指定的記錄呢?刪除的方法也很簡單,只是需要在索引時根據資料來源中的記錄ID專門另建索引,然後利用IndexReader.delete(Termterm)方法通過這個記錄ID刪除相應的Document。

根據某個欄位值的排序功能

lucene預設是按照自己的相關度演算法(score)進行結果排序的,但能夠根據其他欄位進行結果排序是一個在LUCENE的開發郵件列表中經常提到的問題,很多原先基於資料庫應用都需要除了基於匹配度(score)以外的排序功能。而從全文檢索的原理我們可以瞭解到,任何不基於索引的搜尋過程效率都會導致效率非常的低,如果基於其他欄位的排序需要在搜尋過程中訪問儲存欄位,速度回大大降低,因此非常是不可取的。

但這裡也有一個折中的解決方法:在搜尋過程中能夠影響排序結果的只有索引中已經儲存的docID和score這2個引數,所以,基於score以外的排序,其實可以通過將資料來源預先排好序,然後根據docID進行排序來實現。這樣就避免了在LUCENE搜尋結果外對結果再次進行排序和在搜尋過程中訪問不在索引中的某個欄位值。

這裡需要修改的是IndexSearcher中的HitCollector過程:

...
 scorer.score(new HitCollector() {
	private float minScore = 0.0f;
	public final void collect(int doc, float score) {
	  if (score > 0.0f &&			  // ignore zeroed buckets
	      (bits==null || bits.get(doc))) {	  // skip docs not in bits
	    totalHits[0]++;
	    if (score >= minScore) {
              /* 原先:Lucene將docID和相應的匹配度score例入結果命中列表中:
	       * hq.put(new ScoreDoc(doc, score));	  // update hit queue
               * 如果用doc 或 1/doc 代替 score,就實現了根據docID順排或逆排
               * 假設資料來源索引時已經按照某個欄位排好了序,而結果根據docID排序也就實現了
               * 針對某個欄位的排序,甚至可以實現更復雜的score和docID的擬合。
               */
              hq.put(new ScoreDoc(doc, (float) 1/doc )); 
	      if (hq.size() > nDocs) {		  // if hit queue overfull
		hq.pop();			  // remove lowest in hit queue
		minScore = ((ScoreDoc)hq.top()).score; // reset minScore
	      }
	    }
	  }
	}
      }, reader.maxDoc());

更通用的輸入輸出介面

雖然lucene沒有定義一個確定的輸入文件格式,但越來越多的人想到使用一個標準的中間格式作為Lucene的資料匯入介面,然後其他資料,比如PDF只需要通過解析器轉換成標準的中間格式就可以進行資料索引了。這個中間格式主要以XML為主,類似實現已經不下4,5個:

資料來源: WORD       PDF     HTML    DB       other
         \          |       |      |         /
                       XML中間格式
                            |
                     Lucene INDEX


索引過程優化

索引一般分2種情況,一種是小批量的索引擴充套件,一種是大批量的索引重建。在索引過程中,並不是每次新的DOC加入進去索引都重新進行一次索引檔案的寫入操作(檔案I/O是一件非常消耗資源的事情)。

Lucene先在記憶體中進行索引操作,並根據一定的批量進行檔案的寫入。這個批次的間隔越大,檔案的寫入次數越少,但佔用記憶體會很多。反之佔用記憶體少,但檔案IO操作頻繁,索引速度會很慢。在IndexWriter中有一個MERGE_FACTOR引數可以幫助你在構造索引器後根據應用環境的情況充分利用記憶體減少檔案的操作。根據我的使用經驗:預設Indexer是每20條記錄索引後寫入一次,每將MERGE_FACTOR增加50倍,索引速度可以提高1倍左右。

搜尋過程優化

lucene支援記憶體索引:這樣的搜尋比基於檔案的I/O有數量級的速度提升。
http://www.onjava.com/lpt/a/3273
而儘可能減少IndexSearcher的建立和對搜尋結果的前臺的快取也是必要的。

Lucene面向全文檢索的優化在於首次索引檢索後,並不把所有的記錄(Document)具體內容讀取出來,而起只將所有結果中匹配度最高的頭100條結果(TopDocs)的ID放到結果集快取中並返回,這裡可以比較一下資料庫檢索:如果是一個10,000條的資料庫檢索結果集,資料庫是一定要把所有記錄內容都取得以後再開始返回給應用結果集的。所以即使檢索匹配總數很多,Lucene的結果集佔用的記憶體空間也不會很多。對於一般的模糊檢索應用是用不到這麼多的結果的,頭100條已經可以滿足90%以上的檢索需求。

如果首批快取結果數用完後還要讀取更後面的結果時Searcher會再次檢索並生成一個上次的搜尋快取數大1倍的快取,並再重新向後抓取。所以如果構造一個Searcher去查1-120條結果,Searcher其實是進行了2次搜尋過程:頭100條取完後,快取結果用完,Searcher重新檢索再構造一個200條的結果快取,依此類推,400條快取,800條快取。由於每次Searcher物件消失後,這些快取也訪問那不到了,你有可能想將結果記錄快取下來,快取數儘量保證在100以下以充分利用首次的結果快取,不讓Lucene浪費多次檢索,而且可以分級進行結果快取。

Lucene的另外一個特點是在收集結果的過程中將匹配度低的結果自動過濾掉了。這也是和資料庫應用需要將搜尋的結果全部返回不同之處。

  • 支援中文的Tokenizer:這裡有2個版本,一個是通過JavaCC生成的,對CJK部分按一個字元一個TOKEN索引,另外一個是從SimpleTokenizer改寫的,對英文支援數字和字母TOKEN,對中文按迭代索引。
  • 基於XML資料來源的索引器:XMLIndexer,因此所有資料來源只要能夠按照DTD轉換成指定的XML,就可以用XMLIndxer進行索引了。
  • 根據某個欄位排序:按記錄索引順序排序結果的搜尋器:IndexOrderSearcher,因此如果需要讓搜尋結果根據某個欄位排序,可以讓資料來源先按某個欄位排好序(比如:PriceField),這樣索引後,然後在利用這個按記錄的ID順序檢索的搜尋器,結果就是相當於是那個欄位排序的結果了。

從Lucene學到更多

Luene的確是一個面對物件設計的典範

  • 所有的問題都通過一個額外抽象層來方便以後的擴充套件和重用:你可以通過重新實現來達到自己的目的,而對其他模組而不需要;
  • 簡單的應用入口Searcher, Indexer,並呼叫底層一系列元件協同的完成搜尋任務;
  • 所有的物件的任務都非常專一:比如搜尋過程:QueryParser分析將查詢語句轉換成一系列的精確查詢的組合(Query),通過底層的索引讀取結構IndexReader進行索引的讀取,並用相應的打分器給搜尋結果進行打分/排序等。所有的功能模組原子化程度非常高,因此可以通過重新實現而不需要修改其他模組。 
  • 除了靈活的應用介面設計,Lucene還提供了一些適合大多數應用的語言分析器實現(SimpleAnalyser,StandardAnalyser),這也是新使用者能夠很快上手的重要原因之一。

這些優點都是非常值得在以後的開發中學習借鑑的。作為一個通用工具包,Lunece的確給予了需要將全文檢索功能嵌入到應用中的開發者很多的便利。

此外,通過對Lucene的學習和使用,我也更深刻地理解了為什麼很多資料庫優化設計中要求,比如:

  • 儘可能對欄位進行索引來提高查詢速度,但過多的索引會對資料庫表的更新操作變慢,而對結果過多的排序條件,實際上往往也是效能的殺手之一。
  • 很多商業資料庫對大批量的資料插入操作會提供一些優化引數,這個作用和索引器的merge_factor的作用是類似的,
  • 20%/80%原則:查的結果多並不等於質量好,尤其對於返回結果集很大,如何優化這頭幾十條結果的質量往往才是最重要的。
  • 儘可能讓應用從資料庫中獲得比較小的結果集,因為即使對於大型資料庫,對結果集的隨機訪問也是一個非常消耗資源的操作。

參考資料:

特別感謝:
前網易CTO許良傑(Jack Xu)給我的指導:是您將我帶入了搜尋引擎這個行業。