1. 程式人生 > >lucene 拼寫檢查

lucene 拼寫檢查

spellChecker是用來對使用者輸入的“檢索內容”進行校正,例如百度上搜索“麻辣將”,他的提示如下圖所示:

 我們首先借用lucene簡單實現該功能。

本文內容如下(簡單實現、原理簡介、現有問題)

 


 

lucene中spellchecker簡述

lucene 的擴充套件包中包含了spellchecker,利用它我們可以方便的實現拼寫檢查的功能,但是檢查的效果(推薦的準確程度)需要開發者進行調整、優化。

 

lucene實現“拼寫檢查”的步驟

步驟1:建立spellchecker所需的索引檔案

spellchecker也需要藉助lucene的索引實現的,只不過其採用了特殊的分詞方式和相關度計算方式。

建立spellchecker所需的索引檔案可以用文字檔案提供內容,一行一個片語,類似於字典結構。

例如(dic.txt):

麻辣燙

中文測試

麻辣醬

麻辣火鍋

中國人

中華人民共和國

建立spellchecker索引的關鍵程式碼如下:

     /**

 * 根據字典檔案建立spellchecker所使用的索引。

 *

 * @param spellIndexPath

 *            spellchecker索引檔案路徑

 * @param idcFilePath

 *            原始字典檔案路徑

 * @throws IOException

 */

public void createSpellIndex(String spellIndexPath, String idcFilePath)

        throws IOException {

    Directory spellIndexDir = FSDirectory.open(new File(spellIndexPath));

    SpellChecker spellChecker = new SpellChecker(spellIndexDir);

    IndexWriterConfig config = new IndexWriterConfig(Version.LUCENE_35,

            null);

    spellChecker.indexDictionary(new PlainTextDictionary(new File(

            idcFilePath)), config, false);

    // close

    spellIndexDir.close();

    spellChecker.close();

}

這裡使用了PlainTextDictionary物件,他實現了Dictionary介面,類結構如下圖所示:

除了PlainTextDictionary(1 word per line),我們還可以使用:

  • FileDictionary(1 string per line, optionally with a tab-separated integer value | 片語之間用tab分隔)
  • LuceneDictionary(Lucene Dictionary: terms taken from the given field of a Lucene index | 用現有的index的term建立索引)
  • HighFrequencyDictionary(HighFrequencyDictionary: terms taken from the given field of a Lucene index, which appear in a number of documents above a given threshold. | 在LuceneDictionary的基礎上加入了一定的限定,term只有出現在各document中的次數滿足一定數量時才被spellchecker採用)

例如我們採用luceneDictionary,主要程式碼如下:

/**

 * 根據指定索引中的字典建立spellchecker所使用的索引。

 *

 * @param oriIndexPath

 *            指定原始索引

 * @param fieldName

 *            索引欄位(某個欄位的字典)

 * @param spellIndexPath

 *            原始字典檔案路徑

 * @throws IOException

 */

public void createSpellIndex(String oriIndexPath, String fieldName,

        String spellIndexPath) throws IOException {

    IndexReader oriIndex = IndexReader.open(FSDirectory.open(new File(

            oriIndexPath)));

    LuceneDictionary dict = new LuceneDictionary(oriIndex, fieldName);

    Directory spellIndexDir = FSDirectory.open(new File(spellIndexPath));

    SpellChecker spellChecker = new SpellChecker(spellIndexDir);

    IndexWriterConfig config = new IndexWriterConfig(Version.LUCENE_35,

            null);

    spellChecker.indexDictionary(dict, config, true);

}

我們對dic.txt建立索引後,可以對其內部文件和term進行進一步瞭解,如下:

Document<stored,indexed,omitNorms,indexOptions=DOCS_ONLY<word:麻辣燙>>

Document<stored,indexed,omitNorms,indexOptions=DOCS_ONLY<word:中文測試>>

Document<stored,indexed,omitNorms,indexOptions=DOCS_ONLY<word:麻辣醬>>

Document<stored,indexed,omitNorms,indexOptions=DOCS_ONLY<word:麻辣火鍋>>

