关于mongodb:纯-MongoDB-实现中文全文搜索

64次阅读

共计 6454 个字符,预计需要花费 17 分钟才能阅读完成。

摘要

MongoDB 在 2.4 版中引入全文索引后几经迭代更新曾经比拟完满地反对以空格分隔的西语,但始终不反对中日韩等语言,社区版用户不得不通过挂接 ElasticSearch 等反对中文全文搜寻的数据库来实现业务需要,由此引入了许多业务限度、平安问题、性能问题和技术复杂性。作者独辟蹊径,基于纯 MongoDB 社区版(v4.x 和 v5.0)实现中文全文搜寻,在靠近四千万个记录的商品表搜寻商品名,检索工夫在 200ms 以内,并应用 Change Streams 技术同步数据变动,满足了业务须要和用户体验需要。

本文首先形容遇到的业务需要和艰难,介绍了 MongoDB 和 Atlas Search 对全文搜寻的反对现状,而后从全文搜寻原理讲起,联合 MongoDB 全文搜寻实现,挂接中文分词程序,达到纯 MongoDB 社区版实现中文全文搜寻的指标;针对性能需求,从分词、组合文本索引、用户体验、实时性等多方面给出了优化实际,使整个计划达到商业级的实用性。

业务需要和艰难

电商易是作者公司的电商大数据工具品牌,旗下多个产品都有搜寻商品的业务需要。晚期的时候,咱们的搜寻是间接用 $regex 去匹配的,在数据量比拟大的时候,须要耗时十几秒甚至几分钟,所以用户总是反馈说搜不出货色来。其实不是搜不进去,而是搜的工夫太长,服务器掐断连贯了。加上咱们广泛应用极简格调的首页,像搜索引擎那样,有个框,右侧是一个“一键剖析”的按钮,用户点击后显示相干的商品的数据。搜寻成为用户最罕用的性能,搜寻性能的问题也就变得更加突出了,优化搜寻成为了火烧眉毛的工作。

MongoDB 在 2.4 版中引入文本索引(Text Index)实现了全文搜寻(Full Text Search,下文简称 FTS),尽管起初在 2.6 和 3.2 版本中两经改版优化,但始终不反对中日韩等语言。MongoDB 官网推出服务 Atlas Search,也是通过外挂 Lucene 的形式反对的,这个服务须要付费,而且未在中国大陆地区经营,与咱们无缘,所以还是要寻找本人的解决之道。

那么是否仅仅基于 MongoDB 社区版实现中文全文搜寻呢?带着这个问题,作者深刻到 MongoDB 文本索引的文档、代码中去,发现了些许端倪,并逐渐实现和优化了纯 MongoDB 实现中文全文搜寻的计划,下文将从全文搜寻的原理讲起,详细描述这个计划。

过程

全文搜寻原理

倒排索引是搜寻引警的根底。倒排是与正排绝对的,假如有一个 ID 为 1 的文档,内容为“My name is LaiYonghao.“,那么通过 ID 1 总能找到这个文档所有的词。通过文档 ID 找蕴含的词,称为正排;反过来通过词找到包含该词的文档 ID,称为倒排,词与文档 ID 的对应关系称为倒排索引。上面间接援用一下维基百科上的例子。

0 “it is what it is”
1 “what is it”
2 “it is a banana”

下面 3 个文档的倒排索引大略如下:

“a”: {2}
“banana”: {2}
“is”: {0, 1, 2}
“it”: {0, 1, 2}
“what”: {0, 1}

这时如果要搜寻 banana 的话,利用倒排索引能够马上查找到包含这个词的文档是 ID 为 2 的文档。而正排的话,只能一个一个文档找过来,找完 3 个文档能力找到(也就是 $regex 的形式),这种状况下的耗时大部分是无奈承受的。

倒排索引是所有反对全文搜寻的数据库的根底,无论是 PostgreSQL 还是 MySQL 都是用它来实现全文搜寻的,MongoDB 也不例外,这也是咱们最终解决问题的根底底座。简略来说,倒排索引相似 MongoDB 里的多键索引(Multikey Index),可能通过内容元素找到对应的文档。文本索引能够简略类比为对字符串宰割(即分词)转换为由词组成的数组,并建设多键索引。尽管文本索引还是进行词、同义词、大小写、权重和地位等信息须要解决,但大抵如此了解是能够的。

