因为网站搜索的需要,启动了一个搜索引擎项目,其实也算不上完整的搜索引擎,需求很简单,如下:
1)搜索产品名、类别名、品牌名、副标题、关键字等字段
2)数据量目前为13000左右,未来可能在5万左右,超出10万的可能性不大
3)搜索必须精确
4)搜索结果需要按照一定的规则排序
5)搜索结果可以按条件过滤
可选的产品主要有3种,sphinx、solr、ElasticSearch,其中sphinx是基于C++的,体积小,运行速度快,分布式查询较困难,查询接口支持多种语言。solr和ElasticSearch基于Lucene,开发语言是Java,提供http访问支持。简单分析一下区别:
1)sphinx建立索引较快,一般单机使用
2)solr和es都是基于lucene的,有一些细微的区别,其中solr更正宗,因为solr和lucene现在已经合并了,但是es的查询效果更快、更稳定,尤其是solr的集群需要zookeeper,而es不需要,它本身设计就考虑了分布式。所以solr和es更适用于大项目,数据量较大的情况。比如较流行的elk日志分析系统,基于ElasticSearch、Logstash、Kiabana,理论上可以支持上百万台机器的日志分析、PB级别的数据分析。
相对来说,我们的项目比较小,只需要用sphinx就可以了,另外考虑到项目时间较紧,就没有过多的测试es和solr。
在这里,要区分两个概念:查询分词和索引分词
1)查询分词指的是将输入查询的词进行分词,如输入“我是中国人”会分成“我”“是”“中国人”“中国” “人”
2)索引分词指的是在原始数据进行索引时对原始数据进行分词,即如原始数据是“我是中国人”,会和上面一样的进行分词,并存储为索引。
3)sphinx的原始版本只能对中文进行单字切割,所以需要在查询时进行分词,然后全词匹配,否则会出现很多莫名其妙的结果,好在这种情况下速度也挺快的。如果数据量非常大,单字切割速度就会变慢,更好的办法是索引分词,有一个corseek支持中文分词,只可惜好久不更新了
因为原始的sphinx版本只支持单字分词,所以需要使用查询分词,选择的是scws分词,配置起来较为容易。步骤如下:
1、环境
1)CentOS : 6.5和7.0均可
2)编译环境: yum install gcc gcc-c++
2、sphinx
1)sphinx主页在http://sphinxsearch.com/,文档在http://sphinxsearch.com/docs/current.html,比较详细
2)编译(在测试服务器172.16.8.97上的步骤)
$ tar xvf sphinx-2.2.10-release.tar.gz
$ cd sphinx-2.2.10
$ ./configure
$ make
$ make install
这种安装的配置文件在/usr/local/etc下,默认有三个文件example.sql,是用来创建test索引的数据库,sphinx.conf.dist是比较详细的配置文件,sphinx-min.conf.dist是最小的配置文件,可以将sphinx-min.conf.dist改名或复制为sphinx.conf,这是默认的配置文件。
sphinx有两个命令比较重要,indexer和searchd,前者是用来建索引的,后者是查询的守护进程,提供查询服务。
3)配置sphinx.conf
sphinx.conf也很容易懂,大致分为source, index, indexer, searchd这几部分,source代表数据来源,支持mysql、pgsql、mssql、odbc、xmlpipe、xmlpipe2,我们用的是mysql,基本配置如下
#source配置
source goods
{
type = mysql
sql_host = localhost
sql_user = root
sql_pass =
sql_db = xigang
sql_port = 3306 # optional, default is 3306
sql_query_pre = SET NAMES utf8
sql_query =
SELECT a.goods_id,goods_sn,a.goods_name,a.goods_brief,b.cat_name,c.brand_name,a.keywords goods_keywords,a.specification, a.goods_spec,e.region_name goods_country,c.alias brand_alias,a.brand_country,c.country brand_country1, b.alias category_alias, a.last_update,b.cat_id, c.brand_id,b.keywords category_keywords,to_pinyin(a.goods_name) goods_name_pinyin,to_fpinyin(a.goods_name) goods_name_fpinyin,market_price,shop_price,origin_price,promote_price, IF(promote_price>0,1,0) promote_flag, goods_number, IF(goods_number>0,1,0) goods_num_flag,sales_volume,if(sales_volume>0,1,0) volume_flag,is_new,a.is_delete goods_delete,a.sort_order,a.is_delete goods_delete,b.is_delete cat_delete,is_on_sale FROM ecs_goods a LEFT JOIN ecs_category b ON a.cat_id=b.cat_id LEFT JOIN ecs_brand c ON a.brand_id=c.brand_id LEFT JOIN ecs_region e ON a.goods_country=e.region_id
sql_attr_uint = cat_id
sql_attr_uint = brand_id
sql_attr_float = market_price
sql_attr_float = origin_price
sql_attr_float = promote_price
sql_attr_uint = promote_flag
sql_attr_uint = sales_volume
sql_attr_uint = goods_number
sql_attr_uint = goods_num_flag
sql_attr_uint = is_new
sql_attr_uint = goods_delete
sql_attr_float = sort_order
sql_attr_uint = volume_flag
sql_attr_uint = is_on_sale
sql_attr_uint = cat_delete
sql_attr_timestamp = last_update
sql_field_string = goods_name
sql_field_string = goods_sn
sql_field_string = shop_price
sql_field_string = goods_brief
sql_field_string = cat_name
sql_field_string = brand_name
sql_ranged_throttle = 0
}
配置简非常易懂,需要注意以下内容
1)sql_query_pre = SET NAMES utf8 是必须的,否则索引建立了,却搜索不出来,这在拷贝sphinx-min.conf.dist作为默认配置文件要特别注意,因为该文件中没有这一条,在sphinx.conf.dist中存在,如果有注释,去掉就可以了。
2)sql_attr_*,这些字段都包含在搜索结果中,可以用来过滤、排序、分组等,需要注意的是sql_attr_string字段,这个字段也可以达到过滤、排序和分组的效果,但是这个字段不会为全文索引,所以需要用sql_field_string字段代替,它兼有过滤、分组、排序,还有索引的功能。sql_fied_string可以用在列表显示的时候,这样可以减少对mysql的查询,直接显示所有数据
3)其他问题看手册为准
#index配置
index goods
{
source = goods
path = /var/data/sphinx/goods
docinfo = extern
dict = keywords
mlock = 0
min_stemming_len = 1
min_word_len = 1
min_infix_len = 2
ngram_len = 1
ngram_chars = U+4E00..U+9FBB, U+3400..U+4DB5, U+20000..U+2A6D6, U+FA0E, U+FA0F, U+FA11, U+FA13, U+FA14, U+FA1F, U+FA21, U+FA23, U+FA24, U+FA27, U+FA28, U+FA29, U+3105..U+312C, U+31A0..U+31B7, U+3041, U+3043, U+3045, U+3047, U+3049, U+304B, U+304D, U+304F, U+3051, U+3053, U+3055, U+3057, U+3059, U+305B, U+305D, U+305F, U+3061, U+3063, U+3066, U+3068, U+306A..U+306F, U+3072, U+3075, U+3078, U+307B, U+307E..U+3083, U+3085, U+3087, U+3089..U+308E, U+3090..U+3093, U+30A1, U+30A3, U+30A5, U+30A7, U+30A9, U+30AD, U+30AF, U+30B3, U+30B5, U+30BB, U+30BD, U+30BF, U+30C1, U+30C3, U+30C4, U+30C6, U+30CA, U+30CB, U+30CD, U+30CE, U+30DE, U+30DF, U+30E1, U+30E2, U+30E3, U+30E5, U+30E7, U+30EE, U+30F0..U+30F3, U+30F5, U+30F6, U+31F0, U+31F1, U+31F2, U+31F3, U+31F4, U+31F5, U+31F6, U+31F7, U+31F8, U+31F9, U+31FA, U+31FB, U+31FC, U+31FD, U+31FE, U+31FF, U+AC00..U+D7A3, U+1100..U+1159, U+1161..U+11A2, U+11A8..U+11F9, U+A000..U+A48C, U+A492..U+A4C6
html_strip = 0
}
内容也很易懂,需要注意以下内容
1)source是上面建立的source名字,不能写错了
2)path是索引文件的位置,需要注意一下这个目录是否存在,是否有权限
3)min_infix_len,主要用于单词内部搜索,因为默认情况下,单词按照空格或符号分割,如果只搜索一部分,就搜索不出来,增加这个属性,就可以了,但是会增加索引的大小,因为产生了很多小词。
4)ngram_*是用于cjk字符的,即中文、日文和朝鲜文的分词,其中ngram_len说明分词宽度为1,ngram_chars指的那些词会被当作cjk,这是文档上的标准写法,拷贝就可以了。
5)其他问题,看文档。
indexer和searchd没什么可改的,对于我们的系统也够用了。
3、索引生成和查询
1)indexer --all --rotate
--all代表重新生成索引, --rotate用于searchd服务已经启动的情况下
2)searchd
启动搜索服务
4、使用sphinx
1)sphinx提供了两套机制来访问sphinx索引服务,一个是sphinxapi,一个是sphinxql,前者比较通用,资料较多,但性能差一些,使用的端口是9312,sphinxql实际上是一种类似sql的查询语言,协议用的是mysql客户端,速度要快一些。两者在功能上是等价的,区别在于sphinxql支持实时索引,使用的接口是9306。我比较喜欢sphinxql,因为可以直接打印出来,在mysql客户端工具里执行,看到效果。
2)匹配模式
SPH_MATCH_ALL 匹配所有查询词(sphinxapi默认值,但是效果不好,比如‘日本’,会搜索出‘今日买本子去了’)
SPH_MATCH_ANY 匹配任何词
SPH_MATCH_PHRASE 查询词整个匹配,返回最佳结果(适合精确搜索)
SPH_MATCH_BOOLEAN 将查询词当作布尔值搜索(不知道怎么用)
SPH_MATCH_EXTENED 查询词可以作为内部查询语言,即可以使用异或、正则之类的功能(默认值)。
SPH_MATCH_EXTENED2 和SPH_MATCH_EXTENED类似
4)SphinxQL
$mysql -h0 -P 9306 #连接
myql> select * from goods where match('奶粉'); #所有匹配的字段匹配"奶粉"这两个字
mysql>select * from goods where match('"奶粉"); #所有匹配的字段匹配“奶粉”这个词
mysql>select * from goods where match('@goods_name 奶粉'); #商品名中匹配“奶粉”这两个字
mysql>select * from goods where match('@goods_name "奶粉"'); #商品名中匹配“奶粉”这个词
mysql>select * from goods where match('@(goods_name,goods_brief) "喜宝" "配方奶粉"');
#这里是goods_name,goods_biref这两个字段包括"喜宝" "配方奶粉"这两个词,如果没有双引号,就是所有的字
mysql>select id,weight() from goods where match('@(goods_name,goods_brief) "喜宝" "配方奶粉"') order by weight() desc option ranker=proximity,field_weights=(goods_name=100,goods_brief=10);
#这里增加权重,goods_name=100,goods_brief=10,如果两者都匹配,weight()应该是类似110,不过由于算法问题,可能会是220,440之类的。可以通过权重知道匹配情况,及时处理一些不合适的搜索问题
mysql> select max(id),count(*),archival_sn, weight() wt from goods where is_on_sale=1 and goods_delete=0 and pr_area='01' group by archival_sn having count(*)>1 order by goods_num_flag desc ,promote_flag desc ,sort_order desc ,weight() desc ,id desc limit 0,20 option ranker=proximity ,field_weights=(goods_name=10000000,goods_sn=1000000,goods_keywords=100000,cat_name=10000,brand_name=1000,goods_brief=100,specification=10,goods_spec=1);
#这个比较复杂,含义是按照一定的条件筛选数据,同一备案号的商品选择id最大的,只显示一个,然后去数据库中抓取相应的数据,当然也可以在sphinx中抓取数据,简单字段都保存在sphinx中了。
#另外sql_attr_*和sql_field_string相当于索引goods的数据列,select cat_id,cat_name from goods
5、PHP访问sphinx
PHP访问sphinx有两种方法,SphinxAPI和SphinxQL,前者是调用sphinx提供的接口函数,这些函数保存在sphinx源代码包的api目录下,包括php、python、java、ruby的接口。php调用SphinxAPI文档很多,就不多说了。使用SphinxQL也非常简单,用mysql的操作函数就可以了,区别在于端口是9306,用户名密码都是空,如
$pdo = new PDO("mysql:host=localhost;port=9306;charset=utf-8','','');
$query1="select * from goods where match('奶粉'); ";
$sth1 = $pdo->prepare($query1);
$sth1->execute();
$result1 = $sth1->fetchAll();
6、使用scws分词
scws分词主页在http://www.xunsearch.com/scws/,这是一个开源分词程序,可以自定义分词,速度也比较快,也比较简单,所以就用这个了。
1)配置
$ tar xvf scws-1.2.2.tar.bz2
$ cd scws-1.2.2
$ ./configure --prefix=/usr/local/scws
$ make
$ make install
默认程序是安装到/usr/local/scws下,可以去这个目录看看是否安装成功。
2)分词词典
$ cd /usr/local/scws/etc
$ tar xvjf scws-dict-chs-gbk.tar.bz2
$ tar xvjf scws-dict-chs-utf8.tar.bz2
在/usr/local/scws/etc下会产生两个文件dict.xdb和dict.utf8.xdb,前者是gbk编码的,后者是utf8编码的字典
3)PHP扩展
scws是一个C语言程序,可以用C语言直接调用,不过它提供了php接口,安装也很简单,如下
$ cd phpext #scws源程序根目录
$ phpize #需要安装php开发包,yum install php-devel
$ ./configure
$ make
$ make install
$ vim /etc/php.ini #也有可能在php-fpm目录下,看你的服务器情况
增加如下内容
[scws]
extension = /usr/lib64/php/modules/scws.so
scws.default.charset = utf8
scws.default.fpath = /usr/local/scws/etc
编写简单的demo,如下
//scws0.php
<?phperror_reporting(0);$so = scws_new();$so->set_charset('utf8');$so->set_dict('/usr/local/scws/etc/dict.utf8.xdb');$so->set_rule('/usr/local/scws/etc/rules.utf8.ini');//$so->add_dict('/usr/local/scws/etc/new.txt',SCWS_XDICT_TXT);scws_set_multi($so,2);$so->set_ignore(true); //忽略标点符号//$text=$_GET["text"];$text='我是一个中国人,我爱我的祖国';$so->send_text($text);$scws='';while ($tmp = $so->get_result()) // get_result()要重复调用,直到所有的分词结果都返回为止{for($i=0;$i<count($tmp);$i++){$scws=$scws.' "'.$tmp[$i]["word"].'" ';}}$so->close();echo substr($scws,1);?>
$ php scws0.php
"我" "是" "一个" "中国人" "我爱" "我" "的" "祖国"
这里要说明一下,这个分词程序用起来是大同小异,可以在主页看详细文档,这里做了一个处理,将每个词增加了双引号,这样是为了在sphinxql中调用方便。
$so->add_dict可以增加其他词典,词典是xdb格式,也可以用文本,只不过文本要慢一些,初期可以用文本,等正式上线再生成xdb文件。下面以文本为例,内容如下
爱他美 100 100 nt施华蔻 100.01 100.01 nt瘦身 100.02 100.02 nt护眼 100.03 100.03 nt1段 100.04 100.04 nt2段 100.04 100.04 nt3段 100.05 100.05 nt4段 100.06 100.06 nt5段 100.07 100.07 nt结构很简单,第一列是分词,第二列是tf,第三列是idf,第四列是词性,用起来很容易,如果没有这个文件,将上面的程序中的$text值修改为“爱他美”,会输出“爱”“他”“美”,如果增加了这个分词文件,并将scws0.php中的注释去掉,执行结果就变成了“爱他美”。到此,分词问题就解决了,如果多个服务器使用也很简单,可以将这个程序对外发布。
7、做一个简单的demo
# Pf.php
<?phperror_reporting(0);class Pf {public static function splitSearch($search){$so = scws_new();$so->set_charset('utf8');$so->set_dict('/usr/local/scws/etc/dict.utf8.xdb');$so->set_rule('/usr/local/scws/etc/rules.utf8.ini');$so->add_dict('/usr/local/scws/etc/new.txt',SCWS_XDICT_TXT);scws_set_multi($so,2);$so->set_ignore(true);$text=$search;//$text='爱他美';$so->send_text($text);//scws_add_dict($so,'/usr/local/scws/etc/new.txt',SCWS_XDICT_TXT);$scws='';while ($tmp = $so->get_result()){for($i=0;$i<count($tmp);$i++){$scws=$scws.' "'.$tmp[$i]["word"].'" ';}}$so->close();return substr($scws,1);}}#test.php<?phprequire('./Pf.php');$query = "爱他美";$search =Pf::splitSearch($query);$host = "127.0.0.1";$port = 9306;$pdo = new PDO("mysql:host=".$host.';port='.$port.';charset=utf-8','','');$sql = "select id,goods_name from goods where match('".$search."') limit 1,2";$sth=$pdo->prepare($sql);$sth->execute();$result = $sth->fetchAll();echo "<pre>";print_r($result);echo "</pre>";输出结果为
Array ( [0] => Array ( [id] => 2927 [0] => 2927 [goods_name] => 德国原装 Aptamil爱他美 婴儿配方奶粉2段800g [1] => 德国原装 Aptamil爱他美 婴儿配方奶粉2段800g ) [1] => Array ( [id] => 3223 [0] => 3223 [goods_name] => 德国原装 Aptamil爱他美 婴儿配方奶粉2+段600g [1] => 德国原装 Aptamil爱他美 婴儿配方奶粉2+段600g ) )
8、词典生成导出工具
2)unzip phptool_for_scws_xdb.zip
3)解压缩有四个文件readme.txt xdb.class.php make_xdb_file.php dump_xdb_file.php,其中make_xdb_file.php是从文件生成xdb的,dump_xdb_file.php是生成文本文件的,执行过程如下
php make_xdb_file.php 字典文件 文本文件
php dump_xdb_file.php 字典文件 文本文件
dump比较快,make很慢,所以自定义分词不要放在标准库里,还是单独做文件吧,然后生成独立的字典
9、正式上线需要做的
1)要使用字典文件,并且加载到内存里,这样可以提高一下分词速度,如下
$ php make_xdb_file.php new.xdb new.txt
$ cp new.xdb /usr/local/scws/etc
$ vim Pf.php
$so->set_dict('/usr/local/scws/etc/dict.utf8.xdb',SCWS_XDICT_MEM);
$so->add_dict('/usr/local/scws/etc/new.xdb',SCWS_XDICT_MEM);
修改set_dict和add_dict函数的参数
2)如果要支持英文部分搜索,如搜索deb即可看到deben,使用*匹配,修改Pf.php,如下
$scws=$scws.' "'.$tmp[$i]["word"].'*" ';
这需要英文切词支持,indexer中需要有min_infix_len属性