Document<stored,indexed,omitNorms,indexOptions=DOCS_ONLY<word:中國人>>

Document<stored,indexed,omitNorms,indexOptions=DOCS_ONLY<word:中華人民共和國>>

end1:人 

end1:燙  end1:試  end1:醬  end1:鍋  end2:國人 end2:測試 end2:火鍋 end2:辣燙 end2:辣醬 end3:共和國   

end4:民共和國   gram1:中 gram1:人 gram1:國 gram1:文 gram1:測 gram1:火 gram1:燙 gram1:試 gram1:辣

gram1:醬 gram1:鍋 gram1:麻 gram1:  gram2:中國    gram2:中文    gram2:國人    gram2:文測    gram2:測試    gram2:火鍋   

gram2:辣火    gram2:辣燙    gram2:辣醬    gram2:麻辣    gram2:麻 gram3:中華人   gram3:人民共   gram3:共和國   gram3:華人民   gram3:民共和  

gram4:中華人民  gram4:人民共和  gram4:華人民共  gram4:民共和國  start1:中    start1:麻    start1: start2:中國   start2:中文   start2:麻辣  

start2:麻    start3:中華人  start4:中華人民 word:中華人民共和國    word:中國人    word:中文測試   word:麻辣火鍋   word:麻辣醬    word:麻辣燙   

可以看出,每一個片語(dic.txt每一行的內容)被當成一個document,然後採用特殊的分詞方式對其進行分詞,我們可以看出field的名稱比較奇怪,例如:end1,end2,gram1,gram2等等。

為什麼這麼做,什麼原理?我們先留下這個疑問,看完效果後再說明!

 

步驟二:spellchecker的“檢查建議”

我們使用第一步建立的索引,利用spellChecker.suggestSimilar方法進行拼寫檢查。全部程式碼如下:

package com.fox.lab;

 

import java.io.File;

import java.io.IOException;

import java.util.Iterator;

 

import org.apache.lucene.index.IndexReader;

import org.apache.lucene.search.spell.LuceneDictionary;

import org.apache.lucene.search.spell.SpellChecker;

import org.apache.lucene.store.Directory;

import org.apache.lucene.store.FSDirectory;

 

/**

 * @author huangfox

 * @createDate 2012-2-16

 * @eMail [email protected]

 */

public class DidYouMeanSearcher {

    SpellChecker spellChecker = null;

    LuceneDictionary dict = null;

 

    /**

     *

     * @param spellCheckIndexPath

     *            spellChecker索引位置

     */

    public DidYouMeanSearcher(String spellCheckIndexPath, String oriIndexPath,

            String fieldName) {

        Directory directory;

        try {

            directory = FSDirectory.open(new File(spellCheckIndexPath));

            spellChecker = new SpellChecker(directory);

            IndexReader oriIndex = IndexReader.open(FSDirectory.open(new File(

                    oriIndexPath)));

            dict = new LuceneDictionary(oriIndex, fieldName);

        } catch (IOException e) {

            e.printStackTrace();

        }

    }

 

    /**

     * 設定精度,預設0.5

     *

     * @param v

     */

    public void setAccuracy(float v) {

        spellChecker.setAccuracy(v);

    }

 

    /**

     * 針對檢索式進行spell check

     *

     * @param queryString

     *            檢索式

     * @param suggestionsNumber

     *            推薦的最大數量

     * @return

     */

    public String[] search(String queryString, int suggestionsNumber) {

        String[] suggestions = null;

        try {

            // if (exist(queryString))

            // return null;

            suggestions = spellChecker.suggestSimilar(queryString,

                    suggestionsNumber);

        } catch (IOException e) {

            e.printStackTrace();

        }

        return suggestions;

    }

 

    private boolean exist(String queryString) {

        Iterator<String> ite = dict.getWordsIterator();

        while (ite.hasNext()) {

            if (ite.next().equals(queryString))

                return true;

        }

        return false;

    }

}

測試效果:

package com.fox.lab;

 

import java.io.IOException;

 

public class DidYouMeanMainApp {

 

    /**

     * @param args

     */

