dot 快速的未来正在您的城市举办的活动中到来。

加入我们参加 Redis 发布会

掌握 RediSearch / 第三部分

今天我们将深入探讨,并使用 Node.js、RediSearch 和我们在 第二部分 开始的客户端库来构建一些有用的东西。

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

为了跟上进度,最好获取 GitHub 存储库的 chapter-3 分支

了解数据集结构

第一个文件是 tmdb_5000_credits.csv,其中包含演员表和工作人员信息。虽然演员表和工作人员行在建模方面略有不同,但它们确实共享一些功能。最初,它不是一个非常实用的 CSV 文件,因为两列(演员表,工作人员)包含 JSON。

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

  • character 角色的名称
  • cast_id 角色在多部电影中的标识符
  • name 演员或女演员的姓名
  • credit_id A 此演出的唯一标识符

crew(包含 JSON 的列)

  • department 此角色的部门或类别
  • job 片场角色的名称
  • name 工作人员成员的姓名
  • credit_id A 此演出的唯一标识符

CSV 的另一个问题是它对于这种类型的文件来说太大了:40mb(相比之下,莎士比亚的全部作品只有 5.5mb)。虽然 Node.js 可以处理这种大小的文件,但这肯定不是最有效的方法。为了优化此数据的摄取,我们将使用 csv-parse,这是一个流式解析器。这意味着在读取 CSV 文件时,它也会被解析并发出事件。流式解析是已经高效的 RediSearch 的一个合适的补充。

CSV 中的每一行代表一部电影,文件中大约有 4,800 部电影。可以想象,每部电影都有几十到几百个演员表和工作人员成员。总的来说,您可以预期索引大约 235,838 个演员表和工作人员成员,每个成员由九个字段表示。

TMDB 数据集中的另一个文件是电影数据。这相当简单明了。每部电影都是一行,包含一些包含 JSON 数据的列。在本系列的这一部分中,我们可以忽略这些 JSON 数据列。以下是字段的表示方式

movie_id(列)A 每部电影的唯一 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 导入数据

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

值得庆幸的是,这些文件中的大多数数据都相当干净,但这并不意味着我们不需要进行调整。在演员表/工作人员文件中,我们面临着演员表和工作人员条目具有略微不同的数据集的挑战。在数据中,每一行代表一部电影,演员表列包含所有演员表成员,而工作人员列包含所有工作人员成员。因此,当我们在模式中表示它时,我们实际上是在创建字段的并集(因为存在重叠)。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)
    ];
  }
};

为了导入电影和工作人员,我们将使用异步队列和上述流式 CSV 解析器。这两个模块的工作方式类似,并且可以很好地协同工作,但在语法上有一些不同的术语。首先,CSV 解析器将读取一批数据(parser.on('readable',…)),然后继续一次读取一整行(while(record = parser.read()){ … })。每一行都会被操作并准备好用于 RediSearch(csvRecord(record))。在此函数中,格式化了几行,而另一些则被忽略,最后将项目推入队列(q.push(…))。

Async 是一个非常有用的 JavaScript 库,它提供了大量用于处理异步行为的隐喻。队列实现非常有趣——项目被推入队列,并且由在实例化时定义的单个工作函数以给定的并发度进行处理。工作函数有两个参数:要处理的项目和一个回调函数。回调函数运行后,下一个项目将可用进行处理(直到给定的并发度)。有一个很棒的动画可以解释它

来自 async 文档

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

如前所述,信用 CSV 更加复杂,表中的每一行都代表多个演员表/工作人员成员。为了管理这种复杂性,我将使用批处理。我们将使用与 CSV 解析器和异步队列相同的总体结构,但每一行将包含对 RediSearch 的多个调用,通过批处理进行(每个演员表或工作人员成员一个)。我们不会将普通对象推入队列,而是会将 RediSearch 批处理推入队列。在工作函数中,我们将对其调用 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=”your search string”),并指定要搜索的数据库(–searchtype movies 或 –searchtype castcrew)。其他命令行参数是连接 JSON 文件(–connection path-to-your-connection-file.json)以及用于设置偏移量和结果数的可选参数( –offset –resultsize)。

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

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

我们只需要对结果执行 *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

这将为您提供 Lone Star 的演员和工作人员的前十项。看起来很直观,除了搜索字符串——为什么要重复 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 博客!