分享

Lucene的語彙分析與其擴充方式

 maomao 2005-12-10

Lucene的語彙分析與其擴充方式

Lucene這一個開放原碼的全文搜尋引擎,在軟體架構上的設計挺不錯的。尤其在整個全文搜尋程序中相關的各個環節,幾乎都可以獨立的拆解出來,由程式員自行再加以擴充或者是改變。最近我們遇到一個例子,正好可以說明它的這個彈性。

使用過Lucene的程式員應當都知道,在Lucene中,和語彙分析相關的幾個主要類別,以及它們各自的角色有:

  • org.apache.lucene.analysis.Token:語彙分析後的最小單位,代表一個不能再予以分割的字詞。
  • org.apache.lucene.analysis.TokenStream:Token類別的串流,能夠以遞代(iterative)的方式,讓你將一個一個Token從中取出。
  • org.apache.lucene.analysis.Tokenizer:繼承自TokenStream,能夠將給定的文字,劃分為一連串有順序性的Token,並且以TokenStream的型式提供外界讀取。
  • org.apache.lucene.analysis.TokenFilter:繼承自TokenStream。其建構子通常也是TokenStream,其責任在於對建構時所傳入的TokenStream進行「過濾」的動作,過濾後的結果則提供另一TokenStream做為外界讀取之用。
  • org.apache.lucene.analysis.Analyzer:依據輸入文字建構TokenStream。此類別是語彙分析相關類別中,用戶端程式員必須直接面對的類別。在大多數情況下,用戶端程式員並不需要接觸到其餘類別與其衍生類別。

Analyzer是整個語彙分析相關類別中最主要的類別。一般來說,Lucene內建提供了許多不同的Analyzer的衍生類別,分別具有不同的語彙分析策略,例如org.apache.lucene.analysis.WhitespaceAnalyzer,便是使用空白字元來劃分語彙。而多數情況下,org.apache.lucene.analysis.standard.StandardAnalyzer便足堪應付。

每個Analyzer其實內部都會運用到一個Tokenizer,並且依需要採用一個或多個的TokenFilter來對語彙串流(TokenStream)進行過濾。所以Analyzer對外呈現的,是它內部Tokenizer和所有TokenFilter的綜效,而它同時也隱藏了Tokenizer和TokenFilter的存在。而這意謂著Analyzer是整個語彙分析子系統的代表。

這樣的設計起碼可以看到兩個好處:(1)由於Analyzer是組裝各個獨立的Tokenizer和TokenFilter來呈現單一的語彙分析策略,因此,當多個Analyzer有部份效果是類似,它們就可以共用相同的Tokenizer或TokenFilter。例如都需要將文字一律轉換成為小寫的Analyzer,都可以使用相同的LowerCaseFilter。這一段轉換為小寫的程式碼,就產生了複用(reuse)的效果。(2)由Analyzer來代表整個語彙分析子系統,抽換掉Analyzer,就可以立即改變語彙分析的運作行為。毋需拖泥帶水,為了更動語彙分析行為,還得在用戶端程式中多處修改。

在其設計中,還可以觀察到的是,它採用了Decorator這個設計模式。你可以發現,TokenFilter本身不僅繼承自TokenStream,而且其建構子的參數,仍然也是TokenStream。這是很典型的Decorator。同樣的設計,你也可以在java.io.FilterInputStream身上看到,FilterInputStream繼承自InputStream,而其建構子的唯一參數仍然也是InputStream。基本上,Decorator是模仿父類別的行為(所以它有著和父類別一致的介面),但真正的行為是作用在一個和其位於相同族系(同樣也是其父類別的衍生類別)的物件(由建構子傳入)之上。

Decorator很適合動態的組裝你想要的複合行為,TokenFilter則利用這個特性。讓我們先來看看org.apache.lucene.analysis.standard.StandardAnalyzer的tokenStream()方法:

TokenStream result = new StandardTokenizer(reader);
result = new StandardFilter(result);
result = new LowerCaseFilter(result);
result = new StopFilter(result, stopSet);

