开源搜索框架Lucene学习之分词器(1)——Tokenizer类及其子类
2011-12-29 22:26 左眼微笑右眼泪 阅读(2379) 评论(1) 编辑 收藏 举报在搜索的过程中,有两个地方会用到分词,一个就是建索引的时候,我们都知道,Lucene是以倒排的方式建索引的,就是记录每一个词以及这个词出现的位置,当然还有一些其他的元数据。另外一个地方就是搜索的时候,比如用户输入一个字符串,你要把它解析成一个个的词,然后到索引中去进行查找。所以分词的过程简单的可以理解为把一系列字符串按某种方式分成一个个的词。
这次将要分析的就是建索引过程中的分词,先说说Lucene里建索引的一个大致的过程。Lucene里面有Document和Field,Document可以理解为要建索引的文档,也可以理解为要建索引的一个数据表,Field中文叫域,我们可以理解为数据表中的某一行记录。Document是由一系列Field构建的,至于怎么构建的,网上有相关的资料,这里先不讲,有机会再补上。Document就是我们要建索引的文档,比如我有一个文本文件,里面内容是“Beijing is the Capital of China”,我们就把它当成一个Documnet,先把Document传给分词组件(Tokenizer),分词组件会把这个文档里面的内容分成一个个的单词,去掉标点符号,去除停词(一些没有实际意义的词,如the,a等等),这样处理之后,得到的就是词元(Token)了,比如”Beijing”,”Capitial”,”China”等等就是词元了。然后词元又会经过一系列处理,如转换成小写,还会把单词还原成原型,也就是把过去时,复数等等转换成相应的原来的形式,如把cars转换成car。这样得到的就是词(Term)了,最后得到的”beijing”,”capitial”,”china”就是词了,然后把这些词传递给索引组件,建立索引。
接下来要分析就是完成从Document到Token再得Term的转换过程的代码。
首先要看就是Token类,此类的结构图如下:
四个属性
private string termText; // 文本值 private int startOffset; // 起点的偏移量 private int endOffset; // 终点偏移量 private string type = "word"; // 语汇单元类型,默认的是word
三个构造函数
public Token(string text, int start, int end) public Token(string text, int start, int end, string typ)
public Token(string termText, int startOffset, int endOffset, TokenTypes type)
一个ToString方法
public override string ToString()
Token我们先可以这样理解,就是一个单词,又叫语汇单元。它有四个属性,也就是四个字段,termText代表的是文本值,也就是单词本身;startOffset就是这个单词起始点的偏移量,也就是语汇单元文本的起始字符在原始文本中的位置;endOffset就是这个单词终点的偏移量,终点偏移量是语汇单元文本终止字符的下一个位置;type就是指这个语汇单元的类型,这里的类型可以是文本,数字,主机名,缩写等等。大家可以看到这个类型可以是自定义的,在构造函数中传进去,也可以用它内置的,是一个枚举。也就是TokenTypes 枚举,它里面有如下几个枚举成员:
/// <summary>
/// Types of tokens that used by lexer.
/// 语汇单元的类型
/// </summary>
[Flags]
public enum TokenTypes
{
/// <summary> alphanum(字母和数字) </summary>
ALPHANUM,
/// <summary> apostrophe(撇号,标点符号) </summary>
APOSTROPHE,
/// <summary> acronym(缩写) </summary>
ACRONYM,
/// <summary> company(公司) </summary>
COMPANY,
/// <summary> email(邮件) </summary>
EMAIL,
/// <summary> host(主机) </summary>
HOST,
/// <summary> num(数字) </summary>
NUM,
/// <summary> eof(文件结束符) </summary>
EOF
}
注释翻译的可能不准确,凑合着看吧。接下来要看的是一个比较重要的类叫TokenStream,TokenStream叫语汇单元流,可能理解为一个字符串,就是若干个Token语汇单元组成的。真正的是从别处传过来一个Document,暂且理解为一个字符串,经过一系列操作后,变成一个个Token,然后把这些Token组合成一个TokenStream。咱们来看看TokenStream类,它的代码如下:
/// <summary>
/// 语汇单元流是由一系列语汇单元(也就是词元)组成的,它可以来自Docement,也可以来自查询字符串
/// A TokenStream enumerates the sequence of tokens, either from fields of a document or from query text.
/// </summary>
abstract public class TokenStream
{
///<summary>
///返回语汇单元流中的下一个词元,或者是空,也就是结束,返回EOS
///Returns the next token in the stream, or null at EOS.
///</summary>
abstract public Token Next();
///<summary>
///释放语汇单元流所使用的资源
///</summary>
public virtual void Close()
{
}
}
注意看,它是一个抽象类,里面有一个抽象方法和一个虚方法,其实它什么也没做。它一个方法叫Next,也就是返回一个语汇单元流(TokenStream)的下一个语汇单元(Token,也就是一个词),这个方法由TokenStream的子类来实现,它主要做的就是按照某种方式把一个语汇单元流分解成一个个的语汇单元,然后返回去,分解一个返回一个,直到达到语汇单元流的末尾。Close方法主要就是释放所使用的资源。
再接着我们就要看TokenStream的子类了,这里先介绍一下,TokenStream有两种类型的子类,一种是Tokenizer,也就是真正进行分词的,一种是TokenFilter,就是完成一些其他操作的,比如去除停词,转换成小写。这两个子类也是抽象类,他们也作为父类,后面有很多继承他们的类。我们先分析Tokenizer类和继承于Tokenizer的子类。先看看Tokenizer类的内容:
abstract public class Tokenizer : TokenStream
{
///<summary>
///Tokenizer的文本源,一个TextReader,
///此处是定义成受保护的,在子类中可以直接访问
///The text source for this Tokenizer.
///</summary>
protected TextReader input;
///<summary>
///关闭Reader对象
///By default, closes the input Reader.
///</summary>
public override void Close()
{
input.Close();
}
}
可以看到Tokenizer类也是一个抽象类,它里面定义了一个受保护的TextReader对象,注意之所以定义成受保护的就是因为这样在它的每个子类中都可以共用一个,这是一个文本流的读对象,为什么要用它呢,前面我们不是说过,我们需要对文档的内容进行分词,比如我们传入一个字符串,我们首先把要分词的字符串构建成一个TextReader,然后传进来,其他的方法对TextReader对象进行操作。Close方法就是实现了父类TokenStream中的Close方法,把相应的TextReader关闭掉。我们可以看到Tokenizer的父类TokenStream中还有一个抽象方法,Tokenizer类并没有实现,也就是Next方法,这个方法也是最关键的方法,就是真正实现分词的方法,既然Tokenizer没有实现,那么我们看看他的子类CharTokenizer类。
/// <summary>
/// 一个抽象的基类,通过字符来进行处理,这个类里面包含抽象方法IsTokenChar(),当
/// IsTokenChar()为true时返回连续的语汇单元块。该类还能将字符规范化处理(如转换成小写形式)
/// ,输出的语汇单元所包含的最大字符数为255
/// CharTokenizer是一个抽象类,它主要是对西文字符进行分词处理的。
/// 常见的英文中,是以空格、标点为分隔符号的,在分词的时候,就是以这些分隔符作为分词的间隔符的。
/// An abstract base class for simple, character-oriented tokenizers.
/// </summary>
public abstract class CharTokenizer : Tokenizer
{
public CharTokenizer(TextReader input)
{
this.input = input;
}
private int offset = 0, bufferIndex = 0, dataLen = 0;
private static int MAX_WORD_LEN = 255;
private static int IO_BUFFER_SIZE = 1024;
private char[] buffer = new char[MAX_WORD_LEN];
private char[] ioBuffer = new char[IO_BUFFER_SIZE];
protected abstract bool IsTokenChar(char c);
protected virtual char Normalize(char c)
{
return c;
}
///<summary> Returns the next token in the stream, or null at EOS. </summary>
public override Token Next()
{
int length = 0;
int start = offset;
while (true)
{
char c;
offset++;
if (bufferIndex >= dataLen)
{
dataLen = input.Read(ioBuffer, 0, ioBuffer.Length);
bufferIndex = 0;
};
if (dataLen == 0)
{
if (length > 0)
break;
else
return null;
}
else
c = (char)ioBuffer[bufferIndex++];
//如果是一个token字符,则normalize后接着取下一个字符,否则当前token结束。
if (IsTokenChar(c))
{ // if it's a token char
if (length == 0) // start of token
start = offset - 1;
buffer[length++] = Normalize(c); // buffer it, normalized
if (length == MAX_WORD_LEN) // buffer overflow!
break;
}
else if (length > 0) // at non-Letter w/ chars
break; // return 'em
}
return new Token(new String(buffer, 0, length), start, start + length);
}
}
这个类的作用就是把字符串流拆分成一个个的语汇单元,并记录每个语汇单元的偏移量,里面最重要的就是一个Next方法,这个方法就是遍历文本流中的每个字符,然后来判断这个字符是不是一个语汇单元的的分拆条件,比如如果我的条件是以空格来分词,那么当这个字符不是空格的话,我就接着遍历下一个字符,一直循环,如果到某一个字符,它恰好是空格,那么就符合我们分词的条件,我们就把前面所遍历的字符当作一个语汇单元,也就是一个词(Token)返回去,顺便也返回它的偏移量,大家可以看到,Next方法里面有一个IsTokenChar方法,这个方法就是判断是否达到条件,当然这个方法是一个抽象方法,表示它需要子类来实现,这里为什么要定义为抽象方法呢,我们知道CharTokenizer是想通过字符来进行分词,但英文字符也分种类,有字母类型的字符,有符号类型的字符。比如我可以用字符空格来分词,也可以用英文字母来进行分词,所以根据不同的需要来实现不同的IsTokenChar方法。当然我们在这里面还可以看到一个Normalize方法,也就是标准化方法,这个方法是表示把组成词的字符进行一系列处理,比如转换成小写。这个方法是一个虚方法,在CharTokenizer类里面,这个方法没做什么处理,只是按原样返回,如果有需要,可以在子类里面实现。按分词方式的不同,我们可以为每一种方式编写一个子类,这里主要介绍两个,一个是按非英文字母来分词的LetterTokenizer类,也就是遇到一个非英文字母的字符,就把前面的字符当作一个Token返回;另外一个是按空格来进行分词的WhitespaceTokenizer类,也就是遇到一个空格,就把前面所有的字符当作一个Token返回。按前面的解释我们就应该知道,这两个类都是继承于CharTokenizer类,而且主要是重写了CharTokenizer的IsTokenChar方法。我们来看LetterTokenizer类的具体的代码:
public class LetterTokenizer : CharTokenizer
{
///<summary> Construct a new LetterTokenizer. </summary>
public LetterTokenizer(TextReader lt)
: base(lt)
{
//super(lt);
}
///<summary>
/// Collects only characters which satisfy.
/// </summary>
protected override bool IsTokenChar(char c)
{
return Char.IsLetter(c);
}
}
可以看到LetterTokenizer类就是重写了父类的IsTokenChar方法,在这个方法里面,通过Char.IsLetter方法来判断是不是英文字母,如果是的就返回True,那么它就会接着往下读取,直到遇到的不是英文字母,那么它就会返回False,CharTokenizer类的Next方法就会把这个字符以前的所有字符当作一个Token返回。我们再来看看WhitespaceTokenizer类的代码:
public class WhitespaceTokenizer : CharTokenizer
{
///<summary> Construct a new WhitespaceTokenizer. </summary>
public WhitespaceTokenizer(TextReader wt)
: base(wt)
{
// use base
}
///<summary> Collects only characters which do not satisfy. </summary>
protected override bool IsTokenChar(char c)
{
return !Char.IsWhiteSpace(c);
}
}
可以看到WhitespaceTokenizer类也是重写了父类的IsTokenChar方法,在这个方法里面,通过Char.IsWhiteSpace方法来判断是不是空格,如果是空格,就返回False,CharTokenizer类的Next方法就会把这个字符以前的所有字符当作一个Token返回。
说了这么多举个例子,比如我用
Reader reader = new StringReader("This’s a cat.");
LetterTokenizer ct = new LetterTokenizer(reader);
调用next方法它返回的是(This,0,4),这是因为遇到到了字符”’”,那么它就会把这个单引号之前的字符当作一个Token返回了,如果我改为
Reader reader = new StringReader("Lucy is a Girl.");
WhitespaceTokenizer ct = new WhitespaceTokenizer(reader);
调用next方法它返回的是(Lucy,0,4),因为它遇到了空格,它就会把空格以前的所有字符当作一个Token给传回来.
最后再介绍一个类,就是LowerCaseTokenizer类,这个类也是按照英文字母来区分的,它是继承于LetterTokenizer,但是它多了一个功能,就是进行了标准化,也就是把大写字母全部转换成了小写字母.我们可曾记得CharTokenizer有一个抽象方法IsTokenChar,上面介绍的两个类都已经实现了,但还有一个虚方法Normalize,上面的两个子类都没有实现,现在这个孙子辈的类LowerCaseTokenizer来实现,它继承父类LetterTokenizer的IsTokenChar方法,也就是按照非英文字母进行分拆,所以在它的类里面没有实现IsTokenChar方法,它只实现了Normalize方法,而Normalize方法所做的事也很简单,就是把每个字符转换成小写.我们看具体的代码:
public class LowerCaseTokenizer : LetterTokenizer
{
///<summary> Construct a new LowerCaseTokenizer. </summary>
public LowerCaseTokenizer(TextReader lct)
: base(lct)
{
//super(lct);
}
///<summary>
/// Collects only characters which satisfy.
/// </summary>
protected override char Normalize(char c)
{
return Char.ToLower(c);
}
通过Char.ToLower来把字符转换成小写的,这样返回来的每个Token,它的字符就都是小写的.例如我们把上面的例子改为:
Reader reader = new StringReader("This’s a cat.");
LowerCaseTokenizerct = new LowerCaseTokenizer(reader);
调用Next方法,它返回的就应该是(this,0,4),把大写的T变成了小写的T.
这就是Tokenizer类,它的子类以及子类的子类的介绍,下次我们将来共同分析TokenStream另外一种类型的子类TokenFilter.