Bikin Search Engine FullText dengan Zend Search Lucene

Zend Framework merupakan salah satu framework pengembangan aplikasi PHP yang canggih dan populer (ya iyaaalah yang buat developer di Zend, secara Zend yang buat engine pre-prosesor PHP). Framework ini tidak hanya menyediakan library-library yang memudahkan dalam pengembangan aplikasi yang modular dan kompleks, tetapi juga menyediakan fondasi pengembangan aplikasi model MVC (Model View Controller) yang sangat "sophisticated".

Salah satu library dari Zend Framework yang sangat bermanfaat untuk pengembangan mesin pencari/search engine adalah library Zend Search Lucene. Zend Search Lucene adalah porting dari Apache Lucene, engine Java untuk peng-indeksan dokumen full-text yang sangat canggih saat ini dan masih terus dikembangkan. Hebatnya, hasil index dari Zend Search Lucene bisa dipake juga oleh Lucene dan juga sebaliknya! Perlu diingat secara default Lucene dan turunannya hanya meng-indeks file-file teks biasa seperti HTML, XML, TXT dll. Untuk mengindeks file-file PDF, WORD, Excel, Powerpoint diperlukan eksternal parser yang berfungsi mengubah file-file dalam format tersebut ke dalam bentuk teks biasa. Kalo di platform Linux/UNIX untuk meng-indeks file WORD, Powerpoint dan Excel saya pake program command-line catdoc. Sedangkan untuk file-file PDF kita bisa menggunakan program xpdf untuk mem-parsing menjadi teks.

Sekarang kita langsung aja ke pratik-nya, bagaimana gunain Zend Search Lucene di program kita. Langkah pertama pastinya ada menginstall terlebih dahulu Zend Framework. Download versi terbaru dari Zend Framework di website resmi-nya lalu ikuti instruksi install-nya yang bisa dilihat pada dokumentasi resmi-nya. Setelah kita ter-install dengan baik, maka kita sudah bisa menggunakan library Zend Search Lucene dengan menambahkan baris :

<?php require 'Zend/Search/Lucene.php'; ?>
pada skrip PHP kita. Kalau saya menempatkan file tersebut pada file konfigurasi global aplikasi yang pasti selalu ter-include di hampir semua skrip aplikasi. Contohnya seperti ini :
<?php
/**
 * Arie Nugraha 2008
 * ZLucene config file
 *
 */
 
// Required Library
require 'Zend/Search/Lucene.php';
require 'lib/utils.inc.php';
 
ini_set('display_errors', false);
 
// Constant
define('INDEXES_DIR', 'indexes');
define('INDEXES_BASE_DIR', INDEXES_DIR.DIRECTORY_SEPARATOR.'index');
define('DOCS_DIR', 'docs');
define('DOCS_BASE_DIR', '.'.DIRECTORY_SEPARATOR.DOCS_DIR);
?>


Langkah selanjutnya adalah membuat yang namanya INDEX. INDEX mudahnya adalah database metadata dari semua keyword yang berada pada repository dokumen kita. Untuk membuat INDEX caranya seperti ini :

<?php
// check if the index is already available
try {
    if (file_exists(INDEXES_BASE_DIR)) {
        // open the index
        $index = Zend_Search_Lucene::open(INDEXES_BASE_DIR);
    } else {
        // create the index
        $index = Zend_Search_Lucene::create(INDEXES_BASE_DIR);
        define('NEW_INDEX_CREATED', 'New Indexes Created at '.INDEXES_BASE_DIR);
    }
    // set search result limit
    // Zend_Search_Lucene::setResultSetLimit(30);
    // set default search field
    Zend_Search_Lucene::setDefaultSearchField('content');
} catch (Zend_Search_Lucene_Exception $exc) {
    define('ERROR_OPEN_CREATE_INDEXES', 'Failed to open or create indexes file with error : '.$exc->getMessage());
}
?>


Setelah berhasil membuat INDEX, maka instance/objek hasil dari fungsi factory Zend_Search_Lucene::open (variable $index), bisa kita lakukan untuk melakukan berbagai macam manipulasi INDEX, seperti pencarian, manipulasi field metadata INDEX, dsb. Sebagai mana halnya kita membuat database biasa, metadata dari INDEX harus kita tentukan field-fieldnya. Untuk memudah manipulasi field metadata di kemudian waktu, maka saya membuat definisi field dalam bentuk array yang fleksibel :