    public static void main(String[] args) {

        // 建立index

        DidYouMeanIndexer indexer = new DidYouMeanIndexer();

        String spellIndexPath = "D:\\spellchecker";

        String idcFilePath = "D:\\dic.txt";

        String oriIndexPath = "D:\\solrHome\\example\\solr\\data\\index";

        String fieldName = "ab";

        DidYouMeanSearcher searcher = new DidYouMeanSearcher(spellIndexPath,

                oriIndexPath, fieldName);

        searcher.setAccuracy(0.5f);

        int suggestionsNumber = 15;

        String queryString = "麻辣將";

//      try {

//          indexer.createSpellIndex(spellIndexPath, idcFilePath);

        // indexer.createSpellIndex(oriIndexPath, fieldName, spellIndexPath);

        // } catch (IOException e) {

        // e.printStackTrace();

        // }

        String[] result = searcher.search(queryString, suggestionsNumber);

        if (result == null || result.length == 0) {

            System.out.println("我不知道你要什麼,或許你就是對的!");

        } else {

            System.out.println("你是不是想找:");

            for (int i = 0; i < result.length; i++) {

                System.out.println(result[i]);

            }

        }

    }

 

}

輸出:

你是不是想找:

麻辣醬

麻辣火鍋

麻辣燙

將queryString改為“中文測式”,輸出:

你是不是想找:

中文測試

當輸入正確時,例如“中文測試”,則輸出:

我不知道你要什麼,或許你就是對的!

 


拼寫檢查的基本功能實現了,雖然還存在很多問題需要改進調整。我們先來了解其中兩個基本原理。

第一原理:N-gram

我們要實現spellchecker,其實簡單理解就是將使用者輸入的片語(英文為單詞,中文為片語)和字典裡面“標準”的片語進行“相似性”比較,並給出相似程度最高的片語。

那麼如何比較兩個字串的相似程度就是spellchecker的關鍵所在。

字串P 的N-gram 是P 中任意長度為N 的子串。例如,單詞waist 的Bigram 有wa、ai、is 和st 四個。對於給定的字串P 和W,其N-gram 相似度gram-count(P,W) 定義為同時在P 和W 中出現的N-gram 數目。在lucene的spellchecker中對N-gram進行了擴充套件,對整個單詞、單詞的頭尾都做了處理,例如:麻辣烤翅,分解成:

start2:麻   

start3:麻辣

 

end2:烤翅

end3:辣烤翅

 

gram2:烤翅   

gram2:辣烤   

gram2:麻辣   

gram2:麻

 

gram3:辣烤翅  

gram3:麻辣烤  

gram3:麻辣   

 

word:麻辣烤翅  

當用戶輸入“麻辣靠翅”時,被分解成:

end2:靠翅 end3:辣靠翅 gram2:靠翅 gram2:辣靠 gram2:麻辣 gram2:麻 gram3:辣靠翅 gram3:麻辣靠 gram3:麻辣 start2:麻 start3:麻辣 word:麻辣靠翅

並將這些term組成一個用OR連線的檢索式(不同的term可能賦予不同的權重),在spellchecker的索引裡進行檢索,即可匹配到文件“麻辣烤翅”。但是不是就要把它推薦(suggest)出來呢?還要看他們的相識度是否符合要求。在lucene的spellchecker中,預設相似度為0.5。

lucene——spellchecker的n-gram分詞演算法如下:

private static void addGram(String text, Document doc, int ng1, int ng2) {

  int len = text.length();

  for (int ng = ng1; ng <= ng2; ng++) {

    String key = "gram" + ng;

    String end = null;

    for (int i = 0; i < len - ng + 1; i++) {

      String gram = text.substring(i, i + ng);

      Field ngramField = new Field(key, gram, Field.Store.NO, Field.Index.NOT_ANALYZED);

      // spellchecker does not use positional queries, but we want freqs

      // for scoring these multivalued n-gram fields.

      ngramField.setIndexOptions(IndexOptions.DOCS_AND_FREQS);

      doc.add(ngramField);

      if (i == 0) {

        // only one term possible in the startXXField, TF/pos and norms aren't needed.

        Field startField = new Field("start" + ng, gram, Field.Store.NO, Field.Index.NOT_ANALYZED);

        startField.setIndexOptions(IndexOptions.DOCS_ONLY);

        startField.setOmitNorms(true);

        doc.add(startField);

      }

      end = gram;

    }

    if (end != null) { // may not be present if len==ng1

      // only one term possible in the endXXField, TF/pos and norms aren't needed.

      Field endField = new Field("end" + ng, end, Field.Store.NO, Field.Index.NOT_ANALYZED);

      endField.setIndexOptions(IndexOptions.DOCS_ONLY);

      endField.setOmitNorms(true);

      doc.add(endField);

    }

  }

}

  

 

