通过MySQL内置全文检索实现中文的相关检索 关键字:MySQL 全文检索 全文索引 中文分词 二元分词 区位码 相似度 注:本文使用的MySQL版本为:MySQL 4.0.x 在MySQL4中,是已经开始支持全文检索(索引)的了。但是只是对英文支持全文检索。 由于英文在书写上的特殊性,使得分词算法相对中文来说,简单得多。一般来说,我们可以通过单词与单词之间的空格,以及标点符号来完成这个分词过程。 但是就中文来说,就没有那么简单。MySQL无法对中文做出正确的分词,假设有如下英文句子: "Hello world! Hello PHP!" 通过上面提及的方法,可以很简单的把这个句子分词为: 1 Hello 2 world 3 PHP 我们再来看看中文的句子: "你好世界,你好PHP!" 按照英文的算法,分词如下: 1 你好世界 2 你好PHP 显然是不能满足我们的需要的。 所以,首先我们要做的是,把中文的句子转变为MySQL眼中的英文,以便使得它能以英文分词算法去对句子进行正确的分词处理。 先将上面中文句子进行标点过滤处理,得到以下句子: 你好世界 你好PHP 接着再使用中文分词中较简单实现的二元分词算法对句子进行二元分词,得到以下句子: 你好 好世 世界 你好 PHP 因为把标点符号替换为空格,以及PHP本身为英文字母的关系,可以不用进行二元切分,所以得到上面句子。 这个时候,我们来看看处理过后的句子,会发现,就其书写格式上来说,已经符合英文的书写格式,既以空格,标点来对单词形成自然间隔。只是上面句子没有标点,只有空格而已。 到此,我们已经成功的将中文“翻译”为MySQL能理解的“英文”书写格式。 但是,问题还没解决,首先,MySQL中,ft_min_word_len(分词词汇最小长度)这个参数的默认值为4,也就是4个字母以上长度的单词,才会被考虑,小于4个的,将会被忽略。 如果不改变这个长度,按照上面的分词结果,我们将无法通过 你好,世界,PHP等检索到相关的结果,因为分出来的词太短了,不在MySQL的选择范围内。 我们可以通过修改ft_min_word_len的值,将其设置为2来解决上面问题,但是这样做的话,在检索列表中的原本就为英文的短小词汇,如:PHP,MP3,也会被划入检索范围内,这样做的结果是,出现很多无意义的相关结果。 请看以下列表: [MP3] the look [MP3] because of you 因为他们都同有MP3在标题中,所以会出现上述提到的问题。 回到ft_min_word_len值的问题,我们之所以要修改他,是为了能让MySQL找到我们的二元分词,但是短小的英文又被“无辜”的卷入,我们目前要解决的问题就是,如何使得MySQL能检索到二个字的中文词汇,又能忽略掉原本的英数?第一个反应是把中文MD5,这样以上分词就将转化为以下结果: 你好 好世 世界 你好 PHP => b94ae3c6d892b29cf48d9bea819b27b9 f5625345be46432fb0fd51340fcf6679 9067de5206278a93823f9c5dc2c737fd b94ae3c6d892b29cf48d9bea819b27b9 PHP 这样做,首先是使得中文分词的长度超越了默认的2个字,同时消除了中文的歧义性。(MySQL4对中文的处理有问题),搜索“车轮”时候,不再会出现类似“发动机”结果的问题。(车轮的例子只是为了方便理解而做出的假设) 通过上面的做法,已经解决了分词最小长度的问题,顺利的把中文词汇长度升级,从而达到把中文词汇划入检索范围,把较短的英数划出检索范围。 休息一下,然后发现这个MD5后的字符串是否太长了点……比较占用空间,要不,于是想到区位码,4位数的区位码能表示一个GB汉字,一个词有二个汉字组成,转换为区位码后是8个数字。不但能确定惟一性,也就MD5而已减少了长度。下面是转换后的: 你好 好世 世界 你好 PHP => b94ae3c6d892b29cf48d9bea819b27b9 f5625345be46432fb0fd51340fcf6679 9067de5206278a93823f9c5dc2c737fd b94ae3c6d892b29cf48d9bea819b27b9 PHP => 36672635 26354232 42322971 36672635 PHP 呵呵,是不是比MD5的小了很多呢?最后我们把相同的词汇留一个,多余的删除。得到 36672635 26354232 42322971 PHP 于是就完成了 "你好世界,你好PHP!" 到 "36672635 26354232 42322971 PHP" 的转换。 通过上面方法结合MySQL全文检索语句,我们可以通过给出一个标题例如:"迈克尔·杰克逊 -《危险之旅之布加勒斯特站》"找出类似以下的相关标题 迈克尔杰克逊 -《迈克尔杰克逊危险布加勒斯特演唱会》 Michael Jackson -《迈克尔杰克逊 罗马尼亚 危险演唱会》 迈克尔杰克Michael Jackson -《危险之旅》 迈克尔杰克逊 -《迈克尔杰克逊 美国50annive演唱会危险片段》 迈克尔杰克逊 -《迈克尔杰克逊 终极收藏 原版DVD危险演唱会》 迈克尔杰克逊 杰克逊五兄弟 -《The Jackson Motown 25 演唱会》 迈克尔杰克逊 -《迈克尔杰克逊BAD曰本Yokohama演唱会》 迈克尔杰克逊 -《迈克尔杰克逊曰本大阪演唱会》 迈克尔杰克逊 -《迈克尔杰克逊之胜利-达拉丝演唱会》 迈克尔杰克逊 -《迈克尔杰克逊之胜利演唱会 比丽珍 片段》 迈克尔杰克逊 -《迈克尔杰克逊德国危险演唱会之 billie jean片段》 迈克尔杰克逊 -《Michael Jackson -30周年演唱会》 Michael Jackson -《迈克尔杰克逊 马尼拉 历史演唱会》 迈克尔杰克逊 -《1993年美国橄榄球中场休息精彩表演》 表结构 article title varchar 200 -------- 用于存放标题 (显示用) ft text ---- fulltext 用于存放标题分词结果 (检索用) 首先我们在把标题保存到数据库时候,就已经对标题进行分词转区位码,保存到ft字段中,用于相关性的检索。 然后把给出的标题"迈克尔·杰克逊 -《危险之旅之布加勒斯特站》"转为"34853143 31432291 22910104 01042960 29603143 31434923 46034753 47535414 54143435 34355414 54141828 18282851 28513253 32534325 43254456 44565330",最后进行全文检索查询: SELECT title, MATCH( ft ) AGAINST( '34853143 31432291 22910104 01042960 29603143 31434923 46034753 47535414 54143435 34355414 54141828 18282851 28513253 32534325 43254456 44565330' IN BOOLEAN MODE ) AS score FROM article WHERE MATCH( ft ) AGAINST( '34853143 31432291 22910104 01042960 29603143 31434923 46034753 47535414 54143435 34355414 54141828 18282851 28513253 32534325 43254456 44565330' IN BOOLEAN MODE ) ORDER BY score DESC LIMIT 0, 5 从SQL Query上来看,进行了两次全文检索,其实不然,MySQL会将其视为一次,所以不比担心。 同时使用了AS score,这个score是相似度,分值越高,自然越与给出的标题相近。 二点建议: 1.在实际使用中,挑选score大于1的作为检索结果。 2.检索结果会将本身标题也算入其中,根据score排序,为第一条,别忘记过滤哦 ^_^。 站在用户的立场来说,我们给用户提供了更多的相关内容,站在搜索引擎立场上来说,给关键字提供了更多的相关链接,形成了良好的站内互联结构,提高了搜索引擎对网页的评价。 如果各位碰到错误的不合理的地方,恳请指正,共同进步。谢谢! ============================================================================ 参考资料: ============================================================================ 1.Monkey的二元分词 首先,我们来想想MySQL不支持中文索引的关键原因还是中文是双字节的,如果能把中文转换成单字节的字母或数字,那不就可以使用全文索引了吗 基于这个目的,我们首先需要做的就是分词,如果要实现比较完美的分词的话,还是需要安装相应的插件,但我们很多是虚拟主机,根本没有条件来安装,所以只能采取比较原始的分词方法,二元分词法。 所谓二元分词法,就是将一句话从头到尾,两个字两个字地分开,比如:我们的祖国是花园。就可以划分为:我们,们的,的祖,祖国,国是,是花,花园。虽然有点浪费,但至少面面俱到了。 PHP的相应函数 //Monkey's 二元分词 function sp_str($str) { //所有汉字后添加ASCII的0字符,此法是为了排除特殊中文拆分错误的问题 $str=preg_replace("/[\x80-\xff]{2}/","\\0".chr(0x00),$str); //拆分的分割符 $search = array(",", "/", "\\", ".", ";", ":", "\"", "!", "~", "`", "^", "(", ")", "?", "-", "\t", "\n", "'", "<", ">", "\r", "\r\n", "$", "&", "%", "#", "@", "+", "=", "{", "}", "[", "]", ":", ")", "(", ".", "。", ",", "!", ";", "“", "”", "‘", "’", "[", "]", "、", "—", " ", "《", "》", "-", "…", "【", "】",); //替换所有的分割符为空格 $str = str_replace($search,' ',$str); //用正则匹配半角单个字符或者全角单个字符,存入数组$ar preg_match_all("/[\x80-\xff]?./",$str,$ar);$ar=$ar[0]; //去掉$ar中ASCII为0字符的项目 for ($i=0;$i<count($ar);$i++) if ($ar[$i]!=chr(0x00)) $ar_new[]=$ar[$i]; $ar=$ar_new;unset($ar_new);$oldsw=0; //把连续的半角存成一个数组下标,或者全角的每2个字符存成一个数组的下标 for ($ar_str='',$i=0;$i<count($ar);$i++) { $sw=strlen($ar[$i]); if ($i>0 and $sw!=$oldsw) $ar_str.=" "; if ($sw==1) $ar_str.=$ar[$i]; else if (strlen($ar[$i+1])==2) $ar_str.=$ar[$i].$ar[$i+1].' '; elseif ($oldsw==1 or $oldsw==0) $ar_str.=$ar[$i]; $oldsw=$sw; } //去掉连续的空格 $ar_str=trim(preg_replace("# {1,}#i"," ",$ar_str));//$ar_str = "Monkey s 二元 元分 分词" //返回拆分后的结果 return explode(' ',$ar_str); } 接下来,就该考虑如何把分好的词转换成单字节的,可以使用base64,sha1,md5。但有个问题就是转换后的字符有点长,那如何才能缩短字符呢,对了,就是使用区位码,因为区位码短啊,一个中文只占四个字节。 每个中文都有对应的区位码(除了标点符号和特殊符号),这样只要将上面分词的结果通过区位码转换后,然后存储到数据库里,就可以了。 PHP区位码函数 function quweima($str){ if(preg_match("/^[a-z0-9 ]+$/i",$str)){ return $str; }else{ $str1 = substr($str,0,2); //echo $str1; $str_qwm = sprintf("%02d%02d",ord($str[0])-160,ord($str[1])-160); $str2 = substr($str,2,4); //echo $str2; $str_qwm .= sprintf("%02d%02d",ord($str[0])-160,ord($str[1])-160); return $str_qwm; } } 这里我加了判断,如果是英文或数字直接返回不做处理 经过这两步处理后,准备工作就基本完成了,下面就是建立数据库 我的数据库结构是这样的 id,title,title_ft(fulltext) 添加数据的时候,title存放标题,ft_title存放处理后的标题,内容应该是像这样的:43557401 54903471 … SQL代码 $query = "SELECT title, MATCH( title_ft ) AGAINST( '$title_ft' IN BOOLEAN MODE ) AS score FROM info WHERE MATCH( title_ft ) AGAINST( '$title_ft' IN BOOLEAN MODE ) ORDER BY score DESC "; 其中 $title_ft是经过两个函数处理后的字符串,用它去匹配title_ft。 [解决]monkey的二元分词,分utf编码的中文时出现乱码 <!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN"> <html> <head> <title>momoca test</title> </head> <body> <?php function dualDecom($str) { //所有汉字后添加ASCII的0字符,此法是为了排除特殊中文拆分错误的问题 $str = preg_replace("/[\x80-\xff]{3}/","\\0".chr(0x00),$str); //拆分的分割符 $search = array(",", "/", "\\", ".", ";", ":", "\"", "!", "~", "`", "^", "(", ")", "?", "-", "\t", "\n", "'", "<", ">", "\r", "\r\n", "$", "&", "%", "#", "@", "+", "=", "{", "}", "[", "]", ":", ")", "(", ".", "。", ",", "!", ";", "“", "”", "‘", "’", "[", "]", "、", "—", " ", "《", "》", "-", "…", "【", "】",); //替换所有的分割符为空格 $str = str_replace($search,' ',$str); //用正则匹配半角单个字符或者全角单个字符,存入数组$ar preg_match_all("/[\x80-\xff]+?\\x00/",$str,$ar); $ar = $ar[0]; //去掉$ar中ASCII为0字符的项目 for ( $i = 0; $i < count($ar); $i++ ) if ($ar[$i] != chr(0x00)) $ar_new[]=$ar[$i]; $ar = $ar_new; unset($ar_new); $oldsw = 0; //把连续的半角存成一个数组下标,或者全角的每2个字符存成一个数组的下标 for ( $ar_str = '', $i = 0; $i < count($ar); $i++) { $sw=strlen($ar[$i]); if ( $i > 0 and $sw != $oldsw) $ar_str.=" "; if ( $sw == 1 ) $ar_str.= $ar[$i]; else if ( strlen($ar[$i+1]) >= 2 ) $ar_str.= $ar[$i].$ar[$i+1].' '; elseif ( $oldsw == 1 OR $oldsw == 0 ) $ar_str.= $ar[$i]; $oldsw=$sw; } //去掉连续的空格 $ar_str = trim(preg_replace("# {1,}#i"," ",$ar_str)); return explode(' ',$ar_str); } print_r(dualDecom('比如有一个字符串是“你好PHP!”就只能分出“你好”一个词 ')); ?> </body> </html> 2.PHP里如何实现汉字转区位码 <?php global $PHP_SELF; //echo $PHP_SELF; $t1=$_POST['textfield1']; $t2=$_POST['textfield2']; $t3=$_POST['textfield3']; $t4=$_POST['textfield4']; // 汉字--区位码 if($t1!=""){ $t2= sprintf("%02d%02d",ord($t1[0])-160,ord($t1[1])-160); //echo $t2; } // 区位码--汉字 if($t3!=""){ $t4 = chr(substr($t3,0,2)+160).chr(substr($t3,2,2)+160); //echo $t4; } ?> <!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html xmlns="http://www.w3.org/1999/xhtml"> <head> <meta http-equiv="Content-Type" content="text/html; charset=gb2312" /> <title>无标题文档</title> <style type="text/css"> <!-- .STYLE1 {font-size: 18px} --> </style> </head> <body> <table width="528" height="146" border="1" align="center" cellpadding="0" cellspacing="0"> <tr> <td width="524" height="50"><div align="center" class="STYLE1">汉字区位码查询系统</div></td> </tr> <tr> <td><form id="form1" name="form1" method="post" action="<?=$PHP_SELF ?>"> <label>输入汉字 <input name="textfield1" type="text" value="<?=$t1?>" /> </label> <label> <input type="submit" name="Submit" value=" 转 换 " /> </label> <label> <input name="textfield2" type="text" value="<?=$t2?>" /> </label> </form> <br /> <form id="form2" name="form2" method="post" action="<?=$PHP_SELF ?>"> <label>输入区位码 <input name="textfield3" type="text" value="<?=$t3?>" /> </label> <input type="submit" name="Submit2" value=" 转 换 " /> <input name="textfield4" type="text" value="<?=$t4?>" /> </form> </td> </tr> </table> </body> </html> 3.对dvbbs.php全文搜索的完全分析 这次给大家渗透些比较高级些的.关于搜索的东西. 首先,大家先去下载一份dvbbs.php beta1的代码.解压放在手头上 已经有的同学们就可以继续往下看了. 首先抛开php代码. 找出你的mysql手册.(没有?那直接看下面的吧.) mysql全文搜索,sql的写法: MATCH (col1,col2,…) AGAINST (expr [IN BOOLEAN MODE | WITH QUERY EXPANSION]) 比如: SELECT * FROM articles WHERE MATCH (title,body) AGAINST (’database’); MATCH()函数对于一个字符串执行资料库内的自然语言搜索。一个资料库就是1套1个或2个包含在FULLTEXT内的列。搜索字符串作为对 AGAINST()的参数而被给定。对于表中的每一行, MATCH() 返回一个相关值,即, 搜索字符串和 MATCH()表中指定列中该行文字之间的一个相似性度量。 下面的例子则更加复杂。询问返回相关值,同时对行按照相关性渐弱的顺序进行排序。为实现这个结果,你应该两次指定 MATCH(): 一次在 SELECT 列表中而另一次在 WHERE子句中。这不会引起额外的内务操作,原因是MySQL 优化程序注意到两个MATCH()调用是相同的,从而只会激活一次全文搜索代码。 mysql> SELECT id, body, MATCH (title,body) AGAINST -> (’Security implications of running MySQL as root’) AS score -> FROM articles WHERE MATCH (title,body) AGAINST -> (’Security implications of running MySQL as root’); 所以,到这里你应该会mysql 英文全文搜索了. === 请注意一个问题. 一些词在全文搜索中会被忽略: * 任何过于短的词都会被忽略。 全文搜索所能找到的词的默认最小长度为 4个字符。 * 停止字中的词会被忽略。 === mysql还自带查询扩展功能.这里不做过多讨论. === 下面进行php中文全文搜索的分析. 曾经有一个版本的mysql支持中文全文搜索(海量 mysql chinese+,说是GPL但是最终没有开源) 中文全文搜索的关键是在分词上.mysql本身不支持cjk的分词(cjk:chinese,japanese,korean), 所以 !!!!****如何用php模拟分词是mysql全文索引的关键****!!!! 中文分词是语言分词中最困难的.现在也没有人能够彻底完美的解决(虽然这些搜索引擎做的都还不错.) //fcicq:下面给大家看看这里php的分词是怎么做的. function &DV_ChineseWordSegment($str,$encodingName=’gbk’){ static $objEnc = null; if( $objEnc === null ){ if( !class_exists(’DV_Encoding’) ){ require_once ROOT_PATH.’inc/DV_Encoding.class.php’; } $objEnc =& DV_Encoding::GetEncoding($encodingName); } $strLen = $objEnc->StrLength($str); $returnVal = array(); if( $strLen < = 1 ){ return $str; } $arrStopWords =& DV_GetStopWordList(); //print_r($arrStopWords); //过滤所有HTML标签 $str = preg_replace('#<[a-zA-Z]+?.*?>|#is’, ”, $str); //过滤所有stopword $str = str_replace($arrStopWords[’StrRepl’],’ ‘,$str); $str = preg_replace($arrStopWords[’PregRepl’],’ ‘,$str); //echo “$str:{$str} “; $arr = explode(’ ‘,$str); //fcicq:好了,这下面的才是php分词关键 ******************************************* foreach( $arr as $tmpStr ){ if ( preg_match(”/^[x00-x7f]+$/i”,$tmpStr) === 1 ) { //fcicq:全是E文,没关系,mysql可以认识的 $returnVal[] = ‘ ‘.$tmpStr; } else{ //fcicq:中英混合… preg_match_all(”/([a-zA-Z]+)/i”, $tmpStr, $matches); if( !empty($matches) ){ //fcicq:英语部分 foreach( $matches[0] as $matche ){ $returnVal[] = $matche; } } //过滤ASCII字符 $tmpStr = preg_replace(”/([x00-x7f]+)/i”, ”, $tmpStr); //fcicq:你看,剩下的不就全是中文了? $strLen = $objEnc->StrLength($tmpStr)-1; for( $i = 0 ; $i < $strLen ; $i++ ){ $returnVal[] = $objEnc->SubString($tmpStr,$i,2); //fcicq:注意这里的substr,不是手册上的. //fcicq:你仔细看,所有的词都是分成两个. //比如”数据库的应用”,会被分成数据 据库 库的 的应 应用… //全文搜索: 全文 文搜 搜索 //这分词自然是不怎么样的 //但是,搜索的时候同样这么做. //比如搜索数据库,就相当于搜索了数据 据库. //这是一种相当传统的全文搜索分词方法. } } } return $returnVal; }//end function DV_ChineseWordSegment //fcicq:这就是传说中的substr.偶相信许多人写出来的php代码都比这个好. function &SubString(&$str,$start,$length=null){ if( !is_numeric($start) ){ return false; } $strLen = strlen($str); if( $strLen < = 0 ){ return false; } if( $start < 0 || $length < 0 ){ $mbStrLen = $this->StrLength($str); } else{ $mbStrLen = $strLen; } if( !is_numeric($length) ){ $length = $mbStrLen; } elseif( $length < 0 ){ $length = $mbStrLen + $length - 1; } if( $start < 0 ){ $start = $mbStrLen + $start; } $returnVal = ''; $mbStart = 0; $mbCount = 0; for( $i = 0 ; $i < $strLen ; $i++ ){ if( $mbCount >= $length ){ break; } $currOrd = ord($str{$i}); if( $mbStart >= $start ){ $returnVal .= $str{$i}; if( $currOrd > 0×7f ){ $returnVal .= $str{$i+1}.$str{$i+2}; $i += 2; } $mbCount++; } elseif( $currOrd > 0×7f ){ $i += 2; } $mbStart++; } return $returnVal; }//end function SubString //插入全文搜索分词表.一共两个,一个 topic_ft,一个bbs_ft $arrTopicIndex =& DV_ChineseWordSegment($topic); if( !empty($arrTopicIndex) && is_array($arrTopicIndex) ){ $topicindex = $db->escape_string(implode(’ ‘,$arrTopicIndex)); if( $topicindex !== ” ){ $db->query(”UPD ATE {$dv}topic_ft SET topicindex=’{$topicindex}’ WHERE topicid=’{$RootID}’”); } else{ $db->query(”DEL ETE FROM {$dv}topic_ft WHERE topicid=’{$RootID}’”); } } } 明白了吧?这就是所谓的mysql全文搜索分词 mysql不会分词,而php会.就这么简单. 这虽然是一种比较过时的方法,但被dv这么一炒作就成了香饽饽. 很好理解的. 之后,mysql把这些全文搜索分词的结果 implode(’ ‘,$arrTopicIndex) 再分词(呵呵,数据,据库…),把这些词生硬的记住了. 下面回到mysql上来,下面是php+mysql实现的全文搜索查询. $arrFTKeyWord =& DV_ChineseWordSegment($keyword); //$ftKeyWords = implode(’ ‘,$arrFTKeyWord); $ftKeyWords = ”.implode(’ ‘,$arrFTKeyWord); //fcicq:中间省略了很多…. $stmt = “SELECT {$SQL_CACHE} /*SQL_CALC_FOUND_ROWS*/ ft.topicid FROM {$dv}topic_ft AS ft WHERE MATCH(ft.topicindex) AGAINST(’{$ftKeyWords}’ IN BOOLEAN MODE) “.($boardid>0?” AND ft.boardid=’{$boardid}’”:”).” AND ft.posttable=’{$stable}’”; //in boolean mode,能够保证每一条都查到. $TopicIDList = ‘0′; if( $query = $db->query($stmt,array(’absolutePage’=>$page,’pageSize’=>$pagesize,’debug’=>QUERY_DEBUG)) ){ //$Record_Count = $db->scalar(”SELECT FOUND_ROWS()”); if( $Record_Count < = 0 ){ //没有就输出错误. head(1,0,0,$arrNavMenu); showmsg($lang['str_42'].$lang['str_43']); exit; } while( $tmpResult =& $db->fetch_row($query) ){ //fcicq:注意注意….这里就是保存刚才的id了.这里可以优化一下 $TopicIDList .= “,{$tmpResult[0]}”; } } $PCount = ceil( (float)$Record_Count / (float)$pagesize ); //fcicq:上面用php进行分页预处理,不用管它. $__dbResults =& $db->getResultSet(”SELECT {$SQL_CACHE} t.boardid,t.topicid AS rootid,t.title AS topic,t.expression,t.postusername AS username,t.postuserid,t.dateandtime,t.isbest,t.locktopic,t.child,t.hits,t.hidename FROM {$dv}topic AS t WHERE t.topicid IN({$TopicIDList}) ORDER BY t.topicid DESC”); //fcicq:这里就直接按主题的先后顺序(ORDER BY t.topicid DESC)进行了输出. 讲到这里大家都应该明白了吧,这就是最简单的php,mysql全文搜索分词方式. 肯定有更好的办法,具体如何做,还是要你自己去想. (责任编辑:admin) |