«Новости по теме» с помощью PHP, phpmorphy и MySQL

    Хочу поделиться методом определения «похожих» записей. Думаю, будет полезно для блогов или новостных ресурсов.
    Цель данного поста показать принцип, имплементация может быть не совсем комильфо, так как автор не проф. программист, а любитель.


    Итак, задача

    Новости хранятся в MySQL таблице типа:

    Небходимо для каждой новости при выводе на странице определить максимально похожие из той же таблицы.
    Здесь нас интересует контент полей title, lead, body. Для простоты, будем считать что создаем все с нуля и не будем рассматривать необходимость обработки уже существующих записей.

    Поле tags

    Добавляем поле tags (на самом деле это псевдо-тэги, но нигде на сайте они показываться не будут — это поле нужно исключительно для сравнения текстов). Тип поля укажите как VARCHAR(512) и добавьте индекс типа fulltext ( FULLTEXT (tags) ).

    Генерация псевдо-тэгов

    Сгенерируем псевдо-тэги из полей title, lead, body перед записью новости в базу (непосредственно перед INSERT statement). Для этого загрузите phpmorphy и словари отсюда.

    Для исключения малозначащих слов (стоп-слов) создадим массив $stopwords, будем использовать текстовый файл для стоп-слов (пример — сохраните как stopwords.txt ).

    $stopwords=explode("\n", file_get_contents("stopwords.txt"));


    Далее, подключаем phpmorphy и его словари, объединяем title,lead и body и прогоняем все слова через phpmorphy.
    Генерация псевдо-тэгов
    
    $lowercaseLetters = array("'а'", "'б'", "'в'", "'г'", "'д'", "'е'", "'ё'", "'ж'", "'з'", "'и'", "'й'", "'к'", "'л'", "'м'", "'н'", "'о'", "'п'", "'р'", "'с'", "'т'", "'у'", "'ф'", "'х'", "'ц'", "'ч'", "'ш'", "'щ'", "'ъ'", "'ы'", "'ь'", "'э'", "'ю'", "'я'");
    $uppercaseLetters = array("'А'", "'Б'", "'В'", "'Г'", "'Д'", "'Е'", "'Ё'", "'Ж'", "'З'", "'И'", "'Й'", "'К'", "'Л'", "'М'", "'Н'", "'О'", "'П'", "'Р'", "'С'", "'Т'", "'У'", "'Ф'", "'Х'", "'Ц'", "'Ч'", "'Ш'", "'Щ'", "'Ъ'", "'Ы'", "'Ь'", "'Э'", "'Ю'", "'Я'");
    
    function cyrUpper($str)
    {
    global $lowercaseLetters;
    global $uppercaseLetters;
    
    return str_replace("'", "", preg_replace($lowercaseLetters, $uppercaseLetters, $str));
    }
    function cyrLower($str)
    {
    global $lowercaseLetters;
    global $uppercaseLetters;
    
    return str_replace("'", "", preg_replace( $uppercaseLetters,$lowercaseLetters, $str));
    }
    
    function cleanUP ($new_string)
    
    { 
    //$new_string=nl2br($new_string);
     $new_string= str_replace("-"," ",$new_string);
      $new_string= str_replace("\r\n"," ",$new_string);
      $new_string= str_replace("\r"," ",$new_string);
        $new_string= str_replace("\n"," ",$new_string);
      $new_string= str_replace("."," ",$new_string);
    $new_string = ereg_replace("[^0-9 абвгдеёжзийклмнопрстуфхцчшщъыьэюяАБВГДЕЁЖЗИЙКЛМНОПРСТУФХЦЧШЩЪЫЬЭЮЯ]", "",$new_string );
    return $new_string;
    }
    
     
     
    require_once( 'morphy/src/common.php');
    
    $text=cleanUP($_REQUEST[title]." ".$_REQUEST[lead]." ".$_REQUEST[body]." ");
    $aText = explode(' ',$text);
            $aPort = array();
            $aMorph = array();
            
            foreach ($aText as $word)
    		 
                $aMorph[] = cyrUpper($word);//нужно в вин1251 давать не сьедение
        
     
            
            // set some options
            $opts = array(
                'storage' => PHPMORPHY_STORAGE_FILE,
                // Extend graminfo for getAllFormsWithGramInfo method call
                'with_gramtab' => false,
                // Enable prediction by suffix
                'predict_by_suffix' => true,
                // Enable prediction by prefix
                'predict_by_db' => true );
            
          $dir = 'morphy/dicts';
    $lang = 'ru_RU';
    
            
            // Create descriptor for dictionary located in $dir directory with russian language
            $dict_bundle = new phpMorphy_FilesBundle($dir, 'rus');
            
            // Create phpMorphy instance
            try {
                $morphy = new phpMorphy($dict_bundle, $opts);
            } catch(phpMorphy_Exception $e) {
                throw new Exception('Error occured while creating stemmer instance: ' . $e->getMessage());
            }
            
            
            try {
        
    	
                if($getroot==22)
                    $pseudo_root = $morphy->getPseudoRoot($aMorph);//можно либо взять корни слов
                else
                    $pseudo_root = $morphy->getBaseForm($aMorph);//либо базовую форму
                //для нашей задачи $getroot=TRUE
                
            } catch(phpMorphy_Exception $e) {
                throw new Exception('Error occured while text processing: ' . $e->getMessage());
            }
    
    	 
    
    foreach ($pseudo_root as $roots){
    
    $slovo=cyrLower($roots[0]);
    if (strlen( $slovo)>3 && !in_array($slovo,$stopwords) && count($roots)==1 ) {
     
    $tags.=$slovo." ";  }
    
    }
    
    }
    


    Полученный список тэгов в переменной $tags записываем в соотв. поле таблицы. В результате для каждой новости в этом поле будет список слов который мы и будем использовать для сравнения.

    Пример

    Исходный текст
    Компания Samsung начала производство твердотельных жестких дисков с использованием трехмерной памяти V-NAND. Технология позволяет увеличить объем накопителей, а так же обеспечивает в 2 раза более высокую скорость передачи информации и повышает надежность устройств до 10 раз. На данный момент создаются SSD диски объемом 480 и 960 ГБ, только для корпоративных серверов. Что же касается домашних компьютеров, то конкретных сроков выпуска названо не было.

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


    SQL запрос

    Теперь самое интересное- вот такой SQL запрос будет использоваться для определения похожих записей:

     SELECT * FROM news WHERE  MATCH (tags)  AGAINST ('[ список псевдо тэгов ]'  ) > [значение релевантности]
    


    Здесь, значение релевантности это и есть «похожесть» текстов — поэкспериментируйте (начните с единицы)
    Поделиться публикацией

    Комментарии 17

      +1
      Продход простой и эффективный.
      Я для подобной задачи поступал по другому. Во главу угла поставил коэффициенты полезности слов (Считаются как 1/количество статей с этим словом). После создания статьи искал другие статьи с наибольшей суммой 10 наиболее полезных слов в статье.
      Так удалось избавится от мануальной работы со стопвордами, любителями вставлять подписи и названия своего брэнда в статью.
        0
        интересно, только вычисление коэффициентов полезности слов каждый раз для больших таблиц может создать проблемы
          +1
          Нет, при нескольких миллионах статей вставки делались меньше чем за секунду. В упрощенной форме:
          Таблици:
          article ( id, title, content )
          words ( id, word, counter)
          article_words ( aticle_id, word_id )
          aticle_related ( article_id, related_id, fi )
          При вставки статьи вставляем ID всех слов у которых counter < 1M в aricle_words, (добавляем при надобности d words ). Выбираем 20 слов с наименьшим counter, обновляем для них counter как
          select article_id, sum(1/counter) as fi from  article_words where word_id = айди слова. select article_id, sum(counter) as fi from article_words, words where words.id in (список 10 лучших слов) and words.id = article_words.word_id group by article_id order by fi desc limit 10
          

          Вставляем в кэш aticle_related (в обе стороны).
          Профит.
          Я описал саму идею, реальная реализация сложнее. Но в результате и по диску и по производительности, и главное по качеству будет лучше.
          Сейчас работает подобная схема но на mapreduce подходе (слишком много «статей»).
            +2
            может опишете отдельной статьёй?
              0
              Если увижу внимание к этой статье то опишу SQL решение. mapreduce не опишу, так как NDA. А то пока мы друг с другом общаемся.
                0
                Было бы крайне интересно почитать ваше решение
        +1
        Код ужасающий, MyISAM — зло. Я у себя сделал следующим образом: Новость разбиваем на слова и прогоняем их через стеммер. Получается массив «корней» (это не корни, на самом деле). Дальше считаем количество вхождений каждого «корня» и сортируем в порядке убывания. Выбираем N самых популярных «корней» в новости и для них забираем соответствующие им слова из начального списка. Это ключевые слова новости (они же будут в meta-keywords страницы) Дальше ищем ключевые слова в общем индексе (Sphinxsearch). Аналогичным образом формируем description — делим текст на предложения и выбираем N предложений с максимальным количеством вхождений ключевых слов.
          0
          Немного оптимизации чтения из файла в массив:
          $stopwords=explode("\n", file_get_contents("stopwords.txt"));
          
          $stopwords=file("stopwords.txt", FILE_IGNORE_NEW_LINES | FILE_SKIP_EMPTY_LINES);
          
            0
            Еще можно в str_replace первым параметром пихать массив чего заменять (вторым тоже можно, но вам нужно всё менять на пробелы, поэтому второй оставляем строкой), а еще нужно использовать preg_replace, а не ereg_replace, ибо второй уже давно DEPRECATED. Возможно вам стоило обернуть всё в класс.
            Понимаю, что код работает и задача решена, но он является значительной частью статьи, а не как просто какой-то код, чтобы продемонстрировать работу алгоритма.
              0
              согласен, но этому коду лет 8, и создавался методом с миру по нитке
              я описал принцип ( и изначально оговорился по поводу имплементации)
            0
            Есть задача: в нескольких разных таблицах содержатся фамилии и имена авторов, записанных в разных форматах. Надо найти одних и тех же авторов в разных таблицах. Подскажите, кто-нибудь, чем это лучше сделать? Пробовал разные варианты, но после каждого надо проверять ошибки вручную.
              0
              записанных в разных форматах

              не очень понятно, можно пример?
              и еще, фамилии русские?
                0
                Не всегда русские. Я нашёл отличный api, с его помощью можно парсить русские фамилии (то-есть он легко отличает Ф. М. Достоевский и Достоевский Фёдор Михайлович и Достоевский Ф. ). Но этот же сервис совершенно бесполезен при парсинге иностранных фамилий (Андерсен Г.-Х., Эдгар По).
                  +1
                  теоретически, если сравнивать поля где будут только имена и фамилии — это возможно с тем же phpmorphy. Он приведет из к какой то общей форме, например Иванов будет «иваны» (phpmorphy будет рассматривать как сущ. в множественном числе) — выглядеть будет не очень, но для сравнения подойдет.

              0
              $aMorph[] = cyrUpper($word);//нужно в вин1251 давать не сьедение
              

              А зачем? Там же есть словари в utf-8.
                0
                точно не помню, но похоже там сама база была в вин1251
                0
                treffynnon.github.io/php_ssdeep/
                Как на счет Fuzzy hashing? На одном проекте очень даже неплохо себя показало.

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

                Самое читаемое