前面几个章节我们使用到了 Lucene 的中文分词器 HanLPAnalyzer,它并不是 Lucene 自带的中文分词器。Lucene 确实自带了一些中文分词器,但是效果比较弱,在生产实践中多用第三方中文分词器。分词的效果直接影响到搜索的效果,比如默认的 HanLPAnalyser 对「北京大学」这个短语的处理是当成完整的一个词,搜索「北京」这个词汇就不一定能匹配到包含「北京大学」的文章。对语句的处理还需要过滤掉停用词,除掉诸于「的」、「他」、「是」等这样的辅助型词汇。如果是英文还需要注意消除时态对单词形式的影响,比如「drive」和「driven」、「take」和「taked」等。还有更加高级的领域例如同义词、近音词等处理同样也是分词器需要考虑的范畴。

Lucence 中的分词器包含两个部分,分别是切词器 Tokenizer 和过滤器 TokenFilter。切词器顾名思义负责切,将一个句子切成一连串单词流,切词器输出的单词流是过滤器的输入,它负责去掉无用的词汇比如停用词,过滤器还可以是词汇转换,比如大小写转换,过滤器还可以生成新词汇,比如同义词。抽象类 Tokenizer 和 TokenFilter 都继承自 TokenStream 抽象类,Tokenizer 负责将文本(Reader)转成单词流,TokenFilter 负责将输入单词流转成另一个单词流。

图片

有了上图中的流水线构造出的最终的 TokenStream,Lucene 就会将输入的文章灌入其中得到最终的单词流,然后对单词流中的每个单词建立 Key 到 PostingList 的映射以形成倒排索引。这里的单词流串联的是带有 Payload 的单词,每个单词都会有一些附加属性,诸于单词的文本、单词在文档中的偏移量、单词在单词流中的位置等。

而 Lucene 的分词器 Analyzer 就是上述流水线的工厂类,由它负责制造整条流水线。Lucene 内置了很多种不同功用的分词器,每种分词器都会生产出不同的流水线。

下面我们使用 Lucene 提供的标准切词器观察分词效果,标准切词器是一个基于空格的切词器。

var tokenizer = new StandardTokenizer();
tokenizer.setReader(new StringReader("Dog eat apple and died"));
tokenizer.reset();
var termAttr = tokenizer.addAttribute(CharTermAttribute.class);
var offsetAttr = tokenizer.addAttribute(OffsetAttribute.class);
var positionIncrAttr = tokenizer.addAttribute(PositionIncrementAttribute.class);
while(tokenizer.incrementToken()) {System.out.printf("%s offset=%d,%d position_incr=%d\n", termAttr.toString(), offsetAttr.startOffset(), offsetAttr.endOffset(), positionIncrAttr.getPositionIncrement());
}-------------
Dog offset=0,3 position_incr=1
eat offset=4,7 position_incr=1
apple offset=8,13 position_incr=1
and offset=14,17 position_incr=1
died offset=18,22 position_inc=1

incrementToken() 表示往前走一个词,单词位置+1,到了文本末尾它就会返回 false。termAttr、offsetAttr 和 positionIncrAttr 都是当前单词位置上的附加属性,分别是单词的文本、字符偏移量的开始和结束位置和单词的位置间隔(一般都是 1),这三个属性就停在那里「守株待兔、雁过拔毛」,来一个单词,就立即抽取它的属性值。其中 positionIncrement 代表单词的位置间隔,通常连续两个单词之间的间隔都是 1。

图片

下面我们再加上过滤器,将停用词过滤掉,同时再加上大小写转换器,将大写字母转成小写字母。从代码形式上过滤器和切词器会通过构造器串联起来形成一条流水线。

