基于JieBaNet+Lucene.Net实现全文搜索
实现效果:
上一篇文章有附全文搜索结果的设计图,下面截一张开发完成上线后的实图:
基本风格是模仿的百度搜索结果,绿色的分页略显小清新。
目前已采集并创建索引的文章约3w多篇,索引文件不算太大,查询速度非常棒。
刀不磨要生锈,人不学要落后。每天都要学一些新东西。
基本技术介绍:
还记得上一次做全文搜索是在2013年,主要核心设计与代码均是当时的架构师写的,自己只能算是全程参与。
当时使用的是经典搭配:盘古分词+lucene.net。
前几篇文章有说到,盘古分词已经很多年不更新了,我在supportyun系统一直引用的jiebanet来做分词技术。
那么是否也有成型的jiebanet+lucene.net的全文搜索方案呢?
经过多番寻找,在github上面找到一个简易的例子:https://github.com/anderscui/jiebaforlucenenet
博主下面要讲的实现方案就是从这个demo得到的启发,大家有兴趣可以去看看这个demo。
博主使用的具体版本:lucene.net 3.0.3.0 ,jiebanet 0.38.3.0(做过简易的调整与扩展,前面文章有讲到)
首先我们对lucene.net的分词器tokenizer、分析器analyzer做一个基于jiebanet的扩展。
1.基于lucenenet扩展的jieba分析器jiebaforluceneanalyzer
1 /// <summary> 2 /// 基于lucenenet扩展的jieba分析器 3 /// </summary> 4 public class jiebaforluceneanalyzer : analyzer 5 { 6 protected static readonly iset<string> defaultstopwords = stopanalyzer.english_stop_words_set; 7 8 private static iset<string> stopwords; 9 10 static jiebaforluceneanalyzer() 11 { 12 stopwords = new hashset<string>(); 13 var stopwordsfile = path.getfullpath(jiebanet.analyser.configmanager.stopwordsfile); 14 if (file.exists(stopwordsfile)) 15 { 16 var lines = file.readalllines(stopwordsfile); 17 foreach (var line in lines) 18 { 19 stopwords.add(line.trim()); 20 } 21 } 22 else 23 { 24 stopwords = defaultstopwords; 25 } 26 } 27 28 public override tokenstream tokenstream(string fieldname, textreader reader) 29 { 30 var seg = new jiebasegmenter(); 31 tokenstream result = new jiebaforlucenetokenizer(seg, reader); 32 result = new lowercasefilter(result); 33 result = new stopfilter(true, result, stopwords); 34 return result; 35 } 36 }
2.基于lucenenet扩展的jieba分词器:jiebaforlucenetokenizer
1 /// <summary> 2 /// 基于lucene的jieba分词扩展 3 /// </summary> 4 public class jiebaforlucenetokenizer:tokenizer 5 { 6 private readonly jiebasegmenter segmenter; 7 private readonly itermattribute termatt; 8 private readonly ioffsetattribute offsetatt; 9 private readonly itypeattribute typeatt; 10 11 private readonly list<token> tokens; 12 private int position = -1; 13 14 public jiebaforlucenetokenizer(jiebasegmenter seg, textreader input):this(seg, input.readtoend()) { } 15 16 public jiebaforlucenetokenizer(jiebasegmenter seg, string input) 17 { 18 segmenter = seg; 19 termatt = addattribute<itermattribute>(); 20 offsetatt = addattribute<ioffsetattribute>(); 21 typeatt = addattribute<itypeattribute>(); 22 23 var text = input; 24 tokens = segmenter.tokenize(text, tokenizermode.search).tolist(); 25 } 26 27 public override bool incrementtoken() 28 { 29 clearattributes(); 30 position++; 31 if (position < tokens.count) 32 { 33 var token = tokens[position]; 34 termatt.settermbuffer(token.word); 35 offsetatt.setoffset(token.startindex, token.endindex); 36 typeatt.type = "jieba"; 37 return true; 38 } 39 40 end(); 41 return false; 42 } 43 44 public ienumerable<token> tokenize(string text, tokenizermode mode = tokenizermode.search) 45 { 46 return segmenter.tokenize(text, mode); 47 } 48 }
理想如果不向现实做一点点屈服,那么理想也将归于尘土。
实现方案设计:
我们做全文搜索的设计时一定会考虑的一个问题就是:我们系统是分很多模块的,不同模块的字段差异很大,怎么才能实现同一个索引,既可以单个模块搜索又可以全站搜索,甚至按一些字段做条件来搜索呢?
这些也是supportyun系统需要考虑的问题,因为目前的数据就天然的拆分成了活动、文章两个类别,字段也大有不同。博主想实现的是一个可以全站搜索(结果包括活动、文章),也可以在文章栏目/活动栏目分别搜索,并且可以按几个指定字段来做搜索条件。
要做一个这样的全文搜索功能,我们需要从程序设计上来下功夫。下面就介绍一下博主的设计方案:
一、索引创建
1.我们设计一个indexmanager来处理最基本的索引创建、更新、删除操作。
2.创建、更新使用到的标准数据类:indexcontent。
我们设计tablename(对应db表名)、rowid(对应db主键)、collecttime(对应db数据创建时间)、moduletype(所属系统模块)、title(检索标题)、indextextcontent(检索文本)等六个基础字段,所有模块需要创建索引必须构建该6个字段(大家可据具体情况扩展)。
然后设计10个预留字段tag1-tag10,用以兼容各大模块其他不同字段。
预留字段的存储、索引方式可独立配置。
其中baseindexcontent含有六个基础字段。
3.创建一个子模块索引构建器的接口:iindexbuilder。
各子模块通过继承实现iindexbuilder,来实现索引的操作。
4.下面我们以活动模块为例,来实现索引创建。
a)首先创建一个基于活动模块的数据类:activityindexcontent,可以将我们需要索引或存储的字段都设计在内。
b)我们再创建activityindexbuilder并继承iindexbuilder,实现其创建、更新、删除方法。
代码就不解释了,很简单。主要就是调用indexmanager来执行操作。
我们只需要在需要创建活动数据索引的业务点,构建activityindexbuilder对象,并构建activityindexcontent集合作为参数,调用buildindex方法即可。
二、全文搜索
全文搜索我们采用同样的设计方式。
1.设计一个抽象的搜索类:baseindexsearch,所有搜索模块(包括全站)均需继承它来实现搜索效果。
1 public abstract class baseindexsearch<tindexsearchresultitem> 2 where tindexsearchresultitem : indexsearchresultitem 3 { 4 /// <summary> 5 /// 索引存储目录 6 /// </summary> 7 private static readonly string indexstorepath = configurationmanager.appsettings["indexstorepath"]; 8 private readonly string[] fieldstosearch; 9 protected static readonly simplehtmlformatter formatter = new simplehtmlformatter("<em>", "</em>"); 10 private static indexsearcher indexsearcher = null; 11 12 /// <summary> 13 /// 索引内容命中片段大小 14 /// </summary> 15 public int fragmentsize { get; set; } 16 17 /// <summary> 18 /// 构造方法 19 /// </summary> 20 /// <param name="fieldstosearch">搜索文本字段</param> 21 protected baseindexsearch(string[] fieldstosearch) 22 { 23 fragmentsize = 100; 24 this.fieldstosearch = fieldstosearch; 25 } 26 27 /// <summary> 28 /// 创建搜索结果实例 29 /// </summary> 30 /// <returns></returns> 31 protected abstract tindexsearchresultitem createindexsearchresultitem(); 32 33 /// <summary> 34 /// 修改搜索结果(主要修改tag字段对应的属性) 35 /// </summary> 36 /// <param name="indexsearchresultitem">搜索结果项实例</param> 37 /// <param name="content">用户搜索内容</param> 38 /// <param name="docindex">索引库位置</param> 39 /// <param name="doc">当前位置内容</param> 40 /// <returns>搜索结果</returns> 41 protected abstract void modifyindexsearchresultitem(ref tindexsearchresultitem indexsearchresultitem, string content, int docindex, document doc); 42 43 /// <summary> 44 /// 修改筛选器(各模块) 45 /// </summary> 46 /// <param name="filter"></param> 47 protected abstract void modifysearchfilter(ref dictionary<string, string> filter); 48 49 /// <summary> 50 /// 全库搜索 51 /// </summary> 52 /// <param name="content">搜索文本内容</param> 53 /// <param name="filter">查询内容限制条件,默认为null,不限制条件.</param> 54 /// <param name="fieldsorts">对字段进行排序</param> 55 /// <param name="pageindex">查询结果当前页,默认为1</param> 56 /// <param name="pagesize">查询结果每页结果数,默认为20</param> 57 public pagedindexsearchresult<tindexsearchresultitem> search(string content 58 , dictionary<string, string> filter = null, list<fieldsort> fieldsorts = null 59 , int pageindex = 1, int pagesize = 20) 60 { 61 try 62 { 63 if (!string.isnullorempty(content)) 64 { 65 content = replaceindexsensitivewords(content); 66 content = getkeywordssplitbyspace(content, 67 new jiebaforlucenetokenizer(new jiebasegmenter(), content)); 68 } 69 if (string.isnullorempty(content) || pageindex < 1) 70 { 71 throw new exception("输入参数不符合要求(用户输入为空,页码小于等于1)"); 72 } 73 74 var stopwatch = new stopwatch(); 75 stopwatch.start(); 76 77 analyzer analyzer = new jiebaforluceneanalyzer(); 78 // 索引条件创建 79 var query = makesearchquery(content, analyzer); 80 // 筛选条件构建 81 filter = filter == null ? new dictionary<string, string>() : new dictionary<string, string>(filter); 82 modifysearchfilter(ref filter); 83 filter lucenefilter = makesearchfilter(filter); 84 85 #region------------------------------执行查询--------------------------------------- 86 87 topdocs topdocs; 88 if (indexsearcher == null) 89 { 90 var dir = new directoryinfo(indexstorepath); 91 fsdirectory entitydirectory = fsdirectory.open(dir); 92 indexreader reader = indexreader.open(entitydirectory, true); 93 indexsearcher = new indexsearcher(reader); 94 } 95 else 96 { 97 indexreader indexreader = indexsearcher.indexreader; 98 if (!indexreader.iscurrent()) 99 { 100 indexsearcher.dispose(); 101 indexsearcher = new indexsearcher(indexreader.reopen()); 102 } 103 } 104 // 收集器容量为所有 105 int totalcollectcount = pageindex*pagesize; 106 sort sort = getsortbyfieldsorts(fieldsorts); 107 topdocs = indexsearcher.search(query, lucenefilter, totalcollectcount, sort ?? sort.relevance); 108 109 #endregion 110 111 #region-----------------------返回结果生成------------------------------- 112 113 scoredoc[] hits = topdocs.scoredocs; 114 var start = (pageindex - 1)*pagesize + 1; 115 var end = math.min(totalcollectcount, hits.count()); 116 117 var result = new pagedindexsearchresult<tindexsearchresultitem> 118 { 119 pageindex = pageindex, 120 pagesize = pagesize, 121 totalrecords = topdocs.totalhits 122 }; 123 124 for (var i = start; i <= end; i++) 125 { 126 var scoredoc = hits[i - 1]; 127 var doc = indexsearcher.doc(scoredoc.doc); 128 129 var indexsearchresultitem = createindexsearchresultitem(); 130 indexsearchresultitem.docindex = scoredoc.doc; 131 indexsearchresultitem.moduletype = doc.get("moduletype"); 132 indexsearchresultitem.tablename = doc.get("tablename"); 133 indexsearchresultitem.rowid = guid.parse(doc.get("rowid")); 134 if (!string.isnullorempty(doc.get("collecttime"))) 135 { 136 indexsearchresultitem.collecttime = datetime.parse(doc.get("collecttime")); 137 } 138 var title = gethighlighter(formatter, fragmentsize).getbestfragment(content, doc.get("title")); 139 indexsearchresultitem.title = string.isnullorempty(title) ? doc.get("title") : title; 140 var text = gethighlighter(formatter, fragmentsize) 141 .getbestfragment(content, doc.get("indextextcontent")); 142 indexsearchresultitem.content = string.isnullorempty(text) 143 ? (doc.get("indextextcontent").length > 100 144 ? doc.get("indextextcontent").substring(0, 100) 145 : doc.get("indextextcontent")) 146 : text; 147 modifyindexsearchresultitem(ref indexsearchresultitem, content, scoredoc.doc, doc); 148 result.add(indexsearchresultitem); 149 } 150 stopwatch.stop(); 151 result.elapsed = stopwatch.elapsedmilliseconds*1.0/1000; 152 153 return result; 154 155 #endregion 156 } 157 catch (exception exception) 158 { 159 logutils.errorlog(exception); 160 return null; 161 } 162 } 163 164 private sort getsortbyfieldsorts(list<fieldsort> fieldsorts) 165 { 166 if (fieldsorts == null) 167 { 168 return null; 169 } 170 return new sort(fieldsorts.select(fieldsort => new sortfield(fieldsort.fieldname, sortfield.float, !fieldsort.ascend)).toarray()); 171 } 172 173 private static filter makesearchfilter(dictionary<string, string> filter) 174 { 175 filter lucenefilter = null; 176 if (filter != null && filter.keys.any()) 177 { 178 var booleanquery = new booleanquery(); 179 foreach (keyvaluepair<string, string> keyvaluepair in filter) 180 { 181 var termquery = new termquery(new term(keyvaluepair.key, keyvaluepair.value)); 182 booleanquery.add(termquery, occur.must); 183 } 184 lucenefilter = new querywrapperfilter(booleanquery); 185 } 186 return lucenefilter; 187 } 188 189 private query makesearchquery(string content, analyzer analyzer) 190 { 191 var query = new booleanquery(); 192 // 总查询参数 193 // 属性查询 194 if (!string.isnullorempty(content)) 195 { 196 queryparser parser = new multifieldqueryparser(version.lucene_30, fieldstosearch, analyzer); 197 query queryobj; 198 try 199 { 200 queryobj = parser.parse(content); 201 } 202 catch (parseexception parseexception) 203 { 204 throw new exception("在filelibraryindexsearch中构造query时出错。", parseexception); 205 } 206 query.add(queryobj, occur.must); 207 } 208 return query; 209 } 210 211 private string getkeywordssplitbyspace(string keywords, jiebaforlucenetokenizer jiebaforlucenetokenizer) 212 { 213 var result = new stringbuilder(); 214 215 var words = jiebaforlucenetokenizer.tokenize(keywords); 216 217 foreach (var word in words) 218 { 219 if (string.isnullorwhitespace(word.word)) 220 { 221 continue; 222 } 223 224 result.appendformat("{0} ", word.word); 225 } 226 227 return result.tostring().trim(); 228 } 229 230 private string replaceindexsensitivewords(string str) 231 { 232 str = str.replace("+", ""); 233 str = str.replace("+", ""); 234 str = str.replace("-", ""); 235 str = str.replace("-", ""); 236 str = str.replace("!", ""); 237 str = str.replace("!", ""); 238 str = str.replace("(", ""); 239 str = str.replace(")", ""); 240 str = str.replace("(", ""); 241 str = str.replace(")", ""); 242 str = str.replace(":", ""); 243 str = str.replace(":", ""); 244 str = str.replace("^", ""); 245 str = str.replace("[", ""); 246 str = str.replace("]", ""); 247 str = str.replace("【", ""); 248 str = str.replace("】", ""); 249 str = str.replace("{", ""); 250 str = str.replace("}", ""); 251 str = str.replace("{", ""); 252 str = str.replace("}", ""); 253 str = str.replace("~", ""); 254 str = str.replace("~", ""); 255 str = str.replace("*", ""); 256 str = str.replace("*", ""); 257 str = str.replace("?", ""); 258 str = str.replace("?", ""); 259 return str; 260 } 261 262 protected highlighter gethighlighter(formatter formatter, int fragmentsize) 263 { 264 var highlighter = new highlighter(formatter, new segment()) { fragmentsize = fragmentsize }; 265 return highlighter; 266 } 267 }
几个protected abstract方法,是需要继承的子类来实现的。
其中为了实现搜索结果对命中关键词进行高亮显示,特引用了盘古分词的highlighter。原则是此处应该是参照盘古分词的源码,自己使用jiebanet来做实现的,由于工期较紧,直接引用了盘古。
2.我们设计一个indexsearchresultitem,表示搜索结果的基类。
3.我们来看看具体的实现,先来看全站搜索的searchservice
1 public class indexsearch : baseindexsearch<indexsearchresultitem> 2 { 3 public indexsearch() 4 : base(new[] { "indextextcontent", "title" }) 5 { 6 } 7 8 protected override indexsearchresultitem createindexsearchresultitem() 9 { 10 return new indexsearchresultitem(); 11 } 12 13 protected override void modifyindexsearchresultitem(ref indexsearchresultitem indexsearchresultitem, string content, 14 int docindex, document doc) 15 { 16 //不做修改 17 } 18 19 protected override void modifysearchfilter(ref dictionary<string, string> filter) 20 { 21 //不做筛选条件修改 22 } 23 }
是不是非常简单。由于我们此处搜索的是全站,结果展示直接用基类,取出基本字段即可。
4.再列举一个活动的搜索实现。
a)我们首先创建一个活动搜索结果类activityindexsearchresultitem,继承自结果基类indexsearchresultitem
b)然后创建活动模块的搜索服务:activityindexsearch,同样需要继承baseindexsearch,这时候activityindexsearch只需要相对全站搜索修改几个参数即可。
1 public class activityindexsearch: baseindexsearch<activityindexsearchresultitem> 2 { 3 public activityindexsearch() 4 : base(new[] { "indextextcontent", "title" }) 5 { 6 } 7 8 protected override activityindexsearchresultitem createindexsearchresultitem() 9 { 10 return new activityindexsearchresultitem(); 11 } 12 13 protected override void modifyindexsearchresultitem(ref activityindexsearchresultitem indexsearchresultitem, string content, 14 int docindex, document doc) 15 { 16 indexsearchresultitem.activitytypes = doc.get("tag1"); 17 indexsearchresultitem.url = doc.get("tag2"); 18 indexsearchresultitem.sourcename = doc.get("tag3"); 19 indexsearchresultitem.sourceofficialhotline = doc.get("tag4"); 20 indexsearchresultitem.sourceurl = doc.get("tag5"); 21 indexsearchresultitem.cityid=new guid(doc.get("tag6")); 22 indexsearchresultitem.address = doc.get("tag7"); 23 indexsearchresultitem.activitydate = doc.get("tag8"); 24 } 25 26 protected override void modifysearchfilter(ref dictionary<string, string> filter) 27 { 28 filter.add("moduletype", "活动"); 29 } 30 }
筛选条件加上模块=活动,返回结果数据类指定,活动特有字段返回赋值。
业务调用就非常简单了。
全站全文搜索:我们直接new indexsearch(),然后调用其search()方法
活动全文搜索:我们直接new activityindexsearch(),然后调用其search()方法
search()方法几个参数:
///<param name="content">搜索文本内容</param>
/// <param name="filter">查询内容限制条件,默认为null,不限制条件.</param>
/// <param name="fieldsorts">对字段进行排序</param>
/// <param name="pageindex">查询结果当前页,默认为1</param>
/// <param name="pagesize">查询结果每页结果数,默认为20</param>
如果我们用软能力而不是用技术能力来区分程序员的好坏 – 是不是有那么点反常和变态。
上一篇: 什么面清淡?这些面清淡又营养,美味多汁!