Minggu, Agustus 31, 2008

Bikin Search Engine FullText dengan Zend Search Lucene - Searching/Pencarian

Pada posting sebelumnya saya sudah sedikit memaparkan bagaimana cara membuat dan menambahkan indeks database dokumen fulltext dengan menggunakan Zend Search Lucene. Sekarang saya akan sedikit memaparkan bagaimana cara untuk melakukan pencarian ke dalam indeks yang telah dibuat dengan menggunakan Zend Search Lucene.

Untuk melakukan pencarian, Zend Search Lucene menyediakan beberapa metode, tetapi yang paling simpel adalah menggunakan metode find(), dari objek INDEX (instance fungsi factory Zend_Search_Lucene::open). Metode find() mempunyai 2 argumen, argumen pertama adalah kata kunci/keyword yang ingin kita query, dan argumen kedua adalah default field metadata yang akan di-coba temukan oleh indexer. HATI-HATI DENGAN QUERY WILDCARD (*)! Listing programnya kira-kira seperti ini :

<?php
// include paging class
require 'lib/simbio_paging.inc.php';
require 'lib/utils.inc.php';
// get keywords
$keywords = trim($_GET['keywords']);
if ($keywords AND $keywords != '*') {
    // search the index
    $matches = $index->find($keywords, 'title');
    if ($num_matches = count($matches)) {
        // slice the array
        if ($num_matches > $config['recs_each_page']) {
            // get page number from http get var
            $page = 1;
            if (isset($_GET['page']) AND $_GET['page'] > 1) {
                $page = (integer)$_GET['page'];
            }
            // count the row offset
            $remove_offset = $config['recs_each_page'];
            if ($page > 1) {
                $remove_offset = ($page*$config['recs_each_page']);
                // slice from first element of array
                array_splice($matches, 0, ($remove_offset-$config['recs_each_page']));
            }
            // slice the rest elements of array
            array_splice($matches, $config['recs_each_page']);
        }
        echo 'Found <b>'.$num_matches.'</b> document matches your keyword : <hr size="1" />'."\n";
        foreach ($matches as $doc) {
            echo '<div style="clear: both: margin: 5px; margin-bottom: 20px;">'
                .'<div style="font-weight: bold;">'.$doc->title.'</div>'
                .'<div style="margin-left: 10px;">'.$doc->author.'</div>'
                .'<div style="margin-left: 10px;"><a href="?mod=search&action=action&docID='.urlencode($doc->checksum).'" target="_blank">'.basename($doc->file_name).'</a></div>'
                .'</div>';
        }
    }
    // paging
    if ($num_matches > $config['recs_each_page']) {
        echo simbio_paging::paging($num_matches, $config['recs_each_page'], 10);
    }
} else {
    echo utils::showError('No Keywords Entered!');
}
?>


Yang agak rumit mungkin adalah paging search resultnya. Zend Search Lucene kaga nyediain tuh yang namanya klausa "LIMIT", "OFFSET", "TOP" kaya di RDBMS-RDBMS populer, agak-agak tricky si caranya, tapi "it works". Ini saya langsung kasih juga script pagingnya yang saya ambil dari library development PHP, SIMBIO 2 yang selalu saya gunakan dalam mengembangkan aplikasi.

<?php
/**
 * simbio_paging
 * Paging Generator class
 *
 * Copyright (C) 2007,2008  Arie Nugraha (dicarve@yahoo.com)
 *
 * This program is free software; you can redistribute it and/or modify
 * it under the terms of the GNU General Public License as published by
 * the Free Software Foundation; either version 2 of the License, or
 * (at your option) any later version.
 *
 * This program is distributed in the hope that it will be useful,
 * but WITHOUT ANY WARRANTY; without even the implied warranty of
 * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
 * GNU General Public License for more details.
 *
 * You should have received a copy of the GNU General Public License
 * along with this program; if not, write to the Free Software
 * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301  USA
 *
 */
 