西文的分词较为简单,基本上是按空格分切即可,这就是 MongoDB 内置的默认分词器:当建设文本索引时,默认分词器将按空格分切句子。而 CJK 语言并不应用空格切分,而且最小单位是字,所以没有方法间接利用 MongoDB 的全文搜寻。那么如果咱们事后将中文句子进行分词,并用空格分隔从新组装为“句子”,不就能够利用上 MongoDB 的全文搜寻性能了吗?通过这一个突破点进行深挖,试验证实,这是可行的,由此咱们的问题就转化为了分词问题。

一元分词和二元分词

从上文可知,数据库的全文搜寻是基于空格切分的词作为最小单位实现的。中文分词的办法有很多,最根底的是一元分词和二元分词。

所谓一元分词:就是一个字一个字地切分,把字当成词。 如我爱北京天安门,能够切分为我爱北京天安门,这是最简略的分词办法。这种办法带来的问题就是文档过于集中,罕用汉字只有几千个,权且算作一万个,如果有一千万个文档,每一个字会对应到 10000000/10000*avg_len(doc) 个。以文档内容是电商平台的商品名字为例,均匀长度约为 60 个汉字,那每一个汉子对应 6 万个文档,用北京两字搜寻的话,要求两个长度为 6 万的汇合的交加,就会要很久的工夫。所以大家更常应用二元分词法。

所谓二元分词:就是按两字两个分词。 如我爱北京天安门,分词后果是我爱爱北北京京天天安安门。可见两个字的组合数量多了很多,绝对地一个词对应的文档也少了许多,当搜寻两个字的时候,如北京不必再求交加,能够间接失去后果。而搜寻三个字以上的话,如天安门也是由天安和安门两个不太常见的词对应的文档汇合求交加,数量少,运算量也小,速度就很快。上面是纯中文的二元分词 Python 代码,理论工作中须要思考多语言混合的解决,在此仅作示例:

def bigram_tokenize(word):
  return' '.join(word[i:i+2]for i inrange(len(word))if i+2<=len(word)
  )

print(bigram_tokenize('我爱北京天安门'))
# 输入后果:我爱 爱北 北京 京天 天安 安门 

Lucene 自带一元分词和二元分词,它的中文全文搜寻也是基于二元分词和倒排索引实现的。接下来只须要事后把句子进行二元分词再存入 MongoDB,就能够借助它已有的西语全文搜寻性能实现对中文的搜寻。

编写索引程序

编写一个分词程序,它将全表遍历须要实现全文搜寻的汇合(Collection),并将指定的文本字段内容进行分词,存入指定的全文索引字段。

以对 products 表的 name 字段建设全文索引为例,代码大略如下:

def build_products_name_fts():
  # 在 _t 字段建设全文索引
 db.products.create_index([('_t', 'TEXT')])
  # 遍历汇合
  for prod in db.products.find({}):
   db.products.update_one({'_id': prod['_id']},
      {
        '$set': {'_t': bigram_tokenize(prod['name'])  # 写入二元分词后果
        }
      }
    )

if__name__=="__main__":
   build_products_name_fts()

只须要 10 来行代码就行了,它在首次运行的时候会做一次全表更新,实现后即可用以全文搜寻。MongoDB 的高级用户也能够用带更新的聚合管道实现这个性能,只须要写针对二元分词实现一个 javascript 函数(应用 $function 操作符)放到数据库中执行即可。

查问词预处理

因为咱们针对二元分词的后果做搜寻,所以无奈间接搜寻。以牛仔裤为例,二元分词的全文索引里基本没有三个字的词,是搜寻不进去后果的,必须转换成短语 ” 牛仔仔裤 ” 这样能力匹配上,所以要对查问词作预处理:进行二元分词,并用双引号束缚地位,这样能力正确查问。

