基于Lucene的图书全文搜索引擎
Baofeng Zhang@zju
转载请注明出处:http://blog.csdn.net/zbf8441372
背景介绍
这是一个关于图书的多侧面,多粒度的搜索引擎。仿照“读秀”(http://www.duxiu.com/)那样的搜索方式和搜索结果呈现方式,可以根据书的一些基本属性进行关键字搜索,展现的时候还附加进行了搜索结果的统计,也可以看到相关的全文信息。多侧面,多粒度的搜索和展示都是为了给用户更好的体验,方便用户的各种搜索需求和阅读需求。
这个搜索引擎是我前段时间开发的,前后台都是由我一个人开发完成的,没有采用框架或者任何现成代码。现在也只是个可以看的demo,很多地方还需要很大的改善。下面我会介绍下采用的技术,数据的说明,功能说明和设计,开发过程中遇到的难点问题以及他的不足和改进。
主要技术
前台:采用的是html + css + javascript(包括jquery库)
后台:主要采用的是JSP和Struts2(Spring还没有使用),还有Ajax(jquery来实现)
搜索:采用lucene包完成索引的建立和查询,暂时用的是很老的2.0版本
切词:切词工具是je切词器,可能比较老,切词效果一般
数据说明
元数据
数据是1万多本的工程类图书(中途分类法中N字母及以后的那些类的图书)。每本书是一个文件夹,里面的txt目录内的所有.ok文件是OCR扫描得到的每一页的书的全文内容。
meta目录下的dc.xml文件里是书的基本信息和属性(书名,作者,id,出版时间等),Catalog.xml文件里是书的每一章及每一节的标题和页码信息。
索引
现在索引的大小是1G多。meta目录里的两个xml我都解析出来存在索引里,并对需要的部分进行切词(如书名要切词,出版时间就不切词,直接存在索引里)。txt目录下的是书的全文内容,我对所有的.ok进行读取并切词建立索引,但是文本本身不存索引,为了避免索引过大。在“设计实现”部分我会再具体说明索引的设计。
功能说明
现在的搜索都是精确匹配的搜索,即切词器切的是什么词,我就只能搜到完全一样的词,没有模糊匹配之类的搜索。而且只限于中文搜索,英文没有进行切词,不能进行搜索。
书名搜索
输入关键字,得到书名中带有该关键字的图书,在中间显示,现在的搜索结果是全部展示的,也可以设置只显示前10条。其实对搜索来说是一样的,总是返回所有符合的书的,所以这样对速度没有拖累。
作者搜索
输入作者全名,得到该作者的书。这个搜索不太好用,没有实现好,因为xml里的元数据的作者就很杂乱。
内容搜索
这是基于全文的检索。和之前不同的地方在于。在本书目录的部分出现了一些文字,这些文字是本书含有这个关键字的页里的第一个页里的内容。我好像截取了从关键字开始的后200个字,所以有的书没有显示出来,是因为之后可能没有200个字了,这个地方我处理的不是很好。
左栏多了一个“相关数目列表”,点击之后可以看到这本书里含有这个关键字的每一页的全文内容。并且也是有加亮的,也只是显示了前几百个字。
目录搜索
能检索到每本书中的所有章节目录里含有该搜索关键字的目录全文信息,显示在每本书下面。
左栏统计
左侧是对分类,出版时间,作者,出版社的统计。只显示top 5。
右栏结果
右侧是百度里前五条搜索结果。(直接爬了过来)
其他
当“出版时间”的统计结果多于五条时, 可以通过“展开”,“收起”动态选择查看全部或者前五条。
对于返回的记录总数和搜索时间,可以在搜索框下面看到。
其他还有分类浏览的功能,
是按照中图分类法来做的。只是这部分实现的不是很好,而且涉及到数据库,就不展示了,这里贴张图。会列出一级分类,二级分类,三级分类以及某一分类下的符合的图书。
设计实现
我尽量把设计实现介绍地简单,到位,因为过多的表述可能没有必要。
索引建立
索引的建立是边解析文件系统里的相应文件,边建立的。代码主要在index.writer的package内。Lucene建立索引方便,使用IndexWriter类并addCocument,会自动帮我建立倒排索引,并且按照他自己的方式建立一些特定文件存在我指定的索引文件夹下。
/*中文切词器可以选择别的*/ IndexWriter writer = new IndexWriter(this.indexDir, new MMAnalyzer(), true);
下面介绍索引的设计。Lucene里有documemt类,document类里可以add很多field。可以把Document理解为数据库的table,field是每个table的一行行属性。在搜索的时候,是按照某个field的名字去搜索他的value,并且返回所有含有这样的field的document的。所以我在建索引的时候,一本书当作一个document,书的作者,名字,等等信息都是这个document里的field,我可以在field里具体设置name, value, store_or_not,index_or_not。具体如下:
这是对“书”这个document建立的field。如对“title”,即书名这个field,我进行了切词,并且存储了内容,但是对“date”,即出版日期这个field,我没有进行切词,原因是用户不需要对时间进行搜索,只需要把这个属性完整保留下来,存进索引就可以。而且这些属性的内容都不多,全部存进索引有利于我在搜索的时候,直接通过document得到所有field的全文内容,被前台获取并展现。
对于目录,我建立了另外的document。每一本书的每一章的每一节,我都建立一个document,document里包含了书的id和page的id,以及目录catalog的全文内容,只有目录全文是要切词的,其他的只要直接存内容。目的是通过目录内容得到相应的书,然后再次搜索bookid得到书的其他属性。因为我不知道一本书有多少目录,就不知道我该设置多少的field,所以采取这样的方式,可以当作bookID这个field相当于是两个document的外键。
对于全文,也是和目录一样,单独给每一本书的每一页书建立一个document,理由也是因为我不知道每本书有多少页,所以不能一起建立在书的document内,需要用bookid来关联起来。和目录有区别的是,由于全文内容比较多,所以我只选择建立索引,不存储全文内容。这样的后果就是,我可以搜索得到哪本书,哪一页有这个词,但是我不能从索引文件里得到这页书里的所有文本内容,所以我记录下okfilename,去我的文件系统里读取这个.ok文件,而不是把全文存在索引里,把索引变得很大。
搜索
搜索主要通过Term类和Query类组成查询请求,用IndexSearcher类去搜索,最后得到Hits类,里面包含了所有含有关键字的document,让我可以一一遍历和取值(从索引中取)。
IndexSearcher is = new IndexSearcher(this.path); Term t = new Term(searchType, searchKey); Query q = new TermQuery(t); Hits hits = is.search(q); if (hits.length() == 0) { System.out.println("nobook~~~~~~~~~~~~~~"); } else { for (int i = 0; i < hits.length(); i ++) { Documentdoc = hits.doc(i); //… } }
针对不同类型的搜索,我在index.search package下的getBook.java内,设计了searchWithoutContent(String,String),searchWithContent(String)和searchCatalog (String)三个类,分别对应非全文内容的搜索(如作者,标题,主题词的搜索),全文内容的搜索和目录的搜索。这三种搜索其实分别对应了我之前的索引的建立里三种document(书,page内容和目录内容)。区别在于,像searchWithoutContent和searchCatalog两个函数其实都需要通过得到的document里的bookid再用searchWithoutContent去搜索得到这本book的别的信息,进行了二次搜索(保证这点的可实现性在于我上面的索引设计,这么设计的原因是我不知道一本书有多少目录和书页,所以不能包含在一个document里解决所有问题,需要用bookid充当关联)。
这样对于Struts的action,只要new getBook这个类,调用相应的搜索函数就可以满足前台的搜索请求。
结果展现
由于在搜索结果的中间栏显示的是书的基本信息,所以以上的三个关键搜索函数返回都是ArrayList<BookAttributes>。BookAttributes是每本书的实例类(在book.entity的package内),里面包含了书的基本信息和getter,setter函数。返回所有满足条件(包含搜索关键字的field的document)书的list,让前台获取searchAciton的结果集里的
publicList<BookAttributes>booklist =new ArrayList<BookAttributes>();
通过struts的<s:iterator>遍历展现在页面上。
关键代码如下:
<% int i = 0; pageContext.setAttribute("ba",request.getAttribute("booklist")); List<BookAttributes> ba = new ArrayList<BookAttributes>(); %> <s:iterator id="BookAttributes"value="booklist" > <table class="book1"style="font-size:13px;" cellSpacing=0 cellPadding=5> <tbody><tr> <td id="b_img"vAlign="top"> <div id="bb"> <img height=127 alt=封面 src="img/zkz.jpg" width=90 /></div> <div class="grade s1hide" id="score_000008033324"></div> <div class="score"id="fenshu_000008033324"></div> </td> <td vAlign=top width="88%"> <table border="0"cellspacing="0" cellpadding="0"width="100%"> <tr width="100%"> <td height="20"> <a href="#"target="_blank" id="b_title">${BookAttributes.title}</a> <div style="padding-top:3px;"> </div> </td> </tr> </table> 作者:${BookAttributes.creator} <br /> 分类:${BookAttributes.CRC} <br /> ISBN:${BookAttributes.ISBN} <br /> 出版日期:${BookAttributes.date} <br /> 本书目录:<br /> <font color="blue">${BookAttributes.content}</font><br /> <br>主题词:${BookAttributes.subject} <br> <b>分类</b>: <% // 通过循环变量i和request,从action读取list,用java处理成正确的url。struts标签没办法做双重循环,也取不了list内的类的某个变量 //… %> <span id=m_fl><a class="l" href=<%=url1%>>${BookAttributes.firstCat}</a>-><a class="l" href=<%=url2%>>${BookAttributes.secondCat}</a>-> <a class="l" href=<%=url3%>>${BookAttributes.thirdCat}</a></span><br> </td> </tr> <tr> <td colspan="3"align="right"> <a title=收藏 href="javascript:subAdd_new('0');"><IMG border="0"alt=收藏 src="/images/shoucang.jpg" /></a> </td> </tr> </tbody> </table>
左侧统计
左侧是对搜索得到的书的分类,作者,出版日期等的统计,采用Ajax的异步调用方式,给addupAction来完成。实质上是对搜索关键字再进行一次搜索,而没有通过request传参的方式把searchAction里的booklist直接传给addupAction,因为lucene做一次搜索还是很快的。我是通过
$(document).ready(function(){ $("#booksearch").click(show()); }
的方式触发Ajax的统计Action的。$("#booksearch")对应的就是页面上“中文搜索”这个submit的button。而show()这个js函数中,通过Jquery包装过了的Ajax
$.getJSON(url, function(data,stat){}, “json”);
获取前台表单里的参数值传给action,
sk = $("#searchkey").val(); type = $("#js2").val(); url= "ShowAddup?sk=" + sk + "&type=" + type;
并在回调函数中通过js变量得到action里的参数通过DOM模型改变特定id标签的内容在页面上显示。这部分代码都写在了search.jsp内。
统计结果是通过tool package内的处理类处理之后返回一个字符串的list在页面上遍历输出实现的。
而对于当统计结果多于五条时的“展开”“收起”功能是通过jquery来实现的,展开的时候将全部list都遍历,收起的时候只显示前五条遍历结果。
// 显示更多 $("#more").click(function(){ for (var i = yearlist.length-listnum-1; i >= 0; i --) { $("#yearadd").append("<li><a href='#'>" + yearlist[i] + "</a></li>" ); } $("#yearadd").show(); }); // 再隐藏 $("#hide").click(function(){ $("#yearadd").hide("slow"); });
右侧结果
右侧的结果是将搜索框内的输入放到百度内搜索,抓取了前五条百度的原搜索内容进行呈现的。和左侧的实现方式是一样,也是用了Ajax异步的方式,对应的是baiduAction。两个返回类型是json的action在struts.xml里配置的时候如下:
<package name="test"extends="json-default"> <action name="ShowAddup" class="action.addupAction"> <result type="json"></result> </action> <action name="ShowBaidu" class="action.baiduAction"> <result type="json"></result> </action> </package>
百度搜索的抓取在url.fetch的package下的GetBaidu.java类内,用URL类构建url,获取百度搜索结果的html内容,对in.readLine()的内容进行判断,得到前五条符合要求的搜索结果。
this.source= "http://www.baidu.com/s?tn=monline_5_dg&f=8&rsv_bp=1&rsv_spt=3&wd=" + this.strKey; URL url = new URL(this.source); BufferedReader in = new BufferedReader(new InputStreamReader(url.openStream())); // …
全文内容
在搜索“内容”的时候,在得到的结果中,可以点击左侧的书的标题,展示书中含有搜索关键字的每一页的全文内容。这也是用ajax异步实现,对应的是allpagesAction。每次
先通过document.getElementById("pagecontent").innerHTML=" ";清空内容,再添加内容$("#pagecontent").append(…);。
具体的搜索类是index.search包下的BookContentSearch类。是去文件系统中读取特定.ok文件的内容。
if (doc.getField("bookid").stringValue().equals(bookid)) { okfilename = doc.getField("okfilename").stringValue(); String tmp = loadFileToString(new File("E:\\06_1000" + "\\" + bookid + "\\txt\\txt\\" + okfilename)); contentsmap.put(okfilename, tmp); }
加亮处理
通过yit(document.body, k);这个加亮js函数,在需要的地方调用这个函数,k是传入的需要加亮的关键字。我在异步返回的各个回调函数中都会使用这个加亮函数。下面是第一次页面展现的时候也使用了加亮。
window.onload = function(){ var k=document.getElementById("searchkey").value; yit(document.body, k); }
技术难点
这里简单说明下我在开发过程中遇到的和解决的主要技术关键点。
主要的难点还是在struts的灵活运用上,因为这也是我第一次使用struts,边学边用。我自己也在博客的一篇文章里(http://blog.csdn.net/zbf8441372/article/details/7602329)总结了开发经验。主要是Java对象, Js对象, Action内的对象在同一个jsp页面内到底能怎样互通的问题。
Ø struts的标签,和jsp页面内js代码,java代码的结合和灵活使用
我现在理解,struts的标签的实现,实质上应该也是servlet中request的setter函数(action里配置)读取到jsp上来的,但是struts标签无法赋给js变量,无法赋给<% %>内的java变量,只能这样赋:
<input id="js" type=hidden value="<s:text name='sk'></s:text>" ></input> <input id="js2" type=hidden value="<s:text name='type'></s:text>" ></input>
他永远只是个setter方法,当你的值是一个对象,比如一个List时,你这样赋的结果,就是一个list.toString()的值,对象的属性不能获取,对象在标签下就死掉了。所以当我需要在<s:iterator>递归下,同时遍历两个action的list时,我通过
pageContext.setAttribute("ba",request.getAttribute("booklist"));
将某个其中一个list额外先取到pageContext内,然后同时在外部初始一个计数器,在<s:iterator>内我的计数器也是一次次增加,可以实现两个list的同时遍历。
Ø jquery的一些使用
Jquery的ajax方法有四个:
$.get( url [, data] [, callback] ) $.post(url,[data],[callback],[type]) $.getJSON(url,[data],[callback]) $.ajax(options)
使用ajax,通过json形式包装的时候,struts的action在配置文件中的配置需要改变,包括两个地方,extends="json-default",<resulttype="json"></result>
灵活使用jquery,对DOM模型进行操作,对标签内容进行添改删藏,并可以设置简单的动画。这些也都是边学边用。
Ø 索引的设计
建立索引和搜索索引并不困难,lucene都进行了很好的封装。但是索引的设计需要考虑。如前面所说的,我的设计是基于全文,目录和普通搜索三块内容,设计了三种ducoment。在搜索的时候会出现三种document之间的互相关联,二次搜索,form表单的同步执行和异步返回,让每种类型的搜索分别对应一个action,不同的action以同步或者异步的形式返回在前台页面上,避免在呈现中间栏结果的时候,由于统计结果和百度搜索而延长了搜索时间。
缺点不足
代码
后台代码还是比较乱的,package的层次也分得不清晰,没有进行过重构和优化。
搜索性能
索引由于超过了一个G,搜索结果变慢很多。无论从lucene版本,代码重构方面还是索引设计方面都需要改进以减少搜索时间,提高用户体验。
界面
搜索结果的展现还需要改进,有些地方不太舒服,用户体验可能会不好。界面也没有进行过美工,最多达到了层次清晰,结构简洁。左右侧异步请求,明显延迟的时候没有一个load之类的提醒用户等待。
总结和未来工作
代码
提高lucene版本(新的版本性能方面会有不同),重构代码,合理使用ajax和struts,提高速度。
功能
分页功能还没有完成。有些统计数据的链接还没有完成。分类浏览还需要完善。
索引
1. 将索引分开建立,减小每份索引大小。图书原信息存一份索引,图书全文的索引存在另一份,图书目录的索引也存在另一份。这样将原本的一整个索引分需求和搜索类型的不同分开建立,减小索引大小,提高搜索速度。
2. 分布式索引。以后打算建立分布式索引,一个query转发多台服务器,多台机器搜索结果进行合并。还可以结合Hadoop的HDFS文件系统,HBase或者别的NoSQL把图书的全文txt内容合理存储,提高数据的读取速度。
3. IndexSearcher池。虽然lucene的IndexSearcher,IndexWriter,IndexReader这些类都是线程安全的,但是open,close的过程中还是造成了比较大的开销。所以除了数据库需要DBpool之外,这些lucene的关键类也需要池来维护,减少不必要开销。
4. 其他很多关于搜索引擎的性能优化和代码,运行环境参数调优。我打算将这个搜索引擎不断完善和深入,其实现在处于打通了一些技术问题,真正的性能还没有开始着手,只是畅想以后要做的是一个分布式的搜索。