A- A+

Поиск с учётом морфологии русского языка в Joomla! 1.5

В любой системе всегда есть что улучшать. Зачастую это вопрос "красоты", но иногда — совершенная необходимость. В этой статье я покажу, как сделать в Joomla! 1.5 нормальный поиск с поддержкой морфологии русского языка.

Поисковые системы я разрабатываю не первый год. Даже не второй и не третий :) Поэтому имею собственное представление о правильном поиске — это создание полноценных индексов на основе документов со словами, приведёнными к канонической форме (для существительных — именительный падеж, единственное число). То есть отдельное храненое ПОДов (поисковых образов документов). Плюс к этому словари тематик для автоопределения смысла документов и измерения расстояний между кластерами тематик… Всё даже сложнее, но пока вернёмся к поиску в рассматриваемой CMS.

Поиск в Joomla! — компромисс между простотой реализации, возможностью искать в контенте сторонних компонентов и скоростью. Для любознательных скажу, что ведётся он методом прямого перебора текстовых полей через LIKE. Без индекса. Это хорошо, когда данных мало, а памяти (и кэша процессора в частности) — много, однако для больших объёмов нужен, конечно, совсем другой подход. Но сегодня мы рассмотрим другую проблему — поиск с помощью SQL-запроса с LIKE-условием плохо работает для языков с обилием словоформ, русского в частности.

Существует два распространённых решения проблемы учёта русской морфологии при реализации поиска:

  • приведение слов к канонической форме, например, по словарю Зализняка (менее точно, но быстро) или комбинированным способом через поиск по таблице и попытку получения канонической формы по тому же Зализняку в случае отсутствия слова в таблице (точно, но медленнее);
  • стемминг, то есть отсечение окончаний, характерных для словоформ.

Первый способ не получится прикрутить к Joomla! в силу особенностей реализации поиска в ней, отмеченных выше: отсутствие отдельных поисковых индексов. Поэтому наш выбор падает на стемминг.

Стемминг очень хорош с точки зрения скорости работы, но за всё приходится платить — не лишён этот метод и недостатков: например, вы никогда не сможете найти документ, содержащий только слово "человек" по запросу "люди".

Воспользуемся широко распространённым стеммером Портера для русского языка. Так как в Joomla! 1.5 текстовые данные хранятся в UTF-8, в коде стеммера придётся заменить вызовы некоторых стандартных функций работы со строками на методы класса JString, применяемого в Joomla! для абстракции от того, как реализована поддержка UTF-8 в php на сервере. После внесения изменений, получаем такой код:

 
<?php
class Lingua_Stem_Ru 
{
    var $VERSION = "0.02";
    var $Stem_Caching = 0;
    var $Stem_Cache = array();
    var $VOWEL = '/аеиоуыэюя/';
    var $PERFECTIVEGROUND = '/((ив|ивши|ившись|ыв|ывши|ывшись)|((?<=[ая])(в|вши|вшись)))$/';
    var $REFLEXIVE = '/(с[яь])$/';
    var $ADJECTIVE = '/(ее|ие|ые|ое|ими|ыми|ей|ий|ый|ой|ем|им|ым|ом|его|ого|еых|ую|юю|ая|яя|ою|ею)$/';
    var $PARTICIPLE = '/((ивш|ывш|ующ)|((?<=[ая])(ем|нн|вш|ющ|щ)))$/';
    var $VERB = '/((ила|ыла|ена|ейте|уйте|ите|или|ыли|ей|уй|ил|ыл|им|ым|ены|ить|ыть|ишь|ую|ю)|((?<=[ая])(ла|на|ете|йте|ли|й|л|ем|н|ло|но|ет|ют|ны|ть|ешь|нно)))$/';
    var $NOUN = '/(а|ев|ов|ие|ье|е|иями|ями|ами|еи|ии|и|ией|ей|ой|ий|й|и|ы|ь|ию|ью|ю|ия|ья|я)$/';
    var $RVRE = '/^(.*?[аеиоуыэюя])(.*)$/';
    var $DERIVATIONAL = '/[^аеиоуыэюя][аеиоуыэюя]+[^аеиоуыэюя]+[аеиоуыэюя].*(?<=о)сть?$/';
 
    function s(&$s, $re, $to)
    {
        $orig = $s;
        $s = preg_replace($re, $to, $s);
        return $orig !== $s;
    }
 
    function m($s, $re)
    {
        return preg_match($re, $s);
    }
 
