มาทำ Full Text Serach กันกับ Zend_Search
ต่อจากคราวที่แล้วเราพูดกันไปเรื่องของ การตัดคำ (ไม่รู้มีใครเอาไปทำอะไรบ้างยัง) มาคราวนี้ก็จะเป็น ภาคต่อ ของการนำไปประยุกต์ใช้กับระบบ ภายในเว็บไซต์ นั่นก็คือ เทคนิคการทำ Search นั่นเอง
สำหรับ การทำระบบค้นหาแบบ Full text นั้น ถ้าจะให้เห็นภาพกันก็ลองนึกถึงพวก Search Engine อาทิเช่น Google โดยที่เราสามารถทำการค้นหาเข้าไปแบบไหนก็ได้ ไม่ใช่ค้นหาเหมือนกับที่ส่วนใหญ่เราทำในระบบ เว็บไซต์พื้นฐานทั่วไป
โดยการค้นหานั้นเราอาจจะกรอก เข้าไปเป็นประโยค แล้วให้โปรแกรมทำการวิเคราะห์เอาว่าเราต้องการอะไร ซึ่งการค้นหาแบบนี้จะเป็นมิตรกับผู้ใช้ และยังสามารถทำให้ได้ผลลัพธ์ที่แม่นยำ และจำนวนมากขึ้นด้วย
การทำระบบ Search แบบนี้จะถูกคิดออกมาเป็น Score ซึ่ง ข้อมูลที่มี Score ดีกว่าจะเข้าถึงผู้ใช้งานได้มากกว่า
โดยการทำแบบนี้จริงๆแล้วมีมาตั้งนานมากๆ แล้วอาทิเช่น Sphinx, Lucene แต่ติดที่ว่ามัน Implement ค่อนข้างยากไปหน่อย แต่ตอนนี้ผมจะมาพูดถึงทางลัดกัน นั่นก็คือเจ้าเก่า Zend นั้นเอง
โดยวันนี้เราจะหยิบเอา Zend_Search_Lucene มาทำงานกัน
ผมจะขอลำดับบทความออกเป็น 2 ส่วน และถ้าไม่หมดแรงไปซะก่อนจะอธิบายให้จบรวดเดียวไปเลย โดยแบ่งออกเป็นดังนี้
1. การจัดทำดัชนีคำค้นหา
2. การค้นหาผลลัพธ์จากดัชนี
เอาล่ะเพื่อไม่ให้เสียเวลาโดยใช่เหตุ เราจะมาเริ่มลงมือกันเลย ก่อนอื่น คุณต้องมีชุดสำหรับการตัดคำ ภาษาไทยจะทำมาเป็น function หรืออะไรก็แล้วแต่ ถ้ายังไม่รู้จะเริ่มยังไง ลองไปดู บทความก่อนหน้านี้ เอา
แล้วก็ที่ขาดไม่ได้คือชุด Framework ของ Zend
ก่อนอื่นเรามาเริ่มโดยการ Include ข้อมูลสองส่วนนี้เข้ามา พร้อมกับระบุ Path จัดเก็บดัชนี้คำค้น
include(FCPATH . '_resources/Software/swath/func.php');
/* Zend Lucene instance */
include('Zend/Search/Lucene.php');
/* This use for set utf-8 */
$analyzer = new Zend_Search_Lucene_Analysis_Analyzer_Common_Utf8_CaseInsensitive();
Zend_Search_Lucene_Analysis_Analyzer::setDefault($analyzer);
/* Where to keep index files */
$storage = FCPATH . 'data/search';
(ข้อมูลที่เราจะจัดเก็บขอให้เป็น UTF-8 ที่เป็น Case Insensitive นะครับ)
เอาล่ะทีนี้เราก็พร้อมแล้วที่จะทำ indexing ลองมาดูโคดของการทำดัชนีกัน
/* Create new indexing */
$index = Zend_Search_Lucene::create($this->storage);
/* Data fetch from database */
$documents = array(
array(
'id' => 1,
'url' => 'http://....',
'created' => '2011-01-01',
'teaser' => 'Some title',
'title' => 'Full Title',
'author' => 'Tee++',
'content' => strip_tags('Full Content')
),
array(
.....
),
array(
.....
)
);
/* Zend Lucene Document instance */
$doc = new Zend_Search_Lucene_Document();
/* Zend Lucene make indexing from document */
foreach ($documents as $document)
{
$doc->addField(Zend_Search_Lucene_Field::Keyword('document_id', $document['id']));
$doc->addField(Zend_Search_Lucene_Field::UnIndexed('url', $document['url']));
$doc->addField(Zend_Search_Lucene_Field::UnIndexed('created', $document['created']));
$doc->addField(Zend_Search_Lucene_Field::UnIndexed('teaser', $document['teaser'], 'utf-8'));
/* Index title and store */
//$doc->addField(Zend_Search_Lucene_Field::Text('title', $document['title'], 'utf-8'));
$doc->addField(Zend_Search_Lucene_Field::UnIndexed('title', $document['title'], 'utf-8'));
$doc->addField(Zend_Search_Lucene_Field::Unindexed('author', $document['author'], 'utf-8'));
/* This line above use for indexing, but don't need to store */
$doc->addField(Zend_Search_Lucene_Field::UnStored('topic', swath($document['title']), 'utf-8'));
$doc->addField(Zend_Search_Lucene_Field::UnStored('content', swath($document['content']), 'utf-8'));
$index->addDocument($doc);
}
$index->commit();
$index->optimize();
เท่านี้เองจริงๆ ไม่ได้โกหก ครับ การทำ Index จาก Zend_Search_Lucene มันง่ายแบบนี้เลย โดยเริ่มต้นมาเราก็แค่ทำการ เปิดไฟล์ index จากนั้น ตรงส่วน document ก็เป็นการดึงข้อมูลจากฐานข้อมูล (ในที่นี้ผมจำลองด้วย Array) จากนั้นก็ส่งข้อมูลไปทำการ index ซะ แต่ที่มันวุ่นคือตรงการทำความเข้าใจ ซึ่งอันนี้ง่ายเลยครับ เพราะผมไปทำมาแทนแล้ว ฮาๆๆๆๆ
โดยที่การ Add Field เข้าทำการ Index นั้นจะมี Type ทั้งหมด 5 แบบครับ ลองดูตารางด้านล่างนี้ก่อน