<?php
// metadata field definition
$config['md_field']['indexed'][] = 'title';
$config['md_field']['indexed'][] = 'author';
$config['md_field']['unindexed'][] = 'file_name';
$config['md_field']['unindexed'][] = 'file_mime_type';
$config['md_field']['unindexed'][] = 'file_size';
$config['md_field']['unindexed'][] = 'input_date';
$config['md_field']['unindexed'][] = 'last_update';
$config['md_field']['unindexed'][] = 'checksum';
$config['md_field']['unstored'][] = 'content';
 
// metadata ID for document delete/update purpose
$config['md_id_field'] = 'checksum';
$config['md_id_checksumed_field'] = 'file_name';
$config['md_field']['keyword'][] = $config['md_id_field'];
?>


Ada beberapa tipe field metadata yang harus kita kenal di Zend Search Lucene :

  1. Text
    Tipe field yang di-indeks, disimpan pada INDEX dan di-tokenize (dipecah-pecah per-kata). Sangat berguna untuk menyimpan data-data seperti Subjek/Topik dokumen, Pengarang dan Judul dokumen.
  2. Keyword
    Tipe field yang di-indeks, disimpan pada INDEX tetapi tidak di-tokenize. Berguna untuk menyimpan istilah yang mengandung lebih dari satu kata dan tidak terpisahkan.
  3. Unindexed
    Tipe field yang tidak di-indeks, tetapi tersimpan dalam INDEX dan bisa dimunculkan pada hasil pencarian.
  4. UnStored
    Tipe field yang di-indeks dan di-tokenize, tetapi tidak tersimpan dalam INDEX. Tipe field ini bisa digunakan untuk menyimpan indeks konten/isi dokumen yang besar.

Untuk memudahkan dalam melakukan proses peng-indeksan saya membuat sebuah kelas yang berfungsi sebagai wrapper proses peng-indeksan. Kelas ini dilengkapi metode-metode tambahan untuk melakukan peng-indeksan isi directory secara recursif. Untuk saat ini, kelas ini hanya bisa melakukan peng-indeksan pada dokumen-dokumen text biasa seperti HTML, XML dan TXT. Definisi kelasnya sebagai berikut :

<?php
/**
 * Arie Nugraha 2008
 * A Zend Search Lucene Indexer Wrapper
 *
 */
 
class ZLucene_Indexer
{
    const AUTO_COMMIT_AFTER_INDEX = 1;
    private $zend_search_lucene = false;
    private $indexed_file_type = array('html', 'htm', 'xml', 'txt');
    private $recursive_index = false;
    private $doc_id_field = 'checksum';
    private $doc_id_checksumed_field = 'checksum';
    protected $md_fields = array();
 