    function stem_word($word) 
    {
        $word = JString::strtolower($word);
 
        $word = str_replace("ё","е",$word);
        # Check against cache of stemmed words
        if ($this->Stem_Caching && isset($this->Stem_Cache[$word])) {
            return $this->Stem_Cache[$word];
        }
        $stem = $word;
        do {
          if (!preg_match($this->RVRE, $word, $p)) break;
          $start = $p[1];
          $RV = $p[2];
          if (!$RV) break;
 
          # Step 1
          if (!$this->s($RV, $this->PERFECTIVEGROUND, '')) {
              $this->s($RV, $this->REFLEXIVE, '');
 
              if ($this->s($RV, $this->ADJECTIVE, '')) {
                  $this->s($RV, $this->PARTICIPLE, '');
              } else {
                  if (!$this->s($RV, $this->VERB, ''))
                      $this->s($RV, $this->NOUN, '');
              }
          }
 
          # Step 2
          $this->s($RV, '/и$/', '');
 
          # Step 3
          if ($this->m($RV, $this->DERIVATIONAL))
              $this->s($RV, '/ость?$/', '');
 
          # Step 4
          if (!$this->s($RV, '/ь$/', '')) {
              $this->s($RV, '/ейше?/', '');
              $this->s($RV, '/нн$/', 'н'); 
          }
 
          $stem = $start.$RV;
        } while(false);
        if ($this->Stem_Caching) $this->Stem_Cache[$word] = $stem;
        return $stem;
    }
 
    function stem_caching($parm_ref) 
    {
        $caching_level = @$parm_ref['-level'];
        if ($caching_level) {
            if (!$this->m($caching_level, '/^[012]$/')) {
                die(__CLASS__ . "::stem_caching() - Legal values are '0','1' or '2'. '$caching_level' is not a legal value");
            }
            $this->Stem_Caching = $caching_level;
        }
        return $this->Stem_Caching;
    }
 
    function clear_stem_cache() 
    {
        $this->Stem_Cache = array();
    }
}
?>
 

Создаём директорию helpers в components/com_search. Сохраняем в неё файл с указанным выше содержимым под именем rus_stemmer.php

Теперь нужно сделать так, чтобы от слов поискового запроса отбрасывались окончания словоформ перед тем, как будет сформирован запрос к базе данных. Другими словами, нужно вставить вызов стеммера в код компонента Joomla!

Открываем файл components/com_search/models/search.php, находим реализацию метода setSearch() класса SearchModelSearch. Изменяем код метода на следующий:

 
  function setSearch($keyword, $match = 'all', $ordering = 'newest')
  {
    if(isset($keyword)) {
      /* Наши изменения *******************************************/
 
      // Указываем, где лежит код стеммера
      require_once(JPATH_COMPONENT.DS.'helpers'.DS.'rus_stemmer.php' );
      // Создаём экземплярчик класса стеммера
      $stemmer = new Lingua_Stem_Ru();
 
      // Разбиваем запрос на отдельные слова
      $words = explode(' ',$keyword);
 
      // (!) &$word - к каждому элементу массива обращаемся по указателю, иначе будет изменяться лишь *копия* элемента массива
      foreach ($words as &$word) {    
        // Отсекаем окончание словоформы  
        $word = $stemmer->stem_word($word);      
      }
      // Указатель нам больше не нужен - подчищаем за собой
      unset($word);
 
      // Склеиваем обрезанные слова в поисковый запрос
      $newKeyword = implode(' ',$words);      
 
      // Для поиска устанавливаем получившийся запрос
      $this->setState('keyword', $newKeyword);
 
      // Для отображения в поле ввода оставляем немодифицированный запрос
      $this->setState('original_keyword',$keyword);
 
      /* /Наши изменения *******************************************/
    }
 
    if(isset($match)) {
      $this->setState('match', $match);
    }
 
    if(isset($ordering)) {
      $this->setState('ordering', $ordering);
    }
  }
 
 

Конечно, правильнее было бы не патчить ключевой компонент системы, а оформить подключение стеммера в качестве плагина. Но для этого необходимо наличие события вроде onSearchKeywordsPrepare и возможности модифицировать запрос в плагине. Этого пока нет, так что приходится патчить :(

Осталось сделать так, чтобы по завершении поиска пользователю показывался исходный запрос. Не просто же так мы храним его в переменной original_keyword ! Для этого в файле components/com_search/views/search/view.html.php в самом конце заменяем одну строчку:

 
    $this->assign('ordering',    $state->get('ordering'));
 
    // Наша строчка *********************************************************
    $this->assign('searchword',    $state->get('original_keyword'));
    // /Наша строчка *********************************************************    
    $this->assign('searchphrase',  $state->get('match'));
    $this->assign('searchareas',  $areas);
 
    $this->assign('total',      $total);
    $this->assign('error',      $error);
    $this->assign('action',       $uri->toString());
 
    parent::display($tpl);
  }
}
 

Всё, наслаждаемся правильным поиском с учётом русской морфологии. Теперь важно не забывать патчить файлы после установки очередной сборки Joomla!

Создать закладку в (наведите курсор на иконку снизу)
MemoriМистер Вонг (mister-wong.ru)БобрДобр (BobrDobr.ru)МоёМесто.ru (MoeMesto.ru)RUmarkz (RuMarkz.ru)del.icio.us (http://del.icio.us)Digg!