Elasticsearch是目前最流行的搜索引擎之一。在众多热爱它并在生产中积极使用它的大公司中,有Netflix、Medium、GitHub等巨头。
Elasticsearch非常强大,主要的用例是全文搜索、实时日志和安全分析。
不幸的是,Elasticsearch并没有得到Rails社区的关注,所以本文试图改变这种情况,有两个目标:向读者介绍Elasticsearch的概念,并展示如何在Ruby on Rails中使用它。
你可以在这里找到我们要建立的一个示例项目的源代码。提交历史或多或少与本文各部分的顺序相符。
简介
从更广泛的角度来看,Elasticsearch是一个搜索引擎,它建立在Apache Lucene的基础上。
- 建立在Apache Lucene之上。
- 存储并有效索引JSON文档。
- 是开源的。
- 提供了一套REST API用于与之交互。
- 默认情况下没有安全性(任何人都可以通过公共端点查询它)。
- 可以很好地进行横向扩展。
让我们快速看一下一些基本概念。
在Elasticsearch中,我们把文档放到索引中,然后对其进行数据查询。
索引类似于关系型数据库中的表;它是一个存储空间,我们把文档(行)放在里面,然后可以查询。
一个文件是一个字段的集合(类似于关系数据库中的行)。
映射就像关系型数据库中的模式定义。映射可以明确定义,也可以由Elasticsearch在插入时猜测;最好是预先定义索引映射。
说了这么多,现在让我们来设置我们的环境。
安装Elasticsearch
在macOS上安装Elasticsearch最简单的方法是使用brew:
brew tap elastic/tap
brew install elastic/tap/elasticsearch-full
作为一个替代方案,我们可以通过docker来运行它:
docker run \
-p 127.0.0.1:9200:9200 \
-p 127.0.0.1:9300:9300 \
-e "discovery.type=single-node" \
docker.elastic.co/elasticsearch/elasticsearch:7.16.2
Elasticsearch接受请求的端口默认为9200。你可以通过简单的curl请求来检查它是否在运行(或者在浏览器中打开它)。
curl http://localhost:9200
APIs
Elasticsearch提供了一套REST API,可以与各种可能的任务类型进行交互。例如,假设我们运行一个JSON内容类型的POST请求来创建一个文档。
curl -X POST http://localhost:9200/my-index/_doc \
-H 'Content-Type: application/json' \
-d '{"title": "Banana Cake"}'
在这种情况下,my-index 是一个索引的名称(如果它不存在,它会被自动创建)。
_doc 是一个系统路由(所有系统路由以下划线开头)。
我们可以通过多种方式与API进行互动:
- 从命令行中使用
curl(你可能发现jq很方便)。 - 从浏览器上运行GET查询,使用一些漂亮的打印JSON的扩展。
- 安装Kibana并使用开发工具控制台,这是我最喜欢的方式。
- 最后还有一些很棒的 Chrome扩展。
就本文而言,你选择哪种方式并不重要--反正我们不打算直接与API互动。相反,我们将使用一个gem,它与引擎盖下的REST API对话。
开始一个新的应用程序
我们的想法是使用26K+歌曲的公共数据集创建一个歌词应用程序。每首歌都有一个标题、艺术家、流派和文本歌词字段。我们将使用Elasticsearch进行全文搜索。
让我们从创建一个简单的Rails应用程序开始:
rails new songs_api --api -d postgresql
由于我们只将其作为API使用,我们提供了--api 标志来限制使用的中间件集合。
让我们为我们的应用程序搭建一个脚手架:
bin/rails generate scaffold Song title:string artist:string genre:string lyrics:text
现在,让我们运行迁移并启动服务器:
bin/rails db:create db:migrate
bin/rails server
之后,我们验证GET端点是否工作:
curl http://localhost:3000/songs
这将返回一个空数组,这并不奇怪,因为现在还没有数据。
引入Elasticsearch
让我们把Elasticsearch加入到这个组合中。要做到这一点,我们需要elasticsearch-modelgem。这是一个官方的Elasticsearch gem,可以很好地与Rails模型集成。
将以下内容添加到你的Gemfile :
gem 'elasticsearch-model'
默认情况下,它将连接到localhost的9200端口,这很适合我们,但如果你想改变这一点,你可以通过以下方式初始化客户端
Song.__elasticsearch__.client = Elasticsearch::Client.new host: 'myserver.com', port: 9876
接下来,为了使我们的模型可以被Elasticsearch索引,我们需要做两件事。首先,我们需要准备一个映射(这基本上是告诉Elasticsearch我们的数据结构),其次,我们应该构建一个搜索请求。我们的宝石可以做这两件事,所以让我们看看如何使用它。
将Elastisearch相关的代码放在一个单独的模块中总是一个好主意,所以让我们在app/models/concerns/searchable.rb ,并添加一个关注点:
# app/models/concerns/searchable.rb
module Searchable
extend ActiveSupport::Concern
included do
include Elasticsearch::Model
include Elasticsearch::Model::Callbacks
mapping do
# mapping definition goes here
end
def self.search(query)
# build and run search
end
end
end
尽管这只是一个骨架,但这里有一些东西需要解开。
首先,最重要的是Elasticsearch::Model ,它增加了一些与ES交互的功能。Elasticsearch::Model::Callbacks 模块确保当我们更新一条记录时,它会自动更新Elasticsearch中的数据。mapping 块是我们放置Elasticsearch索引映射的地方,它定义了哪些字段将被存储在Elasticsearch中,以及它们应该有什么类型。最后,有一个search 方法,我们将用它来实际搜索Elasticsearch的歌词。我们使用的gem提供了一个search 方法,可以用于像Song.search("genesis”) 这样的简单查询,但我们将使用查询DSL构建一个更复杂的搜索查询(后面会有更多的介绍)。
让我们不要忘记在我们的模型类中包含关注点:
# /app/models/song.rb
class Song < ApplicationRecord
include Searchable
end
映射(Mappings
在Elasticsearch中,映射就像关系型数据库中的模式定义。我们描述我们想要存储的文件的结构。与典型的关系型数据库不同,我们不需要预先定义我们的映射。Elasticsearch会尽力为我们猜测类型。不过,由于我们不希望出现任何意外,我们将事先明确定义我们的映射。
映射可以通过REST端点使用PUT /my-index/_mapping 进行更新,通过GET /my-index/_mapping 进行读取,但是elasticsearch gem为我们抽象了这一点,所以我们需要做的就是提供mapping 块。
# app/models/concerns/searchable.rb
mapping do
indexes :artist, type: :text
indexes :title, type: :text
indexes :lyrics, type: :text
indexes :genre, type: :keyword
end
我们将使用文本类型对artist 、title 和lyrics 字段进行索引。这是唯一可以为全文搜索建立索引的类型。对于genre ,我们将使用关键字类型,这是一个理想的搜索,由一个精确的值过滤。
现在用bin/rails console ,运行rails控制台,然后运行
Song.__elasticsearch__.create_index!
这将在Elasticsearch中创建我们的索引。__elasticsearch__ 对象是我们进入Elasticsearch世界的大门,它包含了很多与Elasticsearch互动的有用方法。
导入数据
每当我们创建一条记录,它就会自动将数据发送到Elasticsearch。所以,我们要下载一个包含歌词的数据集,并将其导入我们的应用程序。首先,从这个链接下载它(Creative Commons Attribution 4.0 International license 下的数据集)。这个CSV文件包含26000多条记录,我们将用下面的代码将其导入到我们的数据库和Elasticsearch:
require 'csv'
class Song < ApplicationRecord
include Searchable
def self.import_csv!
filepath = "/path/to/your/file/tcc_ceds_music.csv"
res = CSV.parse(File.read(filepath), headers: true)
res.each_with_index do |s, ind|
Song.create!(
artist: s["artist_name"],
title: s["track_name"],
genre: s["genre"],
lyrics: s["lyrics"]
)
end
end
end
打开rails控制台,运行Song.import_csv! (这将需要一些时间)。另外,我们也可以批量导入数据,这要快得多,但在这种情况下,我们要确保在我们的PostgreSQL数据库和Elasticsearch中创建记录。
当导入完成后,我们现在有大量的歌词可以搜索。
搜索数据
elasticsearch-model gem添加了一个search 方法,允许我们在所有索引字段中搜索。让我们在我们的可搜索关注中使用它:
# app/models/concerns/searchable.rb
# ...
def self.search(query)
self.__elasticsearch__.search(query)
end
# ...
打开rails控制台,运行res = Song.search('genesis') 。响应对象包含很多元信息:请求花了多少时间,使用了哪些节点,等等。我们对点击率感兴趣,在res.response["hits"]["hits"] 。
让我们改变我们的控制器的index 方法来代替查询ES。
# app/controllers/songs_controller.rb
def index
query = params["query"] || ""
res = Song.search(query)
render json: res.response["hits"]["hits"]
end
现在我们可以尝试在浏览器中加载它,或者使用curlhttp://localhost:3000/songs?query=genesis 。响应将看起来像这样:
[ { "_index": "songs", "_type": "_doc", "_id": "22676", "_score": 12.540506, "_source": { "id": 22676, "title": "genesis", "artist": "grimes", "genre": "pop", "lyrics": "heart know heart ...", "created_at": "...", "updated_at": "..." } },...]
正如你所看到的,实际的数据是在_source 关键下返回的,其他字段是元数据,其中最重要的是_score ,显示该文件与特定搜索的相关性。我们很快就会讨论这个问题,但首先让我们学习如何进行查询。
查询DSL
Elasticsearch查询DSL提供了一种构建复杂查询的方法,我们也可以从ruby代码中使用它。例如,让我们修改搜索方法,只搜索艺术家字段:
# app/models/concerns/searchable.rb
module Searchable
extend ActiveSupport::Concern
included do
# ...
def self.search(query)
params = {
query: {
match: {
artist: query,
},
},
}
self.__elasticsearch__.search(params)
end
end
end
查询匹配结构允许我们只搜索一个特定的字段(在这种情况下,艺术家)。现在,如果我们用 "Genesis "再次查询歌曲(通过加载http://localhost:3000/songs?query=genesis ),我们将只得到 "Genesis "乐队的歌曲,而不是标题中带有 "Genesis "的歌曲。如果我们想查询多个字段,这往往是一种情况,我们可以使用多匹配查询:
# app/models/concerns/searchable.rb
def self.search(query)
params = {
query: {
multi_match: {
query: query,
fields: [ :title, :artist, :lyrics ]
},
},
}
self.__elasticsearch__.search(params)
end
筛选
如果我们只想搜索,例如,在摇滚歌曲中搜索呢?那么,我们就需要按流派进行过滤!这将使我们的搜索变得更加容易。这将使我们的搜索变得更加复杂,但不用担心--我们会一步一步地解释一切
def self.search(query, genre = nil)
params = {
query: {
bool: {
must: [
{
multi_match: {
query: query,
fields: [ :title, :artist, :lyrics ]
}
},
],
filter: [
{
term: { genre: genre }
}
]
}
}
}
self.__elasticsearch__.search(params)
end
第一个新的关键词是bool,它只是一种将多个查询组合成一个的方法。在我们的例子中,我们要把must 和filter 。第一个(must)是对分数的贡献,包含了我们之前已经使用过的相同的查询。第二个(filter)对分数没有贡献,它只是做它所说的:过滤掉不符合查询的文件。我们想通过流派来过滤我们的记录,所以我们使用术语查询。
值得注意的是,filter-term 组合与全文搜索没有任何关系。它只是一个按准确值的常规过滤器,与SQL中的WHERE 子句的作用相同(WHERE genre = 'rock')。知道如何使用term 过滤是很好的,但我们在这里不需要它。
计分
搜索结果是按_score ,显示一个项目与特定搜索的相关程度。分数越高,文件就越相关。你可能已经注意到,当我们搜索genesis ,弹出的第一个结果是Grimes的歌曲,而我实际上对Genesis乐队更感兴趣。那么,我们能不能改变评分机制,更多地关注艺术家这一领域呢?是的,我们可以,但要做到这一点,我们首先需要调整我们的查询:
def self.search(query)
params = {
query: {
bool: {
should: [
{ match: { title: query }},
{ match: { artist: query }},
{ match: { lyrics: query }},
],
}
},
}
self.__elasticsearch__.search(params)
end
这个查询基本上等同于前一个查询,只是它使用了bool关键字,这只是一种将多个查询合并为一个的方法。我们使用should ,它分别包含三个查询(每个字段一个):它们基本上是用逻辑OR结合起来的。如果我们使用must ,它们就会用逻辑AND来组合。为什么我们需要每个字段单独匹配?这是因为现在我们可以指定提升属性,这是一个将特定查询的分数乘以的系数。
def self.search(query)
params = {
query: {
bool: {
should: [
{ match: { title: query }},
{ match: { artist: { query: query, boost: 5 } }},
{ match: { lyrics: query }},
],
}
},
}
self.__elasticsearch__.search(params)
end
在其他条件相同的情况下,只要查询与艺术家匹配,我们的分数就会高出五倍。再试试genesis ,用http://localhost:3000/songs?query=genesis ,你会看到Genesis乐队的歌曲排在第一位。很好!
突出显示
Elasticsearch的另一个有用的功能是能够在文档中突出显示匹配的内容,这可以让用户更好地理解为什么某个特定的结果出现在搜索中。
在HTML中,有一个专门的HTML标签,Elasticsearch可以自动添加这个标签。
让我们再次打开searchable.rb 关注,添加一个新的关键词:
def self.search(query)
params = {
query: {
bool: {
should: [
{ match: { title: query }},
{ match: { artist: { query: query, boost: 5 } }},
{ match: { lyrics: query }},
],
}
},
highlight: { fields: { title: {}, artist: {}, lyrics: {} } }
}
self.__elasticsearch__.search(params)
end
新的highlight 字段,指定哪些字段应该被突出显示。我们选择所有的。现在,如果我们加载http://localhost:3000/query=genesis ,我们应该看到一个叫做 "高亮 "的新字段,它包含了用em 标签包裹的匹配短语的文档字段。
模糊性
好吧,如果我们误写了benesis ,而不是genesis 呢?这不会返回任何结果,但我们可以告诉Elasticsearch不要太挑剔,允许模糊搜索,所以它也会显示genesis 。
这里是如何做到的。只要把艺术家查询从{ match: { artist: { query: query, boost: 5 } }} 改为{ match: { artist: { query: query, boost: 5, fuzziness: "AUTO" } }} 。