标题:【迅搜17】SCWS分词(二)自定义字典及分词器
SCWS分词(二)自定义字典及分词器
经过上篇文章的学习,相信大家对分词的概念已经有了更深入的了解了吧。我们也知道了,SCWS 是 XS 中的一个重要组成部分,但它也是可以单独拿出来使用的。而对于分词器来说,不管是 SCWS 还是现在流行的 IK、Jieba ,其实概念方面都是差不多的。比如说它们都需要字典来做为分词的依据,也会有停用词库这一类的附加字典。今天,我们主要来学习的就是 SCWS 字典相关的一些配置。此外,还有自定义分词器的实现。
自定义字典
上回已经说过,SCWS 有提供一个非常小的,但词汇量非常大的字典。不过现在的网络社会,各种新鲜词汇层出不穷,总会有超出默认字典的新词出现。同时,还有一种情况就是一些专业领域的专业词汇,比如医学或者工程上面的,也不会在通用的字典库中。像这类的词项,我们就可以通过自定义字典库来添加。
XS 的字典库分为两种,一种是全局的,一种是针对某个项目的。对于全局字典库,我们可以在 XS 的安装目录中的 etc 目录下找到。
# /usr/local/xunsearch/etc
[root@localhost etc]# ll
total 14832
-rw-r--r--. 1 501 games 14315504 May 23 2022 dict.utf8.xdb
-rw-r--r--. 1 root root 447 May 23 2022 dict_user.txt
-rw-r--r--. 1 root root 843730 Nov 11 20:55 py.xdb
-rw-r--r--. 1 root root 3729 May 23 2022 rules.ini
-rw-r--r--. 1 root root 4424 May 23 2022 rules.utf8.ini
-rw-r--r--. 1 root root 4383 May 23 2022 rules_cht.utf8.ini
-rw-r--r--. 1 root root 272 May 23 2022 stopwords.txt
默认情况下,dict.uft8.xdb 就是 XS 自带的那个字典库。而 dict_user.txt 则是我们可以自己定义的字典库。py.xdb 是拼音库、rules开头的文件名全都是规则文件,也就是我们各种词要如何组合,词性变化与评分配置等。最后还有一个 stopwords.txt 停用词库。好了,咱们先用之前的数据来测试吧,就是最早那篇文章里的数据。
> php vendor/hightman/xunsearch/util/Indexer.php --source=csv --clean ./config/demo3_56.101.ini
1,关于 xunsearch 的 DEMO 项目测试,项目测试是一个很有意思的行为!,1314336158
2,测试第二篇,这里是第二篇文章的内容,1314336160
3,项目测试第三篇,俗话说,无三不成礼,所以就有了第三篇,1314336168
> php ./vendor/hightman/xunsearch/util/Quest.php ./config/demo3_56.101.ini ''
在 3 条数据中,大约有 3 条包含 ,第 1-3 条,用时:0.0014 秒。
1. 关于 xunsearch 的 DEMO 项目测试 #1# [100%,0.00]
项目测试是一个很有意思的行为!
Chrono:1314336158 Author:
2. 测试第二篇 #2# [100%,0.00]
这里是第二篇文章的内容
Chrono:1314336160 Author:
3. 项目测试第三篇 #3# [100%,0.00]
俗话说,无三不成礼,所以就有了第三篇
Chrono:1314336168 Author:
我们先来测试一个不正常的词,比如说“无三”。很明显,它不是一个传统意义上的正常的单词,但就像很多专业词汇一样,我们假设“无三”就是一个专业词汇。目前直接搜索“无三”肯定是没有数据的。
> php ./vendor/hightman/xunsearch/util/Quest.php ./config/demo3_56.101.ini --show-query '无三'
--------------------
解析后的 QUERY 语句:Query(无三@1)
--------------------
在 3 条数据中,大约有 0 条包含 无三 ,第 0-0 条,用时:0.0009 秒。
好了,那么咱们就打开 dict_user.txt 进行配置。
// vim /usr/local/xunsearch/etc/dict_user.txt
# Custom dictionary for scws (UTF-8 encoding)
# 每行一条记录,以 # 开头的号表示注释忽略
# 每行最多包含 4 个字段,依次代表 "词条" "TF" "IDF" "词性"
# 字段之间用空格或制表符分开,特殊词性 "!" 用于表示删除该词
# 参见 scws 自定义词典帮助:
# http://bbs.xunsearch.com/showthread.php?tid=1303
# $Id$
#
# WORD TF IDF ATTR
# ------------------------------------------------------
无三
可以看到文件上方的注释已经很清晰了,四个字段,分别是词条、TF词频、IDF逆文档频率和词性,用逗号分隔,并按行划分。后面三个字段属性其实是可以不用写的,它会有默认值。
我们直接添加一个“无三”,后面的不用填,然后重新索引添加数据。再次查询,就可以看到“无三”可以被搜索到了。
> php ./vendor/hightman/xunsearch/util/Quest.php ./config/demo3_56.101.ini --show-query '无三'
--------------------
解析后的 QUERY 语句:Query(无三@1)
--------------------
在 3 条数据中,大约有 1 条包含 无三 ,第 1-1 条,用时:0.0020 秒。
1. 项目测试第三篇 #3# [100%,0.62]
俗话说,无三不成礼,所以就有了第三篇
Chrono:1314336168 Author:
后面的三个参数,我也不知道怎么填,查了一些资料也没讲得特别明白的。所以咱们保持默认就好,或者说你明确的知道这个新词的词性,那就把 TF 和 IDF 都设置成 1 ,然后指定词性就好了。大部分专业词汇其实都是名词居多的,直接设置一个 n 就行了。
项目字典
接下来我们来自定义一个项目字典。这种字典就是针对某一个具体项目的,比如说针对我们的 demo 项目,那么就直接找到安装目录的 data 目录,然后找到 demo 文件夹,在这个文件夹中创建一个 dict_user.txt 文件。关于项目数据目录的问题,我们之前在学习索引管理时就说过,后面学习 Xapian 的时候也会再说一下。
好了,直接在项目目录下面的 dict_user.txt 文件中添加新词吧,词条规则和全局文件是一样的。
// vim /usr/local/xunsearch/data/demo/dict_user.txt
无三不
对于项目字典,我们在在代码中也可以直接操作,比如使用 XSIndex 对象的 getCustomDict() 方法就可以获取到自定义字典的内容,使用 setCustomDict() 就可以设置当前项目的自定义字典信息。
print_r($xs->index->getCustomDict()); // 无三不
$dict = <<<EOF
无三不
无三不成 0 0 n
EOF;
print_r($xs->index->setCustomDict($dict));
print_r($xs->index->getCustomDict());
// 无三不
// 无三不成 0 0 n
在上面的代码中,我们通过 PHP 来设置了自定义字典,大家可以再回到项目目录下,看看 dict_user.txt 文件是不是也已经被重写成了新的内容,多了“无三不成”这样一个单词。这种功能有个什么好处呢?那就是我们的字典也可以通过在 MySQL 或其它数据库中进行存储,然后直接在 PHP 代码中操作字典,是不是非常方便。
当然,txt 格式的明文字典效率其实不高的,而且如果单词数量比较多,占用的空间也会比较大。SCWS 在命令行还提供了一个 scws-gen-dict 工具。和上篇文章中我们命令行操作 scws 的工具是放在一起的。这个工具可以将 txt 文件转换为 xdb 文件,也就是 SCWS 的默认词典文件一样的压缩格式。如果确实有非常大量的专业词汇,建议还是转换一下哦。这里我就不演示了,SCWS 还是比较智能的,普通的 txt 文件其实大部分情况下还是能满足需求的。
接下来咱们测试一下。
php ./vendor/hightman/xunsearch/util/Quest.php ./config/demo3_56.101.ini --show-query '无三不成'
--------------------
解析后的 QUERY 语句:Query((无三不成@1 SYNONYM (无三不@78 AND 不成@79)))
--------------------
在 1 条数据中,大约有 1 条包含 无三不成 ,第 1-1 条,用时:0.0018 秒。
1. 项目测试第三篇 #3# [100%,0.15]
俗话说,无三不成礼,所以就有了第三篇
Chrono:1314336168 Author:
“无三不成”这个新词没问题。那么“无三不”呢?
> php ./vendor/hightman/xunsearch/util/Quest.php ./config/demo3_56.101.ini --show-query ' 无三不'
--------------------
解析后的 QUERY 语句:Query((无三不@1 SYNONYM (无三@78 AND 三不@79)))
--------------------
在 1 条数据中,大约有 0 条包含 无三不 ,第 0-0 条,用时:0.0008 秒。
嗯?咋查不到数据了?明明添加到字典里了啊!使用 SDK 的分词工具来看也是正常分词的,分词结果中有“无三不成”、“无三不”这两个单词。
$tokenizer = new XSTokenizerScws; // 直接创建实例
$words = $tokenizer->getResult("俗话说,无三不成礼,所以就有了第三篇");
foreach($words as $w){
echo $w['word']."/".$w['attr']."/".$w['off']," ";
}
// 话说/n/0 俗话/n/0 话说/n/3 ,/un/9 无三不成/n/12 无三不/@/12 不成/d/18 礼/n/24 ,/un/27 所以/c/30 就/d/36 就有/v/36 有了/v/39 了第/m/42 第三/m/45 三篇/q/48
好吧,自己玩出来的火,就得自己灭。原理我也没搞明白,但是从上面 getQuery() 的分析中,我们可以通过两种方式来搜索到。
使用 fuzzy 模糊查询
在 getQuery() 返回的数据中,我们看到了“无三不”以及它的同义二元词“无三”和“三不”。在同义词中,是 AND 连接,也就是必须要同时出现“无三”和“三不”(其实应该也不需要这样,比如搜索“俗话说”,是AND同义词,但是可以搜索到)。但咱们换成 setFuzzy() 效果,全部变成 OR ,就可以通过全局字典中的“无三”查到了。
> php ./vendor/hightman/xunsearch/util/Quest.php ./config/demo3_56.101.ini --show-query '无三不' --fuzzy
--------------------
解析后的 QUERY 语句:Query((无三不@1 SYNONYM (无三@78 OR 三不@79)))
--------------------
在 1 条数据中,大约有 1 条包含 无三不 ,第 1-1 条,用时:0.0018 秒。
1. 项目测试第三篇 #3# [100%,0.15]
俗话说,无三不成礼,所以就有了第三篇
Chrono:1314336168 Author:
好吧,承认我是猜的,上面的分析不是权威分析哦。希望有懂得大佬能在评论区指点一下。
删除全局字典中的那个“无三”,再重新索引数据。
很奇怪,我们直接删全局字典中的那个“无三”,重新索引添加数据之后,使用“无三不”就可以搜索到数据了。这个真的不知道原因,也不瞎猜了,反正就告诉大家,这样可以搜到。ES 类似的能力我没有测,将来或者说小伙伴有兴趣的可以使用 ES 的 IK 分词器测一下,并在评论区说下啥效果哦,就当是一个小作业啦!
停用词库
XS 的停用词库这一块,即使在官方文档上也没有详细的说明,全网也找不到什么有用的资料,真的独一份哦。
停用词的意思就是这个词不用了,不参与分词。或者说分词器如果看到这个词了,直接略过不管它。如果你学过 ES 中的 IK ,一定会知道这个东西。而且 XS 中也是使用 stopwords 这个文件名来定义停用词库的。
前面在 XS 安装目录的 etc 中目录中,大家就已经看到有一个 stopwords.txt 文件了吧,直接打开它,里面已经有一堆默认内容了,我们再添加一个。
# vim etc/stopwords.txt
………………
俗话说
添加完之后,来测试一下吧。
> php ./vendor/hightman/xunsearch/util/Quest.php ./config/demo3_56.101.ini --show-query '俗话说'
--------------------
解析后的 QUERY 语句:Query((俗话说@1 SYNONYM (俗话@78 AND 话说@79)))
--------------------
在 1 条数据中,大约有 1 条包含 俗话说 ,第 1-1 条,用时:0.0020 秒。
1. where is which who #3# [100%,0.23]
俗话说,无三不成礼,所以就有了第三篇
Chrono: Author:
还是能查到?跟你说,不管你是重建索引,还是重启服务,都没用,这个停用词库感觉就跟没配一样,完全不起作用。这是为啥呢?因为默认情况下,XS 就根本没启用停用词库的功能。所以在官方文档上,你会看到有同学报怨说这个功能没用。但其实,咱们只要开启加载这个功能及停用词库就好啦。
查看我们启动服务器的 xs-ctl.sh 脚本。就是安装目录下面的 bin 目录中的那个脚本文件,我的虚拟机上是位于 /usr/local/xunsearch/bin/xs-ctl.sh 。给最后启动服务的两行代码中,加上 -s etc/stopwords.txt
就可以了。
# vim bin/xs-ctl.sh
………………
# run
case "$cmd" in
start|stop|faststop|fastrestart|restart|reload)
# index
if test "$server" != "search"; then
# bin/xs-indexd $opt_index $opt_pub -k $cmd # 这里是之前的
bin/xs-indexd $opt_index $opt_pub -s etc/stopwords.txt -k $cmd
fi
# search
if test "$server" != "index"; then
# bin/xs-searchd $opt_search $opt_pub -k $cmd # 这里是之前的
bin/xs-searchd $opt_search $opt_pub -s etc/stopwords.txt -k $cmd
fi
;;
*)
echo "Unknown command: $cmd"
show_usage
;;
esac
这个参数就表明加载指定的停用词库,并启用停用词功能。然后再来测试。
> php ./vendor/hightman/xunsearch/util/Quest.php ./config/demo3_56.101.ini --show-query '俗话说'
--------------------
解析后的 QUERY 语句:Query(<alldocuments>)
--------------------
在 3 条数据中,大约有 3 条包含 俗话说 ,第 1-3 条,用时:0.0015 秒。
1. 关于 xunsearch 的 DEMO 项目测试 #1# [100%,0.00]
项目测试是一个很有意思的行为!
Chrono:1314336158 Author:
2. 测试第二篇 #2# [100%,0.00]
这里是第二篇文章的内容
Chrono:1314336160 Author:
3. 项目测试第三篇 #3# [100%,0.00]
俗话说,无三不成礼,所以就有了第三篇
Chrono:1314336168 Author:
注意看,我们搜索“俗话说”,getQuery() 返回的结果是“alldocument”,就和空查询条件一样。下面的搜索结果也是全部结果都出来了。也就是说,“俗话说”这个停用词已经生效了,SCWS 看到它根本就不管,直接略过。由于没有别的词了,我们的查询结果就和完全没有搜索条件一样,直接返回全部数据了。
我是怎么找到这个功能的?额,在 Github 的 XS 源码中搜 stopword ,结果就找到在 import.cc 、indexed.c 和 searchd.c 中有相关的内容。再仔细看下源码,它们在 -s
这个参数的注释中都写明了要通过这个参数加载停用词库。接着就来看 xs-ctl.sh 脚本,发现在启动索引和搜索服务时,压根就没传这两个参数。剩下的,就不用我多说了吧。
自定义分词器
接下来,我们来试试自定义分词器的功能。这一块是针对 SDK 来说的。在索引配置文件中,我们之前说过有默认的 scws、full、split、none、xlen、xstep 这几种分词类型。其实它们的源码都在 vendor/hightman/xunsearch/lib/XSTokenizer.class.php 这个文件里面。除了 scws 是请求服务端进行分词的,其它几种其实都是 PHP 代码的算法实现,比如说 split ,最终就是一个 explode() 。具体源码大家可以自己去看一下。
这些分词器,其实都是实现了一个 XSTokenizer 接口。而这个接口中,只有一个方法,那就是 getTokens() 方法。那么,如果要实现我们自己自定义的分词器,其实只要和那些自带的分词器一样,实现这个接口方法就可以了嘛。
我们在 vendor/hightman/xunsearch/lib 目录下新建一个 XSTokenizerJieba.class.php 文件,我想使用 Jieba 分词来实现一个分词器。Jiaba-PHP(结巴分词PHP版) https://github.com/fukuball/jieba-php 的文档和说明大家可以在这个 Github 链接上看一下。
# vendor/hightman/xunsearch/lib/XSTokenizerJieba.class.php
require_once XS_LIB_ROOT . '/../../../autoload.php';
class XSTokenizerJieba implements XSTokenizer
{
public function __construct($arg = null)
{
}
public function getTokens($value, XSDocument $doc = null)
{
// composer require fukuball/jieba-php:dev-master
ini_set("memory_limit", "-1");
\Fukuball\Jieba\Jieba::init();
\Fukuball\Jieba\Finalseg::init();
return \Fukuball\Jieba\Jieba::cut($value);;
}
}
为什么是在 vendor/hightman/xunsearch/lib 目录呢?能不能像 ini 文件一样放到一个我们指定的目录中呢?抱歉,我看了源码,不行。
// vendor/hightman/xunsearch/lib/XSFieldScheme.class.php
public function getCustomTokenizer()
{
if (isset(self::$_tokenizers[$this->tokenizer])) {
return self::$_tokenizers[$this->tokenizer];
} else {
if (($pos1 = strpos($this->tokenizer, '(')) !== false
&& ($pos2 = strrpos($this->tokenizer, ')', $pos1 + 1))) {
$name = 'XSTokenizer' . ucfirst(trim(substr($this->tokenizer, 0, $pos1)));
$arg = substr($this->tokenizer, $pos1 + 1, $pos2 - $pos1 - 1);
} else {
$name = 'XSTokenizer' . ucfirst($this->tokenizer);
$arg = null;
}
if (!class_exists($name)) {
$file = $name . '.class.php';
if (file_exists($file)) {
require_once $file;
} else if (file_exists(XS_LIB_ROOT . DIRECTORY_SEPARATOR . $file)) {
require_once XS_LIB_ROOT . DIRECTORY_SEPARATOR . $file;
}
if (!class_exists($name)) {
throw new XSException('Undefined custom tokenizer `' . $this->tokenizer . '\' for field `' . $this->name . '\'');
}
}
$obj = $arg === null ? new $name : new $name($arg);
if (!$obj instanceof XSTokenizer) {
throw new XSException($name . ' for field `' . $this->name . '\' dose not implement the interface: XSTokenizer');
}
self::$_tokenizers[$this->tokenizer] = $obj;
return $obj;
}
}
为指定字段加载分词器的源码就是上面这段,你可以看到,它固定了分词器的名称。然后在下面 require 时,判断了名称如果在当前目录存在,就加载,如果不存在,则接上 XS_LIB_ROOT 目录去加载。可问题是,$name
在前面是通过前缀的方式来固定的,这就相当于给了我们两个选择。
一是自定义分词器要放在运行当前代码的目录路径下,也就是直接 require_once "XSTokenizerJiaba.php"
这一行的效果。比如我们在 source 目录下运行 php 代码,则这个自定义文件就要放在 source 目录下。
二是放在 vendor/hightman/xunsearch/lib 目录下,也就是第二个拼接上 XL_LIB_ROOT 的效果,require_once XS_LIB_ROOT."XSTokenizerJiaba.php"
。
反正怎么都不是太爽。如果确实需要用到,还是使用第一种吧,毕竟 composer 里面的东西,能不动不就要动了。
好了,不多吐槽了,这一块说不定将来也会有变动呢。我们还是先来测功能。将配置文件中的相关字段的分词器换成我们自定义的。
# config/demo3_56.101.ini
……………………
[subject]
type = title
#tokenizer=scws(15)
tokenizer=jieba
[message]
type = body
#tokenizer=scws(15)
tokenizer=jieba
……………………
重新索引数据后,进行测试。
> php ./vendor/hightman/xunsearch/util/Quest.php ./config/demo3_56.101.ini --show-query '俗话说'
--------------------
解析后的 QUERY 语句:Query((俗话说@1 SYNONYM (俗话@78 AND 话说@79)))
--------------------
在 2 条数据中,大约有 1 条包含 俗话说 ,第 1-1 条,用时:0.0027 秒。
1. 项目测试第三篇 #3# [100%,0.39]
俗话说,无三不成礼,所以就有了第三篇
Chrono:1314336168 Author:
“俗话说”要是查不到,可以取消前端的停用词功能哦。我们可以直接使用分词器的 getTokens() 来进行直接的分词测试,并且看看当前默认的分词器用得是哪个。
var_dump($xs->getFieldTitle()->getCustomTokenizer());
// object(XSTokenizerJieba)#9 (0) {}
var_dump((new XSTokenizerJieba())->getTokens('俗话说,无三不成礼,所以就有了第三篇'));
// array(11) {
// [0]=>
// string(9) "俗话说"
// [1]=>
// string(3) ","
// [2]=>
// string(6) "无三"
// [3]=>
// string(6) "不成"
// [4]=>
// string(3) "礼"
// [5]=>
// string(3) ","
// [6]=>
// string(6) "所以"
// [7]=>
// string(3) "就"
// [8]=>
// string(3) "有"
// [9]=>
// string(3) "了"
// [10]=>
// string(9) "第三篇"
// }
var_dump((new XSTokenizerScws)->getResult('俗话说,无三不成礼,所以就有了第三篇'));
// array(16) {
// [0]=>
// array(3) {
// ["off"]=>
// int(0)
// ["attr"]=>
// string(4) "n"
// ["word"]=>
// string(9) "俗话说"
// }
// [1]=>
// array(3) {
// ["off"]=>
// int(0)
// ["attr"]=>
// string(4) "n"
// ["word"]=>
// string(6) "俗话"
// }
// [2]=>
// array(3) {
// ["off"]=>
// int(3)
// ["attr"]=>
// string(4) "n"
// ["word"]=>
// string(6) "话说"
// }
// ………………………………
// ………………………………
看到两个分词器的效果不同了吧。当然,Jiaba 我们没有做任何配置,而 SCWS 默认是 3 也就是最短词和二元一起进行分词的,所以“话说”也会被分出来。
解决单字 Like 问题
大家还记得吧?早前的文章中,我们就说过一个问题,那就是搜索“项”这种默认不会分词的单字,也就是不会建立倒排索引的单字,是无法查询出数据的。那么在学习了分词的原理之后,特别是字典以及上期讲的复合分词等级的内容之后,大家是不是有想法,也能猜到如何来解决这个问题了吧。使用字典,可能比较麻烦,你需要将很多单字加到字典,而且我测试有的情况下还没效果。那么使用复合分词等级呢?咱们就直接设置分词复合等级为 15 。也就是配置文件中的 title 和 body 字段,都将它们的 tokenizer
设置成 scws(15)
。
接下来重新索引添加数据,然后查询试试。
> php ./vendor/hightman/xunsearch/util/Quest.php ./config/demo3_56.101.ini --show-query '项'
--------------------
解析后的 QUERY 语句:Query(项@1)
--------------------
在 2 条数据中,大约有 2 条包含 项 ,第 1-2 条,用时:0.0012 秒。
1. 关于 xunsearch 的 DEMO 项目测试 #1# [100%,0.16]
项目测试是一个很有意思的行为!
Chrono:1314336158 Author:
2. 项目测试第三篇 #3# [99%,0.16]
俗话说,无三不成礼,所以就有了第三篇
Chrono:1314336168 Author:
可以通过单个“项”字查询到数据了吧。复合分词等级的知识上次已经说过,设置成 15 其实就是各种词项、二元、重要单字、全部单字都进行拆分,拆分出来的内容非常多。其实这么做的结果大家也能想到,就是它会带来一个非常重大的问题:倒排索引库会变得非常大,而且如果 body 字段是那种文献类型的超长文章或超大文章,索引缓冲也会出问题导致索引失败。即使是在 ES 中,也没法这么玩的。
又回到当初的那个问题了。搜索引擎+分词器(倒排索引),不是 Like !!
总结
自定义字典有点意思吧?更重要的是,咱们还有全网唯一一份的 XS 中停用词的使用方法哦。这不来个赞真的对不起这篇文章啊。另外,通过自定义分词器,咱们也能看出来,XS 完全也是可以使用别的分词工具的嘛。不管是你是想用第三方的 Jieba 分词还是别的什么非常特殊的行业切分格式,都可以通过代码自己来实现。最后,关于分词的内容,在 ES 中现在最流行的是 IK 分词器,小伙伴们在学习 ES 的时候,如果学到了 IK 的部分,其实有这两篇 SCWS 的基础垫底,掌握起来还是会比较轻松的。毕竟什么词性、分词级别、字典,甚至字典名称有很多都是相同的。
分词部分的学习结束了,我们的搜索引擎整体课程也就接近尾声了。后面的两篇文章是扩展部分,也是很有意思的哦,我们会看一下 Xapian 官方文档中我发现的一些有用的内容。最后还会介绍一套非常有意思的完全 PHP 实现的搜索引擎方案哦。
测试代码:
https://github.com/zhangyue0503/dev-blog/blob/master/xunsearch/source/17.php
参考文档:
http://www.xunsearch.com/doc/php/guide/index.dict
http://www.xunsearch.com/doc/php/guide/ini.tokenizer