分享

Lucene 4 和 Solr 4 学习笔记(3)

 昵称11482448 2014-07-29

    当初说要写写lucene和solr的学习笔记,写了两个后就懒得写了。最近想做个lucene和solr的中文学习网站,翻译一些lucene和solr的英文资料,并提供一个中文的交流学习平台。所以想把这个系列继续下去。

    言归正传,上面说到我们的目标是学习和修改lucene/solr的源代码。不过如果我们从没有用过,那是不可能读懂源代码的。这里推荐《lucene in action》第二版,中文版也有,网上能够下载到英文版的,建议阅读英文版。这本书的第一作者Michael McCandless是现在Lucene PMC的成员,他的blog可以关注一下

http://blog.,另外两个作者是本书第一版的作者,好像现在在lucene和solr的开发中不是特别活跃,在lucene和solr的邮件列表中没怎么见过。

   下面我们简单的学习(或者复习)一下Lucene的建索引过程,我们将给出lucene 2.x/3.x 和 最新trunk正在开发的4.0的建立索引的方法,尤其是它们的区别。

    Lucene 2.x/3.x里建立索引并进行简单搜索的例子

  1. Directory dir=FSDirectory.open(new File("./testindex"));  
  2. for(String fn:dir.listAll()){  
  3.     dir.deleteFile(fn);  
  4. }  
  5. IndexWriter writer=new IndexWriter(dir,new WhitespaceAnalyzer(Version.LUCENE_36),IndexWriter.MaxFieldLength.UNLIMITED);  
  1. Document doc=new Document();  
  2. doc.add(new Field("id","0001",Field.Store.YES,Field.Index.NOT_ANALYZED));  
  3.        doc.add(new Field("body","hello world, this is text body part. ",Field.Store.NO,Field.Index.ANALYZED));  
  4.        doc.add(new Field("clickCount","10",Field.Store.YES,Field.Index.NO));  
  5.        writer.addDocument(doc);  
  6.       
  7.        doc=new Document();  
  8. doc.add(new Field("id","0002",Field.Store.YES,Field.Index.NOT_ANALYZED));  
  9.        doc.add(new Field("body","good bye. that is it. ",Field.Store.NO,Field.Index.ANALYZED));  
  10. doc.add(new Field("clickCount","3",Field.Store.YES,Field.Index.NO));  
  11. writer.addDocument(doc);  
  12.       
  13. writer.close();  
  14.       
  15. IndexReader reader=IndexReader.open(dir);  
  16. IndexSearcher searcher=new IndexSearcher(reader);  
  17. Query q=new TermQuery(new Term("body","is"));  
  18. TopDocs docs=searcher.search(q, 10);  
  19. for(int i=0;i<docs.totalHits;i++){  
  20.       int docId=docs.scoreDocs[i].doc;  
  21.       float score=docs.scoreDocs[i].score;  
  22.       doc=searcher.doc(docId);  
  23.   
  24.       System.out.println("id="+doc.get("id")+", clickcount="+doc.get("clickCount"));  
  25. }  
  26. reader.close();  
   

