PHP

Полнотекстовый поиск с Xapian и PHP

Иногда MySQL просто не достаточно быстрый. Особенно когда дело доходит до полнотекстового поиска. Все индексы должны быть корректно расставлены, а если мы берем разные поля у которых отличается длина для определения соотношения релевантности, то вещи очень скоро становятся весьма сложными. На помощь приходит Xapian.

Что такое Xapian?
Xapian — это библиотека поискового механизма, схожая с Lucene и Sphinx. Скомпилирована из кода C++, следовательно довольно низкоуровневая. Доступны привязки к PHP, Perl и Python, которые сразу готовы к использованию. Пакеты доступны для Ubuntu и Red Hat, компилируется под OSX, а также можно запускать под Windows через CygWin.

Примеры скриптов
Вместо того чтобы объяснять что да как, я решил создать некоторые демонстрационные скрипты.

db.sql
CREATE DATABASE `demo`;
 
CREATE TABLE `demo`.`demo` (
`id` INT( 10 ) UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY ,
`unique_key` VARCHAR( 255 ) NOT NULL ,
`name` VARCHAR( 255 ) NULL DEFAULT NULL ,
`summary` TEXT NULL DEFAULT NULL ,
`date` DATETIME NULL DEFAULT NULL ,
UNIQUE (`unique_key`));
 
INSERT INTO `demo`.`demo` 
(`id`, `unique_key`, `name`, `summary`, `date`)
VALUES (NULL, 'foo', 'foo', 'foo bar test', '2008-11-05 00:00:00'), 
(NULL , 'bar', 'bar', 'test foo bar', '2009-11-05 00:00:00');

XapianWrapper.php
<?php
// includes
require_once 'xapian.php';
 
// main class
class XapianWrapper {
  const XAPIAN_FIELD_URL = 0;
  const XAPIAN_FIELD_NAME = 1;
  const XAPIAN_FIELD_DATE = 2;
  const XAPIAN_FIELD_UID = 3;
  const XAPIAN_FIELD_SUMMARY = 4;
  const XAPIAN_PREFIX_UID = "UID:";
  
  const SETTINGS_XAPIAN_DB = './xapian_db';
 
  const SETTINGS_MYSQL_HOST = 'localhost';
  const SETTINGS_MYSQL_USER = 'root';
  const SETTINGS_MYSQL_PASS = 'root';
  const SETTINGS_MYSQL_DB = 'demo';
  const SETTINGS_MYSQL_TABLE = 'demo';
 
  const DEFAULT_COUNT = 10;
 
  private $mysql_link;
  private $category_cache;
  
  private $xapian_read_db;
  private $xapian_write_db;
  private $xapian_stemmer;
  private $xapian_indexer;
  private $xapian_enquire;
 
  private function xapian_init_readonly() {
    try{
      $this->xapian_read_db = new XapianDatabase(self::SETTINGS_XAPIAN_DB);
      $this->xapian_stemmer = new XapianStem("english");
      $this->xapian_enquire = new XapianEnquire($this->xapian_read_db);
    } catch(Exception $e) {
      throw new Exception('Could initialize Xapian: ' . $e->getMessage());
    } 
  }
  
  private function xapian_init_writable() {
    try{
      $this->xapian_write_db = new XapianWritableDatabase(
self::SETTINGS_XAPIAN_DB, Xapian::DB_CREATE_OR_OPEN);  
      $this->xapian_indexer = new XapianTermGenerator();
      $this->xapian_stemmer = new XapianStem("english");
      $this->xapian_indexer->set_stemmer($this->xapian_stemmer);
    } catch(Exception $e) {
      throw new Exception('Could initialize Xapian: ' . $e->getMessage());
    } 
  }
  
  private function mysql_init() {
    $this->mysql_link = mysql_connect(
self::SETTINGS_MYSQL_HOST, self::SETTINGS_MYSQL_USER, self::SETTINGS_MYSQL_PASS);
    if (!$this->mysql_link) {
      throw new Exception('Could not connect: ' . mysql_error());
    }
 
    $db_selected = mysql_select_db(self::SETTINGS_MYSQL_DB, $this->mysql_link);
    if (!$db_selected) {
      throw new Exception('Can\'t use db : ' . mysql_error());
    }
  }
  
