前篇筆記試用了盤古分詞器跟 StadnardAnalyzer,繼續研究其他分詞器選擇。

英文能依據空白快速精準分詞,中文沒這麼幸運,必須借助演算法,邏輯複雜許多。中文分詞主要有兩個方向: 第一種是自動分詞,依循固定規則自動切分,例如: 一元分詞、二元分詞;第二種則是詞庫分詞,查詢詞庫識找出已知詞彙;也有分詞器選擇兩種做法兼用,以求互補。

一元分詞與二元分詞的優點是做法簡單,不需維護詞庫,但其索引幾乎跟原文一樣大,查詢效率也較差;詞庫分詞的索引可縮小到原文的 30%(參考),但詞庫完整性是成敗關鍵,需要持續訓練(甚至要考慮借助機器學習、人工智慧)提高精準度,要投注的心力不容小覷。

大陸對 Lucene 中文分詞的研究較多,我找到一篇 Lucene中文分析器的中文分词准确性和性能比较實測多種分詞器,看實例比較容易搞清楚什麼是一元分詞、二元分詞與詞庫分詞:

原文

2008年8月8日晚,举世瞩目的北京第二十九届奥林匹克运动会开幕式在国家体育场隆重举行。

StandardAnalyzer 一元分詞

2008/年/8/月/8/日/晚/举/世/瞩/目/的/北/京/第/二/十/九/届/奥/林/匹/克/运/动/会/开/幕/式/在/国/家/体/育/场/隆/重/举/行/

ChineseAnalyzer 一元分詞但去除英文字

年/月/日/晚/举/世/瞩/目/的/北/京/第/二/十/九/届/奥/林/匹/克/运/动/会/开/幕/式/在/国/家/体/育/场/隆/重/举/行/

CJKAnalyzer 二元分詞,不管合不合理,兩兩組合就算一個詞

2008/年/8/月/8/日晚/举世/世瞩/瞩目/目的/的北/北京/京第/第二/二十/十九/九届/届奥/奥林/林匹/匹克/克运/运动/动会/会开/开幕/幕式/式在/在国/国家/家体/体育/育场/场隆/隆重/重举/举行/

PaodingAnalyzer 庖丁分詞器,細粒度全切,字典查不到就二元分詞

2008/年/8/月/8/日/晚/举世/瞩目/举世瞩目/目的/北京/二/第二/十/二十/第二十/九/十九/二十九/九届/奥林/奥林匹克/运动/运动会/奥林匹克运动会/开幕/开幕式/国家/体育/体育场/隆重/举行/隆重举行/

IK_CAnalyzer 細粒度全切,字典查不到的二元分詞

2008年/2008/年/8月/8/月/8日/8/晚/举世瞩目/举世/瞩目/目的/北京/第二十九届/第二十九/第二十/第二/二十九/二十/十九/九届/九/奥林匹克运动会/奥林匹克/奥林/运动会/运动/开幕式/开幕/在国/国家/国/体育场/体育/隆重举行/隆重/举行/行/

MIK_CAnalyzer 最大匹配與細粒度全切搭配

2008年/8月/8日/晚/举世瞩目/目的/北京/第二十九届/奥林匹克运动会/开幕式/在国/国家/体育场/隆重举行/

MMAnalyzer 字典查不到時一元分詞

2008/年/8/月/8/日/晚/举世瞩目/北京/第二十/九届/奥林匹克运动会/开幕式/国家/体育场/隆重举行/

中文分詞是門深奧學問,有膨脹率(原文跟索引大小比率)、準確率、召回率、F值、消歧義... 等等議題值得探討,但我的目標是尋找現成全文檢索解決方案,一頭裁進去還要不要驗收? 但對於有興趣深入了解的同學,附上我找到的幾篇文章:

回到中文分詞器選擇上,各家中文分詞器都有自己的設計哲學,除了考量命中率,也必須考慮建立索引耗用資源及產生的詞彙數,詞彙數愈多命中率上升,但要付出索引檔變大及查詢效能下降的代價。庖丁分詞、IKAnalyzer、MMAnalyzer 可靠字典檔找出詞彙,找不到的部分則用二元或一元分詞,避免如盤古分詞拆錯就回天乏術的缺點。但使用 Lucene.Net 要有心理準備,有些好用的中文分詞器只有 Java 版,未移植到 .NET,看得到不一定吃得到,Lucene.Net 可用的選項沒那麼多。

所以我們再把焦點要放在 Lucene.Net 現成可用中文分詞器的比較上。

除了盤古分詞跟 StandardAnalyzer,我還找到兩個 NuGet 可下載安裝的中文分詞器:

MMSeg 是不少人推崇的演算法,簡單、快速、有效。而 CWSharp 將詞庫分詞、一元分詞、二元分詞一網打盡,基本上有了這兩個分詞器已囊括本次評估的主要分詞演算法。經過一番摸索,我成功使用 MMSegAnalyzer、SimpleAnalyzer、ComplexAnalyzer、CWSharp 詞庫、CWSharp 一元分詞、CWSharp 二元分詞完成索引及查詢,測試範例如下。

    class Program
    {
        static void Main(string[] args)
        {
            AnalyzerTest("盤古分詞", "D:\\PanGuIndex", new PanGuAnalyzer());
            AnalyzerTest("標準分詞", "D:\\StdAnalyzerIndex", 
                new StandardAnalyzer(Version.LUCENE_30));
            AnalyzerTest("MMSeg MaxWord", "D:\\MMSegIndex", new MMSegAnalyzer());
            AnalyzerTest("MMSeg Simple", "D:\\MMSegSimpIndex",
                new Lucene.Net.Analysis.MMSeg.SimpleAnalyzer());
            AnalyzerTest("MMSeg Complex", "D:\\MMSegCompIndex",
                new Lucene.Net.Analysis.MMSeg.ComplexAnalyzer());
            AnalyzerTest("CWSharp詞庫分詞", "D:\\CWStdIndex", 
                new CwsAnalyzer(
                new Yamool.CWSharp.StandardTokenizer(
                    new FileStream("cwsharp.dawg", FileMode.Open))));
            AnalyzerTest("CWSharp一元分詞", "D:\\CWUniIndex", 
                new CwsAnalyzer(new UnigramTokenizer()));
            AnalyzerTest("CWSharp二元分詞", "D:\\CWBiIndex", 
                new CwsAnalyzer(new BigramTokenizer()));
            Console.Read();
        }
 
        public static void AnalyzerTest(string title, 
            string indexPath, Analyzer analyzer)
        {
            //指定索引資料儲存目錄
            var fsDir = FSDirectory.Open(indexPath);
 
            //建立IndexWriter
            using (var idxWriter = new IndexWriter(
                fsDir, //儲存目錄
                analyzer, 
                true, //清除原有索引,重新建立
                IndexWriter.MaxFieldLength.UNLIMITED //不限定欄位內容長度
            ))
 
            {
                //示範為兩份文件建立索引
                var doc = new Document();
                //每份文件有兩個Field: Source、Word
                doc.Add(new Field("Word", 
                    "生活就像一盒巧克力,你永遠也不會知道你將拿到什麼。",
                    Field.Store.YES, Field.Index.ANALYZED));
                idxWriter.AddDocument(doc);
 
                //建立索引
                idxWriter.Commit();
                idxWriter.Optimize();
            }
 
 
            var searcher = new IndexSearcher(fsDir, true);
            //指定欄位名傳入參數
            QueryParser qp = new QueryParser(Version.LUCENE_30, 
                "Word", analyzer);
 
            Action<string> testQuery = (kwd) =>
            {
                var q = qp.Parse(kwd);
                var hits = searcher.Search(q, 10);
                Console.WriteLine($"查詢「{kwd}」找到{hits.TotalHits}筆");
            };
            Console.WriteLine($"{title}測試");
            testQuery("生活");
            testQuery("就像");
            testQuery("一盒");
            testQuery("巧克力");
            testQuery("永遠");
            testQuery("不會知道");
            testQuery("拿到");
            testQuery("什麼");
            Console.WriteLine("========================================");
        }
    }

使用內建詞庫或範例詞庫,實測結果如下:

盤古分詞測試
查詢「生活」找到1筆
查詢「就像」找到1筆
查詢「一盒」找到1筆
查詢「巧克力」找到1筆
查詢「永遠」找到0筆
查詢「不會知道」找到0筆