โดยส่วนที่เราจะใช้มี 4 ตัวก่อนครับ นั่นก็คือ
1. Keyword ตัวนี้ให้มองเหมือนเป็น Primary Key ไว้ทำการอ้างอิงกับ Document เอาไว้ใช้ตอนทำการลบ index ออก
2. UnIndexed ตัวนี้คือเราจะใช้งานสำหรับตอนแสดงผลเท่านั้น แต่จะไม่ใช้อ้างอิงในตอนค้นหา (เอาไว้โชว์ว่างั้น)
3. Text ตัวนี้คือเราให้เก็บข้อมูลไว้ด้วย ทำ Index ด้วย คือ เราจะใช้ทั้งค้นหา และแสดงผล
4. UnStored อันนี้คือเราจะไม่เก็บ แต่จะใช้ค้นเพราะฉะนั้นเอาไปแตกคำมาทำ Index ซะ
สังเกตุนิดนึงตรงโคดส่วนนี้
$doc->addField(Zend_Search_Lucene_Field::UnStored('topic', swath($document['title']), 'utf-8'));
$doc->addField(Zend_Search_Lucene_Field::UnStored('content', swath($document['content']), 'utf-8'));
ผมมีคำสั่งของ swath คลุมอยู่เพราะว่าตัว Zend_Search_Lucene มันตัดคำภาษาไทยไม่ได้ แต่เวลามันตัดภาษาอังกฤษมันจะใช้ ช่องไฟ เราก็แค่ทำ ภาษาไทยแตกออกมาเป็นคำศัพท์แล้วเว้นช่องไฟให้มัน มันก็จะสามารถทำ index ได้แล้วครับ
ทีนี้เราลองไปดู Folder ที่เราทำการจัดเก็บดัชนีจะเห็นว่ามีไฟล์ถูกสร้างขึ้นมาเยอะแยะ นั่นก็แปลว่าเราทำสำเร็จแล้วครับ น้ำตาไหลได้เลย ตามสบาย 555++

เอาล่ะครับ เท่านี้ส่วนของการทำดัชนี้ก็เป็นอันจบแล้ว
**********
เอาล่ะรีบมาต่อส่วนที่สองกันให้จบๆ ไปเลยดีกว่า ส่วนนี้เป็นการค้นหาจาก index เราครับ มาลองดูโคดกัน
$keyword = $_POST['keyword'];
Zend_Search_Lucene::setResultSetLimit(1000);
$index = Zend_Search_Lucene::open($storage);
/* [1] Query everything we indexed */
$query = Zend_Search_Lucene_Search_QueryParser::parse($keyword, 'UTF-8');
$hits = $index->find($query);
echo "<div id='results'>";
echo "<p>Result for: <strong>" . $keyword . "</strong></p>";
echo "<p>Found: <strong>" . count($hits) . "</strong> items</p>";
echo "<p>Index stats: <strong>" . $index->count() . "</strong></p>";
echo "<hr />";
foreach ($hits as $hit)
{
echo "<h3>" . $hit->title . " (score: " . $hit->score . ")</h3>";
echo "<em>" . $hit->document_id . " ,By " .$hit->author . "</em>";
echo "<p>" . $hit->teaser . "<br /><a href='" . $hit->url . "'>" . $hit->url . "</a></p>";
}
echo "</div>";
สำหรับการค้นหา สั้นๆ ง่ายๆเหมือนกัน โดยเราก็แค่ส่ง "Keyword" เข้าไป มาลองดูตัวอย่างของผมกัน อันนี้ผมลอง Dump Database ของ OSCOOL.com มาทำ Index แต่ก็แค่บางส่วนนะครับ ผมลองค้นด้วยคำว่า "เขียน ajax ด้วย jquery" มาลองดูผลลัพธ์ของผมกัน