  /**
   * Index method
   *
   */
  public function index($params) {
    $this->xapian_init_writable();
    $this->mysql_init();
    
    $start = microtime(true);
 
    $response = new stdClass();
    $response->indexed = array();
 
    $offset = (isset($params['offset'])) ? intval($params['offset']) : 0;
    $count = (isset($params['count'])) ? intval($params['count']) : self::DEFAULT_COUNT;
    $sql = 'SELECT * FROM '.
self::SETTINGS_MYSQL_TABLE.' LIMIT ' . $offset . ', ' . $count . ';';
 
    $result = mysql_query($sql);
 
    if (!$result) {
      throw new Exception('Invalid query: ' . mysql_error());
    }
    
    $this->xapian_write_db->begin_transaction();
 
    while ($row = mysql_fetch_array($result, MYSQL_ASSOC)) {
       $response->indexed[] = $this->index_row($row);      
    }
 
    $this->xapian_write_db->commit_transaction();
    mysql_free_result($result);
    mysql_close($this->mysql_link);
 
    return $response;
  }
    
 
  private function index_row($row) {
    $doc = new XapianDocument();
 
    $this->xapian_indexer->set_document($doc);
    $this->xapian_indexer->index_text($row['name'],50);
    $this->xapian_indexer->index_text($row['summary'], 1);
 
    $GUID = self::XAPIAN_PREFIX_UID . $row['unique_key'];
    $doc->add_term($GUID);
 
    $doc->add_value(self::XAPIAN_FIELD_URL, $row['url']);
    $doc->add_value(self::XAPIAN_FIELD_DATE, date('Ymd', strtotime($row['date'])));
    $doc->add_value(self::XAPIAN_FIELD_UID, $row['unique_key']);
    $doc->add_value(self::XAPIAN_FIELD_NAME, $row['name']);
    $doc->add_value(self::XAPIAN_FIELD_SUMMARY, $row['summary']);
    
    $this->xapian_write_db->replace_document(strval($GUID), $doc);
 
    $row_response = array();
    $row_response['name'] = $row['name'];
    $row_response['guid'] = $row['unique_key'];
    $row_response['url'] = $row['url'];
    return $row_response; 
  }
  
  /**
   * Delete method
   *
   */
  public function delete($params) {
    $this->xapian_init_writable();
 
    $this->xapian_write_db->begin_transaction();
 
    $response = array();
 
    foreach($params['items'] as $param_guid) {      
      $GUID = self::XAPIAN_PREFIX_UID . $param_guid;
      $this->xapian_write_db->delete_document(strval($GUID));
      $response[] = $param_guid;
    }
    
    $this->xapian_write_db->commit_transaction();
    return $response;
  }
 
  /**
   * Search method
   *
   */
  public function search($params) {
    $this->xapian_init_readonly();
 
    $start = microtime(true);
 
    // queries array to later construct full query
    $arr_queries = array();
 
    // from date
    if(!empty($params['date_from'])) {
      $arr_queries[] = new XapianQuery(
XapianQuery::OP_VALUE_GE, 6, date('Ymd', strtotime($params['date_from'])));
    }
 
    // to date
    if(!empty($params['date_to'])) {
      $arr_queries[] = new XapianQuery(
XapianQuery::OP_VALUE_LE, 6, date('Ymd', strtotime($params['date_to'])));
    }
 
    // unique key
    if(!empty($params['unique_key'])) {
      $arr_queries[] = new XapianQuery(self::XAPIAN_PREFIX_UID . $params['unique_key']);
    }
 
    // normal search query parsed
    if(!empty($params['search'])) {
      $qp = new XapianQueryParser();
      $qp->set_stemmer($this->xapian_stemmer);
      $qp->set_database($this->xapian_read_db);
      $qp->set_stemming_strategy(XapianQueryParser::STEM_SOME);
      $arr_queries[] = $qp->parse_query($params['search']);
    }
 
    // Find the results for the query.
        // construct final query
    $query = array_pop($arr_queries);
 
    foreach($arr_queries as $sq) {
      $query = new XapianQuery(XapianQuery::OP_AND, $query, $sq);
    }    
    $this->xapian_enquire->set_query($query);
  
    // set the count to the specified params
    $offset = (isset($params['offset'])) ? intval($params['offset']) : 0;
    $count = (isset($params['count'])) ? intval($params['count']) : self::DEFAULT_COUNT;
    $matches = $this->xapian_enquire->get_mset($offset, $count);
 
    $response = new stdClass();
    $response->result_count = $matches->get_matches_estimated();
    $results = array();
 
    $i = $matches->begin();
    while (!$i->equals($matches->end())) {
      $m = array();
 
      $n = $i->get_rank() + 1;
      $doc = $i->get_document();
 
      $m['position'] = $n;
      $m['url'] = $doc->get_value(self::XAPIAN_FIELD_URL);
      $m['name'] = $doc->get_value(self::XAPIAN_FIELD_NAME);
      $m['summary'] = $doc->get_value(self::XAPIAN_FIELD_SUMMARY);
      $m['date'] = $doc->get_value(self::XAPIAN_FIELD_DATE);
      $m['unique_key'] = $doc->get_value(self::XAPIAN_FIELD_UID);
      $m['percent'] = $i->get_percent();
 
      $results[count($results)] = $m;
      $i->next();
    }
 
    $response->results = $results;
    $end = microtime(true);
    
    // runtime info
    $response->execute = new stdClass();
    $response->execute->call = 'search';
    $response->execute->offset = $offset;
    $response->execute->count = $count;
    $response->execute->start = $start;
    $response->execute->end = $end;
    $response->execute->time = $end - $start;
 
    // debug stuff
    $response->execute->debug = $query->get_description();
 
    return $response;
  }
}