products = db.products.find(
    {
        '$text': {'$search': f'"{bigram_tokenize(kw)}"',
        }
    }
)

如果有多个查问词或带有反向查问词,则须要作相应的解决,在此仅以独词查问示例,具体不必细述。

MongoDB 不仅反对在 find 中应用全文搜寻,也可在 aggregate 中应用,在 find 中应用是差不多的,不过要注意的是只能在第一阶段应用带 $text 的 $match。

初步后果

首先值得必定的是做了简略的二元分词解决之后,纯 MongoDB 就可能实现中文全文搜寻,搜寻后果是精准的,没有错搜或漏搜的状况。

不过在性能上比拟差强人意,在约 4000 万文档的 products 汇合中,搜寻牛仔裤须要 10 秒钟以上。而且在我的项目的应用场景中,咱们发现用户理论查问的词很长,往往是间接在电商平台复制商品名的一部分,甚至全副,这种极其状况须要几分钟能力失去查问后果。

在产品层面,能够对用户查问的词长度进行限度,比方最多 3 个词(即 2 个空格)且总长度不要超过 10 个汉字(或 20 个字母,每汉字按两个字母计算),这样能够管制绝对快一点。但这样的规定不容易让用户明确,用户体验受损,须要想方法优化性能。

优化

结巴中文分词

结巴中文分词是最风行的 Python 中文分词组件,它有一种搜索引擎模式,在准确模式的根底上,对长词再次切分,进步召回率,适宜用于搜索引擎分词。上面是援用自它我的项目主页的示例:

seg_list = jieba.cut_for_search("小明硕士毕业于中国科学院计算所,后在日本京都大学深造")  # 搜索引擎模式
print(",".join(seg_list))
# 后果:【搜索引擎模式】:小明, 硕士, 毕业, 于, 中国, 迷信, 学院, 科学院, 中国科学院, 计算, 计算所, 后, 在, 日本, 京都, 大学, 日本京都大学, 深造 

可见它的分词数量比二元分词少了很多,对应地索引产寸也小了。应用二元分词时,4000 万文档的 products 表索引超过 40GB,而应用结巴分词后,缩小到约 26GB。

由上例也可看出,结巴分词的后果失落了地位信息,所以查问词预处理过程也能够省略退出双引号,这样 MongoDB 在全文搜寻时计算量也大大少,搜寻速度减速了数十倍。以牛仔裤为例,应用结巴分词后查问工夫由 10 秒以上降到约 400ms,而间接复制商品名进行长词查问,也基本上可能在 5 秒钟之内实现查问,可用性和用户体验都失去了微小晋升。

结巴分词的缺点是须要行业词典进行分词。比方电商平台的商品名都有长度限度,都是针对搜索引擎优化过的,日常用语“男装牛仔裤”在电商平台上被优化成了“牛仔裤男”,这显然不是一个通常意义上的词。在没有行业词典的状况下,结巴分词的后果是牛仔裤男,用户搜寻时,将计算“牛仔裤”和“男”的后果交加;如果应用自定义词典,将优化为牛仔裤牛仔裤男,则无需计算,搜寻速度更快,但减少了保护自定义词典的老本。

组合全文索引(Compound textIndex)

组合全文索引是 MongoDB 的一个特色性能,是指带有全文索引的组合索引。上面援用一个官网文档的例子:

db.inventory.createIndex(
   {
     dept:1,
     description:"text"
   }
)
// 查问
db.inventory.find({ dept:"kitchen",$text: { $search:"green"} } )

通过这种形式,当查问部门(dept)字段的形容中是否有某些词时,因为先过滤掉了大量的非同 dept 的文档,能够大大减少全文搜寻的工夫,从而实现性能优化。

只管组合全文索引有许多限度,如查问时必须指定前缀字段,且前缀字段只反对等值条件匹配等,但理论利用中还是有很多实用场景的,比方商品汇合中有分类字段,人造就是等值条件匹配的,在此状况依据前缀字段的扩散水平,基本上能够取得等同比例的性能晋升,个别都在 10 倍以上。

用户体验优化

