воскресенье, 20 апреля 2008 г.

Как построить поисковый движок?

Как построить поисковый движок?


Добавляем новую таблицу SearchIndex в schema.yml и перестраиваем модель:


ask_search_index:

ask_search_index:
_attributes:
phpName: SearchIndex
question_id:
type: integer
foreignTable: ask_question
foreignReference: id
onDelete: cascade
word:
type: varchar
size: 255
index: true
weight:
type: integer


Атрибут onDelete котролирует все удаления в таблице поиска SearchIndex при удалении соответствующих записей в таблице вопрос.


Разбиение фразы на слова


Некоторые слова, такие как "a," "of," "the," "I,", "it", "you," и т.д. следует исключить из поисковой таблицы.


Добавим следующий метод в класс /lib/myTools.class.php:


public static function removeStopWordsFromArray($words)

{

$stop_words = array(

'i', 'me', 'my', 'myself', 'we', 'our', 'ours', 'ourselves', 'you', 'your', 'yours',

'yourself', 'yourselves', 'he', 'him', 'his', 'himself', 'she', 'her', 'hers',

'herself', 'it', 'its', 'itself', 'they', 'them', 'their', 'theirs', 'themselves',

'what', 'which', 'who', 'whom', 'this', 'that', 'these', 'those', 'am', 'is', 'are',

'was', 'were', 'be', 'been', 'being', 'have', 'has', 'had', 'having', 'do', 'does',

'did', 'doing', 'a', 'an', 'the', 'and', 'but', 'if', 'or', 'because', 'as', 'until',

'while', 'of', 'at', 'by', 'for', 'with', 'about', 'against', 'between', 'into',

'through', 'during', 'before', 'after', 'above', 'below', 'to', 'from', 'up', 'down',

'in', 'out', 'on', 'off', 'over', 'under', 'again', 'further', 'then', 'once', 'here',

'there', 'when', 'where', 'why', 'how', 'all', 'any', 'both', 'each', 'few', 'more',

'most', 'other', 'some', 'such', 'no', 'nor', 'not', 'only', 'own', 'same', 'so',

'than', 'too', 'very',

);



return array_diff($words, $stop_words);

}


в этот же класс добавляем метод для разбиения фраз на слова



public static function stemPhrase($phrase)

{

// split into words

$words = str_word_count(strtolower($phrase), 1);


// ignore stop words

$words = myTools::removeStopWordsFromArray($words);


// stem words

$stemmed_words = array();

foreach ($words as $word)

{

// ignore 1 and 2 letter words

if (strlen($word) <= 2)

{

continue;

}


$stemmed_words[] = PorterStemmer::stem($word, true);

}


return $stemmed_words;

}

В этом методе используется Porter Stemming Algorithm, реализацию которого можно взять с tartarus.org(http://www.tartarus.org/~martin/PorterStemmer/php.txt) и разместить под именем PorterStemmer.class.php в том же каталоге, где располагается myTools.class.php.


Получаем вес слова


Используем такой алгоритм:


Слово имеет больший вес, если оно встречается в заголовке.

Слово получает больший вес, если оно встречается дважды в содержимом до появления других слов, встретившихся уже один раз.


/apps/frontend/config/app.yml):


all:
...

search:
body_weight: 1
title_weight: 2
tag_weight: 3

Реализация алгоритма выполняется в файле /lib/model/Question.php:


public function save($con = null)

{

$con = sfContext::getInstance()->getDatabaseConnection('propel');

try

{

$con->begin();



$ret = parent::save($con);

$this->updateSearchIndex();



$con->commit();



return $ret;

}

catch (Exception $e)

{

$con->rollback();

throw $e;

}

}



public function updateSearchIndex()

{

// delete existing SearchIndex entries about the current question

$c = new Criteria();

$c->add(SearchIndexPeer::QUESTION_ID, $this->getId());

SearchIndexPeer::doDelete($c);



// create a new entry for each of the words of the question

foreach ($this->getWords() as $word => $weight)

{

$index = new SearchIndex();

$index->setQuestionId($this->getId());

$index->setWord($word);

$index->setWeight($weight);

$index->save();

}

}



public function getWords()