index.php
<?php
 
require_once 'XapianWrapper.php';
 
$x = new XapianWrapper();
$res = $x->index(array());
print_r($res);

search.php
<?php
 
require_once 'XapianWrapper.php';
 
$x = new XapianWrapper();
$params = array('search' => 'foo');
$res = $x->search($params);
print_r($res);

delete.php
Убрать подсветку кода
<?php
 
require_once 'XapianWrapper.php';
$x = new XapianWrapper();
$params = array(
        'items' => array('foo'),
);
$res = $x->delete($params);
print_r($res);

Запуск примера
Теперь мы должны запустить php-примеры через командную строку.

bash$ php index.php
stdClass Object
(
    [indexed] => Array
        (
            [0] => Array
                (
                    [name] => foo
                    [guid] => foo
                    [url] => 
                )
 
            [1] => Array
                (
                    [name] => bar
                    [guid] => bar
                    [url] => 
                )
 
        )
 
)

bash$ php search.php
stdClass Object
(
    [result_count] => 2
    [results] => Array
        (
            [0] => Array
                (
                    [position] => 1
                    [url] => 
                    [name] => foo
                    [summary] => foo bar test
                    [date] => 20081105
                    [unique_key] => foo
                    [percent] => 100
                )
 
            [1] => Array
                (
                    [position] => 2
                    [url] => 
                    [name] => bar
                    [summary] => test foo bar
                    [date] => 20091105
                    [unique_key] => bar
                    [percent] => 50
                )
 
        )
 
    [execute] => stdClass Object
        (
            [call] => search
            [offset] => 0
            [count] => 10
            [start] => 1256674866.79
            [end] => 1256674866.79
            [time] => 0.000944852828979
            [debug] => Xapian::Query(Zfoo:(pos=1))
        )
 
)

bash$ php delete.php
Array
(
    [0] => foo
)

bash$ php search.php
stdClass Object
(
    [result_count] => 1
    [results] => Array
        (
            [0] => Array
                (
                    [position] => 1
                    [url] => 
                    [name] => bar
                    [summary] => test foo bar
                    [date] => 20091105
                    [unique_key] => bar
                    [percent] => 100
                )
 
        )
 
    [execute] => stdClass Object
        (
            [call] => search
            [offset] => 0
            [count] => 10
            [start] => 1256674876.02
            [end] => 1256674876.02
            [time] => 0.000872850418091
            [debug] => Xapian::Query(Zfoo:(pos=1))
        )
 
)

Все верно, я оставил вам примеры для улучшения и подстраивания под ваши личные нужды. Как обычно, любые советы по улучшению приветствуются.
Добавлено: 04 Сентября 2013 01:01:02 Добавил: Андрей Ковальчук Нравится 0
Добавить
Комментарии:
Нету комментариев для вывода...