基于Lucene的联系人拼音检索(第一部分)
需求
实现联系人信息(姓名,电话,邮件,地址等信息的快速实时检索)
姓名字段:全拼的任意相邻组合,每个单字拼音首字母的任意相邻组合,举例:沈从文的全拼是shencongwen,每个单字拼音首字母scw,那么检索shen,shencong,congwen,shencongwen, sc,cw,scw都要能检索出沈从文,当然中文也要ok,比如:沈从,从文,沈从文,沈都要能包含这一条结果,遵循这个思路设计的联系人搜索甚至不自觉的也支持了混合输入比如沈congwen也能检索出沈从文
电话:不少于三个字符的任意前缀后缀都要能检索出来。
邮件地址等信息:使用lucene的StandardAnalyzer来分词,所以搜索shencongwen@gamil.com这个邮箱地址不能使用任意前缀了,因为分词的结果是shencongwen gmail.com,所以只有输入shencongwen或者gmail.com才能包含这个结果。
设计
1. 首先要解决的第一个问题是拼音转换,还要支持简拼全拼,这个以前做FTP搜索的时候就接触过,不过因为速度问题没有使用,这次终于派上用场了,这个工具就是IBM的icu-project的java实现icu4j, 项目主页:http://icu-project.org(操蛋的政府这个网站也墙,害我花钱买了个代理才能看),这个网址:http://demo.icu-project.org/icu-bin/translit?TEMPLATE_FILE=data/translit_rule_main.html 上有很多转换规则的例子,我就是从这找的转换简拼的例子。
2. 第二个事N-Gram的问题,考虑到用户可能输入的既不是前缀也不是后缀,所以此处选择的是N-Gram技术,但不同于常用的N-Gram,我使用的从一边开始的单向的N-Gram,Lucene里的实现叫EdgeNGramTokenFilter,经但的N-Gram技术举例:
对于shencongwen, 2-Gram的结果是sh, he, en, nc, co,on,ng,gw,we,en, 分的太细了,联系人搜索不需要这么复杂EdgeNGramTokenFilter不同于传统的N-Gram, 同样的例子使用EdgeNGramTokenFilter从前往后取2-Gram的结果是sh, 一般是取min–max之间的所有gram,所以使用EdgeNGramTokenFilter取2-20的gram结果就是sh, she, shen, shenc, shenco, shencon, shencong, shencongw, shencongwe, shencongwen, 从这个例子也不难理解为什么我要选择使用EdgeNGramTokenFilter而非一般意义上的N-Gram, 考虑到用户可能输入的不是前缀而是后缀,所以为了照顾这些用户,我选择了从前往后和从后往前使用了两次EdgeNGramTokenFilter,这样不光是前缀、后缀,二十任意的字串都考虑进去了,所以大幅度的提高了搜索体验
3. 其他字段
这些字段就使用了标准的StandardAnalyzer,后续如果有需求会尝试IK-Analyzer: http://code.google.com/p/ik-analyzer/, 非常感谢开源社区的朋友们,越来越多的工具让我应接不暇。
实现
分词
中文全拼
//这个是全拼转换
Transliterator NAME_PINYIN_TRANSLITERATOR = Transliterator
.getInstance("Han-Latin;NFD;[[:NonspacingMark:][:Space:]] Remove");
+
WhitespaceTokenizer(Version.LUCENE_31,cs);
+
TokenStream result = new LowerCaseFilter(Version.LUCENE_31,stream);
result = new PimEdgeNGramTokenFilter(result,
PimEdgeNGramTokenFilter.Side.BACK,
2,
20));
result = new PimEdgeNGramTokenFilter(result,
PimEdgeNGramTokenFilter.Side.FRONT,
2,
20);
result = new org.apache.lucene.analysis.icu.ICUTransformFilter(result, NAME_PINYIN_TRANSLITERATOR);
result = new ICUTransformFilter(result,NAME_PINYIN_TRANSLITERATOR);
return result;
此处的PimEdgeNGramTokenFilter是我在EdgeNGramTokenFilter基础上修改的,因为EdgeNGramTokenFilter会丢弃过短的字符,比如你把minGram设置为4,那么所有长度小于4的token都被移除了,这个不符合我的需求,所以我拷贝了源码进行了修改,代码如下:
import java.io.IOException;
import org.apache.lucene.analysis.TokenFilter;
import org.apache.lucene.analysis.TokenStream;
import org.apache.lucene.analysis.tokenattributes.CharTermAttribute;
import org.apache.lucene.analysis.tokenattributes.OffsetAttribute;
/**
*
* @author cmm
* This {@link org.apache.lucene.analysis.Tokenizer} is copied and modified from {@link org.apache.lucene.analysis.EdgeNGramTokenFilter} to accept term with length less then min-gram
*/
public class PimEdgeNGramTokenFilter extends TokenFilter
{
public static final Side DEFAULT_SIDE = Side.FRONT;
public static final int DEFAULT_MAX_GRAM_SIZE = 1;
public static final int DEFAULT_MIN_GRAM_SIZE = 1;
/** Specifies which side of the input the n-gram should be generated from */
public static enum Side {
/** Get the n-gram from the front of the input */
FRONT {
@Override
public String getLabel() { return "front"; }
},
/** Get the n-gram from the end of the input */
BACK {
@Override
public String getLabel() { return "back"; }
};
public abstract String getLabel();
// Get the appropriate Side from a string
public static Side getSide(String sideName) {
if (FRONT.getLabel().equals(sideName)) {
return FRONT;
}
if (BACK.getLabel().equals(sideName)) {
return BACK;
}
return null;
}
}
private final int minGram;
private final int maxGram;
private Side side;
private char[] curTermBuffer;
private int curTermLength;
private int curGramSize;
private int tokStart;
private final CharTermAttribute termAtt = addAttribute(CharTermAttribute.class);
private final OffsetAttribute offsetAtt = addAttribute(OffsetAttribute.class);
/**
* Creates EdgeNGramTokenFilter that can generate n-grams in the sizes of the given range
*
* @param input {@link TokenStream} holding the input to be tokenized
* @param side the {@link Side} from which to chop off an n-gram
* @param minGram the smallest n-gram to generate
* @param maxGram the largest n-gram to generate
*/
public PimEdgeNGramTokenFilter(TokenStream input, Side side, int minGram, int maxGram) {
super(input);
if (side == null) {
throw new IllegalArgumentException("sideLabel must be either front or back");
}
if (minGram < 1) {
throw new IllegalArgumentException("minGram must be greater than zero");
}
if (minGram > maxGram) {
throw new IllegalArgumentException("minGram must not be greater than maxGram");
}
this.minGram = minGram;
this.maxGram = maxGram;
this.side = side;
}
/**
* Creates EdgeNGramTokenFilter that can generate n-grams in the sizes of the given range
*
* @param input {@link TokenStream} holding the input to be tokenized
* @param sideLabel the name of the {@link Side} from which to chop off an n-gram
* @param minGram the smallest n-gram to generate
* @param maxGram the largest n-gram to generate
*/
public PimEdgeNGramTokenFilter(TokenStream input, String sideLabel, int minGram, int maxGram) {
this(input, Side.getSide(sideLabel), minGram, maxGram);
}
@Override
public final boolean incrementToken() throws IOException {
while (true) {
if (curTermBuffer == null) {
if (!input.incrementToken()) {
return false;
} else {
curTermBuffer = termAtt.buffer().clone();
curTermLength = termAtt.length();
curGramSize = minGram;
tokStart = offsetAtt.startOffset();
}
}
if (curGramSize <= maxGram)
{
if(curGramSize > curTermLength)// if the remaining input is too short, we still generate a gram
{
clearAttributes();
offsetAtt.setOffset(tokStart + 0, tokStart + curTermLength);
termAtt.copyBuffer(curTermBuffer, 0, curTermLength);
curTermBuffer = null;
return true;
}
else
{
// grab gramSize chars from front or back
int start = side == Side.FRONT ? 0 : curTermLength - curGramSize;
int end = start + curGramSize;
clearAttributes();
offsetAtt.setOffset(tokStart + start, tokStart + end);
termAtt.copyBuffer(curTermBuffer, start, curGramSize);
curGramSize++;
return true;
}
}
curTermBuffer = null;
}
}
@Override
public void reset() throws IOException {
super.reset();
curTermBuffer = null;
}
}
中文简拼
与全拼完全类似,出了ICU使用的规则不一样,创建Transliterator的方法调用也不一样
WhitespaceTokenizer(Version.LUCENE_31,cs);
+
Transliterator NAME_ESOPINYIN_TRANSLITERATOR = Transliterator.createFromRules(
"Han-Latin;",
":: Han-Latin;[[:any:]-[[:space:][\uFFFF]]] { [[:any:]-[:white_space:]] >;::Null;[[:NonspacingMark:][:Space:]]>;",
Transliterator.FORWARD);
TokenStream result = new LowerCaseFilter(Version.LUCENE_31,stream);
result = new PimEdgeNGramTokenFilter(result,
PimEdgeNGramTokenFilter.Side.BACK,
2,
20));
result = new PimEdgeNGramTokenFilter(result,
PimEdgeNGramTokenFilter.Side.FRONT,
2,
20);
result = new org.apache.lucene.analysis.icu.ICUTransformFilter(result, NAME_ESOPINYIN_TRANSLITERATOR );
result = new ICUTransformFilter(result,NAME_PINYIN_TRANSLITERATOR);
return result;
电话号码字段
前后各一遍PimEdgeNGramTokenFilter即可,此处的minGram可以设置的大一点,不如4,因为用户搜索号码肯定要多输入几个才有区分度。
其他字段
如前所述,StandardAnalyzer
配置
为了使得项目可以自由配置和部署,所以编码过程将几乎所有可以配置的项都提取出来便于配置。比如拼音转换的规则,可以索引的字段,以及索引字段对应的联系人对象的属性名(此处使用apache beanutils来获取对象属性),包括搜索域也使用了MultiFieldQueryParser来配置,以便对多个域进行检索,对于索引使用了PerFieldAnalyzerWrapper来设置和配置不同域的不同分析器,检索使用MultiFieldQueryParser和配置文件来配置不同的检索域和其对应的分析器。
Rest检索和更新接口
最近接触了很多rest的需求,所以此处的联系人检索也做成了rest接口,使用的restlet框架实现,区分对待了检索和数据更新修改,检索的接口是
{account}/search?q=query
而数据更新的接口是
{account}/{contact}
前面之所以需要account是因为可以控制用户只检索自己的联系人,通过前端的用户认证安全机制来实现这一点,本模块不处理用户认证的任何细节,并且这个服务原则上也不应该是对外网提供服务,而是由外网服务器请求。