class simbio_paging
{
    /**
     * Static Method to print out the paging list
     *
     * @param   integer $int_all_recs_num
     * @param   integer $int_recs_each_page
     * @param   integer $int_pages_each_set
     * @param   string  $str_fragment
     * @param   string  $str_target_frame
     * @return  string
     */
    public static function paging($int_all_recs_num, $int_recs_each_page, $int_pages_each_set = 10, $str_fragment = '', $str_target_frame = '_self')
    {
        // check for wrong arguments
        if ($int_recs_each_page > $int_all_recs_num) {
            return;
        }
 
        // total number of pages
        $_num_page_total = ceil($int_all_recs_num/$int_recs_each_page);
 
        if ($_num_page_total < 2) {
            return;
        }
 
        // total number of pager set
        $_pager_set_num = ceil($_num_page_total/$int_pages_each_set);
 
        // check the current page number
        if (isset($_GET['page']) AND $_GET['page'] > 1) {
            $_page = (integer)$_GET['page'];
        } else {$_page = 1;}
 
        // check the query string
        if (isset($_SERVER['QUERY_STRING']) AND !empty($_SERVER['QUERY_STRING'])) {
            parse_str($_SERVER['QUERY_STRING'], $arr_query_var);
            // rebuild query str without "page" var
            $_query_str_page = '';
            foreach ($arr_query_var as $varname => $varvalue) {
                $varvalue = urlencode($varvalue);
                if ($varname != 'page') {
                    $_query_str_page .= $varname.'='.$varvalue.'&';
                }
            }
            // append "page" var at the end
            $_query_str_page .= 'page=';
            // create full URL
            $_current_page = $_SERVER['PHP_SELF'].'?'.$_query_str_page;
        } else {
            $_current_page = $_SERVER['PHP_SELF'].'?page=';
        }
 
        // target frame
        $str_target_frame = 'target="'.$str_target_frame.'"';
 
        // init the return string
        $_buffer = '<span class="pagingList">';
        $_stopper = 1;
 
        // count the offset of paging
        if (($_page > 5) AND ($_page%5 == 1)) {
            $_lowest = $_page-5;
            if ($_page == $_lowest) {
                $_pager_offset = $_lowest;
            } else {
                $_pager_offset = $_page;
            }
        } else if (($_page > 5) AND (($_page*2)%5 == 0)) {
            $_lowest = $_page-5;
            $_pager_offset = $_lowest+1;
        } else if (($_page > 5) AND ($_page%5 > 1)) {
            $_rest = $_page%5;
            $_pager_offset = $_page-($_rest-1);
        } else {
            $_pager_offset = 1;
        }
 
        // Previous page link
        if (defined('lang_sys_common_paging_first')) {
            $_first = lang_sys_common_paging_first;
        } else {
            $_first = 'First Page';
        }
 
        if (defined('lang_sys_common_paging_prev')) {
            $_prev = lang_sys_common_paging_prev;
        } else {
            $_prev = 'Previous Page';
        }
 
        if ($_page > 1) {
            $_buffer .= ' &nbsp;';
            $_buffer .= '<a href="'.$_current_page.(1).$str_fragment.'" '.$str_target_frame.'>'.$_first.'</a>&nbsp; '."\n";
            $_buffer .= ' &nbsp;';
            $_buffer .= '<a href="'.$_current_page.($_page-1).$str_fragment.'" '.$str_target_frame.'>'.$_prev.'</a>&nbsp; '."\n";
        }
 
        for ($p = $_pager_offset; ($p <= $_num_page_total) AND ($_stopper < $int_pages_each_set+1); $p++) {
            if ($p == $_page) {
                $_buffer .= ' &nbsp;<b>'.$p.'</b>&nbsp; '."\n";
            } else {
                $_buffer .= ' &nbsp;';
                $_buffer .= '<a href="'.$_current_page.$p.$str_fragment.'" '.$str_target_frame.'>'.$p.'</a>&nbsp; '."\n";
            }
 
            $_stopper++;
        }
 
        // Next page link
        if (defined('lang_sys_common_paging_next')) {
            $_next = lang_sys_common_paging_next;
        } else {
            $_next = 'Next';
        }
 
        if (($_pager_offset != $_num_page_total-4) AND ($_page != $_num_page_total)) {
            $_buffer .= ' &nbsp;';
            $_buffer .= '<a href="'.$_current_page.($_page+1).$str_fragment.'" '.$str_target_frame.'>'.$_next.'</a>&nbsp; '."\n";
        }
 
        // Last page link
        if (defined('lang_sys_common_paging_last')) {
            $_last = lang_sys_common_paging_last;
        } else {
            $_last = 'Last Page';
        }
 
        if ($_page < $_num_page_total) {
            $_buffer .= ' &nbsp;';
            $_buffer .= '<a href="'.$_current_page.($_num_page_total).$str_fragment.'" '.$str_target_frame.'>'.$_last.'</a>&nbsp; '."\n";
        }
 
        $_buffer .= '</span>';
 
        return $_buffer;
    }
}
?>

Nah begitulah kira-kira sedikit mengenai penggunaan Zend Search Lucene untuk membuat indeks dokumen fulltext. Dari sini kita bisa saja kembangkan untuk membuat search engine kecil-kecilan yang bermanfaat buat kita, contohnya saya meng-indeks manual PHP offline HTML agar saya bisa dengan cepat menemukan topik yang saya ingin baca.

Untuk peng-indeksan skala besar (jumlah dokumen dalam ukuran giga atau tera), saya menyarankan untuk menggunakan engine-engine indexing yang sudah mumpuni seperti Lucene (java), Clucene (C++), Swish-e, Lemur, Terrier, Xapian dll. Semoga artikel ini bermanfaat untuk anda yang membacanya.

Jumat, Agustus 29, 2008

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.