Becomin' Charles

算法 | LNMP | Flutter | Mac

Becomin' Charles

什么是分词?什么是中文分词?

分词,就是将一段文字,按照语义上的最小单位切割开来。对于中文来说,虽然,很多汉字本身就具有相对独立的意思,但是更多情况下,单个的汉字是与其他一个或多个汉字组合在一起形成一个含义的。举个例子,“我是一个学生”,分词的结果是:“我/是/一个/学生”,再比如,“我/打算/去/做/分词/的/研究”。中文分词,就是将中文段落划分成词。

分词是理解语义的前提。人类依据自身的知识,在看到文字的时候,就自动完成了分词的过程。然而,计算机不具备人类的知识,更加不具备人类的智能,让机器实现自动切分文本,就成为了一个重要的研究课题,隶属于自然语言处理技术领域。在各种语言的分词中,最为困难的,可能就是中文分词了,因为中文语法复杂,规则少,特例多,歧义性强。中文领域文本处理技术,大大落后于西文,分词就是制约因素之一。

分词是搜索引擎建立索引的重要环节。我们固然可以对单个汉字建立索引,但是那样建立的索引,体积庞大,效率低下,检索缓慢,精确率低。分词完毕后,就能大大减少索引的体积,提高检索的效率。对于检索领域来说,分词可能仍非必要环节,但是对于自然语言处理领域来说(典型的如机器翻译),分词就必不可少。

分词的基本方法

分词一般有三种方法,基于字符串匹配的分词方法,将一段文本,与一个充分大辞典,逐条进行匹配,实现分词。按照长度优先级的不同,可以分为最大匹配、最小匹配;按照匹配方向的不同,可以分为正向匹配、逆向匹配。这些方法可以互相组合。

基于理解的分词方法,这种方法让计算机模拟人对句子的理解,对句子进行语法分析,语义分析,最后实现分词。该方法实现难度大,需要大量的语言知识和信息,目前还没有可生产的系统。

基于统计的分词方法,是通过对大量语料统计两个字相邻出现的频率,识别出词的方法。该方法虽然不需要辞典,而且能够实现对新词的识别。

三大类分词方法,各有利弊,参见文献【2】,现实中的分词系统,往往是综合系统,也即两种以上方法的综合,以此实现最优的分词效果。

在Lucene中分词

在Lucene中执行分词任务的是Analyzer对象,该对象中最关键的方法,是tokenStream方法,该方法可以返回一个包含着token的集合,也即TokenStream对象。TokenStream本身,是一个有着类似迭代器接口的抽象类,其具体类有两种,一种是以Reader对象作为输入的Tokenizer对象,另一种是以另一个TokenStream对象作为输入的TokenFilter。

到此,我们已经不难看出,实际执行切割任务的是Tokenizer,而TokenFilter则正如其名,对切割的结果进行过滤。要得到一个分词完毕的结果集合,必须要各类Tokenizer和TokenFilter的合作才可以完成,而Analyzer在这里,就扮演着一个组装器的角色。

从StandardAnalyzer中,我们不难发现Lucene的思路。首先创建一个StandardTokenizer实现第一次切割,然后是StandardTokenFilter,实现对token的归一化,如将复数词变成单数词,接着是一个ToLowerCaseFilter,将所有的token转换成小写字母,最后是StopFilter,将所有的stop words(无意义虚词)去掉。这样就完成了对一个英文文字段落的分词。

这样的设计,将复杂的分词实现全部对用户透明,用户具体使用的时候,就非常容易,只要创建一个Analyzer对象,然后传递给IndexWriter或者QueryParser(分解用户的查询,第一个步骤也是分词)即可。

在Lucene中实现中文分词

有了上面的理解,我们就知道,要让Lucene能够实现中文分词,我们必须创建自己的Analyzer,以及与其相关的Tokenizer和TokenFilter,有了这几个类的配合,就可以实现中文分词。

我在网上调研了数个中文分词系统,但是,里面实现了Lucene接口的并不多,好在我只是做一般性科研用途,对性能,效率不需考量,所以,我就直接选择了按照Lucene的接口设计的Paoding Analysis包。如果,我们期望能够得到更高的精确率和分词效率,我们还需要选用更加优秀的分词组件才行,那时候,就必须要自己手动来进行一次封装,以实现Lucene的接口,不过,这并不是一个复杂的事情。很多分词组件本身已经非常全面,例如ICTCLAS,已经实现了定制化分词,其给出的结果,就直接是最终结果了,所以,如果要包装ICTCLAS,我们要做的事情就很简单了,将ICTCLAS返回的结果,用TokenStream包装即可,可能单实现一个Analyzer就足够了。即便考虑得全面一些,事情也不会太复杂。

