MySQLの標準機能で日本語を全文検索する(1)

MySQLには全文検索機能が付いている。だが、ラテン語のようにスペースがないと語句の区切りを認識しないため、そのままでは日本語が検索できない。
MySQLで全文検索 - FULLTEXTインデックスの基礎知識|blog|たたみラボ
を参考に構築してみた。
データベースの文字コードUTF-8 で、
最低検索語の指定は1が本当はよいとは思うが少しでも節約したいので、2とした。

my.cnf

[mysqld]
ft_min_word_len=2

まず必要なのは、日本語の語句分解なのだが、形態素解析ではなく N-gram により分解する。但し、日本語(ひらがな、カタカナ、漢字)、英語(アルファベット、数字)への分解をまずすることで無駄なインデックス作成を抑制するようにしてみた。日本語に関しては N-gram を用いて、英語に関しては、キャメル記法に対しては、分解するようにした。

myindex.php

<?php
  mb_internal_encoding("UTF-8"); 
  mb_regex_encoding("UTF-8");
  
  class FullTextSearcher {
    var $markwords;
    var $scorequery;
    
    public function FullTextSearcher() {
    }
    
    public function search_sql($statement) {
      return "'". $this->to_ngram($statement, 2) ."' IN BOOLEAN MODE";
    }
    
    public function getScore() {
      return "'".$this->scorequery."'";
    }
    
    public function getMarkupWords() {
      return $this->markwords;
    }
    
    private function to_ngram($string, $n){
      $string = mb_ereg_replace("^(\s| )+","", $string);
      $string = mb_ereg_replace("(\s| )+$","", $string);
      $str_array = preg_split("/(\s| )+/", $string);
        
      $result = array();
      $markwords = array();
      $scores = array();
      foreach ($str_array as $str){
        if ('-' != substr($str, 0, 1)) {
          $markwords[] = $str;
        }
        
        $r = $this->to_ngram_query($str, $n);
        $result[] = $r[0];
        $scores[] = $r[1];
      }
      $this->markwords = $markwords;
      
      $this->scorequery = join(' ', $scores);
      return join(' ', $result);
    }
    
    private function to_ngram_query($string, $n){
      $string = trim($string);
      if ($string == ''){
        return '';
      }
      
      if ('-' != substr($string, 0, 1)) {
        $plus = true;
      } else {
        $plus = false;
        $string = substr($string, 1);
      }
      
      $length = mb_strlen($string);
      if ($length < $n){
        if ($plus) {
          return array("+".$string."*", $string);
        } else {
          return array("-".$string."*", "");
        }
      }
      
      $ngrams = array();
      $scores = array();
      $wa = new WordsAnalyzer();
      $wa->loadStr($string);
      $wds = $wa->get_indexes();
      foreach ($wds as $ngram){
        if ($plus) {
          $ngrams[] = "+" . $ngram;
          $scores[] = $ngram;
        } else {
          $ngrams[] = "-" . $ngram;
        }
      }
      
      return array(join(' ', $ngrams), join(' ', $scores));
    }
    
  }
  
  class WordsAnalyzer {
    private $words;
    
    public function WordsAnalyzer() {
    }
    
    public function get_indexes() {
      return array_keys($this->words);
    }
    
    public function get_fulltext() {
      return join(' ', array_keys($this->words));
    }
    
    public function loadStr($str) {
      $this->words = array();
      
      ereg_replace(13, "", $str);
      ereg_replace(10, " ", $str);
      
      $this->analyze_token($this->split_token($str));
    }
    
    private function split_token($str) {
      $token = array();
      while (1) { 
        $bytes = mb_ereg("[一-龠]+|[ぁ-ん]+|[ァ-ヴー]+|[a-zA-Z0-9]+|[a-zA-Z0-9]+", $str, $match);  
        if ($bytes == FALSE)
          break; 
        
        $match = $match[0]; 
        array_push($token, $match);
        
        $pos = strpos($str, $match);
        $str = substr($str, $pos+$bytes);
      }
      return $token;
    }
    
    private function analyze_token($token) {
      for ($i = 0, $len = count($token); $i < $len; $i++) {
        $word = $token[$i];
                
        if (mb_ereg("[一-龠]", $word, $match) != FALSE) {
          if (mb_strlen($word) > 1) {
            $this->japanese($word);
          } else {
            $this->addword($word);
          }
        } else if (mb_ereg("[ぁ-ん]", $word, $match) != FALSE) {
          if (mb_strlen($word) > 1) {
            $this->hiragana($word);
          }
        } else if (mb_ereg("[ァ-ヴー]", $word, $match) != FALSE) {
          if (mb_strlen($word) > 1) {
            $this->japanese($word);
          }
        } else if (mb_ereg("[a-zA-Z0-9]", $word, $match) != FALSE) {
          if (strlen($word) > 0) {
            // camel-gram
            $this->english($word);
          }
        } else if (mb_ereg("[a-zA-Z0-9]", $word, $match) != FALSE) {
          if (mb_strlen($word) > 0) {
            // camel-gram
            $word = mb_convert_kana($word, 'rn');
            $this->english($word);
          }
        } else {
          $this->addword($word);
        }
      }
    }
    
    private function addword($word) {
      if (strlen($word) == 0) return;
      $this->words{$word} = true;
    }
    
    private function japanese($str) {
      // bi-gram
      $ret = $this->split_ngram($str, 2);
      foreach ($ret as $key => $value) {
        if (mb_strlen($value) > 1) {
          $this->addword($value);
        }
      }
    }
    
    private function hiragana($str) {
      // bi-gram
      $ret = $this->split_ngram($str, 2);
      foreach ($ret as $key => $value) {
        if (mb_strlen($value) > 1) {
          if (mb_ereg("っ.|を|です|ます|する|ある|いる|から", $value, $match) != FALSE) {
          } else {
            $this->addword($value);
          }
        }
      }
    }
    
    private function english($str) {
      $this->addword($str); // そのままも登録
      
      // キャメル記法を分解して登録
      while(1){
        $r = ereg("([a-z]|[A-z]|[0-9])[a-z]+", $str, $match); 
        if ($r == FALSE) {
          break; 
          }
        
        $match = $match[0];
        $this->addword($match);
        
        $pos = strpos($str, $match); 
        $str = substr($str, $pos + $r); 
      }
    }
    
    private function split_ngram($string, $n){
      $ngrams = array();
      $string = trim($string);
      if ($string == '')
        return '';
      
      for ($i = 0, $len = mb_strlen($string); $i < $len; $i++) {
        $ngrams[] = mb_substr($string, $i, $n);
      }      
      return $ngrams;
    }
  }    
?>