借助 Lucene.Net 构建站内搜索引擎(上)
前言:最近翻开了之前老杨(杨中科)的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