总结

市面上的中文分词组件非常多,一般应用,我们完全可以采用现成的系统。没有必要重复发明轮子。但是这些系统大多数难以适用于要求更高的商业系统,那时候就不得不选购一些相关的解决方案,或者在某开源系统的基础上进行再开发。

我个人在实际项目中,选用了Paoding Analysis,主要考虑到该实现完全使用Java,具有优秀的跨平台特性,而且用起来也最为省心,分词效果也还不错。虽然效率可能存在一定的问题,但是由于系统本身内容较少,也就不是矛盾的主要方面了。日后可以考虑更换更为高效和更为准确的系统。当然,前提是你在构建系统的时候,不要将你使用的Analyzer硬编码进系统,而是使用配置文件等方式来接入。

参考文献:

【1】百度百科:中文分词 http://baike.baidu.com/view/19109.html (中文分词的基本方法,技术难点,基本应用)

【2】三种中文分词算法优劣比较 http://www.blogjava.net/jiangyz/articles/238120.html

【3】开源中文分词软件

【4】Lucene中文分析器的中文分词准确性和性能比较

【5】几款免费中文分词模块介绍 (原文已经丢失)

【6】关于中文分词的一些琐碎资料

常见分词系统:

【1】中科院计算所ICTCLAS http://ictclas.org/index.html (很多企业和研究机构的分词系统,带有词性标注,便于搞科研)

【2】海量信息技术有限公司的中文智能分词 http://www.hylanda.com (据说是中搜使用的分词系统,传说业界公认最好的分词)

【3】猎兔 中文分词 http://www.lietu.com/demo/index.jsp

【4】极易中文分词组件 http://www.jesoft.cn/ (基于正向最大匹配算法)

【5】庖丁解牛中文分词 http://code.google.com/p/paoding/ (开源项目)

【6】简易中文分词系统 http://www.hightman.cn/index.php?scws (提供php扩展)

【7】还有许多许多

事实上,在WP这个范围内,我还是看过一点代码的,看多了,我就觉得,怎么有些代码看着就讨厌,而另一些代码看着就很兴奋,觉得很爽。

举个例子,WP本身的代码,看着感觉还不错,至少很清楚,一般一个函数打开,基本上能看懂这个函数在说什么。主题框架hybrid,结构非常清楚,非常有条理,最后就是非常合理,一看就觉得很兴奋,热血沸腾,仿佛你能在这里面做任何事情,你能理清楚来龙去脉。

阅读全文 »

本文提出问题,并不解决问题。

我在研究Extended Live Archive(ELA)插件的代码,发现一个问题,这个问题具有一定的共性,而且,我现在也没有什么好的解决办法,所以我把它描述一下,看看有没有高人高见。

阅读全文 »

当开发好一款插件的时候,要为插件撰写一个readme.txt文件,这是让用户了解你的插件的最好方式。很多插件都会在readme.txt的第一个section(description)里,附上一个简单明了的features list,以便用户可以快速扫描你插件包含的功能,决定是否选用。那么应该如何撰写features list呢?本文为您介绍。

阅读全文 »

想起来好久没有更新WP了,所以今天更新了一下,发现Widgets管理页面出现了重大变化。

上一次是整顿了Widgets API接口,现在又是大规模变动管理界面。WP关于Widget方面的更新,带给我很大的焦虑,因为我开发了巨量的Widget,一旦这些新的API和界面投入使用,就意味着我要增加很多工作量。另外,我看到了一个可怕的问题,就是有些用户可能不会升级到新版本,那么是否意味着一个Widget要维护两个版本的代码呢?太可怕了,希望WP能够处理好这个问题。

不想多写了。