var tokenizer = new StandardTokenizer();
tokenizer.setReader(new StringReader("Dog eat apple and died"));
var stopFilter = new StopFilter(tokenizer, StopFilter.makeStopSet("and"));
var lowercaseFilter = new LowerCaseFilter(stopFilter);
lowercaseFilter.reset();var termAttr = lowercaseFilter.addAttribute(CharTermAttribute.class);
var offsetAttr = lowercaseFilter.addAttribute(OffsetAttribute.class);
var positionIncrAttr = lowercaseFilter.addAttribute(PositionIncrementAttribute.class);while(lowercaseFilter.incrementToken()) {System.out.printf("%s offset=%d,%d position_incr=%d\n", termAttr.toString(), offsetAttr.startOffset(), offsetAttr.endOffset(), positionIncrAttr.getPositionIncrement());
}-------------
dog offset=0,3 position_incr=1
eat offset=4,7 position_incr=1
apple offset=8,13 position_incr=1
died offset=18,22 position_incr=2

注意和前面的例子输出进行对比,所有的单词 offset 值并没有发生变化,因为它表示的是在原文中的字符偏移量,而 position_incr 却发生了变化,因为它代表的是单词序列的位置。当停用词被过滤后,单词序列发生了变化,相应的位置也会跟着改变。

图片

下面我们来编写分词器 Analyzer 将上述切词器、过滤器进行打包封装

var analyzer = new Analyzer(){@Overrideprotected TokenStreamComponents createComponents(String fieldName) {var tokenizer = new StandardTokenizer();var stopFilter = new StopFilter(tokenizer, StopFilter.makeStopSet("and"));var lowercaseFilter = new LowerCaseFilter(stopFilter);return new TokenStreamComponents(tokenizer, lowercaseFilter);}
};
var stream = analyzer.tokenStream("title", "dog eat apple and died");
stream.reset();
var termAttr = stream.addAttribute(CharTermAttribute.class);
var offsetAttr = stream.addAttribute(OffsetAttribute.class);
var positionIncrAttr = stream.addAttribute(PositionIncrementAttribute.class);
while(stream.incrementToken()) {System.out.printf("%s offset=%d,%d position_incr=%d\n", termAttr.toString(), offsetAttr.startOffset(), offsetAttr.endOffset(), positionIncrAttr.getPositionIncrement());
}----------
dog offset=0,3 position_incr=1
eat offset=4,7 position_incr=1
apple offset=8,13 position_incr=1
died offset=18,22 position_incr=2

注意到 analyzer 的 createComponents 有一个 fieldName 参数,这意味着分析器支持为不同的字段定制不同的流水线,这里的 Component 含义就是流水线。analyzer 之所以将流水线的制造过程抽象出来就是为了考虑对象的复用,流水线可以很复杂,涉及到非常繁多的对象构建,analyzer 内部会每个线程共用同一条流水线。当单个流水线对象处理一条又一条文本内容时,需要通过 reset() 方法来重置流水线的状态避免前一条文本内容的状态遗留给后面的内容。

同义词过滤器 SynonymGraphFilter

有一个面试常见的题目就是 Lucene 的同义词搜索是如何实现的?它的实现方式就是通过过滤器对单词流进行泛化扩充,将一个单词变成多个单词,再插入到倒排索引中,在查询阶段也对查询关键词进行同义扩展成多个词汇再合并查询。Lucene 提供了同义词过滤器的默认实现 SynonymFilter,如今在新的版本中它已经被 SynonymGraphFilter 替换,提供了更加精准的实现。同停用词过滤器一样,使用它需要用户自己添加一个同义词表。下面的代码给词汇 dog 增加了同义词 puppy 和 pup。

var analyzer = new Analyzer(){@Overrideprotected TokenStreamComponents createComponents(String fieldName) {var tokenizer = new StandardTokenizer();var lowercaseFilter = new LowerCaseFilter(tokenizer);var builder = new SynonymMap.Builder();builder.add(new CharsRef("dog"), new CharsRef("puppy"), true);builder.add(new CharsRef("dog"), new CharsRef("pup"), true);SynonymMap synonymMap = null;try {synonymMap = builder.build();} catch (IOException ignored) {}assert synonymMap != null;var synonymFilter = new SynonymGraphFilter(lowercaseFilter, synonymMap, true);var stopFilter = new StopFilter(synonymFilter, StopFilter.makeStopSet("and"));return new TokenStreamComponents(tokenizer, stopFilter);}
};
var stream = analyzer.tokenStream("title", "dog eat apple and died");
stream.reset();
var termAttr = stream.addAttribute(CharTermAttribute.class);
var offsetAttr = stream.addAttribute(OffsetAttribute.class);
var positionIncrAttr = stream.addAttribute(PositionIncrementAttribute.class);
while(stream.incrementToken()) {System.out.printf("%s offset=%d,%d position_incr=%d\n", termAttr.toString(), offsetAttr.startOffset(), offsetAttr.endOffset(), positionIncrAttr.getPositionIncrement());
}-----------
puppy offset=0,3 position_incr=1
pup offset=0,3 position_incr=0
dog offset=0,3 position_incr=0
eat offset=4,7 position_incr=1
apple offset=8,13 position_incr=1
died offset=18,22 position_incr=2

