同义词搜索是如何做到的?

前面几个章节我们使用到了 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(){
    @Override
    protected 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
var analyzer = new Analyzer(){    @Override    protected 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=1eat offset=4,7 position_incr=1apple offset=8,13 position_incr=1died 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(){
    @Override
    protected 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 差不多,需要指定文件路径。

Image placeholder
leodean
未设置
  66人点赞

没有讨论,发表一下自己的看法吧

推荐文章
号称以客户为中心的保险行业如何做到真正的“按需”服务?

随着客户消费模式的改变和对服务要求的不断提高,当前保险行业正经历深刻变化。虽然保险行业已经积累了大量的客户数据,但由于其业务的复杂性及缺少系统的建设,大多数数据都是孤立的。而在数字化浪潮的推进下,许多

PHP 内核:foreach 是如何工作的?

foreach是如何工作的? 首先声明,我知道foreach是什么,也知道怎么去用它。但这个问题关心的是,内核中foreach是如何运行的,我不想回答关于“如何使用foreach循环数组”的任何问题。

Kafka 优秀的架构设计!它的高性能是如何保证的?

应大部分的小伙伴的要求,今天这篇咱们用大白话带你认识Kafka。Kafka 基础消息系统的作用大部分小伙伴应该都清楚,这里用机油装箱举个例子:所以消息系统就是如上图我们所说的仓库,能在中间过程作为缓存

YouTube 的视频推荐是如何实现的?

最近,谷歌研究人员发表了一篇论文,并在RecSys2019(丹麦哥本哈根)的论坛上公布,论文中对他们的视频平台Youtube用户视频推荐方式进行了阐述。在这篇文章中,笔者将试着总结我阅读这篇论文后的发

全球“黑客大赛”冠军霸气讲述:我是如何让50个文件一起骗过AI安防系统的?

大数据文摘出品来源:medium编译:邢畅、张睿毅、钱天培你有没有想过当黑客呢?破解手机密码,黑入公司系统,甚至…控制全球电脑。打住打住!违法犯罪的念头显然不能有。再退一步讲,咱也不一定有这本事。尤其

“12306”是如何支撑百万QPS的?

12306抢票,极限并发带来的思考每到节假日期间,一二线城市返乡、外出游玩的人们几乎都面临着一个问题:抢火车票!虽然现在大多数情况下都能订到票,但是放票瞬间即无票的场景,相信大家都深有体会。尤其是春节

盗版12306骗3000万人下载,暴利高仿App是如何花式捞钱的?

眼看着春运一天一天临近,我按捺不住激动的心情,准备加入抢票大军。可是,当我在应用商城搜索12306时,却发现一大批“12306”。这些App下载量从几万到几千万(未标“官方版”的累计下载量超一千万),

如何做一枚合格的数据产品经理

大数据文摘出品编译:王富贵来源:medium每一个公司都有产品经理。根据定义,产品经理负责统筹各方需求,选择业务模式,并根据公司产品的生命周期进行协调、研发、营销、运营。传统业务还好说,毕竟一些流程已

TPC-C解析系列02_OceanBase如何做TPC-C测试

导语:蚂蚁金服自研数据库OceanBase登顶TPC-C引起业内广泛关注,为了更清楚的展示其中的技术细节,我们特意邀请OceanBase核心研发人员对本次测试进行技术解读,共包括五篇:1)TPC-C基

海量数据AtlasDB:把“数据库好用”这件事做到极致

导语:坚守初心、不辱使命,近期海量数据研发的企业级数据库AtlasDB获得了市场的普遍关注。这款以“好用”著称的国产数据库产品,不仅承载着海量数据公司对技术创新的坚持和投入,更凝结着一群拥有“工匠之心

深入探究 RocketMQ 事务机制的实现流程,为什么它能做到发送消息零丢失?

1、解决消息丢失的第一个问题:订单系统推送消息领丢失既然我们已经明确了消息在基于MQ传输的过程中可能丢失的几个地方,那么我们接着就得一步一步考虑如何去解决各个环节丢失消息的问题,首先要解决的第一个问题

互联网是如何把“原始人”逼成“机器人”

【导读】互联网快速发展的这十多年,我们见证了企业软件架构的多次迭代和演变。初期阶段都使用JSP+Servlet,工程师感觉代码直接写在jsp页面上不优雅,也不方便调试。后续发展为JSP+Javabea

“加班文化”到底是如何流行起来的

        说起互联网行业,大家最先想到的都是弹性工作制度,薪资诱人,夜宵福利,晚上报销打车费这些标签,但是作为一个扎根互联网行业的资深战士。我只想告诉大家,这些福利,都是在为互联网行业的陋习:“

资源混淆是如何影响到Kotlin协程的

导言随着kotlin的使用,协程也慢慢在我们工程中被开始被使用起来,但在我们工程中却遇到了一个问题,经过资源混淆处理之后的apk包,协程却不如期工作。那么两者到底有什么关联呢,资源混淆又是如何影响到协

阿里毕玄:从生物系学生,到技术团队 leader,他是如何完成自我蜕变的

©MSuzanneD.Williams编者按:新的技术层数不穷,困扰程序员的不仅有学不完的新技术,还有每个人在职业生涯中必然会面对的成长路线问题。这就像一个产品有了清晰的roadmap,下一步走的才会

技术宅告诉你如何搜索更安全

前言百度从14年开始就已经对外开放了HTTPS的访问,并于15年3月初正式对全网用户进行了HTTPS跳转。你也许会问,切换就切换呗,和我有啥关系?我平常用百度还不是照常顺顺当当的,没感觉到什么切换。话

两个月三项成果,对标谷歌!独家对话小米AutoML团队,如何让模型搜索更公平

大数据文摘出品作者:曹培信机器学习自动化(AutoML)正在引领机器学习的下一个时代,而要想让机器自己学会“炼丹”,其中最关键的步骤就是,找到最合适的算法模型,也即自动化神经架构搜索(NeuralAr

react如何写搜索框

react如何写搜索框react写搜索框的思路:1、添加一个input框,为它绑定onChange事件2、在onChange事件中通过拼接url和input框的内容得到一个搜索链接;3、通过fetch

干货:构建复杂的 Eloquent 搜索过滤

最近,我需要在开发的事件管理系统中实现搜索功能。一开始只是简单的几个选项(通过名称,邮箱等搜索),到后面参数变得越来越多。 今天,我会介绍整个过程以及如何构建灵活且可扩展的搜索系统。如果你想查看代码

Go语言高级编程_6.4 分布式搜索引擎

6.4分布式搜索引擎 在Web一章中,我们提到MySQL很脆弱。数据库系统本身要保证实时和强一致性,所以其功能设计上都是为了满足这种一致性需求。比如writeaheadlog的设计,基于B+树实现的索

搜索引擎百度已死,但其他业务在重生

年初有一篇《搜索引擎百度已死》的文章在全网刷屏,文章尖锐指出百度搜索有一半以上结果导向了自己的百家号,而百家号上大量低劣和营销的内容严重误导了用户,事后百度回应说其百家号的内容占比小于10%。与此同时

在头条和百度搜索了100个关键词之后,我们发现……

作者|闫丽娇苏琦编辑|苏琦• 常用名词搜索方面,百度站外内容占比更高,内容来源比头条更多元。头条搜索的信息流广告目前还没有接入;• 疑问解答类搜索,百度的内容发散性更杂,而头条在信息准确度上更能理解用

微软张若非:搜索引擎和广告系统,那些你所不知的AI落地技术

这两年,被誉为“ 皇冠上的明珠”的自然语言处理领域发展愈发火热,成为了业内新宠,而 搜索和广告这两大老牌技术领域似乎已被大家遗忘。其实,这两大接地气的工程领域仍是各企业竞相抢夺的市场之一。近日,AI科

解决Element UI input输入框不能使用回车进行搜索

因为使用vue修饰符绑定键盘事件报错,不知道怎么解决...所以想出了一个神奇的解决方法...1、绑定输入事件,每次输入就把输入的内容存到本地储存 //输入搜索内容 inputSearchInfo(

同一字段多个查询条件时遇到的一个问题

需求,加载礼物表中,租户id=0和租户id=10的数据,并排除id=10,11,12第一次写法(这个写法是错误的)$whereOr=[ ['tenant_id','=',0], ['gift_id',