    /**
     * Class Constructor
     *
     * @param   object  $obj_zend_search_lucene
     * @param   array   $array_md_fields
     */
    public function __construct($obj_zend_search_lucene, $array_md_fields)
    {
        if (!$obj_zend_search_lucene instanceof Zend_Search_Lucene_Proxy) {
            die('Please supply ZLucene_Indexer with valid Zend_Search_Lucene index instance');
        }
        $this->zend_search_lucene = $obj_zend_search_lucene;
        $this->md_fields = $array_md_fields;
    }
 
 
    /**
     * Method to set document ID field
     *
     * @param   string  $str_doc_id_field
     * @param   string  $str_doc_id_checksumed_field
     */
    public function setDocID($str_doc_id_field, $str_doc_id_checksumed_field)
    {
        $this->doc_id_field = $str_doc_id_field;
        $this->doc_id_checksumed_field = $str_doc_id_checksumed_field;
    }
 
 
    /**
     * Method to set recursive directory indexing for indexDirectory method
     *
     */
    public function setRecursiveIndex()
    {
        $this->recursive_index = true;
    }
 
 
    /**
     * Method to add one document to index
     *
     * @param   object  $obj_zend_search_document
     * @param   array   $array_field_data
     * @param   integer $int_zlucene_const
     */
    public function indexDoc($obj_zend_search_document, $array_field_data, $int_zlucene_const = 0)
    {
        // delete document from indexes first
        $deleted_term = new Zend_Search_Lucene_Index_Term($array_field_data[$this->doc_id_field], $this->doc_id_field);
        $deleted = new Zend_Search_Lucene_Search_Query_Term($deleted_term);
        $matches = $this->zend_search_lucene->find($deleted);
        if ($matches) {
            foreach ($matches as $doc) {
                $this->zend_search_lucene->delete($doc->id);
                // echo $doc->id.' deleted!<br />';
            }
        }
        // iterate trough metadata fields
        foreach ($this->md_fields as $field_type => $fields) {
            foreach ($fields as $field) {
                if ($field_type == 'indexed') {
                    if (isset($array_field_data[$field])) {
                        $obj_zend_search_document->addField(Zend_Search_Lucene_Field::Text($field, $array_field_data[$field]));
                        // echo $array_field_data[$field].' indexed!<br />';
                    }
                } else if ($field_type == 'unindexed') {
                    if (isset($array_field_data[$field])) {
                        $obj_zend_search_document->addField(Zend_Search_Lucene_Field::UnIndexed($field, $array_field_data[$field]));
                        // echo $array_field_data[$field].' unindexed!<br />';
                    }
                } else if ($field_type == 'unstored') {
                    if (isset($array_field_data[$field])) {
                        $obj_zend_search_document->addField(Zend_Search_Lucene_Field::UnStored($field, $array_field_data[$field]));
                        // echo $array_field_data[$field].' unstored!<br />';
                    }
                } else {
                    if (isset($array_field_data[$field])) {
                        $obj_zend_search_document->addField(Zend_Search_Lucene_Field::Keyword($field, $array_field_data[$field]));
                        // echo $array_field_data[$field].' keyword stored!<br />';
                    }
                }
            }
        }
        // add to index
        $this->zend_search_lucene->addDocument($obj_zend_search_document);
        // commit index change
        if ($int_zlucene_const === self::AUTO_COMMIT_AFTER_INDEX) {
             $this->zend_search_lucene->commit();
        }
    }
 
 
    /**
     * Method to index directory content
     *
     * @param   string  $str_dir_path
     * @param   array   $array_default_field_data
     */
    public function indexDirectory($str_dir_path, $array_default_field_data)
    {
        // check if directory exists
        if (!file_exists($str_dir_path)) {
            echo 'Directory '.$str_dir_path.' not exists!'."\n";
            return;
        }
        // open directory
        if ($directory = opendir($str_dir_path)) {
            // number of document indexed
            $doc_count = 0;
            // read directory content
            while (false !== ($file = readdir($directory))) {
                // ignore dots
                if ($file != '.' AND $file != '..') {
                    // current file
                    $file_path = $str_dir_path.DIRECTORY_SEPARATOR.$file;
                    // check if the $file is file or directory
                    if (is_dir($file_path) AND $this->recursive_index) {
                        $doc_count += self::indexDirectory($file_path, $array_default_field_data);
                    } else {
                        preg_match('@\.(html|htm|txt|xml)$@i', $file, $file_ext);
                        if (!empty($file_ext[1]) AND in_array($file_ext[1], $this->indexed_file_type)) {
                            // reset title field value
                            $metadata['title'] = null;
                            // set default mimetype
                            $file_mime_type = 'text/plain';
                            // get file content
                            $content = file_get_contents($file_path);
                            // get value of HTML title tags
                            if ($file_ext[1] == 'html' OR $file_ext[1] == 'htm' OR $file_ext[1] == 'xml') {
                                preg_match('@<title>(.+)<\/title>@i', $content, $title);
                                $file_ext[1] = ($file_ext[1] == 'htm')?'html':$file_ext[1];
                                $file_mime_type = 'text/'.$file_ext[1];
                                $metadata['title'] = !empty($title[1])?trim($title[1]):null;
                                // echo $title[1].'<br />';
                            } else if ($file_ext[1] == 'doc' OR $file_ext[1] == 'rtf') {
 
                            }
                            // set filename as a title field value
                            if (!$metadata['title']) {
                                $uc_file_name = ucwords(str_replace(array('-', '_'), ' ', $file));
                                // replace last file name extension
                                $metadata['title'] = preg_replace('@\.[^\.]+$@i', '', $uc_file_name);
                            }
                            $metadata['content'] = strip_tags($content);
                            $metadata['author'] = 'dicarve@yahoo.com';
                            $metadata['file_name'] = $file_path;
                            $metadata['file_size'] = filesize($file_path);
                            $metadata['file_mime_type'] = $file_mime_type;
                            // create checksum as an ID
                            $metadata['checksum'] = md5($metadata[$this->doc_id_checksumed_field]);
                            self::indexDoc(new Zend_Search_Lucene_Document(), $metadata);
                            $doc_count++;
                            // echo $metadata['title'].' succesfully indexed!<br />';
                        }
                    }
                }
            }
            // close directory handle
            closedir($directory);
            // commit index changes
            $this->zend_search_lucene->commit();
            // optimize the index
            $this->zend_search_lucene->optimize();
            return $doc_count;
        } else {
            die('Directory '.$str_dir_path.' is not readable. Please check directory permission!');
        }
    }
 
 
    /**
     * Method to parse Microsoft Word *.doc file with catdoc
     *
     * @param   string  $str_docfile_path
     * @return  string
     */
    public function parseMSWord($str_docfile_path, $str_catdoc_path = '/usr/bin/catdoc')
    {
        if (!file_exists($str_docfile_path)) {
            echo $str_docfile_path.' not found!'."<br />\n";
            return null;
        }
        if (!file_exists($str_catdoc_path) OR !is_executable($str_catdoc_path)) {
            echo $str_catdoc_path.' not found or not executable!'."<br />\n";
            return null;
        }
        $outputs = array();
        // execute catdoc
        @exec($str_catdoc_path, $outputs, $status);
 
    }
}
?>


