前言:目前本身在作使用Lucene.net和PanGu分詞實現全文檢索的工做,不過本身是把別人作好的項目進行遷移。由於項目總體要遷移到ASP.NET Core 2.0版本,而Lucene使用的版本是3.6.0 ,PanGu分詞也是對應Lucene3.6.0版本的。不過好在Lucene.net 已經有了Core 2.0版本(4.8.0 bate版),而PanGu分詞,目前有人正在作,貌似已經作完,只是尚未測試~,Lucene升級的改變我都會加粗表示。html
Lucene.net 4.8.0 java
https://github.com/apache/lucenenetc++
PanGu分詞(能夠直接使用的)git
https://github.com/SilentCC/Lucene.Net.Analysis.PanGugithub
JIEba分詞(能夠直接使用的)apache
https://github.com/SilentCC/JIEba-netcore2.0ide
Lucene.net 4.8.0 和以前的Lucene.net 3.6.0 改動仍是至關多的,這裏對本身開發過程遇到的問題,作一個記錄吧,但願能夠幫到和我同樣須要升級Lucene.net的人。我也是第一次接觸Lucene ,也但願能夠幫助初學Lucene的同窗。函數
目錄工具
Lucene的自帶分詞工具對中文分詞的效果非常很差。所以在作中文的搜索引擎的時候,咱們須要用額外的中文分詞組件。這裏能夠總結一下中文分詞工具備哪些,在下面這個銜接中,有對不少中文分詞工具的性能測試:post
https://github.com/ysc/cws_evaluation
惋惜咱們看不到PanGu分詞的性能,在PanGu分詞的官網咱們能夠看到:Core Duo 1.8 GHz 下單線程 分詞速度爲 390K 字符每秒,2線程分詞速度爲 690K 字符每秒。 在上面的排行榜中屬於中等吧。但因爲我作的是基於.net的搜索引擎,因此我只找到了IK分詞器,PanGu分詞器,JIEba分詞器的.net core2.0 版本。
這是PanGu分詞.net core 2.0版本的遷移項目:
https://github.com/LonghronShen/Lucene.Net.Analysis.PanGu/tree/netcore2.0
這是一個沒有遷移徹底的項目,在使用過程當中遇到了一些問題,前面的目錄中記錄過。我修改了一些bug,下面的是修改事後的能夠直接使用的PanGu分詞.net core2.0版本:
https://github.com/SilentCC/Lucene.Net.Analysis.PanGu/tree/netcore2.0
我提交了一個Pull Request ,做者尚未合併。我已經用了一段時間,很穩定。
JIEba分詞的.net core 版本遷移項目:
https://github.com/linezero/jieba.NET
可是這是.net core1.0的版本,拿過來也不能直接給Lucene使用,因此我升級到了2.0而且作了一個接口,讓其支持Lucene,通過測試能夠穩定的進行分詞和高亮。固然在其中也遇到了一些問題,在下文中會詳細闡述。這是改過以後的Lucene版:
https://github.com/SilentCC/JIEba-netcore2.0
在Nuget中能夠搜索到(IKNetAnalyzer)
在GitHub中 https://github.com/stanzhai/IKAnalyzer.NET 顯示正在開發中。因爲一些緣由,我並無使用IK分詞。因此也就沒有細看了。
Lucene和PanGu分詞搭配,已是Lucene.net 的經典搭配,可是PanGu分詞已經好久沒有更新,PanGu分詞的字典也是好久之前維護的字典。在網上能夠找到不少Lucene和PanGu分詞搭配的例子。在PanGu分詞和JIEba分詞對比中,我選擇了JIEba分詞。由於個人搜索引擎一直是使用PanGu分詞,而後卻時常出現有些比較新的冷的詞,沒法被分詞,致使搜索效果不好。究其緣由,是PanGu分詞的字典不夠大,可是人工維護字典很煩。固然PanGu分詞有新詞錄入的功能,我一直打開這個功能的開關:
MatchOptions m = new MatchOptions(); m.UnknownWordIdentify = true;
然而並無改善。後來我使用了JIEba分詞測試分詞效果,發現JIEba分詞使用搜索引擎模式,和PanGu分詞打開多元分詞功能開關時的分詞效果以下:
測試樣例:小明碩士畢業於中國科學院計算所,後在日本京都大學深造 結巴分詞(搜索引擎模式):小明/ 碩士/ 畢業/ 於/ 中國/ 科學/ 學院/ 科學院/ 中國科學院/ 計算/ 計算所/ ,/ 後/ 在/ 日本/ 京都/ 大學/ 日本京都大學/ 深造 盤古分詞(開啓多元分詞開關): 小 明 碩士 畢業 於 中國科學院 計算所 後 在 日本 京都 大學 深造
顯然PanGu分詞並無細粒度分詞,這是致使有些搜索召回率很低的緣由。
這裏就不對PanGu分詞,和JIEba分詞的具體分詞方法進行比較了。本篇博文的仍是主要講解Lucene和JIEba分詞
在上面的JIEba分詞.net core版本中,JIEba分詞只是將給到的一個字符串進行分詞,而後反饋給你分詞信息,分詞信息也只是一個一個字符串。顯然這是沒法接入到Lucene中。那麼如何把一個分詞工具成功的接入到Lucene中呢?
全部要接入Lucene中的分詞工具,都要有一個繼承Lucene.Net.Analyzer的類,在這個類:JIEbaAnalyzer中,必需要覆寫TokenStreamComponents函數,由於Lucene正是經過這個函數獲取分詞器分詞以後的TokenStream(一些列分詞信息的集合)咱們能夠在這個函數中給tokenStream中注入咱們想要獲得的屬性,在Lucene.net 4.8.0中分詞的概念已是一些列分詞屬性的組合
public class JieBaAnalyzer :Analyzer { public TokenizerMode mode; public JieBaAnalyzer(TokenizerMode Mode) :base() { this.mode = Mode; } protected override TokenStreamComponents CreateComponents(string filedName,TextReader reader) { var tokenizer = new JieBaTokenizer(reader,mode); var tokenstream = (TokenStream)new LowerCaseFilter(Lucene.Net.Util.LuceneVersion.LUCENE_48, tokenizer); tokenstream.AddAttribute<ICharTermAttribute>(); tokenstream.AddAttribute<IOffsetAttribute>(); return new TokenStreamComponents(tokenizer, tokenstream); } } }
這裏能夠看到,我只使用了ICharTermAttribute 和IOffsetAttribute 也就是分詞的內容屬性和位置屬性。這裏的Mode要提一下,這是JIEba分詞的特性,JIEba分詞提供了三種模式:
這裏的Model只有Default和Search兩種,通常的,寫入索引的時候使用Search模式,查詢的時候使用Default模式
上面的JieBaTokenizer類正是咱們接下來要定義的類
繼承Lucene.Net.Tokenizer 。Tokenizer 是正真將大串文本分紅一系列分詞的類,在Tokenizer類中,咱們必需要覆寫 Reset()函數,IncrementToken()函數,上面的Analyzer類中:
var tokenstream = (TokenStream)new LowerCaseFilter(Lucene.Net.Util.LuceneVersion.LUCENE_48, tokenizer);
tokenizer是生產tokenstream。實際上Reset()函數是將文本進行分詞,IncrementToken()是遍歷分詞的信息,而後將分詞的信息注入的tokenstream,這樣就獲得咱們想要的分詞流。在Tokenizer類中咱們調用JIEba分詞的Segment實例,對文本進行分詞。再將得到分詞包裝,遍歷。
public class JieBaTokenizer : Tokenizer { private static object _LockObj = new object(); private static bool _Inited = false; private System.Collections.Generic.List<JiebaNet.Segmenter.Token> _WordList = new List<JiebaNet.Segmenter.Token>(); private string _InputText; private bool _OriginalResult = false; private ICharTermAttribute termAtt; private IOffsetAttribute offsetAtt; private IPositionIncrementAttribute posIncrAtt; private ITypeAttribute typeAtt; private List<string> stopWords = new List<string>(); private string stopUrl="./stopwords.txt"; private JiebaSegmenter segmenter; private System.Collections.Generic.IEnumerator<JiebaNet.Segmenter.Token> iter; private int start =0; private TokenizerMode mode; public JieBaTokenizer(TextReader input,TokenizerMode Mode) :base(AttributeFactory.DEFAULT_ATTRIBUTE_FACTORY,input) { segmenter = new JiebaSegmenter(); mode = Mode; StreamReader rd = File.OpenText(stopUrl); string s = ""; while((s=rd.ReadLine())!=null) { stopWords.Add(s); } Init(); } private void Init() { termAtt = AddAttribute<ICharTermAttribute>(); offsetAtt = AddAttribute<IOffsetAttribute>(); posIncrAtt = AddAttribute<IPositionIncrementAttribute>(); typeAtt = AddAttribute<ITypeAttribute>(); } private string ReadToEnd(TextReader input) { return input.ReadToEnd(); } public sealed override Boolean IncrementToken() { ClearAttributes(); Lucene.Net.Analysis.Token word = Next(); if(word!=null) { var buffer = word.ToString(); termAtt.SetEmpty().Append(buffer); offsetAtt.SetOffset(CorrectOffset(word.StartOffset),CorrectOffset(word.EndOffset)); typeAtt.Type = word.Type; return true; } End(); this.Dispose(); return false; } public Lucene.Net.Analysis.Token Next() { int length = 0; bool res = iter.MoveNext(); Lucene.Net.Analysis.Token token; if (res) { JiebaNet.Segmenter.Token word = iter.Current; token = new Lucene.Net.Analysis.Token(word.Word, word.StartIndex,word.EndIndex); // Console.WriteLine("xxxxxxxxxxxxxxxx分詞:"+word.Word+"xxxxxxxxxxx起始位置:"+word.StartIndex+"xxxxxxxxxx結束位置"+word.EndIndex); start += length; return token; } else return null; } public override void Reset() { base.Reset(); _InputText = ReadToEnd(base.m_input); RemoveStopWords(segmenter.Tokenize(_InputText,mode)); start = 0; iter = _WordList.GetEnumerator(); } public void RemoveStopWords(System.Collections.Generic.IEnumerable<JiebaNet.Segmenter.Token> words) { _WordList.Clear(); foreach(var x in words) { if(stopWords.IndexOf(x.Word)==-1) { _WordList.Add(x); } } } }
一開始我寫的Tokenizer類並非這樣,由於遇到了一些問題,才逐漸改爲上面的樣子,下面就說下本身遇到的問題。
一開始在Reset函數中,我使用的是JIEba分詞介紹的CutForSearch函數,CutForSearch的到是List<String> ,因此位置屬性OffsetAttribute得我本身來寫:
public Lucene.Net.Analysis.Token Next() { int length = 0; bool res = iter.MoveNext(); Lucene.Net.Analysis.Token token; if (res) { JiebaNet.Segmenter.Token word = iter.Current; token = new Lucene.Net.Analysis.Token(word.Word, word.StartIndex,word.EndIndex); start += length; return token; } else return null; }
本身定義了start,根據每一個分詞的長度,很容易算出來每一個分詞的位置。可是我忘了CutForSearch是一個細粒度模式,會有「中國模式」,「中國」,「模式」同時存在,這樣的寫法就是錯的了,若是是Cut就對了。分詞的位置信息錯誤,帶來的就是高亮的錯誤,由於高亮須要知道分詞的正確的起始和結束位置。具體的錯誤就是:
at System.String.Substring(Int32 startIndex, Int32 length) at Lucene.Net.Search.VectorHighlight.BaseFragmentsBuilder.MakeFragment(StringBuilder buffer, Int32[] index, Field[] values, WeightedFragInfo fragInfo, String[] preTags, String[] postTags, IEncoder encoder) in C:\BuildAgent\work\b1b63ca15b99dddb\src\Lucene.Net.Highlighter\VectorHighlight\BaseFragmentsBuilder.cs:line 195 at Lucene.Net.Search.VectorHighlight.BaseFragmentsBuilder.CreateFragments(IndexReader reader, Int32 docId, String fieldName, FieldFragList fieldFragList, Int32 maxNumFragments, String[] preTags, String[] postTags, IEncoder encoder) in C:\BuildAgent\work\b1b63ca15b99dddb\src\Lucene.Net.Highlighter\VectorHighlight\BaseFragmentsBuilder.cs:line 146 at Lucene.Net.Search.VectorHighlight.BaseFragmentsBuilder.CreateFragments(IndexReader reader, Int32 docId, String fieldName, FieldFragList fieldFragList, Int32 maxNumFragments) in C:\BuildAgent\work\b1b63ca15b99dddb\src\Lucene.Net.Highlighter\VectorHighlight\BaseFragmentsBuilder.cs:line 99
當你使用Lucene的時候出現這樣的錯誤,大多數都是你的分詞位置屬性出錯。
後來才發現JIEba分詞提供了 Tokenize()函數,專門提供了分詞以及分詞的位置信息,我很欣慰的用了Tokenize()函數,結果仍是報錯,同樣的報錯,當我嘗試着加上CorrectOffset()函數的時候:
offsetAtt.SetOffset(CorrectOffset(word.StartOffset),CorrectOffset(word.EndOffset));
雖然不報錯了,可是高亮的效果老是有誤差,總而言之換了Tokenize函數,使用CorrectOffset函數,都沒法使分詞的位置信息變準確。因而查看JIEba分詞的源碼。
Tokenize函數:
public IEnumerable<Token> Tokenize(string text, TokenizerMode mode = TokenizerMode.Default, bool hmm = true) { var result = new List<Token>(); var start = 0; if (mode == TokenizerMode.Default) { foreach (var w in Cut(text, hmm: hmm)) { var width = w.Length; result.Add(new Token(w, start, start + width)); start += width; } } else { foreach (var w in Cut(text, hmm: hmm)) { var width = w.Length; if (width > 2) { for (var i = 0; i < width - 1; i++) { var gram2 = w.Substring(i, 2); if (WordDict.ContainsWord(gram2)) { result.Add(new Token(gram2, start + i, start + i + 2)); } } } if (width > 3) { for (var i = 0; i < width - 2; i++) { var gram3 = w.Substring(i, 3); if (WordDict.ContainsWord(gram3)) { result.Add(new Token(gram3, start + i, start + i + 3)); } } } result.Add(new Token(w, start, start + width)); start += width; } } return result; }
Cut函數:
public IEnumerable<string> Cut(string text, bool cutAll = false, bool hmm = true) { var reHan = RegexChineseDefault; var reSkip = RegexSkipDefault; Func<string, IEnumerable<string>> cutMethod = null; if (cutAll) { reHan = RegexChineseCutAll; reSkip = RegexSkipCutAll; } if (cutAll) { cutMethod = CutAll; } else if (hmm) { cutMethod = CutDag; } else { cutMethod = CutDagWithoutHmm; } return CutIt(text, cutMethod, reHan, reSkip, cutAll); }
終於找到了關鍵的函數:CutIt
internal IEnumerable<string> CutIt(string text, Func<string, IEnumerable<string>> cutMethod, Regex reHan, Regex reSkip, bool cutAll) { var result = new List<string>(); var blocks = reHan.Split(text); foreach (var blk in blocks) { if (string.IsNullOrWhiteSpace(blk)) { continue; } if (reHan.IsMatch(blk)) { foreach (var word in cutMethod(blk)) { result.Add(word); } } else { var tmp = reSkip.Split(blk); foreach (var x in tmp) { if (reSkip.IsMatch(x)) { result.Add(x); } else if (!cutAll) { foreach (var ch in x) { result.Add(ch.ToString()); } } else { result.Add(x); } } } } return result; }
在CutIt函數中JieBa分詞都把空格省去,這樣在Tokenize函數中使用start=0 start+=word.Length 顯示不能獲得正確的原始文本中的位置。
if (string.IsNullOrWhiteSpace(blk)) { continue; }
JIEba分詞也沒有考慮到會使用Lucene的高亮,越是隻能本身改寫了CutIt函數和Tokenize函數:
在CutIt函數中,返回的值不在是一個string,而是一個包含string,startPosition的類,這樣在Tokenize中就很準確的獲得每一個分詞的位置屬性了。
internal IEnumerable<WordInfo> CutIt2(string text, Func<string, IEnumerable<string>> cutMethod, Regex reHan, Regex reSkip, bool cutAll) { //Console.WriteLine("*********************************我開始分詞了*******************"); var result = new List<WordInfo>(); var blocks = reHan.Split(text); var start = 0; foreach(var blk in blocks) { //Console.WriteLine("?????????????當前的串:"+blk); if(string.IsNullOrWhiteSpace(blk)) { start += blk.Length; continue; } if(reHan.IsMatch(blk)) { foreach(var word in cutMethod(blk)) { //Console.WriteLine("?????blk 分詞:" + word + "????????初始位置:" + start); result.Add(new WordInfo(word,start)); start += word.Length; } } else { var tmp = reSkip.Split(blk); foreach(var x in tmp) { if(reSkip.IsMatch(x)) { //Console.WriteLine("????? x reSkip 分詞:" + x + "????????初始位置:" + start); result.Add(new WordInfo(x,start)); start += x.Length; } else if(!cutAll) { foreach(var ch in x) { //Console.WriteLine("?????ch 分詞:" + ch + "????????初始位置:" + start); result.Add(new WordInfo(ch.ToString(),start)); start += ch.ToString().Length; } } else{ //Console.WriteLine("?????x 分詞:" + x + "????????初始位置:" + start); result.Add(new WordInfo(x,start)); start += x.Length; } } } } return result; } public IEnumerable<Token> Tokenize(string text, TokenizerMode mode = TokenizerMode.Default, bool hmm = true) { var result = new List<Token>(); if (mode == TokenizerMode.Default) { foreach (var w in Cut2(text, hmm: hmm)) { var width = w.value.Length; result.Add(new Token(w.value, w.position, w.position + width)); } } else { var xx = Cut2(text, hmm: hmm); foreach (var w in Cut2(text, hmm: hmm)) { var width = w.value.Length; if (width > 2) { for (var i = 0; i < width - 1; i++) { var gram2 = w.value.Substring(i, 2); if (WordDict.ContainsWord(gram2)) { result.Add(new Token(gram2, w.position + i, w.position + i + 2)); } } } if (width > 3) { for (var i = 0; i < width - 2; i++) { var gram3 = w.value.Substring(i, 3); if (WordDict.ContainsWord(gram3)) { result.Add(new Token(gram3, w.position + i, w.position + i + 3)); } } } result.Add(new Token(w.value, w.position, w.position + width)); } } return result; } public class WordInfo { public WordInfo(string value,int position) { this.value = value; this.position = position; } //分詞的內容 public string value { get; set; } //分詞的初始位置 public int position { get; set; } }
這樣的話,終於能夠正確的進行高亮了,果真搜索效果要比PanGu分詞好不少。
是用JIEba的停用詞的方法,是把停用詞的文件裏的內容讀取出來,而後在Reset()函數裏把停用詞都過濾掉:
StreamReader rd = File.OpenText(stopUrl); string s = ""; while((s=rd.ReadLine())!=null) { stopWords.Add(s); } public override void Reset() { base.Reset(); _InputText = ReadToEnd(base.m_input); RemoveStopWords(segmenter.Tokenize(_InputText,mode)); start = 0; iter = _WordList.GetEnumerator(); } public void RemoveStopWords(System.Collections.Generic.IEnumerable<JiebaNet.Segmenter.Token> words) { _WordList.Clear(); foreach(var x in words) { if(stopWords.IndexOf(x.Word)==-1) { _WordList.Add(x); } } }
使用JIEba分詞以後,雖然效果很好,可是寫索引的速度很慢,考慮到時細粒度分詞,相比之前一篇文章多出來不少分詞,因此索引速度慢了8倍左右,可是感受這並不正常,前面的開源代碼測試結果中,CutForSearch很快的,應該是本身的代碼哪裏出了問題。
這裏再對Lucene的高亮的總結一下,Lucene提供了兩種高亮模式,一種是普通高亮,一種是快速高亮。
普通高亮的原理,就是將搜索以後獲得的文檔,使用分詞器再進行分詞,獲得的TokenStream,再進行高亮:
SimpleHTMLFormatter simpleHtmlFormatter = new SimpleHTMLFormatter("<span style='color:red;'>", "</span>"); Lucene.Net.Search.Highlight.Highlighter highlighter = new Lucene.Net.Search.Highlight.Highlighter(simpleHtmlFormatter, new QueryScorer(query)); highlighter.TextFragmenter = new SimpleFragmenter(150); Analyzer analyzer = new JieBaAnalyzer(TokenizerMode.Search); TokenStream tokenStream = analyzer.GetTokenStream("Content", new StringReader(doc.Get("Content"))); var frags = highlighter.GetBestFragments(tokenStream, doc.Get(fieldName), 200);
之所很快速,是由於高亮是直接根據索引儲存的信息進行高亮,前面已經說過咱們索引須要儲存分詞的位置信息,這個就是爲高亮服務的,因此速度很快,固然帶來的後果是你的索引文件會比較大,由於儲存了位置信息。
FastVectorHighlighter fhl = new FastVectorHighlighter(false, false, simpleFragListBuilder, scoreOrderFragmentsBuilder); FieldQuery fieldQuery = fhl.GetFieldQuery(query,_indexReader); highLightSetting.MaxFragNum.GetValueOrDefault(MaxFragNumDefaultValue); var frags = fhl.GetBestFragments(fieldQuery, _indexReader, docid, fieldName, fragSize, maxFragNum);
快速高亮的關鍵源代碼:
protected virtual string MakeFragment(StringBuilder buffer, int[] index, Field[] values, WeightedFragInfo fragInfo, string[] preTags, string[] postTags, IEncoder encoder) { StringBuilder fragment = new StringBuilder(); int s = fragInfo.StartOffset; int[] modifiedStartOffset = { s }; string src = GetFragmentSourceMSO(buffer, index, values, s, fragInfo.EndOffset, modifiedStartOffset); int srcIndex = 0; foreach (SubInfo subInfo in fragInfo.SubInfos) { foreach (Toffs to in subInfo.TermsOffsets) { fragment .Append(encoder.EncodeText(src.Substring(srcIndex, (to.StartOffset - modifiedStartOffset[0]) - srcIndex))) .Append(GetPreTag(preTags, subInfo.Seqnum)) .Append(encoder.EncodeText(src.Substring(to.StartOffset - modifiedStartOffset[0], (to.EndOffset - modifiedStartOffset[0]) - (to.StartOffset - modifiedStartOffset[0])))) .Append(GetPostTag(postTags, subInfo.Seqnum)); srcIndex = to.EndOffset - modifiedStartOffset[0]; } } fragment.Append(encoder.EncodeText(src.Substring(srcIndex))); return fragment.ToString(); }
fragInfo儲存了全部須要高亮的關鍵字和位置信息,src則是原始文本,而以前報的錯誤正是這裏引發的錯誤,因爲位置信息有誤src.Substring就會報錯。
.net core2.0版的中文分詞確實很少,相比較之下,java,c++,的分詞工具備不少,或許能夠用c++的速度快的特色,作一個單獨分詞服務,效果是否是會更好。