{

// body

$raw_text = str_repeat(' '.strip_tags($this->getHtmlBody()), sfConfig::get('app_search_body_weight'));



// title

$raw_text .= str_repeat(' '.$this->getTitle(), sfConfig::get('app_search_title_weight'));



// title and body stemming

$stemmed_words = myTools::stemPhrase($raw_text);



// unique words with weight

$words = array_count_values($stemmed_words);



// add tags

$max = 0;

foreach ($this->getPopularTags(20) as $tag => $count)

{

if (!$max)

{

$max = $count;

}



$stemmed_tag = PorterStemmer::stem($tag);



if (!isset($words[$stemmed_tag]))

{

$words[$stemmed_tag] = 0;

}

$words[$stemmed_tag] += ceil(($count / $max) * sfConfig::get('app_search_tag_weight'));

}



return $words;

}


Мы также должны обновлять индекс вопросов всякий раз , когда происхлдит добавление. Поэтому перепишем метод save() модели Tag:


public function save($con = null)

{

$con = sfContext::getInstance()->getDatabaseConnection('propel');

try

{

$con->begin();



$ret = parent::save($con);

$this->getQuestion()->updateSearchIndex();



$con->commit();



return $ret;

}

catch (Exception $e)

{

$con->rollback();

throw $e;

}

}


Индекс построен, протестируем его


$ php batch/load_data.php




public static function search($phrase, $exact = false, $offset = 0, $max = 10)

{

$words = array_values(myTools::stemPhrase($phrase));

$nb_words = count($words);



if (!$words)

{

return array();

}



$con = sfContext::getInstance()->getDatabaseConnection('propel');



// define the base query

$query = '

SELECT DISTINCT '.SearchIndexPeer::QUESTION_ID.', COUNT(*) AS nb, SUM('.SearchIndexPeer::WEIGHT.') AS total_weight

FROM '.SearchIndexPeer::TABLE_NAME;



if (sfConfig::get('app_permanent_tag'))

{

$query .= '

WHERE ';

}

else

{

$query .= '

LEFT JOIN '.QuestionTagPeer::TABLE_NAME.' ON '.QuestionTagPeer::QUESTION_ID.' = '.SearchIndexPeer::QUESTION_ID.'

WHERE '.QuestionTagPeer::NORMALIZED_TAG.' = ? AND ';

}



$query .= '

('.implode(' OR ', array_fill(0, $nb_words, SearchIndexPeer::WORD.' = ?')).')

GROUP BY '.SearchIndexPeer::QUESTION_ID;



// AND query?

if ($exact)

{

$query .= '

HAVING nb = '.$nb_words;

}



$query .= '

ORDER BY nb DESC, total_weight DESC';



// prepare the statement

$stmt = $con->prepareStatement($query);

$stmt->setOffset($offset);

$stmt->setLimit($max);

$placeholder_offset = 1;

if (sfConfig::get('app_permanent_tag'))

{

$stmt->setString(1, sfConfig::get('app_permanent_tag'));

$placeholder_offset = 2;

}

for ($i = 0; $i < $nb_words; $i++)

{

$stmt->setString($i + $placeholder_offset, $words[$i]);

}

$rs = $stmt->executeQuery(ResultSet::FETCHMODE_NUM);



// Manage the results

$questions = array();

while ($rs->next())

{

$questions[] = self::retrieveByPK($rs->getInt(1));

}



return $questions;

}


Поисковая форма


// add to defaultSuccess.php and questionSuccess.php in askeet/apps/frontend/modules/sidebar/templates/

<h2>find it</h2>

<?php include_partial('question/search') ?>



// create the following askeet/apps/frontend/modules/question/templates/_search.php fragment

<?php echo form_tag('@search_question') ?>

<?php echo input_tag('search', htmlspecialchars($sf_params->get('search')), array('style' => 'width: 150px')) ?>&nbsp;

<?php echo submit_tag('search it', 'class=small') ?>

<?php echo checkbox_tag('search_all', 1, $sf_params->get('search_all')) ?>&nbsp;<label for="search_all" class="small">search with all words</label>

</form>




Определим правило @search_question в routing.yml:


search_question:
url: /search/*
param: { module: question, action: search }


QuestionPeer::search() метод:


public function executeSearch ()

{

if ($this->getRequestParameter('search'))

{

$this->questions = QuestionPeer::search($this->getRequestParameter('search'), $this->getRequestParameter('search_all', false), ($this->getRequestParameter('page', 1) - 1) * sfConfig::get('app_search_results_max'), sfConfig::get('app_search_results_max'));

}

else

{

$this->redirect('@homepage');

}

}



app.yml file:


all:
search:
results_max: 10

Результат поиска


/apps/frontend/modules/question/templates/searchSuccess.php. :


<?php use_helper('Global') ?>



<h1>questions matching "<?php echo htmlspecialchars($sf_params->get('search')) ?>"</h1>



<?php foreach($questions as $question): ?>

<?php include_partial('question/question_block', array('question' => $question)) ?>

<?php endforeach ?>



<?php if ($sf_params->get('page') > 1 && !count($questions)): ?>

<div>There is no more result for your search.</div>

<?php elseif (!count($questions)): ?>

<div>Sorry, there is no question matching your search terms.</div>

<?php endif ?>



<?php if (count($questions) == sfConfig::get('app_search_results_max')): ?>

<div class="right">

<?php echo link_to('more results &raquo;', '@search_question?search='.$sf_params->get('search').'&page='.($sf_params->get('page', 1) + 1)) ?>

</div>

<?php endif ?>


суббота, 19 апреля 2008 г.

Строим свои хелперы

Строим свои хелперы


Создаем файл GlobalHelper.php в каталоге askeet/apps/frontend/lib/helper:


<?php



function pager_navigation($pager, $uri)

{

$navigation = '';



if ($pager->haveToPaginate())

{

$uri .= (preg_match('/\?/', $uri) ? '&' : '?').'page=';



// First and previous page

if ($pager->getPage() != 1)

{

$navigation .= link_to(image_tag('first.gif', 'align=absmiddle'), $uri.'1');

$navigation .= link_to(image_tag('previous.gif', 'align=absmiddle'), $uri.$pager->getPreviousPage()).'&nbsp;';

}



// Pages one by one

$links = array();

foreach ($pager->getLinks() as $page)

{

$links[] = link_to_unless($page == $pager->getPage(), $page, $uri.$page);

}

$navigation .= join('&nbsp;&nbsp;', $links);



// Next and last page

if ($pager->getPage() != $pager->getCurrentMaxLink())

{

$navigation .= '&nbsp;'.link_to(image_tag('next.gif', 'align=absmiddle'), $uri.$pager->getNextPage());

$navigation .= link_to(image_tag('last.gif', 'align=absmiddle'), $uri.$pager->getLastPage());

}



}



return $navigation;

}


Для использования этого хелпера в фрагменте шаблона question/templates/_list.php вызываем его так:


<?php use_helper('Text', 'Global') ?>



<?php foreach($question_pager->getResults() as $question): ?>

<div class="question">

<div class="interested_block">

<?php include_partial('interested_user', array('question' => $question)) ?>

</div>



<h2><?php echo link_to($question->getTitle(), 'question/show?stripped_title='.$question->getStrippedTitle()) ?></h2>



<div class="question_body">

<?php echo truncate_text($question->getBody(), 200) ?>

</div>

</div>

<?php endforeach; ?>



<div id="question_pager">

<?php echo pager_navigation($question_pager, 'question/list') ?>

</div>


Список часто задаваемых вопросов


В модуле question создаем новое действие:


public function executeRecent()

{

$this->question_pager = QuestionPeer::getRecentPager($this->getRequestParameter('page', 1));

}


Добавим в класс /lib/model/QuestionPeer.php:


public static function getRecentPager($page)

{

$pager = new sfPropelPager('Question', sfConfig::get('app_pager_homepage_max'));

$c = new Criteria();

$c->addDescendingOrderByColumn(self::CREATED_AT);

$pager->setCriteria($c);

$pager->setPage($page);

$pager->setPeerMethod('doSelectJoinUser');

$pager->init();



return $pager;

}


Шаблон /apps/frontend/module/question/templates/recentSuccess.php:


<h1>recent questions</h1>



<?php include_partial('list', array('question_pager' => $question_pager)) ?>


Добавим recent_questions правило в frontend/config/routing.yml :


recent_questions:
url: /question/recent/:page
param: { module: question, action: recent, page: 1 }

Меняем последнюю строку в recentSuccess.php на:


<?php include_partial('list', array('question_pager' => $question_pager, 'rule' => 'question/recent')) ?>


а также меняем последнюю строку в _list.php на fragment to:


<div id="question_pager">

<?php echo pager_navigation($question_pager, $rule) ?>

</div>


вызываем _list в modules/question/templates/listSuccess.php.


<h1>popular questions</h1>



<?php echo include_partial('list', array('question_pager' => $question_pager, 'rule' => 'question/list')) ?>


http://ваш хост/question/recent


Список частых ответов


Созаем модуль:


$ symfony init-module frontend answer


Создаем действие:


public function executeRecent()

{

$this->answer_pager = AnswerPeer::getRecentPager($this->getRequestParameter('page', 1));

}


Добавим в классAnswerPeer:


public static function getRecentPager($page)

{

$pager = new sfPropelPager('Answer', sfConfig::get('app_pager_homepage_max'));

$c = new Criteria();

$c->addDescendingOrderByColumn(self::CREATED_AT);

$pager->setCriteria($c);

$pager->setPage($page);

$pager->setPeerMethod('doSelectJoinUser');

$pager->init();



return $pager;

}


Шаблон recentSuccess.php:


<?php use_helper('Date', 'Global') ?>



<h1>recent answers</h1>



<div id="answers">

<?php foreach ($answer_pager->getResults() as $answer): ?>

<div class="answer">

<h2><?php echo link_to($answer->getQuestion()->getTitle(), 'question/show?stripped_title='.$answer->getQuestion()->getStrippedTitle()) ?></h2>

<?php echo count($answer->getRelevancys()) ?> points

posted by <?php echo link_to($answer->getUser(), 'user/show?id='.$answer->getUser()->getId()) ?>

on <?php echo format_date($answer->getCreatedAt(), 'p') ?>

<div>

<?php echo $answer->getBody() ?>

</div>

</div>

<?php endforeach ?>

</div>



<div id="question_pager">

<?php echo pager_navigation($answer_pager, 'answer/recent') ?>

</div>



Профиль пользователя


Для модуля user/show создаем действие


public function executeShow()

{

$this->subscriber = UserPeer::retrieveByPk($this->getRequestParameter('id', $this->getUser()->getSubscriberId()));

$this->forward404Unless($this->subscriber);



$this->interests = $this->subscriber->getInterestsJoinQuestion();

$this->answers = $this->subscriber->getAnswersJoinQuestion();

$this->questions = $this->subscriber->getQuestions();

}


Шаблон /apps/frontend/modules/user/templates/showSuccess.php:


<h1><?php echo $subscriber ?>�s profile</h1>



<h2>Interests</h2>



<ul>

<?php foreach ($interests as $interest): $question = $interest->getQuestion() ?>

<li><?php echo link_to($question->getTitle(), 'question/show?stripped_title='.$question->getStrippedTitle()) ?></li>

<?php endforeach; ?>

</ul>



<h2>Contributions</h2>



<ul>

<?php foreach ($answers as $answer): $question = $answer->getQuestion() ?>

<li>

<?php echo link_to($question->getTitle(), 'question/show?stripped_title='.$question->getStrippedTitle()) ?><br />

<?php echo $answer->getBody() ?>

</li>

<?php endforeach; ?>

</ul>



<h2>Questions</h2>



<ul>

<?php foreach ($questions as $question): ?>

<li><?php echo link_to($question->getTitle(), 'question/show?stripped_title='.$question->getStrippedTitle()) ?></li>

<?php endforeach; ?>

</ul>


Добавим следующие строки в question/templates/showSuccess.php и question/templates/_list.php вначале question_body div:


<div>asked by <?php echo link_to($question->getUser(), 'user/show?id='.$question->getUser()->getId()) ?> on <?php echo format_date($question->getCreatedAt(), 'f') ?></div>


Определим Date helper в _list.php.


Добавим навигационный бар


Меняем layout .


/apps/frontend/templates/layout.php


<div id="content_bar">

<?php include_component_slot('sidebar') ?>

<div class="verticalalign"></div>

</div>


view.yml:


default:
components:
sidebar: [sidebar, default]

$ symfony init-module frontend sidebar


apps/frontend/modules/sidebar/actions/ переимменуем actions.class.php в components.class.php и изменим содержимоена:


<?php



class sidebarComponents extends sfComponents

{

public function executeDefault()

{

}

}


Создадим /apps/frontend/modules/sidebar/templates/_default.php :


<?php echo link_to('ask a new question', 'question/add') ?>



<ul>

<li><?php echo link_to('popular questions', 'question/list') ?></li>

<li><?php echo link_to('latest questions', 'question/recent') ?></li>

<li><?php echo link_to('latest answers', 'answer/recent') ?></li>

</ul>


$ symfony clear-cache


view.yml :


default:
http_metas:
content-type: text/html; charset=utf-8

 metas:
title: symfony project
robots: index, follow
description: symfony project
keywords: symfony, project
language: en

 stylesheets:    [main, layout]

 javascripts:    []

 has_layout:     on
layout: layout

 components:
sidebar: [sidebar, default]

Меняем:


 metas:
title: askeet! ask questions, find answers
robots: index, follow
description: askeet!, a symfony project built in 24 hours
keywords: symfony, project, askeet, php5, question, answer
language: en