笔记03中,已经提到了使用Lucene进行搜索的几个必要组件:

  • IndexSearcher——该对象内包含了很多search方法的重载,搜素一个索引,主要就是使用该对象的实例。
  • Query——该类是一个抽象类,其派生类产生的对象,是对各种形式搜索的封装。
    • TermQuery——匹配那些包含单个查询词语(term)的文档。可以使用BooleanQuery进行组合。
    • BooleanQuery——匹配由其他查询(TermQuery或PhraseQuery或者BooleanQuery)布尔组合后形成的查询的文档。
    • FuzzyQuery——模糊查询。
    • RangeQuery——范围查询。
    • 还有很多……
  • QueryParser——将人类语言翻译成上述某种Query对象。
  • TopDocs——搜索结果的容器。TopFieldDocs是其派生类,也是存放搜索结果的容器。

上面已经提到了,IndexSearcher中有很多重载的search方法,不过我仔细看了一下,建议使用的并不多。

1
2
3
4
5
6
7
public TopFieldDocs search(Query query, Filter filter, int n, Sort sort)
public TopDocs search(Query query, Filter filter, int n)
public TopDocs search(Query query, int n)

//following low-level
public void search(Query query, HitCollector results)
public void search(Query query, Filter filter, HitCollector results)

Filter是一个抽象类,就像其名字标识的一样,该对象将会过滤搜索结果,挡住一些结果,放行另一些。如果我们在建立索引的时候,对日期按照YYYYMMDD的格式建立了索引,那么我们在搜索的时候,可以使用PrefixFilter来查询某一年YYYY的文档。另外,我们也可以使用RangeFilter来搜索某个特定字段存在于某个范围内的文档。Sort则是一个可以改变排序结果的对象,可以告诉Lucene按照某个或者某几个特定的字段排序搜索结果,还可以让Lucene将搜索结果逆序。

HitCollector是一个抽象类,其子类的对象,都要提供一个collect方法,IndexSearcher会将每个文档的id和其原始得分传递给该方法。在这里,就可以自定义对最后结果的排序算法了。这里,可以看一下默认的TopDocCollector的collect方法实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public void collect(int doc, float score) {
if (score > 0.0f) {
totalHits++;
if (reusableSD == null) {
reusableSD = new ScoreDoc(doc, score);
} else if (score >= reusableSD.score) {
// reusableSD holds the last "rejected" entry, so, if
// this new score is not better than that, there's no
// need to try inserting it
reusableSD.doc = doc;
reusableSD.score = score;
} else {
return;
}
reusableSD = (ScoreDoc) hq.insertWithOverflow(reusableSD);
}
}

默认的算法,就是根据原始分数的高低,来对搜索命中结果进行排序。实质上就是一个堆排序,这里用到的hq对象,是一个优先级队列。

  1. 每传递进来一个文档id,将该文档的分数与上一次操作结束后,得分最低的文档比较(优先级队列长度是有限的,填满后,抛弃得分最低的文档)

  2. 如果,得分较高,插入队列,并记录下这次操作被挤出队列的文档,否则,抛弃之。

如果我们自己来设定排序方法的话,我们可能先要调用IndexReader对象,取得我们计算排序所必须的字段,然后,在这个函数内实现我们的计算公式。这里,我就在想,是否可以更早一步地介入到Lucene的搜索过程中,比如这里,score已经计算好了,是否有机会介入到计算scroe之前的地方,这样,我们就可以用自己的公式来计算scroe。这只是一个想法,还没有进一步验证和研究。

今天看过的一些文章的摘抄:

  1. 不选择使用Lucene的6大原因

  2. Moving Lucene a step forward ——上面那篇中文的文章中提到的英文原文。

  3. 数学之美 系列九 – 如何确定网页和查询的相关性 —— TF·IDF统计法的一个基础介绍。

Lucene是允许对索引的并发操作的,具体操作时,要遵循三条简单而严格的规则:

  • 任意数量只读操作可以并行。
  • 对于一个处于写状态的索引来说,也允许任意只读操作并行。
  • 索引的写操作不可以并行,只能有一个实例线程修改索引。

Lucene的并发规则非常简单,而且,这样的规则基本符合我们的直觉思维,因而非常容易记忆。事实上,Lucene并不强制遵守这些规则,但是违背规则,将带来不可预测的风险,例如索引损坏。

实际操作中,一个好的做法是对于执行写操作的对象,使其单一实例化,也就是使用Singleton设计模式。Lucene的索引操作对象,都被设计为线程安全的形式,多个线程可以直接调用,而不需要额外的同步操作。这一点相当体贴。

实际上,可能没有人会故意去不遵守Lucene的并发规则,造成这样的状况,往往是意外,所以,Lucene提供了一套锁,来保护索引。

