dot Redis 8 来了——而且它是开源的

了解更多

掌握 RediSearch / 第三 部分

今天我们将深入探讨,并利用 Node.js、RediSearch 以及我们在第二部分中开始构建的客户端库来做一些有用的事情。

虽然RediSearch是一个优秀的全文搜索引擎,但它的功能远不止于此,作为 Redis 的二级索引具有强大的能力。考虑到这一点,我们来获取一个包含更多基于字段的数据集。我将使用 TMDB(电影数据库)数据集。您可以从Kaggle TMDB 页面下载此数据集。数据以 CSV 格式存储在两个文件中,包含一些我们尚未使用到的特性,因此我们需要构建一个导入脚本。

为了方便跟随,最好获取 GitHub 仓库的 chapter-3 分支

理解数据集结构

第一个文件是 tmdb_5000_credits.csv,其中包含演职人员信息。虽然演职人员的行结构略有不同,但它们确实共享一些特性。最初,这个 CSV 文件并不是非常易于使用,因为有两列(cast, crew)包含 JSON 数据。

movie_id(列) 这与另一个文件中的电影行相关联。
title(列) 与 movie_id 相关联的电影标题
cast(包含 JSON 的列)

  • character 角色的名称
  • cast_id 跨多部电影的角色标识符
  • name 演员的姓名
  • credit_id 此演职员信息的唯一标识符

crew(包含 JSON 的列)

  • department 此角色的部门或类别
  • job 在剧组中的角色名称
  • name 剧组成员的姓名
  • credit_id 此演职员信息的唯一标识符

CSV 的另一个问题是对于这类文件来说它相当大:40MB(相比之下,莎士比亚的全部作品只有 5.5MB)。虽然 Node.js 可以处理这么大的文件,但效率肯定不高。为了最佳地导入这些数据,我们将使用 csv-parse,一个流式解析器。这意味着当 CSV 文件被读取时,它也会被解析并发出事件。流式解析是对本身高性能的 RediSearch 的一个恰当补充。

CSV 中的每一行代表一部电影,文件中有大约 4,800 部电影。正如您可能想象的那样,每部电影都有几十到数百名演职人员。总的来说,您预计将索引大约 235,838 名演职人员——每位演职人员由九个字段表示。

TMDB 数据集中的另一个文件是电影数据。这要直接得多。每部电影是一行,其中有几列包含 JSON 数据。本系列中的这一部分可以忽略这些 JSON 数据列。字段表示如下:

movie_id(列)每部电影的唯一 ID
budget(列)电影总预算
original_language(列)原始语言的 ISO 639–1 版本
original_title(列)电影的原始标题
overview(列)
关于电影的几句话
popularity(列)电影的受欢迎程度排名
release_date(列)电影上映日期(YYYY-MM-DD 格式)
revenue(列)电影收入金额(美元)
runtime(列)电影片长(分钟)
status(列)电影上映状态(已上映”或“未上映”)

忽略的列:genres, production_companies, keywords, production_countries, spoken_languages

从 TMDB 导入 数据

现在我们已经探讨了字段以及如何创建索引,接下来让我们继续创建实际的索引并将数据放入其中。

幸运的是,这些文件中的大多数数据都相当干净,但这并不意味着我们不需要进行调整。在演职人员文件中,我们面临的挑战是演员和工作人员的条目包含略有不同的数据集。在数据中,每行代表一部电影,cast 列包含所有演员,而 crew 列包含所有工作人员。因此,当我们在模式中表示这一点时,我们实际上是在创建字段的联合(因为存在重叠)。castcrew 是数值字段,对于每种类型的演职人员都设置为“1”——将其视为一个标志。

对于电影数据库,我们将把 release_date 转换为数字。我们将简单地将日期解析为 Javascript 时间戳,并将其存储在数值字段中。最后,我们将忽略一些字段——我们只需将列的键与列数组进行比较即可跳过(ignoreFields)。

从项目结构来看,我们以后可能会更多地使用 fieldDefinitions,因此我们将两个模式都存储在一个 Node.js 模块中。这完全是可选的,但这是一个简洁的模式,可以减少以后重复代码的可能性。