จะเห็นได้ว่าค่อนข้างตรงเลยทีเดียว ค้นหามาได้ 15 บทความ แค่อันที่ตรงที่สุดอยู่บนสุด ซึ่งผมไม่ขอลงลึกถึง อัลกอลิทึ่มการคิดคะแนนของเค้านะครับ เพราะผมก็ไม่รู้ 5555+
อาจะเป็นไปได้ว่า คิดจาก จำนวนซ้ำของ index และคำค้นประกอบกัน น่าจะหยั่งงั้น (มั้ง)
เป็นอันว่าเราก็สามารถจบบทความนี้ได้ด้วยดีครับ แต่ว่าผมอยากให้ไปอ่านต่อเอาที่เว็บของ Zend เองด้วย เพราะมีบางส่วนที่ผมไม่มีเวลาพอที่จะพูดถึง อาทิเช่น
การ Append Index
การลบ Index
การ Update Index (อันนี้ต้องอาศัยการลบแล้ว Add เข้าไปใหม่)
การค้นหา Index โดยระบุ Term
การ Sort ผลลัพธ์ แบบไม่ใช้ Score
การทำ Highligh คำค้นหา
Proximity Searches (พวก ^, :, and or ที่เราใช้กับ Search Engine นั่นล่ะ)
เออ เอาจนจะจบแล้วผมยังไม่ได้พูดถึงข้อดีของการทำระบบค้นหาแบบ Full Text Index เลยนี่นา มันมีเยอะครับ มีเยอะจริงๆ เอาเท่าที่นึกออกตอนนี้ก็คือ
1. แน่นอน Performance ดีมากๆ เพราะว่า เราไม่ต้องใช้ LIKE ในระบบค้นหาอักแล้ว 555+
2. ค้นหาได้เร็วมาก แม่นยำสูง
3. เอาไปทำระบบ Relate Content นี่จะเนียนกิ๊กๆ
4. โชว์พาวว์ครับ ระบบในไทยมีน้อยมากๆ ที่จะใช้ตามแบบนี้ 555+
5. เป็นมิตรกับ user ครับ เพราะไม่ต้องมากรอกคำตรงๆ ค้นหายังไง ก็ยังหาผลลัพธ์มาให้ได้ (ถ้ามี)
6. ค้นหาจาก content อะไรก็ได้ ไม่จำกัดอยู่แค่ Table เดียว
ถ้ามีข้อดีมันก็ต้องมีข้อเสียบ้างเป็นธรรมดา
1. ต้องทำงานซ้ำซ้อน คือต้องมาจัดการทำ Index บ่อยๆ
2. ข้อมูลไม่ Real Time แล้วแต่ว่าเราจะทำ Index บ่อยมั้ย แต่ก็แก้ได้ โดยใช้การ Append ข้อมูลเจ้าไป ทุกครั้งที่มี input เข้า
3. ถ้าจะเอา Full Option & Security นี่ Implement กันหัวโตครับ เพราะ จะต้องมีทั้ง Message Queue, ตัวตัดคำ, Lucene
สุดท้ายแล้วอันนี้เอาไว้เป็น โน๊ตช่วยจำให้กับตัวเองหน่อยครับ วิธีการ Merge Index หลายๆ ก้อนเข้าด้วยกัน
$index = new Zend_Search_Lucene_Interface_MultiSearcher();
$index->addIndex(Zend_Search_Lucene::open('search/index1'));
$index->addIndex(Zend_Search_Lucene::open('search/index2'));
แบบนี้ครับ เพราะว่าจริงๆ แล้วมันยังมีข้อจำกัดอยู่นิดนึงตรงเรื่องขนาด นี่แหละ คือถ้าเราจัดเก็บในระบบ 32Bit มันจำจำกัดอยู่ที่ ไฟล์ก้อนละ 2GB แต่ถ้า 64Bit นี่ไม่แน่ใจ
แต่ถ้าเราซอยย่อยแล้วค่อยเอามา merge กันก็จะแก้ปัญหานี้ไปได้ครับ
จบเหอะ เหนื่อย @_@

15 comments