Lucene的锁以文件的形式保存在磁盘上,一共有两种锁,一种是write.lock,另一种是commit.lock。

使用Lucene建立索引,有三个主要步骤。

提取文本。Lucene只能对纯文本建立索引,所以,任何需要建立索引的资料,都要进行过滤处理,从中提取到纯文本。比如对于Word和PDF,我们都要使用相关API将其中的纯文本提取出来,而对于XML和HTML,则意味着要过滤掉所有的tag。

文本分析。要建立索引,首先要将文本分解成一个个片段,一般就是单词,当然也可能是词组,句子等。分割好的东西,可能还要进行归一化处理,以确保最大程度上的检索能力,比如,全部变成小写字母,以后搜索的时候,就能忽略大小写。这个过程对于字母文字,有个步骤,就是回归原型,像英文、德文、法文这些我稍微有点了解的语言里,一般都有“数”,“格”,“态”的变化,而同一个词的变化形式,应该被视为是一个词,而不是不同的词。对于汉语这样的没有变形的语言,这方面就非常方便了,但是汉语却有着另一个不方便的地方,就是汉语的最小单位不是字,而是词。也即汉语需要进行分词处理。英文单词使用空格分隔,分词要简单得多得多。除却这些步骤,还有一个共同的步骤就是删除stop words,简单说就是无意义词,一般来说就是数词,量词,助词,介词,代词等等虚词。

将索引写入磁盘。Lucene将分析好的文本使用一种叫做倒排索引的数据结构写入到磁盘中。倒排索引(inverted index)的建立,完全是为了搜索的方便。如果说,“正排索引”可以回答你一个问题,“这个文档中,包含了哪些关键词?”,那么“倒排索引”回答了你一个相反的问题,就是“哪些文档,包含了关键词X?”。倒排索引是当今所有主流搜索引擎的核心结构,而这些搜索引擎之所以不同,是因为在建立倒排索引时所附加的独特的参数,比如著名的Google PageRank。这些参数决定了最终搜索结果的排序。

笔记02中,写了一些建立索引的基本元件,但是没有详细研究。这里从逻辑上来看一下Lucene的索引。Lucene的索引从逻辑上看是一个整体,内部的结构却很复杂,可以很方便的合并,删除,而且并发访问的能力很强。索引的内部,最小的单位是字段(Field),然后一些字段的集合是文档(Document)。这个文档和我们对其建立索引的那个“文档”是不同的,这是Lucene索引内部组织的一种形式,按照我的理解,就是一组相关的字段,规整在一起,就是一个Lucene Document。举个例子,一个Lucene Document包含标题,作者,日期,关键字,内容五个字段,具体字段的数量,应该是在建立索引的时候决定的,可以根据需要添加足够多的字段。比如还可以添加网址,分类信息等等。事实上,在建立索引完毕后,我们从索引中搜索出来的最终结果,是Lucene Document,而不是原本我们对其建立索引的文件。这些Document通过其内部字段信息,和真正的文件建立关联,比如我们在Google中,搜到的结果,都带有一个超链接指向真正存在于互联网某个角落的网页。

字段是一种数据结构,包含一个名称(name),和对应的值(value),可以看作是一种键值对。字段的值可以是普通文本,比如一个网页的全部正文部分,在构造一个Field的时候,这样的值由java.lang.String或者java.io.Reader封装,这样的字段在建立索引时,会经历上文中第二个步骤——文本分析;字段值也可以是原子化的关键字,这样的值,在建立索引的时候,不会被分析,这样的值往往是用来标识一个文件的日期,url等信息。字段是否需要存储到索引中,是可选的,存储的字段在搜索时也会包含于搜索结果中。

根据一个字段是否需要分析,是否需要索引,是否需要存储,字段应该一共分成八种。但是有些种类是没有意义的,比如需要分析,但是不需要索引的字段,实际上是不存在的,所以,实际常用的字段只有四种类型。

Keyword——这类字段不会被分析,但是会被索引并且会被保存。这类字段的完整性需要收到保护,比如网址,电话号码,日期,人名等等。

