如何使用 Node.js 和 Elastic 编写自己的搜索引擎
作者:费尔南多·多格里奥✏️
许多人倾向于对 Google 的搜索算法(也称为Page Rank )增加很多神秘感,因为它总是设法在前几页向我们显示我们正在寻找的结果(即使在有数百个结果页面的情况下)。
它是如何工作的?为什么它如此准确?这些问题没有真正的答案,除非你是谷歌内部负责维护它的团队成员。
无需侵入 Google 的服务器并窃取他们的算法,我们就可以找到一种方法,为我们提供非常强大的搜索功能,您可以轻松地将其集成到您的网站/网络应用程序中,同时获得出色的用户体验。
我主要指的是通常所说的“全文搜索”。如果你来自传统的 Web 开发领域,你可能习惯使用 SQL 数据库,例如MySQL或PostgreSQL,它们默认允许你在字符串字段中执行基于通配符的搜索,例如:
SELECT * FROM Cities WHERE name like 'new%';
使用上述查询通常会得到匹配的结果,例如:
- 纽约
- 新德里
- 新奥尔良
您获得了模式,并且如果您的数据库中有更复杂的对象,例如带有标题和正文的博客文章,您可能还想对它们进行更“有趣”的搜索,例如:
SELECT * FROM BLOG_POSTS WHERE title like '%2019%' OR body like '%2019%';
现在,上述查询也会产生一些结果,但这些结果的最佳顺序是什么?一篇因为正文中包含电话号码444220192而匹配的博客文章,会先于一篇标题为“2019 年最佳足球队”的文章返回,这合理吗?后者的匹配肯定更相关,但简单的通配符匹配无法做到这一点。
正因为如此,在您的网站上添加全文搜索可能是一个很好的选择(特别是如果您希望用户搜索非结构化内容,例如常见问题解答或可下载文档等等)。
全文
这些用例已经超越了基本的通配符搜索。诚然,最常见的 SQL 数据库(例如 MySQL 和 PostgreSQL)已经包含某种形式的基本全文搜索功能,但如果您想充分利用这项技术,则需要一个专用的搜索引擎,例如Elastic。
这些引擎的工作方式是创建所谓的“倒排索引”。在我们的示例中,我们尝试索引文本文档,它们会从每个文档中提取每个单词,并记录它们出现的文档的引用及其在文档中的位置。因此,您无需像上面的 SQL 示例那样在每个文档中搜索子字符串,而只需在单词列表中搜索子字符串即可,并且这些匹配的单词已经通过索引知道它们出现的位置。
上图以非常简单的方式显示了如何构建倒排索引:
- 每个单词都列在索引中
- 每个单词都存储了对源文档的引用(允许对不同文档进行多次引用)
- 在每个文档中,我们还记录单词的位置(第 3 列)
有了这些信息,我们可以简单地搜索索引并匹配您的查询和索引中的单词之间的任何巧合(我们甚至可以使用子字符串搜索并仍然返回有效结果)。
这仍然没有得到我们需要的结果,因为我们没有任何关于相关性的信息。标题匹配和正文匹配哪个更重要?完全匹配还是部分匹配?这些都是我们的引擎在搜索时需要知道的规则,幸运的是,我们今天要使用的引擎(Elastic)已经处理好了这些规则,甚至更多。
那么让我们采用这个基本的倒排索引,看看如何使用 Elastic 来利用这项技术,好吗?
走向弹性
安装和运行本地版本的 Elastic 实际上非常简单,特别是如果您遵循官方说明。
一旦启动并运行,您将能够使用它的 RESTful API 和任何现有的 HTTP 客户端与它进行交互(我将使用curl,它应该默认安装在最常见的操作系统中)。
一旦设置完毕,真正的工作就可以开始了,不用担心,我将在文章中引导您完成以下步骤:
- 您需要创建一个索引
- 之后,您将为索引内的文档创建映射
- 一旦一切设置完毕,您就可以索引文档
- 最后,搜索将可以
为了使事情更容易理解,我们假设我们正在构建一个图书馆的 API,它可以让您搜索不同数字书籍的内容。
出于本文的目的,我们将尽量减少元数据,但您可以根据具体情况添加所需的元数据。这些书籍将从古腾堡项目下载,并首先进行手动索引。
如何创建您的第一个索引
根据定义,Elastic 中的每个索引文档都需要插入到索引中,这样,如果您开始索引不同的、不相关的对象,您可以轻松地在所需的范围内进行搜索。
如果这样更容易的话,您可以将索引视为一个容器,一旦您决定搜索某些内容,您就需要选择一个容器。
为了创建新索引,您只需运行以下命令:
$ curl -X PUT localhost:9200/books
通过该行,您将请求发送到本地主机(当然假设您正在进行本地测试)并使用端口 9200,这是 Elastic 的默认端口。
路径“books”是实际创建的索引。该命令成功执行后将返回如下内容:
{
"acknowledged" : true,
"shards_acknowledged" : true,
"index" : "books"
}
暂时记住这条路径,让我们继续下一步,创建地图。
如何为您的文档创建地图
此步骤实际上是可选的,您可以在执行查询期间定义这些参数,但我总是发现维护外部映射比维护与代码的业务逻辑相关的映射更容易。
您可以在此处设置以下内容:
- 我们的书名和正文可以进行哪些类型的匹配(是完全匹配吗?我们使用全文还是基本匹配?等等)
- 每个匹配项的权重。或者换句话说,标题中的匹配项与正文中的匹配项的相关性如何?
为了为特定索引创建映射,您必须使用映射端点并发送描述新映射的 JSON。以下是遵循上述想法的索引数字图书的示例:
{
"properties": {
"title": {
"type": "text",
"analyzer": "standard",
"boost": 2
},
"body": {
"type": "text",
"analyzer": "english"
}
}
}
此映射定义了两个字段:书名(需要使用标准分析器进行分析)和正文(考虑到这些书籍均为英文,因此将使用英文语言分析器进行分析)。我还为书名匹配项添加了增强功能,使任何匹配项的相关性都是正文匹配项的两倍。
为了在我们的索引上进行设置,我们需要做的就是使用以下请求:
$ curl -X PUT "localhost:9200/books?pretty" -H 'Content-Type: application/json' -d'
{
"properties": {
"title": {
"type": "text",
"analyzer": "standard",
"boost": 2
},
"body": {
"type": "text",
"analyzer": "english"
}
}
}
'
成功执行将产生如下结果:
{
"acknowledged" : true
}
现在我们的索引和映射已经准备好了,我们要做的就是开始索引,然后执行搜索。
如何将内容索引到 Elastic
尽管从技术上讲,我们无需编码即可完成此操作,但我将在 Node.js 中创建一个快速脚本来加速将书籍发送到 Elastic 的过程。
该脚本很简单,它将从特定目录读取文件的内容,抓取第一行并将其作为标题,然后将其他所有内容编入索引作为正文的一部分。
以下是简单的代码:
const fs = require("fs")
const request = require("request-promise-native")
const util = require("util")
let files = ["60052-0.txt", "60062-0.txt", "60063-0.txt", "pg60060.txt"]
const readFile = util.promisify(fs.readFile)
async function indexBook(fid, title, body) {
let url = "http://localhost:9200/books/_doc/" + fid
let payload = {
url: url,
body: {
title: title,
body: body.join("\n")
},
json: true
}
return request.put(payload)
}
( _ => {
files.forEach( async f => {
let book = await readFile("./books/" + f);
[title, ...body] = book.toString().split("\n");
try {
let result = await indexBook(f, title, body);
console.log("Indexing result: ", result);
} catch (err) {
console.log("ERROR: ", err)
}
})
})();
我所做的就是浏览我阵列中的书籍列表,并将其内容发送到 Elastic。索引使用的方法是 PUT,路径是your-host:your-port/index-name/_doc/a-doc-ID
。
- 我使用默认主机和端口(localhost和9200)
- 我的索引是我之前创建的:书籍
- 我使用的索引是文件名,我知道每本书的文件名都是唯一的
这实际上给我们留下了一件事情要做,那就是查询我们的数据。
如何在 Elastic 中查询索引
为了查询索引,我们可以像迄今为止使用的方式一样使用 Elastic 的 REST API,或者我们可以继续使用Elastic 的官方 Node.js 库。
为了展示一些不同的东西,我将向您展示如何使用 Elastic 的 NPM 模块执行搜索查询,如果您想开始使用它,请随时查看他们的文档。
一个简单的例子足以将我迄今为止讨论的所有内容付诸实践,它将对索引文档执行全文搜索,并根据相关性(这是 Elastic 使用的默认标准)返回排序的结果列表。
下面的代码就是这么做的,让我给你演示一下:
var elasticsearch = require('elasticsearch');
var client = new elasticsearch.Client({
host: 'localhost:9200/books'
});
let q = process.argv[2];
( async query => {
try {
const response = await client.search({
q: query
});
console.log("Results found:", response.hits.hits.length)
response.hits.hits.forEach( h => {
let {_source, ...params } = h;
console.log("Result found in file: ", params._id, " with score: ", params._score)
})
} catch (error) {
console.trace(error.message)
}
})(q)
上述代码将执行脚本时使用的第一个单词作为 CLI 参数,并将其用作查询的一部分。
如果您按照步骤操作,您应该能够从 Guterberng 项目下载并索引一些书籍,然后编辑其中两本。在其中一本中添加单词“testing”作为第一行的一部分,在另一本中添加相同的单词,但将其放在文本中间。这样,您就可以看到相关性是如何根据我们设置的映射来运作的。
就我而言,我得到的结果如下:
Results found: 2
Result found in file: 60052-0.txt with score: 2.365865
Result found in file: pg60060.txt with score: 1.7539438
由于我使用文件名作为文档索引,因此我可以重复使用该信息来显示相关结果。
本质上,你现在可以下载任意数量的书籍,并使用之前的代码对它们进行索引。这样你就拥有了一个搜索引擎,能够快速搜索并返回相关的文件名供你打开。速度是我之前提到的使用倒排索引的好处之一,因为它不必每次都梳理每个文档的整个正文,而是只需在其内部索引中搜索你输入的单词,并返回它在索引过程中的引用列表。
由此直接得出结论,你可以肯定地说,索引文档的计算成本远高于搜索成本。而且,由于通常情况下,大多数搜索引擎将大部分时间花在搜索上,而不是索引上,因此这是一个完全合理的权衡。
结论
以上就是我对 Elastic 的介绍,希望你和我一样觉得它很有趣。就我个人而言,这个 NoSQL 数据库(也被称为 Elastic)是我最喜欢的数据库之一,因为它只需很少的代码就能提供强大的功能。
您可以通过对图书进行分类并将该信息保存为索引元数据的一部分,轻松扩展上述代码。之后,您可以记录用户搜索的图书类型,然后根据用户的偏好,使用不同的提升值调整各个映射(例如,为某些用户优先推荐科幻类图书,而为其他用户优先推荐历史类图书)。这将使您的行为更接近 Google 的行为。想象力无极限!
如果您以前使用过 Elastic,请在评论中告诉我,以及您实现了什么样的疯狂搜索引擎!
否则,下次再见!
编者注:觉得这篇文章有什么问题?您可以在这里找到正确版本。
插件:LogRocket,一个用于 Web 应用的 DVR
LogRocket是一款前端日志工具,可让您重播问题,就像它们发生在您自己的浏览器中一样。无需猜测错误发生的原因,也无需要求用户提供屏幕截图和日志转储,LogRocket 让您重播会话,快速了解问题所在。它可与任何应用程序完美兼容,不受框架限制,并且提供插件来记录来自 Redux、Vuex 和 @ngrx/store 的额外上下文。
除了记录 Redux 操作和状态外,LogRocket 还记录控制台日志、JavaScript 错误、堆栈跟踪、带有标头 + 正文的网络请求/响应、浏览器元数据以及自定义日志。它还会对 DOM 进行插桩,以记录页面上的 HTML 和 CSS,即使是最复杂的单页应用程序,也能重现像素完美的视频。
免费试用。
如何使用 Node.js 和 Elastic 编写自己的搜索引擎一文首先出现在LogRocket 博客上。
文章来源:https://dev.to/bnevilleoneill/how-to-write-your-own-search-engine-using-node-js-and-elastic-10b8