module.exports = {
  movies    : function(search) {
    return [
      search.fieldDefinition.numeric('budget',true),
      //homepage is just stored, not indexed
      search.fieldDefinition.text('original_language',true,{ noStem : true }),
      search.fieldDefinition.text('original_title',true,{ weight : 4.0 }),
      search.fieldDefinition.text('overview',false),
      search.fieldDefinition.numeric('popularity'),
      search.fieldDefinition.numeric('release_date',true),
      search.fieldDefinition.numeric('revenue',true),
      search.fieldDefinition.numeric('runtime',true),
      search.fieldDefinition.text('status',true,{ noStem : true }),
      search.fieldDefinition.text('title',true,{ weight : 5.0 }),
      search.fieldDefinition.numeric('vote_average',true),
      search.fieldDefinition.numeric('vote_count',true)
    ];
  },
  castCrew  : function(search) {
    return [
      search.fieldDefinition.numeric('movie_id',false),
      search.fieldDefinition.text('title',true, { noStem : true }),
      search.fieldDefinition.numeric('cast',true),
      search.fieldDefinition.numeric('crew',true),
      search.fieldDefinition.text('name', true, { noStem : true }),
  
      //cast only
      search.fieldDefinition.text('character', true, { noStem : true }),
      search.fieldDefinition.numeric('cast_id',false),
  
      //crew only
      search.fieldDefinition.text('department',true),
      search.fieldDefinition.text('job',true)
    ];
  }
};

为了导入电影和演职人员,我们将使用 Async 队列和前面提到的流式 CSV 解析器。这两个模块工作方式相似且配合良好,但在语法上有一些不同的术语。首先,CSV 解析器将读取一个数据块 (parser.on(‘readable’,…)),然后继续逐行读取 (while(record = parser.read()){ … })。每一行都经过处理并准备好用于 RediSearch (csvRecord(record))。在此函数中,一些行被格式化,而另一些则被忽略,最后项目被推入队列 (q.push(…))。

Async 是一个非常有用的 Javascript 库,提供了大量用于处理异步行为的比喻。队列实现非常有趣——项目被推入队列,并由实例化时定义的单个 worker 函数以给定的并发度进行处理。worker 函数有两个参数:要处理的项目和回调。一旦回调运行完成,下一个项目即可进行处理(直至达到给定的并发度)。这里有一个很好的动画解释了它

来自 async 文档

我们将使用的队列的另一个特性是 drain 函数。当队列中没有剩余项目时,此函数执行,例如队列处于工作状态但不再处理任何内容。重要的是要理解队列永远不会“完成”,它只是变为空闲。鉴于我们将使用流式解析器,RediSearch 的导入速度可能快于 CSV 解析器,导致 Async 队列为空(触发 drain)。为了解决这个潜在问题,每个记录都被添加到队列中,当它成功索引后,两个单独的计数器会递增(分别是 totalprocessed),并且 CSV 解析器会将 parsed 变量从“false”设置为“true”。因此,当调用 drain 时,我们检查 parsed 是否为 true,以及 processed 的值是否与 total 匹配。如果这两个条件都为 true,我们就知道所有值都已从 CSV 中解析出来,并且所有内容都已添加到我们的 RediSearch 索引中。成功将项目添加到索引后,您调用 worker 函数的回调,其余部分由队列管理。

如前所述,演职人员 CSV 文件更复杂,表中的每一行代表多个演职人员。为了管理这种复杂性,我将使用批处理。 我们将使用与 CSV 解析器和 Async 队列相同的整体结构,但每一行将通过批处理包含对 RediSearch 的多次调用(每位演员或工作人员一次)。我们不会将一个普通对象推入队列,而是实际推入一个 RediSearch 批处理。在 worker 函数中,我们将对其调用 exec,然后调用回调。在电影 CSV 文件中,每部电影对应一个 Redis (RediSearch) 命令;而在演职人员 CSV 文件中,每部电影对应一个批处理(由几十个独立特性组成)。

