前言:最近翻開了之前老楊(楊中科)的Lucene.Net站內搜索項目的教學視頻,於是作為老楊腦殘粉的我又跟着復習了一遍,學習途中做了一些筆記也就成了接下來您看到的這篇博文,僅僅是我的個人筆記,大神請呵呵一笑而過。相信做過站內搜索的.Net程序員應該對Lucene.Net不陌生,沒做過的也許會問:就不是個查詢嘛!為什么不能使用Like模糊查找呢?原因很簡單:模糊查詢的契合度太低,匹配關鍵字之間不能含有其他內容。最重要的是它會造成數據庫全表掃描,效率低下,即使使用視圖,也會造成數據庫服務器"亞歷山大"!因此,有必要了解一下Lucene.Net這個神器(也許現在早已不是)!
一、Lucene.Net簡介

Lucene.Net只是一個全文檢索開發包,不是一個成型的搜索引擎。
它的功能就是負責將文本數據按照某種分詞算法進行切詞,分詞后的結果存儲在索引庫中,從索引庫檢索數據的速度灰常快。
對以上加粗的詞匯稍作下闡述:
文本數據:Lucene.Net只能對文本信息進行檢索,所以非文本信息要么轉換成為文本信息,要么你就死了這條心吧!
分詞算法:將一句完整的話分解成若干詞匯的算法 常見的一元分詞(Lucene.Net內置就是一元分詞,效率高,契合度低),二元分詞,基於詞庫的分詞算法(契合度高,效率低)...
切詞:將一句完整的話,按分詞算法切成若干詞語
比如:"不是所有痞子都叫一毛" 這句話,如果根據一元分詞算法則被切成: 不 是 所 有 痞 子 都 叫 一 毛
如果二元分詞算法則切成: 不是 是所 所有 有痞 痞子 子都 都叫 叫一 一毛
如果基於詞庫的算法有可能:不是 所有 痞子 都叫 一毛 具體看詞庫
索引庫:簡單的理解成一個提供了全文檢索功能的數據庫,見下圖所示:

二、幾種分詞的使用
毫無疑問,Lucene.Net中最核心的內容就是分詞,下面我們來體驗一下基本的一元分詞、二元分詞以及基於詞庫分詞的代表:盤古分詞。首先,我們准備一個ASP.Net Web項目(這里使用的是WebForms技術),引入Lucene.Net和PanGu的dll,以及加入CJK分詞的兩個class(均在附件下載部分可以下載),分詞演示Demo的項目結構如下圖所示:

2.1 一元分詞
核心代碼
protected void btnGetSegmentation_Click(object sender, EventArgs e) { string words = txtWords.Text; if (string.IsNullOrEmpty(words)) { return; } Analyzer analyzer = new StandardAnalyzer(); // 標准分詞 → 一元分詞 TokenStream tokenStream = analyzer.TokenStream("", new StringReader(words)); Token token = null; while ((token = tokenStream.Next()) != null) // 只要還有詞,就不返回null { string word = token.TermText(); // token.TermText() 取得當前分詞 Response.Write(word + " | "); } }
效果演示

可以看到一元分詞將這句話的每個字都作為一個詞組。前面提到,Lucene.Net維護着一個索引庫,如果每個字都作為一個詞組,那么索引庫會變得尤為巨大,當然,分詞的算法很簡單,因此分詞效率上會很高。
2.2 二元分詞
核心代碼
protected void btnGetSegmentation_Click(object sender, EventArgs e) { string words = txtWords.Text; if (string.IsNullOrEmpty(words)) { return; } Analyzer analyzer = new CJKAnalyzer(); // CJK分詞 → 二元分詞 TokenStream tokenStream = analyzer.TokenStream("", new StringReader(words)); Token token = null; while ((token = tokenStream.Next()) != null) // 只要還有詞,就不返回null { string word = token.TermText(); // token.TermText() 取得當前分詞 Response.Write(word + " | "); } }
效果演示

可以看到二元分詞通過將兩個字作為一個詞組,在詞組的數量上較一元分詞有了一定減少,但是分詞的效果仍然不佳,比如:個來 這個分詞結果就不符合語義,加入索引庫也會是沒什么機會會被用到。
2.3 盤古分詞
使用步驟
(1)從PanGu開發包中取得PanGu.dll 與 PanGu.Lucenet.Analyzer.dll並加入到項目中

(2)從PanGu開發包中取得Dict文件,並在Bin目錄下創建一個Dict文件夾將Dict文件一起copy進去

效果演示

可以看到,使用基於詞庫的盤古分詞進行分詞后的效果較前兩種好得太多,不過中間的“就跑不脫”這個詞組優點不符合語義。剛剛提到盤古分詞是基於詞庫的分詞,因此我們可以到詞庫里邊去為跑不脫(四川方言)添加一個詞組到詞庫當中。
分詞擴展
詞庫就是我們剛剛加入到Bin/Dict目錄下的Dict文件,借助PanGu開發包中的DictManage.exe打開Dict文件,為跑不脫添加一個詞組吧!
(1)找到DictManage詞庫管理工具

(2)打開我們的Dict文件並添加一個詞組

(3)在DictManage.exe中查找詞組,然后保存,設置新版本號

(4)重新打開頁面查看分詞結果

修改詞庫之后的分詞結果是不是更加符合我們得常規思維習慣了呢?
三、一個最簡單的搜索引擎
3.1 搭建項目
這個Demo需要模擬的場景是一個BBS論壇,每天BBS論壇都會新增很多新的帖子,每篇帖子都會存入數據庫。從前面介紹可知,數據庫中的內容也會轉換為文本信息存入索引庫,用戶在前端搜索時會直接從索引庫中獲取查詢結果。整個流程如下圖所示:

我們仍然在之前分詞Demo的基礎上實現這個小Demo,整個項目的結構如下圖所示:

好了,准備一個Web頁面來展示吧:
<%@ Page Language="C#" AutoEventWireup="true" CodeBehind="SearchEngineV1.aspx.cs" Inherits="Manulife.SearchEngine.LuceneNet.Views.SearchEngineV1" %> <!DOCTYPE html> <html xmlns="http://www.w3.org/1999/xhtml"> <head runat="server"> <meta http-equiv="Content-Type" content="text/html; charset=utf-8" /> <title>最簡單的搜索引擎</title> </head> <body> <form id="mainForm" runat="server"> <div align="center"> <asp:Button ID="btnCreateIndex" runat="server" Text="Create Index" OnClick="btnCreateIndex_Click" /> <asp:Label ID="lblIndexStatus" runat="server" Visible="false" /> <hr /> <asp:TextBox ID="txtKeyWords" runat="server" Text="" Width="250"></asp:TextBox> <asp:Button ID="btnGetSearchResult" runat="server" Text="Search" OnClick="btnGetSearchResult_Click" /> <hr /> </div> <div> <ul> <asp:Repeater ID="rptSearchResult" runat="server"> <ItemTemplate> <li>Id:<%#Eval("Id") %><br /> <%#Eval("Msg") %></li> </ItemTemplate> </asp:Repeater> </ul> </div> </form> </body> </html>
頁面的結構如下圖所示:

頁面很簡單,只有兩個button,一個textbox,以及一個repeater列表。其中:
(1)Create Index : 點擊該按鈕會遍歷文章/帖子的文本文件夾,對每個帖子進行分詞,並將分詞后的結果存入索引庫;
(2)Search :點擊該按鈕會將用戶輸入的關鍵詞與索引庫中的內容進行匹配,並將匹配后的結果顯示在repeater列表中;
3.2 創建索引
核心代碼:
/// <summary> /// 創建索引 /// </summary> protected void btnCreateIndex_Click(object sender, EventArgs e) { string indexPath = Context.Server.MapPath("~/Index"); // 索引文檔保存位置 FSDirectory directory = FSDirectory.Open(new DirectoryInfo(indexPath), new NativeFSLockFactory()); bool isUpdate = IndexReader.IndexExists(directory); //判斷索引庫是否存在 if (isUpdate) { // 如果索引目錄被鎖定(比如索引過程中程序異常退出),則首先解鎖 // Lucene.Net在寫索引庫之前會自動加鎖,在close的時候會自動解鎖 // 不能多線程執行,只能處理意外被永遠鎖定的情況 if (IndexWriter.IsLocked(directory)) { IndexWriter.Unlock(directory); //unlock:強制解鎖,待優化 } } // 創建向索引庫寫操作對象 IndexWriter(索引目錄,指定使用盤古分詞進行切詞,最大寫入長度限制) // 補充:使用IndexWriter打開directory時會自動對索引庫文件上鎖 IndexWriter writer = new IndexWriter(directory, new PanGuAnalyzer(), !isUpdate, IndexWriter.MaxFieldLength.UNLIMITED); for (int i = 1000; i < 1100; i++) { string txt = File.ReadAllText(Context.Server.MapPath("~/Upload/Articles/") + i + ".txt"); // 一條Document相當於一條記錄 Document document = new Document(); // 每個Document可以有自己的屬性(字段),所有字段名都是自定義的,值都是string類型 // Field.Store.YES不僅要對文章進行分詞記錄,也要保存原文,就不用去數據庫里查一次了 document.Add(new Field("id", i.ToString(), Field.Store.YES, Field.Index.NOT_ANALYZED)); // 需要進行全文檢索的字段加 Field.Index. ANALYZED // Field.Index.ANALYZED:指定文章內容按照分詞后結果保存,否則無法實現后續的模糊查詢 // WITH_POSITIONS_OFFSETS:指示不僅保存分割后的詞,還保存詞之間的距離 document.Add(new Field("msg", txt, Field.Store.YES, Field.Index.ANALYZED, Field.TermVector.WITH_POSITIONS_OFFSETS)); // 防止重復索引,如果不存在則刪除0條 writer.DeleteDocuments(new Term("id", i.ToString()));// 防止已存在的數據 => delete from t where id=i // 把文檔寫入索引庫 writer.AddDocument(document); Console.WriteLine("索引{0}創建完畢", i.ToString()); } writer.Close(); // Close后自動對索引庫文件解鎖 directory.Close(); // 不要忘了Close,否則索引結果搜不到 lblIndexStatus.Text = "索引文件創建成功!"; lblIndexStatus.Visible = true; btnCreateIndex.Enabled = false; }
效果展示:

應用場景:
在BBS論壇新發布一個帖子的事件時,添加到數據庫之后,再進行創建索引的操作,保存到索引庫,這樣帖子內容就存了兩份,一份在數據庫,一份在索引庫。
3.2 獲取結果
核心代碼:
/// <summary> /// 獲取搜索結果 /// </summary> protected void btnGetSearchResult_Click(object sender, EventArgs e) { string keyword = txtKeyWords.Text; string indexPath = Context.Server.MapPath("~/Index"); // 索引文檔保存位置 FSDirectory directory = FSDirectory.Open(new DirectoryInfo(indexPath), new NoLockFactory()); IndexReader reader = IndexReader.Open(directory, true); IndexSearcher searcher = new IndexSearcher(reader); // 查詢條件 PhraseQuery query = new PhraseQuery(); // 等同於 where contains("msg",kw) query.Add(new Term("msg", keyword)); // 兩個詞的距離大於100(經驗值)就不放入搜索結果,因為距離太遠相關度就不高了 query.SetSlop(100); // TopScoreDocCollector:盛放查詢結果的容器 TopScoreDocCollector collector = TopScoreDocCollector.create(1000, true); // 使用query這個查詢條件進行搜索,搜索結果放入collector searcher.Search(query, null, collector); // 從查詢結果中取出第m條到第n條的數據 // collector.GetTotalHits()表示總的結果條數 ScoreDoc[] docs = collector.TopDocs(0, collector.GetTotalHits()).scoreDocs; // 遍歷查詢結果 IList<SearchResult> resultList = new List<SearchResult>(); for (int i = 0; i < docs.Length; i++) { // 拿到文檔的id,因為Document可能非常占內存(DataSet和DataReader的區別) int docId = docs[i].doc; // 所以查詢結果中只有id,具體內容需要二次查詢 // 根據id查詢內容:放進去的是Document,查出來的還是Document Document doc = searcher.Doc(docId); SearchResult result = new SearchResult(); result.Id = Convert.ToInt32(doc.Get("id")); result.Msg = HighlightHelper.HighLight(keyword, doc.Get("msg")); resultList.Add(result); } // 綁定到Repeater rptSearchResult.DataSource = resultList; rptSearchResult.DataBind(); }
效果展示:

附件下載
Lucene.Net開發包 : 點我下載
PanGu盤古分詞開發包:點我下載
簡單搜索引擎Demo:點我下載
參考資料
(1)楊中科,《Lucene.Net站內搜索公開課》
(2)痞子一毛,《Lucene.Net》
(3)MeteorSeed,《使用Lucene.Net實現全文檢索》
(4)Lucene.Net官方網站:http://lucenenet.apache.org/download.html
