在Rails中使用Elasticsearch进行全文搜索的教程

389 阅读12分钟

Elasticsearch是目前最流行的搜索引擎之一。在众多热爱它并在生产中积极使用它的大公司中,有NetflixMediumGitHub等巨头。

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进行互动:

  1. 从命令行中使用curl (你可能发现jq很方便)。
  2. 从浏览器上运行GET查询,使用一些漂亮的打印JSON的扩展
  3. 安装Kibana并使用开发工具控制台,这是我最喜欢的方式。
  4. 最后还有一些很棒的 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

我们将使用文本类型artisttitlelyrics 字段进行索引。这是唯一可以为全文搜索建立索引的类型。对于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,它只是一种将多个查询组合成一个的方法。在我们的例子中,我们要把mustfilter 。第一个(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" } }}