UnIndexed——这类字段不会被分析,也不会被索引,但是会被保存。这种字段一般用来存储你需要显示在搜索结果中的内容,但是你往往不需要直接搜索它们的值。我个人对这个种类是存在疑问的,我基本上想不出来有哪些信息满足这种特征的。搜索引擎发展到今天,我们可能在Google搜索框中敲入任何东西,曾经看到一篇文章,里面有句话,“……你可能无法想象,有多么高的比例的人,在使用Google的时候,在搜索框中直接打入一个完整的网址……”,确实是这样,像我爸这类用户,把Google设成首页,完全无视浏览器的地址栏,跑题了。如果说非有这么一种字段,我想,可能是标识了这个搜索结果的属性的信息。比如,你搜索“Charles”,返回结果里有一些信息,说“性别:男”,“物种:人类”,“居住地:地球”,这种信息应该满足条件了,但是不是现实生活中的例子,呵呵,搜索引擎要真那么恐怖,某些人就惨了。

UnStored——这类字段与Unindex正好完全相反。会被分析,并且索引,但是不会被保存。比如,一本书的全部内容。一个网页的全部正文。

Text——这类字段会被分析,索引,如果是纯文本,也会被存储。符合这个种类的例子,我能想到的例子,比如一篇论文的摘要。这样的文本段落不但有高密度的关键字,而且几乎可以完整的概述一个文件的内容。

上述的四个种类,在早期的Lucene版本中,本身就有对应的类,构造字段的时候直接使用上面的名字构造相应的对象即可。但是在最新版本的Lucene中,使用了一种更为统一的形式,也即只有Field一个类,然后使用一些参数来描述这个字段的属性,通过参数组合,可以组合出各种类别,甚至那四种不存在的类别理论上也是可以组合出来。

现在的Field构造函数原型是如下样子的:

1
public Field(String name, String value, Store store, Index index) 

我想,有了上面的分析,这个构造函数应该是相当容易理解了。一开始就是因为理解得不透彻,导致我自己犯了很低级的错误。然后,根据新的构造函数的原型,来总结一下上面的内容。

col:Store, row:Index

YES

NO

COMPRESS

NO

存储,但是不建立索引,当然也就不分析。这样的字段无法搜索,但是会出现在搜索结果中。

无意义。引发Illegal Argument Exception。

基本等同于YES,但是外加了压缩。对于较长的文本和二进制的字段,应该选用这个参数。计算量更大。

ANALYZED

分析,索引,存储。

分析,索引,不存储。

分析,索引,压缩存储。

NOT_ANALYZED

不分析,但是索引,存储。

不分析,直接索引,但是不存储。

不分析,但是索引,压缩存储。

NOT_ANALYZED_NO_NORMS

(高级)

(高级)

(高级)

ANALYZED_NO_NORMS

(高级)

(高级)

(高级)

呵呵,事实上,Lucene内部的字段种类,一共有十四种之多,像上表描述的一样。从这张表中,能发现,一个实际的,有生产能力的系统,远比理论模型要复杂,细致,考虑的细节更多。

实际上,构造的Field的时候,还有一个可选参数。就是TermVector。所谓的TermVector,字面理解就是关键字向量。通俗点说,就是实际上该字段中,被索引的词语的一个列表,还有跟每个词关联的信息,比如位置(Position,第几个词)或者偏移量(Offset,第几个字节,辅助高亮显示)。这也就暗示着一件事情,如果一个字段没有被索引,那么也不能指定其带有TermVector参数。TermVector有五种取值:

NO——默认值。不保留信息。

YES——保留词及该词出现的次数。

WITH_POSITIONS

WITH_OFFSETS

WITH_POSITIONS_OFFSETS

可以为每个Field指定TermVector选项,但是最后每个Document只维护一个Term Vector。

如果你是一个插件开发者,那么应该使用这款插件,在你发布插件的页面上放上一个Paypal Donate按钮,这样如果用户心情好了,兴许就会给你捐一点。

今天终于看到了这么款插件,但是,我这篇文章其实是想说,这个插件,我对它很不满意,对不起它灌了一个Simple的名字,插入这个按钮好麻烦啊,还要用php函数去写。

我觉得,这款插件可以有个很体贴的改进,就是增加在对shortcode的支持。这样,我不但可以在页面中插入捐赠按钮,也可以在任何一篇文章中插入捐赠按钮,你们说呢?

插件页面在这里

Options Inspector is a tool with which you can list all the options in your database, view a certain one in detail, even its data is serialized, and alter exactly a certain part of option value. It is mainly designed for plugin developers and theme designers.

阅读全文 »