Как построить поисковый движок?
Добавляем новую таблицу 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')) ?>
<?php echo submit_tag('search it', 'class=small') ?>
<?php echo checkbox_tag('search_all', 1, $sf_params->get('search_all')) ?> <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 »', '@search_question?search='.$sf_params->get('search').'&page='.($sf_params->get('page', 1) + 1)) ?>
</div>
<?php endif ?>