从结果中我们能看出几个问题,第一个是 puppy 的长度是 5,但是 offset 还是原词 dog 的 offset,长度是 3。这意味着 TokenStream 中词汇的长度和 offset 不一定会 match。第二个问题是 puppy 和 dog 、pup 是同义词,但是 position_incr 很明显不一样,只有第一个词汇的增量是 1,其它同义词汇都是原地打转。至于为什么 puppy 在单词流中排在第一个位置而不是 dog,这个实际上是不确定的,它也不会对后续的搜索结果产生任何影响。

图片

位置对短语查询 PhraseQuery 的影响

在上一节我们介绍了 Lucene 自带的短语查询功能,它有一个重要的参数 slop,代表着短语之间的最大位置间隔。下面我们来看看同义词对短语查询会产生怎样的影响。下面的代码将会用到上面构造的 analyzer 分析器实例,在构建索引和查询阶段都会用到。

var directory = new RAMDirectory();
var config = new IndexWriterConfig(analyzer);
var indexWriter = new IndexWriter(directory, config);var doc = new Document();
doc.add(new TextField("title", "dog eat apple and died", Field.Store.YES));
indexWriter.addDocument(doc);doc = new Document();
doc.add(new TextField("title", "puppy eat apple and died", Field.Store.YES));
indexWriter.addDocument(doc);doc = new Document();
doc.add(new TextField("title", "pup eat apple and died", Field.Store.YES));
indexWriter.addDocument(doc);indexWriter.close();var reader = DirectoryReader.open(directory);
var searcher = new IndexSearcher(reader);
var parser = new QueryParser("title", analyzer);
var query = parser.parse("\"dog eat\"~0");
System.out.println(query);
var hits = searcher.search(query, 10).scoreDocs;
for (var hit : hits) {doc = searcher.doc(hit.doc);System.out.printf("%.2f => %s\n", hit.score, doc.get("title"));
}
reader.close();
directory.close();------------
title:"(puppy pup dog) eat"
1.51 => dog eat apple and died
0.99 => puppy eat apple and died
0.99 => pup eat apple and died

从代码中可以看到 QueryParser 会将查询短语进行同义扩展变成 OR 表达式(puppy OR pup OR dog),三个文档都被正确的匹配出来了,只不过原词的得分会偏高一些。另外代码中我们使用了 RAMDirectory,这个是用来进行测试的基于内存的虚拟文件目录,使用起来比较方便不需要指定文件路径拿来即用。这个类在 Lucene 的新版本中已经被置为 deprecated,被 MMapDirectory 所取代。MMapDirectory 使用起来和 FSDirectory 差不多,需要指定文件路径。