Lucene 4 里建立索引并进行简单搜索的例子

  1. Directory dir=FSDirectory.open(new File("./testindex"));  
  2. IndexWriterConfig cfg=new IndexWriterConfig(Version.LUCENE_40,new WhitespaceAnalyzer(Version.LUCENE_40));  
  3. cfg.setOpenMode(OpenMode.CREATE);  
  4. IndexWriter writer=new IndexWriter(dir,cfg);  
  5. Document doc=new Document();  
  6. doc.add(new Field("id","0001",StringField.TYPE_STORED));  
  7. doc.add(new TextField("body","hello world, this is text body part. "));  
  8. doc.add(new DocValuesField("clickcount",10,DocValues.Type.FIXED_INTS_8));  
  9. writer.addDocument(doc);  
  10.   
  11. doc=new Document();  
  12. doc.add(new Field("id","0002",StringField.TYPE_STORED));  
  13. doc.add(new TextField("body","good bye. that is it. "));  
  14. doc.add(new DocValuesField("clickcount",3,DocValues.Type.FIXED_INTS_8));  
  15. writer.addDocument(doc);  
  16.   
  17. writer.close();  
  18.   
  19. IndexReader reader=DirectoryReader.open(dir);  
  20. IndexSearcher searcher=new IndexSearcher(reader);  
  21. Query q=new TermQuery(new Term("body","is"));  
  22. TopDocs docs=searcher.search(q, 10);  
  23. DocValues docValues = MultiDocValues.getDocValues(reader, "clickcount");  
  24. Source source = docValues.getSource();  
  25. for(int i=0;i<docs.totalHits;i++){  
  26.   int docId=docs.scoreDocs[i].doc;  
  27.   float score=docs.scoreDocs[i].score;  
  28.   doc=searcher.doc(docId);  
  29.   
  30.   System.out.println("id="+doc.get("id")+", clickcount="+source.getInt(docId));  
  31. }  
  32. reader.close();  

       注意一下里面的区别。

       首先在lucene4里构建IndexWriter必须使用IndexWriterConfig,这个类是3.1才开始有的。以前建立索引相关的一些参数,比如使用什么DeletePolicy或者什么MergeScheduler,都是调用IndexWriter.setXXX,现在把所有这些配置都放到这个类里头了。而且2.x时构造IndexWriter时要特别小心,尤其是传入的Directory里有以前的索引时,你需要小心的处理以前的索引——到底是删除原来的所以索引从新构建还是在原来的索引的基础上增量索引。

       我上面的例子里需要清空原来的索引,在2.x/3.x的版本里,我需要自己删除原来的索引。当然你也可以使用这个构造函数:

       public IndexWriter(Directory d, Analyzer a, boolean create, IndexDeletionPolicy deletionPolicy, MaxFieldLength mfl)

       create为true就会删除原来的索引,false就会append原来的索引。

       但是有个问题:你如果想这样——如果原来索引存在,那么append,如果不存在,那么就create。原来是解决不了的,你必须先打开原来的索引,根据是否