这两个导入过程差异足够大,需要独立的导入脚本。要导入电影,您可以像这样运行脚本:

$ node import-tmdb-movies.node.js --connection ./your-connection-object-as.json --data ./path-to-your-movie-csv-file/tmdb_5000_movies.csv

演职人员的导入方式如下:

$ node import-tmdb-credits.node.js --connection ./your-connection-object-as.json --data ./path-to-your-credits-csv-file/tmdb_5000_credits.csv

RediSearch 的一个杀手级功能是您的数据一旦索引完成,就可以立即进行查询。真正的实时功能!

搜索 数据

现在我们已经导入了所有这些数据,接下来编写一些脚本来从 RediSearch 中检索数据。尽管演职人员和电影数据集的数据差异很大,但我们可以编写一个简短的脚本来执行搜索。

这个小脚本将允许您从命令行传入搜索字符串 (–search=”您的搜索字符串”),并指定您要搜索的数据库 (–searchtype movies 或 –searchtype castcrew)。其他命令行参数包括连接 JSON 文件 (–connection path-to-your-connection-file.json) 以及用于设置偏移量和结果数量的可选参数 (分别是 –offset–resultsize)。

在我们通过传入 argv.searchtype 来实例化模块后,我们只需要使用 search 方法将搜索查询和选项作为对象发送到 RediSearch。我们在上一节中构建的库负责构建将传递给 FT.SEARCH 命令的参数。

在回调函数中,我们得到一个非常标准的错误优先回调。第二个参数包含我们的搜索结果,这些结果经过预解析并以一种有用的方式格式化——每个文档都有自己的对象,包含两个属性:doc 和 docId。docId 包含唯一标识符,document 是一个对象。

我们只需对结果进行 JSON.stringify(这样 console.log 就不会显示 [Object object]),然后退出客户端。

您可以通过运行以下命令来试用:

$ node search.node.js --connection ./your-connection-object-as.json --search="star" --searchtype movies --offset 0 --resultsize 1

这应该会返回关于电影 孤独之星 (Lone Star) 的条目(有人看过吗?我想没有)。现在,让我们在演职人员索引中查找具有相同 move_id 的任何内容:

$ node search.node.js --connection ./your-connection-object-as.json --search="@movie_id:[26748 26748]" --searchtype castCrew

这将为您提供孤独之星的前十个演职人员条目。除了搜索字符串之外,看起来很简单——为什么您必须重复两次 26748?在这种情况下,这是因为数据库中的 movie_id 字段是数字类型,而数字只能通过范围进行限制。

获取 文档

从 RediSearch 获取文档甚至更容易。基本上,我们实例化所有内容的方式与搜索脚本相同,但我们不需要提供任何选项,并且获取的是 docId 而不是搜索字符串。

我们只需要将 docId 和回调函数传递给 getDoc 方法(抽象了 FT.GET 命令),就可以开始工作了!show 函数与搜索函数中的相同。

这对于演职人员文档或电影文档都同样适用

$ node by-docid.node.js --connection ./your-connection-object-as.json --docid="56479e8dc3a3682614004934" --searchtype castCrew

删除 索引

如果您尝试导入两次 CSV 文件,您将收到类似于以下的错误:

if (err) { throw err; }
ReplyError: Index already exists. Drop it first!

这是因为您不能在已有的索引上直接创建索引。随附一个脚本可以快速删除您的一个索引。您可以像这样运行它:

$ node drop-index.node.js --connection ./your-connection-object-as.json --searchtype movies

状态和下一步 计划

在本期中,我们介绍了如何解析大型 CSV 文件并使用针对其不同结构定制的脚本高效地将其导入 RediSearch。然后我们构建了一个脚本来对此数据运行搜索查询、获取单个文档并删除索引。我们现在已经学习了数据集生命周期管理的大部分步骤。

在下一期中,我们将在库中构建更多功能,以更好地抽象搜索并添加更多选项。然后我们将开始构建一个 Web UI 来搜索我们的数据。敬请关注 Redis 博客!