7 * This source file is subject to the new BSD license that is bundled
8 * with this package in the file LICENSE.txt.
9 * It is also available through the world-wide-web at this URL:
10 * http://framework.zend.com/license/new-bsd
11 * If you did not receive a copy of the license and are unable to
12 * obtain it through the world-wide-web, please send an email
13 * to license@zend.com so we can send you a copy immediately.
16 * @package Zend_Search_Lucene
18 * @copyright Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
19 * @license http://framework.zend.com/license/new-bsd New BSD License
20 * @version $Id: Fuzzy.php 16971 2009-07-22 18:05:45Z mikaelkael $
24 /** Zend_Search_Lucene_Search_Query */
25 require_once 'Zend/Search/Lucene/Search/Query.php';
27 /** Zend_Search_Lucene_Search_Query_MultiTerm */
28 require_once 'Zend/Search/Lucene/Search/Query/MultiTerm.php';
33 * @package Zend_Search_Lucene
35 * @copyright Copyright (c) 2005-2009 Zend Technologies USA Inc. (http://www.zend.com)
36 * @license http://framework.zend.com/license/new-bsd New BSD License
38 class Zend_Search_Lucene_Search_Query_Fuzzy extends Zend_Search_Lucene_Search_Query
40 /** Default minimum similarity */
41 const DEFAULT_MIN_SIMILARITY = 0.5;
44 * Maximum number of matched terms.
45 * Apache Lucene defines this limitation as boolean query maximum number of clauses:
46 * org.apache.lucene.search.BooleanQuery.getMaxClauseCount()
48 const MAX_CLAUSE_COUNT = 1024;
51 * Array of precalculated max distances
53 * keys are integers representing a word size
55 private $_maxDistances = array();
58 * Base searching term.
60 * @var Zend_Search_Lucene_Index_Term
65 * A value between 0 and 1 to set the required similarity
66 * between the query term and the matching terms. For example, for a
67 * _minimumSimilarity of 0.5 a term of the same length
68 * as the query term is considered similar to the query term if the edit distance
69 * between both terms is less than length(term)*0.5
73 private $_minimumSimilarity;
76 * The length of common (non-fuzzy) prefix
80 private $_prefixLength;
86 * It's filled during the search (rewrite operation) and may be used for search result
89 * Array of Zend_Search_Lucene_Index_Term objects
93 private $_matches = null;
96 * Matched terms scores
100 private $_scores = null;
103 * Array of the term keys.
104 * Used to sort terms in alphabetical order if terms have the same socres
108 private $_termKeys = null;
111 * Default non-fuzzy prefix length
115 private static $_defaultPrefixLength = 3;
118 * Zend_Search_Lucene_Search_Query_Wildcard constructor.
120 * @param Zend_Search_Lucene_Index_Term $term
121 * @param float $minimumSimilarity
122 * @param integer $prefixLength
123 * @throws Zend_Search_Lucene_Exception
125 public function __construct(Zend_Search_Lucene_Index_Term $term, $minimumSimilarity = self::DEFAULT_MIN_SIMILARITY, $prefixLength = null)
127 if ($minimumSimilarity < 0) {
128 require_once 'Zend/Search/Lucene/Exception.php';
129 throw new Zend_Search_Lucene_Exception('minimumSimilarity cannot be less than 0');
131 if ($minimumSimilarity >= 1) {
132 require_once 'Zend/Search/Lucene/Exception.php';
133 throw new Zend_Search_Lucene_Exception('minimumSimilarity cannot be greater than or equal to 1');
135 if ($prefixLength < 0) {
136 require_once 'Zend/Search/Lucene/Exception.php';
137 throw new Zend_Search_Lucene_Exception('prefixLength cannot be less than 0');
140 $this->_term = $term;
141 $this->_minimumSimilarity = $minimumSimilarity;
142 $this->_prefixLength = ($prefixLength !== null)? $prefixLength : self::$_defaultPrefixLength;
146 * Get default non-fuzzy prefix length
150 public static function getDefaultPrefixLength()
152 return self::$_defaultPrefixLength;
156 * Set default non-fuzzy prefix length
158 * @param integer $defaultPrefixLength
160 public static function setDefaultPrefixLength($defaultPrefixLength)
162 self::$_defaultPrefixLength = $defaultPrefixLength;
166 * Calculate maximum distance for specified word length
168 * @param integer $prefixLength
169 * @param integer $termLength
170 * @param integer $length
173 private function _calculateMaxDistance($prefixLength, $termLength, $length)
175 $this->_maxDistances[$length] = (int) ((1 - $this->_minimumSimilarity)*(min($termLength, $length) + $prefixLength));
176 return $this->_maxDistances[$length];
180 * Re-write query into primitive queries in the context of specified index
182 * @param Zend_Search_Lucene_Interface $index
183 * @return Zend_Search_Lucene_Search_Query
184 * @throws Zend_Search_Lucene_Exception
186 public function rewrite(Zend_Search_Lucene_Interface $index)
188 $this->_matches = array();
189 $this->_scores = array();
190 $this->_termKeys = array();
192 if ($this->_term->field === null) {
193 // Search through all fields
194 $fields = $index->getFieldNames(true /* indexed fields list */);
196 $fields = array($this->_term->field);
199 $prefix = Zend_Search_Lucene_Index_Term::getPrefix($this->_term->text, $this->_prefixLength);
200 $prefixByteLength = strlen($prefix);
201 $prefixUtf8Length = Zend_Search_Lucene_Index_Term::getLength($prefix);
203 $termLength = Zend_Search_Lucene_Index_Term::getLength($this->_term->text);
205 $termRest = substr($this->_term->text, $prefixByteLength);
206 // we calculate length of the rest in bytes since levenshtein() is not UTF-8 compatible
207 $termRestLength = strlen($termRest);
209 $scaleFactor = 1/(1 - $this->_minimumSimilarity);
211 $maxTerms = Zend_Search_Lucene::getTermsPerQueryLimit();
212 foreach ($fields as $field) {
213 $index->resetTermsStream();
216 $index->skipTo(new Zend_Search_Lucene_Index_Term($prefix, $field));
218 while ($index->currentTerm() !== null &&
219 $index->currentTerm()->field == $field &&
220 substr($index->currentTerm()->text, 0, $prefixByteLength) == $prefix) {
221 // Calculate similarity
222 $target = substr($index->currentTerm()->text, $prefixByteLength);
224 $maxDistance = isset($this->_maxDistances[strlen($target)])?
225 $this->_maxDistances[strlen($target)] :
226 $this->_calculateMaxDistance($prefixUtf8Length, $termRestLength, strlen($target));
228 if ($termRestLength == 0) {
229 // we don't have anything to compare. That means if we just add
230 // the letters for current term we get the new word
231 $similarity = (($prefixUtf8Length == 0)? 0 : 1 - strlen($target)/$prefixUtf8Length);
232 } else if (strlen($target) == 0) {
233 $similarity = (($prefixUtf8Length == 0)? 0 : 1 - $termRestLength/$prefixUtf8Length);
234 } else if ($maxDistance < abs($termRestLength - strlen($target))){
235 //just adding the characters of term to target or vice-versa results in too many edits
236 //for example "pre" length is 3 and "prefixes" length is 8. We can see that
237 //given this optimal circumstance, the edit distance cannot be less than 5.
238 //which is 8-3 or more precisesly abs(3-8).
239 //if our maximum edit distance is 4, then we can discard this word
240 //without looking at it.
243 $similarity = 1 - levenshtein($termRest, $target)/($prefixUtf8Length + min($termRestLength, strlen($target)));
246 if ($similarity > $this->_minimumSimilarity) {
247 $this->_matches[] = $index->currentTerm();
248 $this->_termKeys[] = $index->currentTerm()->key();
249 $this->_scores[] = ($similarity - $this->_minimumSimilarity)*$scaleFactor;
251 if ($maxTerms != 0 && count($this->_matches) > $maxTerms) {
252 require_once 'Zend/Search/Lucene/Exception.php';
253 throw new Zend_Search_Lucene_Exception('Terms per query limit is reached.');
260 $index->skipTo(new Zend_Search_Lucene_Index_Term('', $field));
262 while ($index->currentTerm() !== null && $index->currentTerm()->field == $field) {
263 // Calculate similarity
264 $target = $index->currentTerm()->text;
266 $maxDistance = isset($this->_maxDistances[strlen($target)])?
267 $this->_maxDistances[strlen($target)] :
268 $this->_calculateMaxDistance(0, $termRestLength, strlen($target));
270 if ($maxDistance < abs($termRestLength - strlen($target))){
271 //just adding the characters of term to target or vice-versa results in too many edits
272 //for example "pre" length is 3 and "prefixes" length is 8. We can see that
273 //given this optimal circumstance, the edit distance cannot be less than 5.
274 //which is 8-3 or more precisesly abs(3-8).
275 //if our maximum edit distance is 4, then we can discard this word
276 //without looking at it.
279 $similarity = 1 - levenshtein($termRest, $target)/min($termRestLength, strlen($target));
282 if ($similarity > $this->_minimumSimilarity) {
283 $this->_matches[] = $index->currentTerm();
284 $this->_termKeys[] = $index->currentTerm()->key();
285 $this->_scores[] = ($similarity - $this->_minimumSimilarity)*$scaleFactor;
287 if ($maxTerms != 0 && count($this->_matches) > $maxTerms) {
288 require_once 'Zend/Search/Lucene/Exception.php';
289 throw new Zend_Search_Lucene_Exception('Terms per query limit is reached.');
297 $index->closeTermsStream();
300 if (count($this->_matches) == 0) {
301 return new Zend_Search_Lucene_Search_Query_Empty();
302 } else if (count($this->_matches) == 1) {
303 return new Zend_Search_Lucene_Search_Query_Term(reset($this->_matches));
305 $rewrittenQuery = new Zend_Search_Lucene_Search_Query_Boolean();
307 array_multisort($this->_scores, SORT_DESC, SORT_NUMERIC,
308 $this->_termKeys, SORT_ASC, SORT_STRING,
312 foreach ($this->_matches as $id => $matchedTerm) {
313 $subquery = new Zend_Search_Lucene_Search_Query_Term($matchedTerm);
314 $subquery->setBoost($this->_scores[$id]);
316 $rewrittenQuery->addSubquery($subquery);
319 if ($termCount >= self::MAX_CLAUSE_COUNT) {
324 return $rewrittenQuery;
329 * Optimize query in the context of specified index
331 * @param Zend_Search_Lucene_Interface $index
332 * @return Zend_Search_Lucene_Search_Query
334 public function optimize(Zend_Search_Lucene_Interface $index)
336 require_once 'Zend/Search/Lucene/Exception.php';
337 throw new Zend_Search_Lucene_Exception('Fuzzy query should not be directly used for search. Use $query->rewrite($index)');
344 * @throws Zend_Search_Lucene_Exception
346 public function getQueryTerms()
348 if ($this->_matches === null) {
349 require_once 'Zend/Search/Lucene/Exception.php';
350 throw new Zend_Search_Lucene_Exception('Search or rewrite operations have to be performed before.');
353 return $this->_matches;
357 * Constructs an appropriate Weight implementation for this query.
359 * @param Zend_Search_Lucene_Interface $reader
360 * @return Zend_Search_Lucene_Search_Weight
361 * @throws Zend_Search_Lucene_Exception
363 public function createWeight(Zend_Search_Lucene_Interface $reader)
365 require_once 'Zend/Search/Lucene/Exception.php';
366 throw new Zend_Search_Lucene_Exception('Fuzzy query should not be directly used for search. Use $query->rewrite($index)');
371 * Execute query in context of index reader
372 * It also initializes necessary internal structures
374 * @param Zend_Search_Lucene_Interface $reader
375 * @param Zend_Search_Lucene_Index_DocsFilter|null $docsFilter
376 * @throws Zend_Search_Lucene_Exception
378 public function execute(Zend_Search_Lucene_Interface $reader, $docsFilter = null)
380 require_once 'Zend/Search/Lucene/Exception.php';
381 throw new Zend_Search_Lucene_Exception('Fuzzy query should not be directly used for search. Use $query->rewrite($index)');
385 * Get document ids likely matching the query
387 * It's an array with document ids as keys (performance considerations)
390 * @throws Zend_Search_Lucene_Exception
392 public function matchedDocs()
394 require_once 'Zend/Search/Lucene/Exception.php';
395 throw new Zend_Search_Lucene_Exception('Fuzzy query should not be directly used for search. Use $query->rewrite($index)');
399 * Score specified document
401 * @param integer $docId
402 * @param Zend_Search_Lucene_Interface $reader
404 * @throws Zend_Search_Lucene_Exception
406 public function score($docId, Zend_Search_Lucene_Interface $reader)
408 require_once 'Zend/Search/Lucene/Exception.php';
409 throw new Zend_Search_Lucene_Exception('Fuzzy query should not be directly used for search. Use $query->rewrite($index)');
413 * Query specific matches highlighting
415 * @param Zend_Search_Lucene_Search_Highlighter_Interface $highlighter Highlighter object (also contains doc for highlighting)
417 protected function _highlightMatches(Zend_Search_Lucene_Search_Highlighter_Interface $highlighter)
421 $prefix = Zend_Search_Lucene_Index_Term::getPrefix($this->_term->text, $this->_prefixLength);
422 $prefixByteLength = strlen($prefix);
423 $prefixUtf8Length = Zend_Search_Lucene_Index_Term::getLength($prefix);
425 $termLength = Zend_Search_Lucene_Index_Term::getLength($this->_term->text);
427 $termRest = substr($this->_term->text, $prefixByteLength);
428 // we calculate length of the rest in bytes since levenshtein() is not UTF-8 compatible
429 $termRestLength = strlen($termRest);
431 $scaleFactor = 1/(1 - $this->_minimumSimilarity);
434 $docBody = $highlighter->getDocument()->getFieldUtf8Value('body');
435 $tokens = Zend_Search_Lucene_Analysis_Analyzer::getDefault()->tokenize($docBody, 'UTF-8');
436 foreach ($tokens as $token) {
437 $termText = $token->getTermText();
439 if (substr($termText, 0, $prefixByteLength) == $prefix) {
440 // Calculate similarity
441 $target = substr($termText, $prefixByteLength);
443 $maxDistance = isset($this->_maxDistances[strlen($target)])?
444 $this->_maxDistances[strlen($target)] :
445 $this->_calculateMaxDistance($prefixUtf8Length, $termRestLength, strlen($target));
447 if ($termRestLength == 0) {
448 // we don't have anything to compare. That means if we just add
449 // the letters for current term we get the new word
450 $similarity = (($prefixUtf8Length == 0)? 0 : 1 - strlen($target)/$prefixUtf8Length);
451 } else if (strlen($target) == 0) {
452 $similarity = (($prefixUtf8Length == 0)? 0 : 1 - $termRestLength/$prefixUtf8Length);
453 } else if ($maxDistance < abs($termRestLength - strlen($target))){
454 //just adding the characters of term to target or vice-versa results in too many edits
455 //for example "pre" length is 3 and "prefixes" length is 8. We can see that
456 //given this optimal circumstance, the edit distance cannot be less than 5.
457 //which is 8-3 or more precisesly abs(3-8).
458 //if our maximum edit distance is 4, then we can discard this word
459 //without looking at it.
462 $similarity = 1 - levenshtein($termRest, $target)/($prefixUtf8Length + min($termRestLength, strlen($target)));
465 if ($similarity > $this->_minimumSimilarity) {
466 $words[] = $termText;
471 $highlighter->highlight($words);
479 public function __toString()
481 // It's used only for query visualisation, so we don't care about characters escaping
482 return (($this->_term->field === null)? '' : $this->_term->field . ':')
483 . $this->_term->text . '~'
484 . (($this->_minimumSimilarity != self::DEFAULT_MIN_SIMILARITY)? round($this->_minimumSimilarity, 4) : '')
485 . (($this->getBoost() != 1)? '^' . round($this->getBoost(), 4) : '');