同义词搜索是如何做到的?相关推荐

  1. Elasticsearch学习笔记6: 同义词搜索实现

    2019独角兽企业重金招聘Python工程师标准>>> es的同义词搜索功能通过自定义分析器实现 我们知道 一个 分析器 就是在一个包里面组合了三种函数的一个包装器, 三种函数按照顺 ...

  2. Elasticsearch1.x 基于lc-pinyin和ik分词实现 中文、拼音、同义词搜索

    一.简介 有时候我们需要在项目中支持中文 和 拼音的搜索.采用ik分词来做中文分词是目前比好的方式.至于拼音分词可以采用lc-pinyin,虽然lc-pinyin能很好的解决首字母和全拼的搜索,但是在 ...

  3. es html标签,Elasticsearch如何使用同义词搜索富文本html标签过滤以及分权限过滤搜索结果...

    如何建立恰当的索引结点 { "mappings": { "data": { "properties": { "answer_id& ...

  4. elasticsearch ik分词实现 中文、拼音、同义词搜索

    EasticSearch版本:1.5.2 1.配置分词器:配置IK,参照 <ElasticSearch 安装和使用IK分词器> 2.拼音分词器配置:使用已经编译好的:elasticsear ...

  5. Elasticsearch:使用同义词 synonyms 来提高搜索效率

    在我们的很多情况下,我们希望在搜索时,有时能够使用一个词的同义词来进行搜索,这样我们能搜索出来更多相关的内容.我们可以通过 text analysis 来帮助我们形成同义词.如果大家对 Elastic ...

  6. lucene构建同义词分词器

    lucene4.0版本号以后 已经用TokenStreamComponents 代替了TokenStream流.里面包含了filter和tokenizer 在较复杂的lucene搜索业务场景下,直接网 ...

  7. 10个小窍门,让你轻松准确搜索。

    10个小窍门,让你轻松准确搜索. 在今天,用户可以通过搜索引擎轻松找出自己想要的信息,但还是难以避免结果不尽如人意的情况.实际上,用户仅需掌握几个常用技巧即可轻松化解这种尴尬. 下面介绍10个在进行G ...

  8. 使用搜索引擎的10个搜索技巧

    在今天,用户可以通过搜索引擎轻松找出自己想要的信息,但还是难以避免结果不尽如人意的情况.实际上,用户仅需掌握几个常用技巧即可轻松化解这种尴尬.下面介绍 10 个在进行 Google 搜索时可以使用的便 ...

  9. php拉查询封装,php实现搜索类封装示例

    /** * SoClass.php * 索引与搜索类 */ class SoClass { private $_xindex; private $_xsearch; private $_project ...

最新文章

  1. UUID.randomUUID()生成唯一识别码
  2. Gym迎来首个完整环境文档,强化学习入门更加简单!
  3. 清华《摸鱼学导论》开班啦!1000多学子在线摸鱼,无期末考试
  4. ASP.NET MVC笔记
  5. 直播 | ICML 2021论文解读:具有局部和全局结构的自监督图表征学习
  6. 高并发负载均衡(三):LVS的DR模型试验搭建
  7. Spring休眠教程
  8. 【Cocos2d入门教程五】Cocos2d-x动作篇
  9. Hadoop3.1.3安装教程_单机/伪分布式配置_Hadoop3.1.3/Ubuntu18.04(16.04)
  10. 基于地理区域的广告推送模块
  11. HDU 4931 Happy Three Friends(水)
  12. FEEDSKY获得风险投资
  13. 基于java Swing 框架使用socket技术开发的即时通讯系统【源码+数据库】
  14. PG-Strom学习总结
  15. Android 编译优化探索
  16. 我爱你用计算机按出来,iPhone计算器魔法技巧 简单几步获取对方手机号
  17. html5新特性的理解
  18. 【Paper】2020_Anomaly Detection and Identification for Multiagent Systems Subjected to Physical Faults
  19. 记录一个网易云IM和直播功能中,服务器API的Java调用代码
  20. 新特性解读 | MySQL 8.0 对 GTID 的限制解除

热门文章

  1. 联邦图神经网络:概述、技术和挑战
  2. hdu 6029 Graph Theory 【直接连线】
  3. Review paper [Moodlens: an emoticon-based sentiment analysis system for chinese tweets]
  4. MISCONF Redis is configured to save RDB snapshots, but is currently not able to persist on disk.问题解决
  5. 阿里巴巴2017实习生笔试题+JAVA工程师能力评估部分题目
  6. 关于css中“点“,“井号“,“逗号“,“空格“,“冒号“的用法
  7. “聘宝”上线“轻简ATS系统”,在推荐之后增加管理工作,打造一站式智能招聘_36氪...
  8. 制作一杯热咖啡图片的PS实例教程
  9. BaseActivity的封装思想及YzsBaseActivity详解
  10. 计算机网络课程教学,计算机网络课程教学大纲讲义