基於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來處理最基本的索引創建、更新、刪除操作。
View Code
2.創建、更新使用到的標准數據類:IndexContent。
我們設計TableName(對應DB表名)、RowId(對應DB主鍵)、CollectTime(對應DB數據創建時間)、ModuleType(所屬系統模塊)、Title(檢索標題)、IndexTextContent(檢索文本)等六個基礎字段,所有模塊需要創建索引必須構建該6個字段(大家可據具體情況擴展)。
然后設計10個預留字段Tag1-Tag10,用以兼容各大模塊其他不同字段。
預留字段的存儲、索引方式可獨立配置。
View Code
其中BaseIndexContent含有六個基礎字段。
3.創建一個子模塊索引構建器的接口:IIndexBuilder。
各子模塊通過繼承實現IIndexBuilder,來實現索引的操作。
View Code
4.下面我們以活動模塊為例,來實現索引創建。
a)首先創建一個基於活動模塊的數據類:ActivityIndexContent,可以將我們需要索引或存儲的字段都設計在內。
View Code
b)我們再創建ActivityIndexBuilder並繼承IIndexBuilder,實現其創建、更新、刪除方法。
View Code
代碼就不解釋了,很簡單。主要就是調用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,表示搜索結果的基類。
View Code
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
View Code
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>
如果我們用軟能力而不是用技術能力來區分程序員的好壞 – 是不是有那么點反常和變態。