查詢「拿到」找到1筆
查詢「什麼」找到0筆
========================================
標準分詞測試
查詢「生活」找到1筆
查詢「就像」找到1筆
查詢「一盒」找到1筆
查詢「巧克力」找到1筆
查詢「永遠」找到1筆
查詢「不會知道」找到1筆
查詢「拿到」找到1筆
查詢「什麼」找到1筆
========================================
chars loaded time=89986ms,line=12638,on file=C:\Lab\LuceneTest\bin\Debug\data\chars.dic
words loaded time=2465010ms,line=149852,on file=words.dic
load all dic user time=2980004ms
unit loaded time=0ms,line=22,on file=C:\Lab\LuceneTest\bin\Debug
MMSeg MaxWord測試
查詢「生活」找到1筆
查詢「就像」找到1筆
查詢「一盒」找到1筆
查詢「巧克力」找到1筆
查詢「永遠」找到1筆
查詢「不會知道」找到0筆
查詢「拿到」找到1筆
查詢「什麼」找到1筆
========================================
MMSeg Simple測試
查詢「生活」找到1筆
查詢「就像」找到1筆
查詢「一盒」找到1筆
查詢「巧克力」找到1筆
查詢「永遠」找到1筆
查詢「不會知道」找到0筆
查詢「拿到」找到1筆
查詢「什麼」找到1筆
========================================
MMSeg Complex測試
查詢「生活」找到1筆
查詢「就像」找到1筆
查詢「一盒」找到1筆
查詢「巧克力」找到1筆
查詢「永遠」找到1筆
查詢「不會知道」找到0筆
查詢「拿到」找到1筆
查詢「什麼」找到1筆
========================================
CWSharp詞庫分詞測試
查詢「生活」找到1筆
查詢「就像」找到1筆
查詢「一盒」找到1筆
查詢「巧克力」找到1筆
查詢「永遠」找到1筆
查詢「不會知道」找到1筆
查詢「拿到」找到1筆
查詢「什麼」找到1筆
========================================
CWSharp一元分詞測試
查詢「生活」找到1筆
查詢「就像」找到1筆
查詢「一盒」找到1筆
查詢「巧克力」找到1筆
查詢「永遠」找到1筆
查詢「不會知道」找到1筆
查詢「拿到」找到1筆
查詢「什麼」找到1筆
========================================
CWSharp二元分詞測試
查詢「生活」找到1筆
查詢「就像」找到1筆
查詢「一盒」找到1筆
查詢「巧克力」找到1筆
查詢「永遠」找到1筆
查詢「不會知道」找到1筆
查詢「拿到」找到1筆
查詢「什麼」找到1筆
========================================

以「生活就像一盒巧克力,你永遠也不會知道你將拿到什麼。」為例,分別查詢「生活」、「就像」、「一盒」、「巧克力」、「永遠」、「不會知道」、「拿到」、「什麼」。其中盤古分詞最不理想,找不到「永遠」、「不會知道」、「什麼」;MMSeg 的三種實作只錯過「不會知道」;CWSharp 跟內建的 StandardAnalyzer 則全部都查得到。

單以查詢結果涵蓋度,CWSharp 跟 StandardAnalyzer 全過,MMSeg 錯失不算標準常用詞彙的「不會知道」稍稍遜色。StandardAnalyzer 全部拆成單詞,查詢原理不同先不納入比較,進一步研究都有使用詞庫的 CWSharp 及 MMSeg,會發現查不查得到跟詞庫分詞結果有關。MMSeg 之所以查不到「不會知道」是因為依詞庫拆成「也不」「會」「知道」,三個詞彙組不出「不會知道」。而 CWSharp 的詞庫拆成「也」「不」「會」「知道」,可以由後三者組出「不會知道」。

依據這個原理,如果我們查詢「活就像」這種不合理詞彙,所有使用詞庫的分詞器(盤古、MMSeg、CWSharp語庫法)全軍覆沒,只有一元分詞跟二元分詞能過關。

盤古分詞測試
查詢「活就像」找到0筆
========================================
標準分詞(一元分詞)測試
查詢「活就像」找到1筆
========================================
MMSeg MaxWord測試
查詢「活就像」找到0筆
========================================
MMSeg Simple測試
查詢「活就像」找到0筆
========================================
MMSeg Complex測試
查詢「活就像」找到0筆
========================================
CWSharp詞庫分詞測試
查詢「活就像」找到0筆
========================================
CWSharp一元分詞測試
查詢「活就像」找到1筆
========================================
CWSharp二元分詞測試
查詢「活就像」找到1筆
========================================

由上述測試,對 Lucene.Net 分詞器選擇可做個粗淺結論: 如果不在乎索引大小且對搜尋效能要求不嚴嚴苛,可選擇一元分詞或二元分詞;選擇詞庫分詞,索引較小且查詢效能較好,但遇到詞庫未涵蓋的詞彙會出現查不到的狀況,而找不到又分為詞彙本身不合理以及詞庫未函蓋兩種狀況,前者需向使用者解釋查詢無效字彙不在系統支援範圍,後者則需要持續增補詞庫。要選哪一種策略,甚至二者並用,可視專案的需求而定。


Comments

# by Ted

要不要試試看 mecab? 52NLP 蠻推薦這個分詞器的,但我還沒機會去驗證 http://www.52nlp.cn/用mecab打造一套实用的中文分词系统

# by Jeffrey

to Ted, 威力強大的分詞器還蠻多的,沒有移植.NET版的只能先放生。

Post a comment