第二原理:相似度計算(stringDistance)

在lucene的spellchecker中,StringDistance作為介面,有三個實現類,如下:

  • JaroWinklerDistance
  • LevensteinDistance
  • NGramDistance

我們這裡採用LevensteinDistance進行字串相似度計算。LevensteinDistance就是edit distance(編輯距離)。

編輯距離,又稱Levenshtein距離(也叫做Edit Distance),是指兩個字串之間,由一個轉成另一個所需的最少編輯操作次數。許可的編輯操作包括將一個字元替換成另一個字元,插入一個字元,刪除一個字元。

例如將kitten一字轉成sitting:

  sitten (k→s) 

  sittin (e→i) 

  sitting (→g) 

  俄羅斯科學家Vladimir Levenshtein在1965年提出這個概念。

lucene中演算法如下:

public float getDistance (String target, String other) {

      char[] sa;

      int n;

      int p[]; //'previous' cost array, horizontally

      int d[]; // cost array, horizontally

      int _d[]; //placeholder to assist in swapping p and d

       

        /*

           The difference between this impl. and the previous is that, rather

           than creating and retaining a matrix of size s.length()+1 by t.length()+1,

           we maintain two single-dimensional arrays of length s.length()+1.  The first, d,

           is the 'current working' distance array that maintains the newest distance cost

           counts as we iterate through the characters of String s.  Each time we increment

           the index of String t we are comparing, d is copied to p, the second int[].  Doing so

           allows us to retain the previous cost counts as required by the algorithm (taking

           the minimum of the cost count to the left, up one, and diagonally up and to the left

           of the current cost count being calculated).  (Note that the arrays aren't really

           copied anymore, just switched...this is clearly much better than cloning an array

           or doing a System.arraycopy() each time  through the outer loop.)

 

           Effectively, the difference between the two implementations is this one does not

           cause an out of memory condition when calculating the LD over two very large strings.

         */

 

        sa = target.toCharArray();

        n = sa.length;

        p = new int[n+1];

        d = new int[n+1];

       

        final int m = other.length();

        if (n == 0 || m == 0) {

          if (n == m) {

            return 1;

          }

          else {

            return 0;

          }

        }

 

 

        // indexes into strings s and t

        int i; // iterates through s

        int j; // iterates through t

 

        char t_j; // jth character of t

 

        int cost; // cost

 

        for (i = 0; i<=n; i++) {

            p[i] = i;

        }

 

        for (j = 1; j<=m; j++) {

            t_j = other.charAt(j-1);

            d[0] = j;

 

            for (i=1; i<=n; i++) {

                cost = sa[i-1]==t_j ? 0 : 1;

                // minimum of cell to the left+1, to the top+1, diagonally left and up +cost

                d[i] = Math.min(Math.min(d[i-1]+1, p[i]+1),  p[i-1]+cost);

            }

 

            // copy current distance counts to 'previous row' distance counts

            _d = p;

            p = d;

            d = _d;

        }

 

        // our last action in the above loop was to switch d and p, so p now

        // actually has the most recent cost counts

        return 1.0f - ((float) p[n] / Math.max(other.length(), sa.length));

    }

  

 


 

 需要改進的地方

1.精度不高,特別是對於兩個字的片語。可以在距離計算(相似度計算)方面進行調整。

2.沒有拼音的功能,例如麻辣kao翅,將無法進行校正。

3.對於字串中出現的錯誤無法進行校正,例如“常州哪裡有賣變態麻辣靠翅”。