MongoDB 的全文搜寻其实是很快的,但当须要依据其它字段进行排序的时候,就会显著变慢。比方在咱们的场景中,当搜寻牛仔裤并按销量排序时,速度显著变慢。所以在产品设计时,应将搜寻性能独立,只解决“疾速找出最想要的产品”这一个问题,想在一个性能里解决多个问题,必然须要付出性能代价。

另一个有助于晋升晋升用户体验的技术手段是一次搜寻,大量缓存。就是一个搜索词第一次被查问时,间接返回后面若干条后果,缓存起来(比方放到 Redis),当用户翻页或其余用户查问此词时,间接从缓存中读取即可,速度大幅晋升。

实时性优化

前文提到编写索引程序对全文索引字段进行更新,但如果前面继续减少或批改数据时,也须要及时更新,否则实时性没有保障。在此能够引入 Change Streams,它容许应用程序拜访实时数据更改,而不用放心跟踪 oplog 的复杂性和危险。应用程序能够应用 Change Streams 来订阅单个汇合、数据库或整个部署中的所有数据更改,并立刻对它们作出反应。因为 Change Streams 应用聚合框架,应用程序还能够依据须要筛选特定的更改或转换告诉。Change Streams 也是 MongoDB Atlas Search 同步数据变动的办法,所以它是十分牢靠的。应用 Change Streams 非常简单,咱们的代码片断相似于这样:

try:
    # 订阅 products 汇合的新增和批改 Change Streams
    with db.products.watch([{'$match': {'operationType': {'$in':['insert', 'update']}}}]) as stream:
        for insert_change in stream:
            check_name_changed_then_update(insert_change)
exceptpymongo.errors.PyMongoError:
    logging.error('...')

在 check_name_changed_then_update() 函数中咱们查看可搜寻字段是否产生了变动(更新或删除),如果是则对该文档更新_t 字段,从而实时数据更新。

优化

本文形容了作者实现纯 MongoDB 实现中文全文搜寻的过程,最终计划在生产环境中稳固经营了一年多工夫,并为多个产品驳回,禁受住了业务和工夫的考验,证实了计划的可行性和稳定性。在性能上在靠近四千万个记录的商品表搜寻商品名,检索工夫在 200ms 以内,并应用 Change Streams 技术同步数据变动,满足了业务须要和用户体验需要。

作者在实现对中文全文搜寻的摸索过程中,通过对 MongoDB 源代码的剖析,发现 mongo/src/mongo/db/fts 目录蕴含了对不同语言的分词框架,在将来,作者将尝试在 MongoDB 中实现中文分词,期待用上内建中文全文搜寻反对的那一天。

对于作者: 赖勇浩

广州天勤数据有限公司

2005 年至 2012 年在网易(广州)、广州银汉等公司从事网络游戏开发和技术管理工作。2013 年至 2014 年在广东彩惠率领团队从事彩票行业数字化研发和施行。2015 年至今,开办广州齐昌网络科技有限公司,后并入广东天勤科技有限公司,任职 CTO,并且负责广州天勤数据有限公司联结创始人 &CEO,现率领团队负责电商大数据分析软件的研发工作,造成由看店宝等十余个数据工具组成的产品矩阵,笼罩剖析淘宝、天猫、拼多多和抖音等多个电商平台数据,服务全国各地 200 多万电商从业人员。酷爱分享,于 2009 年联结开办程序员社区 TechParty(原珠三角技术沙龙)并负责两届组委主席,于 2021 年开办中小团队技术管理者和技术专家社区小红花俱乐部,均深受目标群体的青睐。

精通 Python、C++、Java 等编程语言和 Linux 操作系统,相熟大规模多人在线零碎的设计与实现,在大数据方面,对数据收集、荡涤、存储、治理、剖析等方面有丰盛教训,设计和实现了准 PB 级别的基于 MongoDB 的电商数据湖零碎,对冷热数据分级解决、零碎老本管制和数据产品设计研发有肯定心得。

曾在《计算机工程》等期刊发表多篇论文,于 2014 年出版《编写高质量代码:改善 Python 程序的 91 个倡议》一书。

正文完
 0