Penggunaan kelas Zlucene_Indexer ini cukup mudah. Skrip untuk meng-indeks konten direktori /var/www/html/docs kira-kira seperti ini :

<?php
/**
 * Arie Nugraha 2008
 *
 */
 
// index directory content recursively
// set PHP script time limit
set_time_limit(0);
$start = microtime(true);
$dir_to_index = '/var/www/html/docs';
$ZLucene_Indexer = new ZLucene_Indexer($index, $config['md_field']);
// recursively index directory contents
$ZLucene_Indexer->setRecursiveIndex();
// set ID field
$ZLucene_Indexer->setDocID($config['md_id_field'], $config['md_id_checksumed_field']);
// array containing default metadata content
$doc_default_metadata= array();
// index directory contents
$num_indexed = $ZLucene_Indexer->indexDirectory($dir_to_index, $doc_default_metadata);
$end = microtime(true);
$index_time = $end-$start;
echo '<strong>'.$num_indexed.'</strong> documents indexed on <strong>'.$dir_to_index.'</strong> directory in '.$index_time.' seconds!'."\n";
?>


Sebagai catatan tambahan, Zend Search Lucene punya beberapa keterbatasan yaitu besar file INDEX maksimal hanya 2GB pada sistem operasi 32 Bit, proses peng-indeksan cenderung lambat terlebih apabila ukuran dan jumlah file besar (saya pernah mencoba mengindeks kurang lebih 11.000 dokumen HTML dan baru selesai dalam waktu setengah jam!).

Nah sekian dulu sampe disini pembahasan mengenai peng-indeksan dokumen full-text dengan menggunakan Zend Search Lucene. Pada posting blog yang akan datang saya akan membahas juga mengenai cara pencarian dokumen pada Zend Search Lucene.

Komentar

Sangbima mengatakan…
Mo tanya nih, saya baru belajar zend framework. Saya cb buat aplikasi untuk mencari daftar username dengan kata kunci tertentu. Tapi gak berhasil. Skrip saya seperti ini:

$textsearch = $formSearch->getValue('searchtext');

$select = $db->select()
->from ( array ('t1' => 'users' ), array ('t1.user_id', 't1.username', 't1.created_date', 't1.activated', 't2.group_name' ) )
->joinLeft ( array ('t2' => 'groups' ), 't2.group_id=t1.group_id' )
->where ("t1.username LIKE '%".$textsearch."%'")
->order ( array ('t1.group_id', 't1.username', 't1.created_date' ) )
->limitPage ( $page, 20 );

Nah kata kunci yang dicari saya ambil dari form search yang saya bikin:

$formSearch = new Zend_Form();
$formSearch->setName('searchform');
$searchText = new Zend_Form_Element_Text('searchtext');
$searchText->setLabel('Enter Text Here');

$searchBtn = new Zend_Form_Element_Submit('searchbtn');
$searchBtn->setLabel('Search');

Ada yang salah gak dari skrip saya?

Postingan populer dari blog ini

Template Aplikasi Web CRUD Sederhana dengan CodeIgniter

Instalasi library YAZ di PHP

An (Relatively) Easy Way for Installing Social Feed Manager on Mac OSX