`
zhangljerry
  • 浏览: 144365 次
  • 性别: Icon_minigender_1
  • 来自: 杭州
社区版块
存档分类
最新评论

构建基于词典的Lucene分析器

阅读更多
构建基于词典的Lucene分析器
solo L
发布日期:2006年09月03日,更新日期:2006年10月03日
Lucene是Apache的一个基于Java的开放源代码的搜索软件包,也是目前最为流行的搜索软件包。但是对于绝大多数中文用户来说其提供的两个中文分析器(ChineseAnalyzer和CJKAnalyzer)的能力又太弱了,因此我们有必要开发适合自己的中文分析器。这篇文章中给出了一个基于词典的简单的实现。
实现这个中文分析器的过程就像是一场精彩的赛事。好了,让我们马上开始。

冗长的代码常常是复杂性的标志,会导致代码难以测试和维护。

这是我在近期的文章中随便找来的一句话,将用它来阐明我们将要做什么和做到什么程度。

既然是比赛嘛就不能没有对手!我们的两个对手分别是ChineseAnalyzer和CJKAnalyzer。兵法云:知己知彼,百战不殆。那就让我们一起来了解一下我们的这两位对手。

ChineseAnalyzer和CJKAnalyzer都源于Apache,但它们不是Lucene的核心组件而是Sandbox组件。它们都继承自Analyzer,这是Lucene的核心组件之一,负责完成分词任务。我们将要完成的基于词典的分析器MMChineseAnalyzer也继承了Analyzer,这是实现Lucene分析器所必须的。

了解了我们对手的名门血统之后,该看看他们可以做什么了。为了更好的进行描述,我们规定:如果没有特别的指出,那么Ci将表示一个字符。这样一个句子就可以表示成Ci的序列,即C1C2...Ci...Cn。

ChineseAnalyzer

如果用ChineseAnalyzer来切分句子C1C2...Ci...Cn,那么切分的结果为C1,C2,...,Ci,...,Cn。

您可以运行下面的代码来查看分词的效果:

清单 1. 测试ChineseAnalyzer分析器的分词效果

public static void main(String[] args) {
  Analyzer analyzer = new ChineseAnalyzer();
  QueryParser queryParser = new QueryParser( "field", analyzer);
  queryParser.setDefaultOperator(QueryParser.AND_OPERATOR);
  Query query = null;
  try {
    String test = "冗长的代码常常是复杂性的标志,会导致代码难以测试和维护。";
    query = queryParser.parse(test);
    System.out.println(query.toString("field"));
  } catch (ParseException e) {
    e.printStackTrace();
  }
}
清单 1 中的代码输出的结果为,“冗 长 的 代 码 常 常 是 复 杂 性 的 标 志 会 导 致 代 码 难 以 测 试 和 维 护”。

这段代码和清单 2、清单 9中的代码都是为了测试分析器的分词效果而特别构建的,它们的不同仅仅在于使用了不同的分析器,因此只在这里做一些解释,以后就不在赘述了。代码中的QueryParser使用相应的分析器analyzer将指定的字符串test进行切分,为了比较切分的效果我们只是简单的进行了输出。

CJKAnalyzer

如果用CJKAnalyzer来切分句子C1C2...Ci...Cn,那么切分的结果为C1C2,C2C3,...,Ci-1Ci,CiCi+1,...,Cn-1Cn。

您可以运行下面的代码来查看分词的效果:

清单 2. 测试CJKAnalyzer分析器的分词效果

public static void main(String[] args) {
  Analyzer analyzer = new CJKAnalyzer();
  QueryParser queryParser = new QueryParser( "field", analyzer);
  queryParser.setDefaultOperator(QueryParser.AND_OPERATOR);
  Query query = null;
  try {
    String test = "冗长的代码常常是复杂性的标志,会导致代码难以测试和维护。";
    query = queryParser.parse(test);
    System.out.println(query.toString("field"));
  } catch (ParseException e) {
    e.printStackTrace();
  }
}
清单 2 中的代码输出的结果为,“冗长 长的 的代 代码 码常 常常 常是 是复 复杂 杂性 性的 的标 标志 会导 导致 致代 代码 码难 难以 以测 测试 试和 和维 维护”。这段代码和清单 1 和 9 中的代码类似。

MMChineseAnalyzer

这是我们即将要实现的基于词典的中文分析器,使用它可以得到更好一些的分词效果,不信,您可以查看下面的输出结果(稍后我将给出完整的实现代码):

“冗长 的 代码 常常 是 复杂性 的 标志 会 导致 代码 难以 测试 和 维护”。

回页首
起跑
哎,等了这么久,队员们总算站到起跑线上了。比赛就要开始了 ...

文中的讨论和实现都是采用Unicode编码并且只处理了CJK Unified Ideographs和Basic Latin两个子集中的字符。

CJK Unified Ideographs中主要是象形文字字符,编码的范围(以十六进制表示的)从4E00开始到9FBF结束,共有20928个字符,这在Unicode编码中算是一个比较大的子集。您可以通过图示 1 来大概的了解一下该子集中都包含一些什么字符,这里不可能展示的很全。如果您对更加详细的内容感兴趣,请参阅 参考资料 ,在那里可以找到一个包含该子集中全部字符的PDF文档。

图示 1. CJK Unified Ideographs



Basic Latin中主要是ASCII码字符,编码的范围(以十六进制表示的)从0000开始到007F结束,共有128个字符。您可以通过图示 2 来大概的了解一下该子集中都包含一些什么字符,这里不可能展示的很全。如果您对更加详细的内容感兴趣,请参阅 参考资料 ,在那里可以找到一个包含该子集中全部字符的PDF文档。

图示 2. Basic Latin



这里说了这么多,不过看起来好象和今天的赛事没有什么太大的关系。其实不然,我们在扫描待分析的句子时将会判断每一个取到的字是否在CJK Unified Ideographs和Basic Latin字符集中。如果不在我们就简单的丢掉这个字符,换句话讲,我们只处理这两个字符集中的字符。这在大多数场景中已经完全可以满足需要了。如果您觉得这些还不够,则可以自己扩充需要的字符集。这些工作比较简单,您不妨亲自试一试。

细心的您可能发现还有一个问题我们没有提到。对,就是如果两个连续的字符分别属于不同的字符集时怎么处理。在这里提到的算法中我们将两个字符进行切分,具体的实现我们将在实现中详细描述。

回页首
加速
比赛已经开始了,队员们跑得越来越快 ...

词典结构对于基于词典的分析器就像是跑鞋对于队员们一样的重要,能够为他们的获胜助一臂之力。我们要使用的词典结构比较简单,是基于文本的。每个词占用一行并且可以使用#进行注释,注释的内容将会被忽略。词典中也不会收录单字词。

清单 3. 词典结构

#双字词
C1C2
#三字词
C3C4C5
#四字词
C6C7C8C9
:
:
依次类推,n字词可以表示为C1C2...Cn,注意这里不要和句子的表示混淆了。

在实际中我们的词典看起来是下面的这个样子,被存储在一个文本文件中,使用UTF-8编码。如果您想用类记事本的编辑器打开请注意编码问题。

清单 4. 以文本形式存在的实际的词典结构

代码
冗长
复杂性
导致
常常
标志
测试
维护
难以
:
:
有了这样的一个基于文本的词典,接下来就要考虑以什么样的规则将它装载到内存中更利于算法的实现了。

首先,我们先把n(n > 2)字词本身装载到内存中中,然后在把C1C2,C1C2C3,...,一直到C1C2...Cn-1这n-1个字串也装载到内存中,注意拆分后的这n-1个字串可能并不是词。如果n=2时我们直接装载C1C2,这时不进行拆分装载。因为我们没有收录单字词所以n不可能为1。

我们使用一个四字的词如一举成名来演示一下装载的过程。首先将一举成名装载到内存中,之后在将一举、一举成装载到内存中。按照这个装载规则,我们有:

清单 5. 装载之前的词典

:
复杂性
:
清单 6. 装载之后的词典

:
复杂性
复杂
:
我们之所以采用这样的装载规则是和算法的实现相关的,它可以使得算法的实现更加简洁。如果您读到这里还不太了解如此装载的目的,没有关系,您只要记住这种方式就可以了。相信在读完算法描述后会有豁然开朗的感觉,继续往下进行吧。

我们所采用的算法,简单的说,就是将句子从左到右扫描一遍,遇到词典中有的词,就把它切分出来。如果遇到如朴素大方这样的复合词就按最长规则取朴素大方进行匹配,而不是切分成朴素和大方两个词。如果有字串不能被识别,就把它切分成单字词。这样一个简单的分词算法就完成了。

下面我们具体的来描述一下这个算法:

1. 使用一个变量word来保存切分过程中的字符序列

2. 开始扫描句子C1C2...Ci...Cn,取出字符Ci

2.1 如果word的长度为0,那么将Ci附加到word中,将i增1,返回到步骤 2

2.2 如果word的长度大于0,那么将Ci和word连接到一起,并将连接后的字符序列与词典匹配(装载之后的形式)

2.2.1 如果匹配成功,则将Ci附加到word中同时将i增1,返回到步骤 2

2.2.2 如果匹配不成功,说明word中的字符序列是可匹配的最长的词,进行切分,同时将word清空(即长度为0)但保持i不变,返回到步骤 2

3. 当取完句子中的所有字符时结束

这只是对于算法的一个轮廓性的描述,并且只适合句子中只有汉字的情况。如果是汉字和拉丁文混合的情况,那么还要添加一些处理的逻辑。细节可以参阅完整的源代码,这里不在赘述。

回页首
冲刺
最后的精彩时刻来临了,队员们开始冲刺了 ...

清单 7. MMChineseAnalyzer分析器的完整源代码

package org.solol.lucene.analysis;

import java.io.Reader;
import java.util.Set;

import org.apache.lucene.analysis.Analyzer;
import org.apache.lucene.analysis.StopFilter;
import org.apache.lucene.analysis.TokenStream;

/**
* @author solo L
*
*/
public class MMChineseAnalyzer extends Analyzer {
  public final static String[] STOP_WORDS = {};

  private Set stopTable;

  public MMChineseAnalyzer() {
    stopTable = StopFilter.makeStopSet(STOP_WORDS);
  }

  public TokenStream tokenStream(String fieldName, Reader reader) {
    return new StopFilter(new MMChineseTokenizer(reader), stopTable);
  }
}
清单 7 中的类MMChineseAnalyzer是文中要实现的基于词典的分析器的主类,它没有直接实现分词的具体逻辑,而是将这些逻辑代理给了清单 8 中的MMChineseTokenizer类。其中的STOP_WORDS用来指定在切分的过程中要忽略的一些在中文中没有太大意义的字或词,比如的、地、得、这等等。

清单 8. MMChineseTokenizer的完整源代码

package org.solol.lucene.analysis;

import java.io.BufferedReader;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.InputStreamReader;
import java.io.Reader;
import java.util.TreeMap;

import org.apache.lucene.analysis.Token;
import org.apache.lucene.analysis.Tokenizer;

/**
* @author solo L
*
*/
public class MMChineseTokenizer extends Tokenizer {
  //我们没有处理5字或5字以上的次,如果您需要处理可以修改这里
  private static final int WORD_MAX_LENGTH = 4;

  private static TreeMap<String, String> dictionary = null;

  private static final int IO_BUFFER_SIZE = 2048;

  private int bufferIndex = 0;

  private int dataLength = 0;

  private int offset = 0;

  private final char[] ioBuffer = new char[IO_BUFFER_SIZE];

  private String tokenType = "word";

  public MMChineseTokenizer(Reader input) {
    this.input = input;
  }

  public Token next() throws IOException {
    //装载词典
    loadWords();

    StringBuffer word = new StringBuffer();

    while (true) {
      char c;
      char nextChar;
      Character.UnicodeBlock cUnicodeBlock;
      Character.UnicodeBlock nextCharUnicodeBlock;

      offset++;

      if (bufferIndex >= dataLength) {
        dataLength = input.read(ioBuffer);
        bufferIndex = 0;
      }

      if (dataLength == -1) {
        if (word.length() == 0) {
          return null;
        } else {
            break;
        }
      }

      c = ioBuffer[bufferIndex++];
      cUnicodeBlock = Character.UnicodeBlock.of(c);

      nextChar = ioBuffer[bufferIndex];
      nextCharUnicodeBlock = Character.UnicodeBlock.of(nextChar);
      boolean isSameUnicodeBlock = cUnicodeBlock.toString().equalsIgnoreCase(nextCharUnicodeBlock.toString());

      if (cUnicodeBlock == Character.UnicodeBlock.CJK_UNIFIED_IDEOGRAPHS) {
        tokenType = "double";
        if (word.length() == 0) {
          word.append(c);
          // 增强部分--开始
          if (word.length() != 0 && (!isSameUnicodeBlock)) {
            break;
          }
          // 增强部分--结束
         } else {
             String temp = (word.toString() + c).intern();
             if (dictionary.containsKey(temp)) {
               word.append(c);
               // 增强部分--开始
               if (word.length() != 0 && (!isSameUnicodeBlock)) {
                 break;
               }
               // 增强部分--结束
             } else {
                bufferIndex--;
                offset--;
                break;
             }
          }
       } else if (cUnicodeBlock == Character.UnicodeBlock.BASIC_LATIN) {
         tokenType = "single";
         if (Character.isWhitespace(c)) {
           if (word.length() != 0)
               break;
         } else {
             word.append(c);
             // 增强部分--开始
             if (word.length() != 0 && (!isSameUnicodeBlock)) {
               break;
             }
             // 增强部分--结束
         }
       }
    }

    Token token = new Token(word.toString(), offset - word.length(), offset, tokenType);
    word.setLength(0);
    return token;
  }

  public void loadWords() {
    if (dictionary == null) {
      dictionary = new TreeMap<String, String>();

      InputStream is = null;
      InputStreamReader isr = null;
      BufferedReader br = null;

      try {
        is = new FileInputStream("dictionary.txt");
        isr = new InputStreamReader(is, "UTF-8");
        br = new BufferedReader(isr);

        String word = null;

        while ((word = br.readLine()) != null) {

          int wordLength = word.length();

          if ((word.indexOf("#") == -1) && (wordLength <= WORD_MAX_LENGTH)) {

            dictionary.put(word.intern(), "1");

            int i = wordLength-1;
            while(i >= 2){
              String temp = word.substring(0, i).intern();
              if (!dictionary.containsKey(temp)) {
                dictionary.put(temp,"2");
              }
              i--;
            }
          }
        }
      } catch (IOException e) {
        e.printStackTrace();
      }finally{
        try {
          if(br!=null){
            br.close();
          }
          if(isr!=null){
            isr.close();
          }
          if(is!=null){
            is.close();
          }
        } catch (IOException e) {
          e.printStackTrace();
        }
      }
    }
  }
}
清单 8 中的代码有两处需要注意一下,分别是next()方法和loadWords()方法。在next()方法的开始处,我们调用了loadWords()方法,它依照文章前部描述的规则将词典装载到内存中。为了提高算法的效率这里使用了Singleton模式,这保证词典只被装载一次。另外,还专门选择了TreeMap这个数据结构,这有利于查找。

在装载了词典之后,我们就可以进行分词了。先将input输入流中的内容读到ioBuffer缓冲区中,下面将逐个扫描ioBuffer缓冲区中的字符,处理过程依照文章前部描述的算法过程,不在赘述。

在上面描述算法使用的两个字符集时,提到过如果有两个连续的字符分别属于不同的字符集时,我们也要把它们切分开。这体现在实现的代码中就是boolean isSameUnicodeBlock = cUnicodeBlock.toString().equalsIgnoreCase(nextCharUnicodeBlock.toString()),也就是我们同时也扫描了当前字符的下一个字符,并判断这两个字符是否处于同一个字符集。要是不处于同一字符集则将他们分开。

完成了清单 8 中的代码,我们的基于词典的分析器也就初步完成了。如果您要将它用于实际,我们希望您能自己进行一些测试,以确保可靠性。

回页首
获胜
终于撞线了 ...

所有代码都是在 eclipse 3.2 中完成的,您可以立即把它作为项目导入并运行它。

清单 9. 测试MMChineseAnalyzer分析器的分词效果

public static void main(String[] args) {
  Analyzer analyzer = new MMChineseAnalyzer();
  QueryParser queryParser = new QueryParser( "field", analyzer);
  queryParser.setDefaultOperator(QueryParser.AND_OPERATOR);
  Query query = null;
  try {
    String test = "冗长的代码常常是复杂性的标志,会导致代码难以测试和维护。";
    query = queryParser.parse(test);
    System.out.println(query.toString("field"));
  } catch (ParseException e) {
    e.printStackTrace();
  }
}
清单 9 给出了测试MMChineseAnalyzer分析器的代码,这和清单 1 清单 2 类似。您可以查看它的输出,这样就可以确信我们完成了任务。

回页首
结束语
分词是一项很有挑战性的任务,这里的实现还相当的初级,要想获得更佳的分词效果,还有很长的路要走。

如果您对分词非常的感兴趣,还想进一步深入的研究,我们建议您不妨从研究分词技术的发展历史开始,了解一下前辈们的智慧结晶。

参考资料
MMSeg是一个开放源代码的中文分词软件包,可以方便的和Lucene集成。它实现了MMSEG: A Word Identification System for Mandarin Chinese Text Based on Two Variants of the Maximum Matching Algorithm算法。

如果您还没有安装Eclipse3.2,那么可以到eclipse.org下载。虽然这不是必须的但是它可以使您更加容易的运行文中提到的代码。
要运行文中的代码,您还需要到这里下载 Lucene。
The Unicode Standard,这里是Unicode编码的官方主页。
CJK Unified Ideographs 包含全部CJK Unified Ideographs子集字符的PDF文档。
Basic Latin 包含全部Basic Latin子集字符的PDF文档。
Lucene 的官方主页
Lucene Sandbox
关于作者
solo L 一位有些理想主义的软件工程师,创建了solol.org。他常常在这里发表一些对技术的见解。

分享到:
评论

相关推荐

    lucene3源码分析

    ### Lucene3源码分析知识点概述 #### 一、全文检索的基本原理 ##### 1. 总论 全文检索系统是一种高效的信息检索技术,能够帮助用户在海量文档中快速找到包含特定关键词的信息。Lucene是Java领域内最受欢迎的全文...

    lucene5.5做同义词分析器

    本篇文章将深入探讨如何基于Lucene 5.5版本构建一个同义词分析器,以及它对理解Lucene内部分析构造的重要性。 首先,我们要明白同义词分析器在文本处理中的作用。在信息检索系统中,同义词分析器能够识别并处理具有...

    Lucene 原理与代码分析完整版.pdf

    下面,我们将基于给定文件的关键信息,提炼并扩展关于Lucene原理与代码分析的知识点。 ### 全文检索的基本原理 全文检索是指在大量文档集合中查找包含特定关键词或短语的所有文档的过程。Lucene作为开源全文检索...

    中文分词及其在基于Lucene的全文检索中的应用

    《中文分词及其在基于Lucene的全文检索中的应用》这篇论文主要探讨了中文分词在全文检索系统,特别是基于Lucene平台的应用。全文检索技术是现代信息检索领域的重要组成部分,而Lucene作为一款开源的全文检索引擎框架...

    lucene.net构建搜索引擎ppt

    **标题:“lucene.net构建搜索引擎PPT”** **一、Lucene.NET简介** Lucene.NET是Apache Lucene...以上是构建基于Lucene.NET的搜索引擎的基本流程和关键技术,通过深入学习和实践,您可以构建出高效、精准的搜索系统。

    lucene 原理与代码分析

    《Lucene原理与代码分析》深入探讨了几乎最新版本的Lucene的工作机制和代码实现细节,为...以上概述了Lucene的核心原理和代码分析中的关键点,通过深入理解这些内容,开发者能够更好地应用和优化基于Lucene的搜索系统。

    基于Lucene的全文检索引擎研究与应用.pdf

    ### 基于Lucene的全文检索引擎研究与应用 #### 概述 随着信息技术的飞速发展,尤其是互联网的普及,企业和个人积累了大量的电子文档。如何高效地管理和检索这些文档成为了亟待解决的问题。全文检索技术作为一种...

    基于Lucene的搜索系统-客户端调用

    在构建一个基于Lucene的搜索系统时,客户端调用是实现用户交互的关键环节。Lucene是一个高性能、全文检索库,它提供了丰富的API供开发者使用,能够帮助我们建立强大的搜索引擎。而在这个系统中,同时采用了Paoding这...

    关于Lucene的词典FST深入剖析-申艳超1

    《关于Lucene的词典FST深入剖析》这篇文章是由申艳超撰写的,主要探讨了Apache Lucene这个全文搜索引擎库中的一个关键数据结构——有限状态转换器(Finite State Transducer,简称FST)。FST在Lucene中被用于构建和...

    基于lucene的去词、禁词、搜索等操作

    它为开发者提供了强大的文本分析、索引和查询功能,使得构建高效的搜索应用变得简单。本资料包重点探讨了基于Lucene的核心操作,包括去词、禁词以及搜索等关键环节。 一、Lucene简介 Lucene是由Apache软件基金会...

    支持lucene的词典机械中文分词

    本文将深入探讨一种基于Lucene的词典机械中文分词方法,该方法采用了反向机械分词算法,尤其关注对数字、英文以及中英文数字混合词的特殊处理,旨在提高分词速度和准确性。 首先,反向机械分词算法是一种常用的中文...

    Lucene 原理与代码分析完整版

    段合并器执行合并存储域、合并标准化因子、合并词向量以及合并词典和倒排表等操作。 此外,Lucene的搜索过程包括对索引的搜索和对查询语句的处理。搜索过程中要对索引进行词法分析、语法分析和语言处理,然后根据...

    lucene分析

    《Lucene分析:深入理解搜索引擎核心技术》 Lucene,作为Apache软件基金会的开源项目,是Java语言编写的一个全文检索库,被广泛应用于各种搜索引擎的开发中。它提供了文本的索引和搜索功能,使得开发者可以方便地在...

    lucene 所有jar包 包含IKAnalyzer分词器

    `lucene-analyzers-3.6.1.jar`则包含了各种分析器,用于对输入文本进行预处理,包括分词、去除停用词、词形还原等。这些分析器是搜索引擎处理文本数据的关键,它们确保了搜索的准确性和效率。 接下来,我们要讨论的...

    Lucene2.1 Source Code,分词源码

    例如,IK 分词器就是一种常用的基于词典的中文分词器,它支持动态加载词典,能较好地处理常见词汇。 - **统计语言模型分词**:这种方法利用概率统计模型,如隐马尔可夫模型(HMM)或条件随机场(CRF),根据词语...

    lucene:基于Java的全文检索引擎简介

    对于中文这样的非空格分隔的语言,Lucene提供了一系列内置和可扩展的分析器(Analyzer)来处理文本。这些分析器可以实现不同的切分词策略,包括但不限于: - **基于词典的切分词**:依赖于预定义的词典来进行切分词,...

    lucene.NET 中文分词

    2. **配置分词器**:在Lucene.NET的索引创建阶段,需要配置Analyzer类,指定使用特定的分词器。例如,使用IK Analyzer可以创建`IKAnalyzer analyzer = new IKAnalyzer();`。 3. **字段分析**:在创建Document对象时...

    Lucene.Net_2_9_1.zip

    1. 分析器(Analyzer):Lucene.Net中的分析器负责将输入的文本分解为可搜索的词汇项。分析器的选择直接影响到搜索的准确性和效率。例如,英文分析器会处理停用词、词干提取等问题,而中文分析器则需要处理词的切分...

Global site tag (gtag.js) - Google Analytics