tokenStream()方法是每個Analyzer都會實作的方法,用以對外界提供語彙串流。不同的Analyzer最大的差別,幾乎都在此處,這個方法通常顯露出Analyzer究竟是組裝那些Tokenizer和TokenFilter。所以我們可以看到StandardAnalyzer採用的是StandardTokenizer,以及StandardFilter、LowerCaseFilter、StopFilter等TokenFilter。你可以注意到,當建出StandardTokenizer後,程式就得到了一個最原始的TokenStream,接著便將此TokenStream傳入第一個TokenFilter。你可以想像這是一個接水管的過程,一開始我們有一小段水管,水會從一端流至另一端,接著我們在這小段水管上接上了一個會染色的另一小段水管,當水自其出口流出時,顏色就會被改變了。接著,程式依樣畫葫蘆的再接上兩段水管,所以我們最後可以得到這四段水管的綜合效果。這裡你就可以看到Decorator的影子,每個TokenFilter都是個TokenStream也接受TokenStream物件做為建構子的輸入(也就是來源TokemStream),並且會對此輸入物件進行行為的改變。

自訂語彙分析行為,有三處可為之。首先是自訂Analyzer,這是最簡單的層次,你幾乎只要在tokenStream()中選擇欲組裝的Tokenizer和TokenFilter就行了。再來便是自訂TokenFilter,你可以在自己實作的TokenFilter中選擇丟棄來源TokenStream中所讀出的Token,也可以對Token內容進行修改。難度最高的便是自訂Tokenizer,在這裡頭你得自己制定劃分語彙的方式。

最近我們所遇上的應用是這樣子的,當我們的文字中含有網址時,例如: www.yahoo.com.tw,我們希望輸入yahoo也能找出來。然而,當我們使用的是StandardAnalyzer,其採用的StandardTokenizer是JavaCC所產生出來的,而JavaCC會依據其StandardTokenizer.jj檔所規範的語法來產生StandardTokenizer的原始碼。在StandardTokenizer.jj檔有這麼一條語法:

| <HOST: <ALPHANUM> ("." <ALPHANUM>)+ >

所以www.yahoo.com.tw就會符合這條語法,而被視為是一個單一的語彙(如果你並不了解JavaCC,就只要了解這個結論就好了)。一旦它被視為是一個單一的語彙時,在建索引時,整個www.yahoo.com.tw就會是一個完整的索引項目,日後用yahoo進行搜尋,自然也就搜尋不到了。

為了解這個問題,我可以有兩種解法。首先,我可以小改StandardTokenizer.jj檔成為另一個自訂的.jj檔,藉此產生出另一個Tokenizer。或者,我也可以自己撰寫一個TokenFilter,針對Token一一去判斷,如果是HOST的型式,那麼就自行再加以細分。

我自己偏好後者,因為寫好這個TokenFilter後,還可以提供給日後其它不同Analyzer來使用,如此一來才能收綜合搭配的效果。

所以我們寫了如下的TokenFilter:

import java.io.IOException;
import java.util.Vector;

import org.apache.lucene.analysis.Token;
import org.apache.lucene.analysis.TokenFilter;
import org.apache.lucene.analysis.TokenStream;

public class URLPlusFilter extends TokenFilter
{
private Vector vTokenBuffer = new Vector();
//
public Token next() throws IOException
{
if( vTokenBuffer.size() > 0 )
return (Token) vTokenBuffer.remove(0);
Token token = input.next();
if( token == null )
return null;
String text = token.termText();
int p = 0;
int q = text.indexOf(‘.‘);
if( q < 0 )
return token;
while( p < (text.length()-1) )
{
if( q < 0 )
{
vTokenBuffer.add(new Token(text.substring(p), token.startOffset()+p, token.startOffset()+text.length()));
break;
}
else
vTokenBuffer.add(new Token(text.substring(p, q), token.startOffset()+p, token.startOffset()+q));
p = q+1;
q = text.indexOf(‘.‘, p+1);
}
return next();
}
public URLPlusFilter(TokenStream input)
{
super(input);
}
}

