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.