Lucene.net和PanGu分詞實現全文檢索
Lucene.net(4.8.0) 學習問題記錄五: JIEba分詞和Lucene的結合,以及對分詞器的思考
前言:目前自己在做使用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升級的改變我都會加粗表示。
Lucene.net 4.8.0
https://github.com/apache/lucenenet
PanGu分詞
https://github.com/LonghronShen/Lucene.Net.Analysis.PanGu/tree/netcore2.0
Lucene.net 4.8.0 和之前的Lucene.net 3.6.0 改動還是相當多的,這里對自己開發過程遇到的問題,做一個記錄吧,希望可以幫到和我一樣需要升級Lucene.net的人。我也是第一次接觸Lucene ,也希望可以幫助初學Lucene的同學。
目錄
- Lucene.net(4.8.0) 學習問題記錄一:分詞器Analyzer的構造和內部成員ReuseStategy
- Lucene.net(4.8.0) 學習問題記錄二: 分詞器Analyzer中的TokenStream和AttributeSource
- Lucene.net(4.8.0) 學習問題記錄三: 索引的創建 IndexWriter 和索引速度的優化
- Lucene.net(4.8.0) 學習問題記錄四: IndexWriter 索引的優化以及思考
一,PanGu分詞與JIEba分詞
1.中文分詞工具
Lucene的自帶分詞工具對中文分詞的效果很是不好。因此在做中文的搜索引擎的時候,我們需要用額外的中文分詞組件。這里可以總結一下中文分詞工具有哪些,在下面這個銜接中,有對很多中文分詞工具的性能測試:
https://github.com/ysc/cws_evaluation
可惜我們看不到PanGu分詞的性能,在PanGu分詞的官網我們可以看到:Core Duo 1.8 GHz 下單線程 分詞速度為 390K 字符每秒,2線程分詞速度為 690K 字符每秒。 在上面的排行榜中屬於中等吧。但由於我做的是基於.net的搜索引擎,所以我只找到了IK分詞器,PanGu分詞器,JIEba分詞器的.net core2.0 版本。
1.1 PanGu分詞 .net core 版
這是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 ,作者還沒有合並。我已經用了一段時間,很穩定。
1.2 JIEba分詞 .net core 版
JIEba分詞的.net core 版本遷移項目:
https://github.com/linezero/jieba.NET
但是這是.net core1.0的版本,拿過來也不能直接給Lucene使用,所以我升級到了2.0並且做了一個接口,讓其支持Lucene,經過測試可以穩定的進行分詞和高亮。當然在其中也遇到了一些問題,在下文中會詳細闡述。這是改過之后的Lucene版:
https://github.com/SilentCC/JIEba-netcore2.0
1.3 IK分詞 .net core 版
在Nuget中可以搜索到(IKNetAnalyzer)
在GitHub中 https://github.com/stanzhai/IKAnalyzer.NET 顯示正在開發中。由於一些原因,我並沒有使用IK分詞。所以也就沒有細看了。
2.PanGu分詞和JIEba分詞的對比
Lucene和PanGu分詞搭配,已經是Lucene.net 的經典搭配,但是PanGu分詞已經很久沒有更新,PanGu分詞的字典也是很久以前維護的字典。在網上可以找到很多Lucene和PanGu分詞搭配的例子。在PanGu分詞和JIEba分詞對比中,我選擇了JIEba分詞。因為我的搜索引擎一直是使用PanGu分詞,然后卻時常出現有些比較新的冷的詞,無法被分詞,導致搜索效果很差。究其原因,是PanGu分詞的字典不夠大,但是人工維護字典很煩。當然PanGu分詞有新詞錄入的功能,我一直打開這個功能的開關:
1
2
|
MatchOptions m =
new
MatchOptions();
m.UnknownWordIdentify =
true
;
|
然而並沒有改善。后來我使用了JIEba分詞測試分詞效果,發現JIEba分詞使用搜索引擎模式,和PanGu分詞打開多元分詞功能開關時的分詞效果如下:
1
2
3
4
5
|
測試樣例:小明碩士畢業於中國科學院計算所,后在日本京都大學深造
結巴分詞(搜索引擎模式):小明/ 碩士/ 畢業/ 於/ 中國/ 科學/ 學院/ 科學院/ 中國科學院/ 計算/ 計算所/ ,/ 后/ 在/ 日本/ 京都/ 大學/ 日本京都大學/ 深造
盤古分詞(開啟多元分詞開關): 小 明 碩士 畢業 於 中國科學院 計算所 后 在 日本 京都 大學 深造
|
顯然PanGu分詞並沒有細粒度分詞,這是導致有些搜索召回率很低的原因。
這里就不對PanGu分詞,和JIEba分詞的具體分詞方法進行比較了。本篇博文的還是主要講解Lucene和JIEba分詞
二,JIEba分詞支持Lucene
在上面的JIEba分詞.net core版本中,JIEba分詞只是將給到的一個字符串進行分詞,然后反饋給你分詞信息,分詞信息也只是一個一個字符串。顯然這是無法接入到Lucene中。那么如何把一個分詞工具成功的接入到Lucene中呢?
1.建立Analyzer類
所有要接入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類正是我們接下來要定義的類
1.建立Tokenizer類
繼承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類並不是這樣,因為遇到了一些問題,才逐漸改成上面的樣子,下面就說下自己遇到的問題。
3.問題和改進
3.1 JIEba CutForSearch
一開始在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分詞好很多。
4.停用詞
是用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); } } }
5.索引速度
使用JIEba分詞之后,雖然效果很好,但是寫索引的速度很慢,考慮到時細粒度分詞,相比以前一篇文章多出來很多分詞,所以索引速度慢了8倍左右,但是感覺這並不正常,前面的開源代碼測試結果中,CutForSearch很快的,應該是自己的代碼哪里出了問題。
三,Lucene的高亮
這里再對Lucene的高亮的總結一下,Lucene提供了兩種高亮模式,一種是普通高亮,一種是快速高亮。
1.普通高亮
普通高亮的原理,就是將搜索之后得到的文檔,使用分詞器再進行分詞,得到的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);
2.快速高亮
之所很快速,是因為高亮是直接根據索引儲存的信息進行高亮,前面已經說過我們索引需要儲存分詞的位置信息,這個就是為高亮服務的,所以速度很快,當然帶來的后果是你的索引文件會比較大,因為儲存了位置信息。
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++的速度快的特點,做一個單獨分詞服務,效果是不是會更好。