实现这个中文分析器的过程就像是一场精彩的赛事。好了,让我们马上开始。
冗长的代码常常是复杂性的标志,会导致代码难以测试和维护。
这是我在近期的文章中随便找来的一句话,将用它来阐明我们将要做什么和做到什么程度。
既然是比赛嘛就不能没有对手!我们的两个对手分别是ChineseAnalyzer和CJKAnalyzer。兵法云:知己知彼,百战不殆。那就让我们一起来了解一下我们的这两位对手。
ChineseAnalyzer和CJKAnalyzer都源于Apache,但它们不是Lucene的核心组件而是Sandbox组件。它们都继承自Analyzer,这是Lucene的核心组件之一,负责完成分词任务。我们将要完成的基于词典的分析器MMChineseAnalyzer也继承了Analyzer,这是实现Lucene分析器所必须的。
了解了我们对手的名门血统之后,该看看他们可以做什么了。为了更好的进行描述,我们规定:如果没有特别的指出,那么Ci将表示一个字符。这样一个句子就可以表示成Ci的序列,即C1C2...Ci...Cn。
ChineseAnalyzer
如果用ChineseAnalyzer来切分句子C1C2...Ci...Cn,那么切分的结果为C1,C2,...,Ci,...,Cn。
您可以运行下面的代码来查看分词的效果:
清单 1. 测试ChineseAnalyzer分析器的分词效果
public static void main(String[] args) { Analyzer analyzer = new ChineseAnalyzer(); QueryParser queryParser = new QueryParser( "field", analyzer); queryParser.setDefaultOperator(QueryParser.AND_OPERATOR); Query query = null; try { String test = "冗长的代码常常是复杂性的标志,会导致代码难以测试和维护。"; query = queryParser.parse(test); System.out.println(query.toString("field")); } catch (ParseException e) { e.printStackTrace(); } }
清单 1 中的代码输出的结果为,“冗 长 的 代 码 常 常 是 复 杂 性 的 标 志 会 导 致 代 码 难 以 测 试 和 维 护”。
这段代码和清单 2、清单 9中的代码都是为了测试分析器的分词效果而特别构建的,它们的不同仅仅在于使用了不同的分析器,因此只在这里做一些解释,以后就不在赘述了。代码中的QueryParser使用相应的分析器analyzer将指定的字符串test进行切分,为了比较切分的效果我们只是简单的进行了输出。
CJKAnalyzer
如果用CJKAnalyzer来切分句子C1C2...Ci...Cn,那么切分的结果为C1C2,C2C3,...,Ci-1Ci,CiCi+1,...,Cn-1Cn。
您可以运行下面的代码来查看分词的效果:
清单 2. 测试CJKAnalyzer分析器的分词效果
public static void main(String[] args) { Analyzer analyzer = new CJKAnalyzer(); QueryParser queryParser = new QueryParser( "field", analyzer); queryParser.setDefaultOperator(QueryParser.AND_OPERATOR); Query query = null; try { String test = "冗长的代码常常是复杂性的标志,会导致代码难以测试和维护。"; query = queryParser.parse(test); System.out.println(query.toString("field")); } catch (ParseException e) { e.printStackTrace(); } }
清单 2 中的代码输出的结果为,“冗长 长的 的代 代码 码常 常常 常是 是复 复杂 杂性 性的 的标 标志 会导 导致 致代 代码 码难 难以 以测 测试 试和 和维 维护”。这段代码和清单 1 和 9 中的代码类似。
MMChineseAnalyzer
这是我们即将要实现的基于词典的中文分析器,使用它可以得到更好一些的分词效果,不信,您可以查看下面的输出结果(稍后我将给出完整的实现代码):
“冗长 的 代码 常常 是 复杂性 的 标志 会 导致 代码 难以 测试 和 维护”。
哎,等了这么久,队员们总算站到起跑线上了。比赛就要开始了 ...
文中的讨论和实现都是采用Unicode编码并且只处理了CJK Unified Ideographs和Basic Latin两个子集中的字符。
CJK Unified Ideographs中主要是象形文字字符,编码的范围(以十六进制表示的)从4E00开始到9FBF结束,共有20928个字符,这在Unicode编码中算是一个比较大的子集。您可以通过图示 1 来大概的了解一下该子集中都包含一些什么字符,这里不可能展示的很全。如果您对更加详细的内容感兴趣,请参阅 参考资料 ,在那里可以找到一个包含该子集中全部字符的PDF文档。
图示 1. CJK Unified Ideographs
Basic Latin中主要是ASCII码字符,编码的范围(以十六进制表示的)从0000开始到007F结束,共有128个字符。您可以通过图示 2 来大概的了解一下该子集中都包含一些什么字符,这里不可能展示的很全。如果您对更加详细的内容感兴趣,请参阅 参考资料 ,在那里可以找到一个包含该子集中全部字符的PDF文档。
图示 2. Basic Latin
这里说了这么多,不过看起来好象和今天的赛事没有什么太大的关系。其实不然,我们在扫描待分析的句子时将会判断每一个取到的字是否在CJK Unified Ideographs和Basic Latin字符集中。如果不在我们就简单的丢掉这个字符,换句话讲,我们只处理这两个字符集中的字符。这在大多数场景中已经完全可以满足需要了。如果您觉得这些还不够,则可以自己扩充需要的字符集。这些工作比较简单,您不妨亲自试一试。
细心的您可能发现还有一个问题我们没有提到。对,就是如果两个连续的字符分别属于不同的字符集时怎么处理。在这里提到的算法中我们将两个字符进行切分,具体的实现我们将在实现中详细描述。
比赛已经开始了,队员们跑得越来越快 ...
词典结构对于基于词典的分析器就像是跑鞋对于队员们一样的重要,能够为他们的获胜助一臂之力。我们要使用的词典结构比较简单,是基于文本的。每个词占用一行并且可以使用#进行注释,注释的内容将会被忽略。词典中也不会收录单字词。
清单 3. 词典结构
#双字词 C1C2 #三字词 C3C4C5 #四字词 C6C7C8C9 : :
依次类推,n字词可以表示为C1C2...Cn,注意这里不要和句子的表示混淆了。
在实际中我们的词典看起来是下面的这个样子,被存储在一个文本文件中,使用UTF-8编码。如果您想用类记事本的编辑器打开请注意编码问题。
清单 4. 以文本形式存在的实际的词典结构
代码 冗长 复杂性 导致 常常 标志 测试 维护 难以 : :
有了这样的一个基于文本的词典,接下来就要考虑以什么样的规则将它装载到内存中更利于算法的实现了。
首先,我们先把n(n > 2)字词本身装载到内存中中,然后在把C1C2,C1C2C3,...,一直到C1C2...Cn-1这n-1个字串也装载到内存中,注意拆分后的这n-1个字串可能并不是词。如果n=2时我们直接装载C1C2,这时不进行拆分装载。因为我们没有收录单字词所以n不可能为1。
我们使用一个四字的词如一举成名来演示一下装载的过程。首先将一举成名装载到内存中,之后在将一举、一举成装载到内存中。按照这个装载规则,我们有:
清单 5. 装载之前的词典
: 复杂性 :
清单 6. 装载之后的词典
: 复杂性 复杂 :
我们之所以采用这样的装载规则是和算法的实现相关的,它可以使得算法的实现更加简洁。如果您读到这里还不太了解如此装载的目的,没有关系,您只要记住这种方式就可以了。相信在读完算法描述后会有豁然开朗的感觉,继续往下进行吧。
我们所采用的算法,简单的说,就是将句子从左到右扫描一遍,遇到词典中有的词,就把它切分出来。如果遇到如朴素大方这样的复合词就按最长规则取朴素大方进行匹配,而不是切分成朴素和大方两个词。如果有字串不能被识别,就把它切分成单字词。这样一个简单的分词算法就完成了。
下面我们具体的来描述一下这个算法:
1. 使用一个变量word来保存切分过程中的字符序列
2. 开始扫描句子C1C2...Ci...Cn,取出字符Ci
2.1 如果word的长度为0,那么将Ci附加到word中,将i增1,返回到步骤 2
2.2 如果word的长度大于0,那么将Ci和word连接到一起,并将连接后的字符序列与词典匹配(装载之后的形式)
2.2.1 如果匹配成功,则将Ci附加到word中同时将i增1,返回到步骤 2
2.2.2 如果匹配不成功,说明word中的字符序列是可匹配的最长的词,进行切分,同时将word清空(即长度为0)但保持i不变,返回到步骤 2
3. 当取完句子中的所有字符时结束
这只是对于算法的一个轮廓性的描述,并且只适合句子中只有汉字的情况。如果是汉字和拉丁文混合的情况,那么还要添加一些处理的逻辑。细节可以参阅完整的源代码,这里不在赘述。
最后的精彩时刻来临了,队员们开始冲刺了 ...
清单 7. MMChineseAnalyzer分析器的完整源代码
package org.solol.lucene.analysis; import java.io.Reader; import java.util.Set; import org.apache.lucene.analysis.Analyzer; import org.apache.lucene.analysis.StopFilter; import org.apache.lucene.analysis.TokenStream; /** * @author solo L * */ public class MMChineseAnalyzer extends Analyzer { public final static String[] STOP_WORDS = {}; private Set stopTable; public MMChineseAnalyzer() { stopTable = StopFilter.makeStopSet(STOP_WORDS); } public TokenStream tokenStream(String fieldName, Reader reader) { return new StopFilter(new MMChineseTokenizer(reader), stopTable); } }
清单 7 中的类MMChineseAnalyzer是文中要实现的基于词典的分析器的主类,它没有直接实现分词的具体逻辑,而是将这些逻辑代理给了清单 8 中的MMChineseTokenizer类。其中的STOP_WORDS用来指定在切分的过程中要忽略的一些在中文中没有太大意义的字或词,比如的、地、得、这等等。
清单 8. MMChineseTokenizer的完整源代码
package org.solol.lucene.analysis; import java.io.BufferedReader; import java.io.FileInputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; import java.io.Reader; import java.util.TreeMap; import org.apache.lucene.analysis.Token; import org.apache.lucene.analysis.Tokenizer; /** * @author solo L * */ public class MMChineseTokenizer extends Tokenizer { //我们没有处理5字或5字以上的次,如果您需要处理可以修改这里 private static final int WORD_MAX_LENGTH = 4; private static TreeMap<String, String> dictionary = null; private static final int IO_BUFFER_SIZE = 2048; private int bufferIndex = 0; private int dataLength = 0; private int offset = 0; private final char[] ioBuffer = new char[IO_BUFFER_SIZE]; private String tokenType = "word"; public MMChineseTokenizer(Reader input) { this.input = input; } public Token next() throws IOException { //装载词典 loadWords(); StringBuffer word = new StringBuffer(); while (true) { char c; char nextChar; Character.UnicodeBlock cUnicodeBlock; Character.UnicodeBlock nextCharUnicodeBlock; offset++; if (bufferIndex >= dataLength) { dataLength = input.read(ioBuffer); bufferIndex = 0; } if (dataLength == -1) { if (word.length() == 0) { return null; } else { break; } } c = ioBuffer[bufferIndex++]; cUnicodeBlock = Character.UnicodeBlock.of(c); nextChar = ioBuffer[bufferIndex]; nextCharUnicodeBlock = Character.UnicodeBlock.of(nextChar); boolean isSameUnicodeBlock = cUnicodeBlock.toString().equalsIgnoreCase(nextCharUnicodeBlock.toString()); if (cUnicodeBlock == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS) { tokenType = "double"; if (word.length() == 0) { word.append(c); // 增强部分--开始 if (word.length() != 0 && (!isSameUnicodeBlock)) { break; } // 增强部分--结束 } else { String temp = (word.toString() + c).intern(); if (dictionary.containsKey(temp)) { word.append(c); // 增强部分--开始 if (word.length() != 0 && (!isSameUnicodeBlock)) { break; } // 增强部分--结束 } else { bufferIndex--; offset--; break; } } } else if (cUnicodeBlock == Character.UnicodeBlock.BASIC_LATIN) { tokenType = "single"; if (Character.isWhitespace(c)) { if (word.length() != 0) break; } else { word.append(c); // 增强部分--开始 if (word.length() != 0 && (!isSameUnicodeBlock)) { break; } // 增强部分--结束 } } } Token token = new Token(word.toString(), offset - word.length(), offset, tokenType); word.setLength(0); return token; } public void loadWords() { if (dictionary == null) { dictionary = new TreeMap<String, String>(); InputStream is = null; InputStreamReader isr = null; BufferedReader br = null; try { is = new FileInputStream("dictionary.txt"); isr = new InputStreamReader(is, "UTF-8"); br = new BufferedReader(isr); String word = null; while ((word = br.readLine()) != null) { int wordLength = word.length(); if ((word.indexOf("#") == -1) && (wordLength <= WORD_MAX_LENGTH)) { dictionary.put(word.intern(), "1"); int i = wordLength-1; while(i >= 2){ String temp = word.substring(0, i).intern(); if (!dictionary.containsKey(temp)) { dictionary.put(temp,"2"); } i--; } } } } catch (IOException e) { e.printStackTrace(); }finally{ try { if(br!=null){ br.close(); } if(isr!=null){ isr.close(); } if(is!=null){ is.close(); } } catch (IOException e) { e.printStackTrace(); } } } } }
清单 8 中的代码有两处需要注意一下,分别是next()方法和loadWords()方法。在next()方法的开始处,我们调用了loadWords()方法,它依照文章前部描述的规则将词典装载到内存中。为了提高算法的效率这里使用了Singleton模式,这保证词典只被装载一次。另外,还专门选择了TreeMap这个数据结构,这有利于查找。
在装载了词典之后,我们就可以进行分词了。先将input输入流中的内容读到ioBuffer缓冲区中,下面将逐个扫描ioBuffer缓冲区中的字符,处理过程依照文章前部描述的算法过程,不在赘述。
在上面描述算法使用的两个字符集时,提到过如果有两个连续的字符分别属于不同的字符集时,我们也要把它们切分开。这体现在实现的代码中就是boolean isSameUnicodeBlock = cUnicodeBlock.toString().equalsIgnoreCase(nextCharUnicodeBlock.toString()),也就是我们同时也扫描了当前字符的下一个字符,并判断这两个字符是否处于同一个字符集。要是不处于同一字符集则将他们分开。
完成了清单 8 中的代码,我们的基于词典的分析器也就初步完成了。如果您要将它用于实际,我们希望您能自己进行一些测试,以确保可靠性。
终于撞线了 ...
所有代码都是在 eclipse 3.2 中完成的,您可以立即把它作为项目导入并运行它。
清单 9. 测试MMChineseAnalyzer分析器的分词效果
public static void main(String[] args) { Analyzer analyzer = new MMChineseAnalyzer(); QueryParser queryParser = new QueryParser( "field", analyzer); queryParser.setDefaultOperator(QueryParser.AND_OPERATOR); Query query = null; try { String test = "冗长的代码常常是复杂性的标志,会导致代码难以测试和维护。"; query = queryParser.parse(test); System.out.println(query.toString("field")); } catch (ParseException e) { e.printStackTrace(); } }
清单 9 给出了测试MMChineseAnalyzer分析器的代码,这和清单 1 清单 2 类似。您可以查看它的输出,这样就可以确信我们完成了任务。
分词是一项很有挑战性的任务,这里的实现还相当的初级,要想获得更佳的分词效果,还有很长的路要走。
如果您对分词非常的感兴趣,还想进一步深入的研究,我们建议您不妨从研究分词技术的发展历史开始,了解一下前辈们的智慧结晶。
MMSeg是一个开放源代码的中文分词软件包,可以方便的和Lucene集成。它实现了MMSEG: A Word Identification System for Mandarin Chinese Text Based on Two Variants of the Maximum Matching Algorithm算法。