抛出异常来判断原来是否存在,如果不存在就create,否则append

       使用IndexWriterConfig就不用担心了,可以简单的使用IndexWriterConfig.setOpenMode(OpenMode.CREATE_OR_APPEND);就好了。

  

       Document的变化   

       Document是lucene里基本的一个索引单元,如果和数据库比较的话,一般可以对应到表的一行。概念上,document包含许多的Field,Field可以对应到表的一列。不过与数据库使用固定的schema不同。lucene中一个document的Field是不固定的,比如document 0可能有title ,document 1可能没有。增加新的document到索引里是可以使用任意的Field。不过实际使用时Field一般还是固定的。

       在lucene3.x里,Document里用List<Fieldable> fields = new ArrayList<Fieldable>();

       Fieldable接口的继承关系为:

       

      另外还有个DateField,不过这个类已经deprecated了,在3.x保留它只是为了能读取老版本的索引。我们应该使用NemericField或者DateTools。

      在lucene in action第二版的2.6.1节讲到了这个类的用法,其实和JavaDoc里的基本没有区别,里面稍微提到了实现的细节,不过不是很详细,如果你是读的中文版,估计更会云里雾里,Trie竟然被翻译成了特里,google translate也不会这么翻译啊。

      

      我们继续回来把NumericField相关的内容讲完。

      不过在这之前需要说明一下在NumericField出现之前怎么索引数值类型。

      如果不需要支持范围查询,那么我们简单的把整数变成字符串就行了。但是要支持范围查询,那就有点麻烦了。因为Lucene2.x/3.x索引的基本单位是Term,保存在tis或者tii(前者是字典文件,后者是字典的索引,后面的学习笔记会详细说明,如果你想现在就了解,可以参考http://lucene./core/old_versioned_docs/versions/3_5_0/fileformats.html 和 http://www.cnblogs.com/forfuture1978/archive/2009/12/14/1623597.html)文件里。

      lucene要求term都是按照字典序(lexicographic sortable)排列,然后它的范围查询根据tii找到范围的起始Term,然后把这中间的所以Term展开成一个BooleanQuery。

      如果我们简单的把数字变成字符串,那么2  4  8 31的字母顺序变成了 2 31 4 8,那么查找[2, 4]的RangeQuery也会把31也搜索出来,这显然是错误的。

      当然最容易想到的trick就是在前面补0,比如把上面的数字变成字符串 02 04 08 31,这样就不会有问题了。但是补多少个0是个问题。补0太多了,浪费空间(不过lucene的tis会使用前缀压缩,所以还不算太坏);补0太少了,不能保存太大的数值。

      这是RangeQuery的第一个问题。第二个问题就是展开成所有的Term的BooleanOr的query有一个问题,那就是如果范围太大,那么可能包含非常多的Boolean Clause,较早的版本可能会抛出Too Many Boolean Clause的Exception。后来的版本做了改进,不展开所以的term,而是直接合并这些term的倒排表。这样的缺点是合并后的term的算分成了问题,比如tf,你是把所有的term的tf加起来算一个term,idf呢,coord呢?(lucene的Scoring也会在后面讲到,可以参考http://lucene./core/old_versioned_docs/versions/3_5_0/api/core/org/apache/lucene/search/Similarity.html

http://lucene./core/old_versioned_docs/versions/3_5_0/scoring.html

      算法我们暂且放下,即使我们可以合并成一个term,合并这些term的docIds也是很费时间的,因为这些信息都在磁盘上。

      Uwe Schindler(现在也是lucene PMC)基于Trie的数据结构做了优化。我们简单的介绍一下他的思路,具体的代码就不讲了,我会说明它们的位置,有兴趣的同学可以自己去看。

      其实思想很简单:

      1. 首先可以把数值转换成一个字符串,并且保持顺序。也就是说如果 number1 < number2 ,那么transform(number) < transform(number)。transform就是把数值转成字符串的函数,如果拿数学术语来说,transform就是单调的。

      1.1 首先float可以转成int,double可以转成long,并且保持顺序。

                这个是不难实现的,因为float和int都是4个字节,double和long都是8个字节,从概念上讲,如果是用科学计数法,把指数放在前面就行了,因为指数大的肯定大,指数相同的尾数大的排前面。 比如 0.5e3, 0.4e3, 0.2e4,那么逻辑上保存的就是<4, 0.2> <3, 0.5> <3, 0.4>,那么显然是保持顺序的。Java的浮点数采用了ieee 754的表示方法(参考http://docs.oracle.com/javase/6/docs/api/java/lang/Float.html#floatToIntBits(float)),它的指数在前,尾数在后。这很好,不过有一点,它的最高位是符号位,正数0,负数1。这样就有点问题了。

                那么我们怎么解决这个问题呢?如果这个float是正数,那么把它看成int也是正数,而且根据前面的说明,指数在前,所以顺序也是保持好的。如果它是个负数,把它看出int也是负数,但是顺序就反了,举个例子 <4,-0.2> <3, -0.5>,如果不看符号,显然是前者大,但是加上符号,那么正好反过来。也就是说,负数的顺序需要反过来,怎么反过来呢? 就是符号位不变,其它位0变成1,1变成0?具体怎么实现呢?还记得异或吗?1 ^ 0 = 1; 1 ^ 1 = 0,注意左边那个加粗的1,然后看第二个操作数,也就是想把一个位取反,那么与1异或运算就行了。类似的,如果想保持某一位不变,那么就让它与0异或。

                因此我们可以发现NumericUtils有这样一个方法,就是上面说的实现。

  1. public static int floatToSortableInt(float val) {  
  2.   int f = Float.floatToIntBits(val);  
  3.   if (f<0) f ^= 0x7fffffff;  
  4.   return f;  
  5. }  

                同理,double也可以转成long

        1.2  一个int可以转换成一个字符串,并且保持顺序

                我们这里考虑的是java的int,也就是有符号的32位正数,补码表示。如果只考虑正数,从0x0-0x7fffffff,那么它的二进制位是升序的(也就是把它看成无符号整数的时候);如果只考虑负数,从0x10000000-0xffffffff,那么它的二进制位也是升序的。唯一美中不足的就是负数排在正数后面。

                因此如果我们把正数的最高符号位变成1,把负数的最高符号位变成0,那么就可以把一个int变成有序的二进制位。

                我们可以在intToPrefixCoded看到这样的代码:int sortableBits = val ^ 0x80000000;

                因为lucene只能索引字符串,那么现在剩下的问题就是怎么把一个4个byte变成字符串了。Java在内存使用Unicode字符集,并且一个Java的char占用两个字节(16位),我们可能很自然的想到把4个byte变成两个char。但是Lucene保存Unicode时使用的是UTF-8编码,这种编码的特点是,0-127使用一个字节编码,大于127的字符一般两个字节,汉字则需要3个字节。这样4个byte最多需要6个字节。其实我们可以把32位的int看出5个7位的整数,这样的utf8编码就只有5个字节了。这段代码就是上面算法的实现:

  1. int sortableBits = val ^ 0x80000000;  
  2. sortableBits >>>= shift;  
  3. while (nChars>=1) {  
  4.   // Store 7 bits per character for good efficiency when UTF-8 encoding.  
  5.   // The whole number is right-justified so that lucene can prefix-encode  
  6.   // the terms more efficiently.  
  7.   buffer[nChars--] = (char)(sortableBits & 0x7f);  
  8.   sortableBits >>>= 7;  
  9. }  

                 首先把val用前面说的方法变成有序的二进制位。然后把一个32位二进制数变成5个7位的正数(0-127)。

                 细心的读者可以会发现sortableBits >>>= shift;这行代码。我们下面会讲到这点。

                 总结一下,我们可以通过上面的方法把Java里常见的数值类型(int,float,long,double)转成字符串,并且保持顺序。【大家可以思考一下其它的类型比如short】。这样很好的解决了用原来的方法需要给整数补0的问题。

                 现在我们来看看第二个问题:范围查询时需要展开的term太多的问题。参考下图:


    引自Schindler, U, Diepenbroek, M, 2008. Generic XML-based Framework for Metadata Portals. Computers & Geosciences 34 (12)

            我们可以建立trie结构的索引。比如我们需要查找423--642直接的文档。我们只需要构建一个boolean or query,包含6个term(423,44,5,63,641,642)就行了。而不用构建一个包含11个term的query。当然要做到这点,那么需要在建索引的时候把445和446以及448的docId都合并到44。怎么做到这一点呢?我们可以简单的构建一个分词器。比如423我们同时把它分成3个词,4,42和423。当然这是把数字直接转成字符串,我们可以用上面的方法把一个整数变成一个UTF8的字符串。但现在的问题是怎么索引它的前缀。比如在上图中,我们把423“分词”成423,42,4;类似的,我们可以把一个二进制位也进行“前缀”分词,比如6的二进制位表示是110,那么我们可以同时索引它的前缀11和1。当然对于上图,对于423,我们可以只分词成423和4,也就是只索引百位,这样trie索引本身要小一些,对某些query,比如搜索300-500,和原来一样,只需要搜索term “4”,但是某些query,比如搜索420-450,那么需要搜索更多的term。

            因此NumericRangeQuery有一个precisionStep,默认是4,也就是隔4位索引一个前缀,比如0100,0011,0001,1010会被分成下列的二进制位“0100,0011,0001,1010“,”0100,0011,0001“,”0100,0011“,”0100“。这个值越大,那么索引就越小,那么范围查询的性能(尤其是细粒度的范围查询)也越差;这个值越小,索引就越大,那么性能越差。这个值的最优选择和数据分布有关,最优值的选择只能通过实验来选择。

            另外还有一个问题,比如423会被分词成423,42和4,那么4也会被分词成4,那么4表示哪个呢?

            所以intToPrefixCoded方法会额外用一个char来保存shift:buffer[0] = (char)(SHIFT_START_INT + shift);

            比如423分词的4的shift是2(这里是10进制的例子,二进制也是同样的),423分成423的shift是0,4的shift是0,因此前缀肯定比后缀大。

             上面说了怎么索引,那么Query呢?比如我给你一个Range Query从423-642,怎么找到那6个term呢?

             需要说明的一点:我们虽然概念上有一棵树,实际上,我们的这棵树和一般书的表示方法有些不同。一般的树保存了一个节点的孩子节点(当然有的还保存了父亲节点),我们这里正好相反,只能知道一个节点的父亲节点(前缀)。

             我们首先可以用shift==0找到范围的起点后终点(有可能没有相等的,比如搜索422,也会找到423)。然后一直往上找,直到找到一个共同的祖先(肯定能找到,因为树根是所有叶子节点的祖先),对应起点,每次往上走的时候都要把它右边的兄弟节点都加进去。

             比如423没有兄弟,42右边的兄弟是44,4的兄弟是5;

             642左边的兄弟是641,64左边的兄弟是63。

             上面说明原理时我使用了十进制的例子,二进制也是一样的,具体细节参考NumericUtils.splitRange

     

             这一部分先就到这吧。因为我事先也没有规划,代码看到哪里就写到哪里,有些地方可能过于详细,过于细节了,不懂也没有关系,一旦哪天用到了再来看体会就更深刻。另外如果对这个学习笔记有什么建议,比如想了解lucene/solr哪些方面的实现也可以跟我交流,我也可以有针对性的写一下那些内容。

             

            

    本站是提供个人知识管理的网络存储空间,所有内容均由用户发布,不代表本站观点。请注意甄别内容中的联系方式、诱导购买等信息,谨防诈骗。如发现有害或侵权内容,请点击一键举报。
    转藏 分享 献花(0

    0条评论

    发表

    请遵守用户 评论公约

    类似文章 更多