這個TokenFilter最重要的責任,便在next()這個方法之內。整個TokenFilter的寫法是這樣子的,先利用父類別的建構子來將傳入的來源TokemStream記住(也就是TokenFilter.input這個欄位)。而在next()當中,基本上就直接回傳input.next()。但是有一個例外,就是當input.next()所回傳的Token物件,其文字內容含有「.」時,就對此Token做再進一步的拆解。將一個Token依據「.」拆解成多個Token後,接著將拆解後的多個Token,儲存在一個buffer裡頭。所以當next()被呼叫時,會先檢查buffer中是否有未傳回的Token,如果有的話,就直接回傳,反之,才利用input.next()自來源TokemStream取得。透過這個方法,當來源TokemStream回傳的Token含有「.」時,便會被拆解成多個Token,並且被適當的回傳。

最後,就只要再寫一個Analyzer來選擇搭配Tokenizer和Filter就可以了。

import java.io.Reader;
import java.util.Set;

import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.LowerCaseFilter;
import org.apache.lucene.analysis.StopAnalyzer;
import org.apache.lucene.analysis.StopFilter;
import org.apache.lucene.analysis.standard.StandardFilter;
import org.apache.lucene.analysis.standard.StandardTokenizer;

public class URLPlusAnalyzer extends Analyzer
{
public static final String[] STOP_WORDS;
public static final String[] STOP_WORDS1 = StopAnalyzer.ENGLISH_STOP_WORDS;
public static final String[] STOP_WORDS2 = {"http", "www", "com", "net"};
//
private Set stopSet;
//
static
{
STOP_WORDS = new String[STOP_WORDS1.length+STOP_WORDS2.length];
for(int i=0;i STOP_WORDS[i] = STOP_WORDS1[i];
for(int i=0;i STOP_WORDS[i+STOP_WORDS1.length] = STOP_WORDS2[i];
}
public URLPlusAnalyzer()
{
this(STOP_WORDS);
}
public URLPlusAnalyzer(String[] stopWords)
{
stopSet = StopFilter.makeStopSet(stopWords);
}
public TokenStream tokenStream(String fieldName, Reader reader)
{
TokenStream result = new StandardTokenizer(reader);
result = new StandardFilter(result);
result = new LowerCaseFilter(result);
result = new URLPlusFilter(result);
result = new StopFilter(result, stopSet);
return result;
}
}

這個URLPlusAnalyzer是仿StandardAnalyzer而得。但有兩個地方不同,一個是額外再定義了一組stop words。所謂的stop words是餵給StopFilter做為輸入之用,代表的是不希望被拿來做為索引的語彙。例如,像網址中的「www」、「com」、「net」都太常出現,而且不具太多的實質意義,就應該被加到stop words中。另一個不同的地方便是tokenStream()中選擇組裝的TokenFilter多了一個適才完成的URLPlusFilter。這代表所有分析出來的Token都會經過URLPlusFilter的檢查,如果是網址的型式,便會被再細分出來。

這要必須要注意,URLPlusFilter必須在StopFilter起作用前先行發揮功效,這個順序性是必要的。倘若把URLPlusFilter擺在StopFilter的後頭,那麼由於在StopFilter起作用時,整個網址是被視為一個單一的語彙,所以並不會符合stop words中的com或net等字詞,因此也不會被過濾掉。因此,必須將URLPlusFilter擺在前頭,先讓URLPlusFilter把這些字詞劃分出來後,才能由StopFilter將它們過濾掉。

一如本文所展示的,Lucene允許你透過多種途徑來改變其語彙的行為。藉由修改或擴充原有Lucene類別的方式,只需添加少許額外的程式碼,就可以進行這樣的改變,這顯示出其原始架構的彈性與可擴充性。我們透過這樣子的一個範例,希望明白的不只是Lucene的語彙分析運作行為,也不只是在Lucene中如何對語彙分析行為進行擴充,更重要的是,如何在觀察其架構的設計之後,學習或模仿如何設計此種具彈性與擴充性的軟體架構。

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多