Computer >> 컴퓨터 >  >> 프로그래밍 >> Ruby

Rails와 Elasticsearch로 구현하는 전체 텍스트 검색 완벽 가이드

Elasticsearch는 현재 가장 인기 있는 검색 엔진 중 하나입니다. Netflix, Medium, GitHub 같은 거대 기업들이 프로덕션 환경에서 적극적으로 사용하며 사랑하고 있는 기술이기도 합니다.

Elasticsearch는 매우 강력한 도구로, 대표적인 활용 분야로는 전체 텍스트 검색(full-text search), 실시간 로그 분석, 보안 분석 등이 있습니다.

하지만 아쉽게도 Rails 커뮤니티에서는 Elasticsearch에 대한 관심이 많지 않은 편입니다. 이 글은 이러한 상황을 바꿔보고자 두 가지 목표를 가지고 작성되었습니다. 첫째, 독자에게 Elasticsearch의 핵심 개념을 소개하는 것, 둘째, Ruby on Rails와 함께 사용하는 방법을 알려주는 것입니다.

이 글에서 만들어볼 예제 프로젝트의 전체 소스 코드는 공개되어 있으며, 커밋 히스토리가 본문의 섹션 순서와 대체로 일치하므로 학습에 참고하시기 바랍니다.

소개

넓은 관점에서 보면, Elasticsearch는 다음과 같은 특징을 가진 검색 엔진입니다.

  • Apache Lucene을 기반으로 구축됨
  • JSON 문서를 저장하고 효율적으로 색인(indexing)함
  • 오픈소스 프로젝트임
  • 상호작용을 위한 REST API 세트를 제공함
  • 기본적으로 보안 기능이 없음(누구나 공개 엔드포인트를 통해 쿼리 가능)
  • 수평적 확장(scaling)에 매우 유리함

그럼 몇 가지 기본 개념을 빠르게 살펴보겠습니다.

Elasticsearch에서는 문서(document)를 인덱스(index)에 넣고, 이후 해당 인덱스를 쿼리하여 데이터를 조회합니다.

인덱스(Index)는 관계형 데이터베이스의 테이블과 유사한 개념으로, 나중에 쿼리할 수 있는 문서(Document)(행과 비슷)를 저장하는 공간입니다.

매핑(Mapping)은 관계형 데이터베이스의 스키마 정의와 비슷합니다. 매핑은 명시적으로 정의할 수도 있고, 데이터 삽입 시점에 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 https://localhost:9200

API 살펴보기

Elasticsearch는 모든 종류의 작업을 수행할 수 있는 REST API 세트를 제공합니다. 예를 들어, JSON 콘텐츠 타입으로 POST 요청을 보내 문서를 생성한다고 가정해봅시다.

curl -X POST https://localhost:9200/my-index/_doc \
  -H 'Content-Type: application/json' \
  -d '{"title": "Banana Cake"}'

여기서 my-index는 인덱스 이름입니다(존재하지 않으면 자동으로 생성됩니다).

_doc은 시스템 라우트입니다(모든 시스템 라우트는 밑줄로 시작합니다).

API와 상호작용하는 방법은 여러 가지가 있습니다.

  1. 커맨드라인에서 curl 사용하기(jq를 함께 사용하면 편리합니다)
  2. JSON 출력을 예쁘게 보여주는 확장 프로그램을 설치하여 브라우저에서 GET 쿼리 실행하기
  3. Kibana를 설치하고 Dev Tools 콘솔 사용하기 — 필자가 가장 선호하는 방법입니다
  4. 훌륭한 Chrome 확장 프로그램 활용하기

어차피 이 글에서는 API를 직접 호출하지 않고 gem을 통해 내부적으로 REST API와 통신할 것이기 때문에 어떤 방법을 선택하든 크게 중요하지 않습니다.

새 애플리케이션 만들기

이번 튜토리얼의 목표는 26,000곡 이상의 공개 데이터셋을 활용해 노래 가사 검색 애플리케이션을 만드는 것입니다. 각 노래에는 제목(title), 아티스트(artist), 장르(genre), 가사(lyrics) 필드가 있으며, 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 https://localhost:3000/songs

아직 데이터가 없기 때문에 빈 배열이 반환됩니다.

Elasticsearch 도입하기

이제 Elasticsearch를 추가해보겠습니다. 이를 위해 elasticsearch-model gem이 필요합니다. 이 gem은 Elasticsearch 공식 gem으로, Rails 모델과 잘 통합됩니다.

Gemfile에 다음을 추가합니다.

gem 'elasticsearch-model'

기본적으로 localhost의 9200 포트에 연결되는데, 이 설정이면 충분합니다. 변경하고 싶다면 다음과 같이 클라이언트를 초기화하면 됩니다.

Song.__elasticsearch__.client = Elasticsearch::Client.new host: 'myserver.com', port: 9876

다음으로 모델을 Elasticsearch에서 색인할 수 있도록 두 가지 작업이 필요합니다. 첫째, 매핑을 준비하는 것(즉, Elasticsearch에게 데이터 구조를 알려주는 것), 둘째, 검색 요청을 구성하는 것입니다. 우리가 사용할 gem이 두 가지 모두 지원하므로 사용법을 살펴보겠습니다.

Elasticsearch 관련 코드는 별도의 모듈로 분리해두는 것이 좋습니다. app/models/concerns/searchable.rb에 컨선(concern)을 만들고 다음 코드를 추가합니다.

# 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 인덱스 매핑을 정의하는 곳으로, 어떤 필드가 저장되고 어떤 타입을 가져야 하는지 결정합니다. 마지막으로 search 메서드는 실제로 노래 가사를 검색하는 데 사용할 메서드입니다. 사용 중인 gem은 Song.search("genesis")처럼 간단한 쿼리에 사용할 수 있는 search 메서드를 제공하지만, 여기서는 쿼리 DSL로 구성된 더 복잡한 검색 쿼리를 사용할 것입니다(자세한 내용은 뒤에서 다룹니다).

모델 클래스에 컨선을 포함시키는 것도 잊지 마세요.

# /app/models/song.rb

class Song < ApplicationRecord
  include Searchable
end

매핑 정의하기

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 필드는 text 타입으로 색인합니다. text 타입은 전체 텍스트 검색을 위해 색인되는 유일한 타입입니다. 반면 genre는 keyword 타입을 사용하는데, 이는 특정 값으로 정확히 필터링하는 검색에 이상적입니다.

이제 bin/rails console로 Rails 콘솔을 열고 다음을 실행합니다.

Song.__elasticsearch__.create_index!

이렇게 하면 Elasticsearch에 인덱스가 생성됩니다. __elasticsearch__ 객체는 Elasticsearch 세계로 향하는 관문으로, 상호작용에 유용한 다양한 메서드를 제공합니다.

데이터 가져오기

레코드를 생성할 때마다 데이터가 자동으로 Elasticsearch로 전송됩니다. 따라서 노래 가사 데이터셋을 다운로드해 앱에 가져오겠습니다. 먼저 링크에서 CSV 파일을 다운로드하세요(Creative Commons Attribution 4.0 International 라이선스로 배포되는 데이터셋입니다). 이 CSV 파일에는 26,000개 이상의 레코드가 담겨 있으며, 아래 코드를 사용해 데이터베이스와 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!를 실행하세요(시간이 다소 걸립니다). 물론 벌크(bulk) 방식으로 임포트하면 훨씬 빠르지만, 여기서는 PostgreSQL 데이터베이스와 Elasticsearch 양쪽에 레코드가 제대로 생성되는 과정을 확인하는 것이 목적입니다.

임포트가 완료되면 이제 검색할 수 있는 방대한 양의 가사 데이터가 준비됩니다.

데이터 검색하기

elasticsearch-model gem은 색인된 모든 필드를 대상으로 검색할 수 있는 search 메서드를 추가해 줍니다. searchable 컨선에서 활용해 봅시다.

# app/models/concerns/searchable.rb

# ...
def self.search(query)
  self.__elasticsearch__.search(query)
end
# ...

Rails 콘솔을 열고 res = Song.search('genesis')를 실행해 보세요. 응답 객체에는 요청 처리 시간, 사용된 노드 등 다양한 메타 정보가 포함되어 있습니다. 우리가 원하는 것은 res.response["hits"]["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

이제 브라우저나 curl로 https://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 코드에서도 사용할 수 있습니다. 예를 들어, artist 필드만 검색하도록 search 메서드를 수정해 보겠습니다.

# 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

query-match 구조를 사용하면 특정 필드만 검색할 수 있습니다(여기서는 artist). 이제 "genesis"로 다시 검색하면(https://localhost:3000/songs?query=genesis로 확인해 보세요), 제목에 "genesis"가 포함된 곡이 아니라 "Genesis"라는 밴드의 곡만 반환됩니다. 여러 필드를 동시에 검색하고 싶은 경우가 흔한데, 이럴 때는 multi-match 쿼리를 사용할 수 있습니다.

# app/models/concerns/searchable.rb

def self.search(query)
  params = {
    query: {
      multi_match: {
        query: query, 
        fields: [ :title, :artist, :lyrics ] 
      },
    },
  }

  self.__elasticsearch__.search(params)
end

필터링 적용하기

예를 들어 록(rock) 장르의 곡만 검색하고 싶다면 어떻게 해야 할까요? 장르로 필터링하면 됩니다! 검색이 조금 더 복잡해지지만 걱정하지 마세요. 단계별로 차근차근 설명해 드리겠습니다.

  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)는 점수(score)에 영향을 주며, 이전에 사용했던 것과 동일한 쿼리를 포함합니다. 후자(filter)는 점수에 영향을 주지 않고, 말 그대로 쿼리와 일치하지 않는 문서를 걸러내는 역할만 합니다. 장르로 레코드를 필터링하고 싶으므로 term 쿼리를 사용합니다.

중요한 점은 filter-term 조합은 전체 텍스트 검색과는 무관하다는 것입니다. SQL의 WHERE 절(WHERE genre = 'rock')과 마찬가지로 정확한 값을 기준으로 하는 일반적인 필터입니다. term 필터링 사용법을 알아두면 좋지만, 여기서는 꼭 필요하지는 않습니다.

점수(Scoring) 조정하기

검색 결과는 특정 검색에 대한 문서의 관련성을 나타내는 _score를 기준으로 정렬됩니다. 점수가 높을수록 더 관련성이 높은 문서입니다. genesis를 검색했을 때 가장 먼저 나오는 결과가 Grimes의 곡이었던 것을 눈치채셨을 겁니다. 하지만 필자는 사실 Genesis 밴드의 곡에 더 관심이 있었습니다. 그렇다면 scoring 메커니즘을 조정해서 artist 필드에 더 큰 가중치를 둘 수 있을까요? 네, 가능합니다. 다만 그 전에 쿼리를 먼저 다듬어야 합니다.

  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로 조합됩니다. 필드마다 별도의 match가 필요한 이유는 무엇일까요? 이제 boost 속성을 지정할 수 있기 때문입니다. boost는 특정 쿼리의 점수에 곱해지는 계수입니다.

  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

이제 다른 조건이 같다면, 쿼리가 artist와 일치할 경우 점수가 5배 높아집니다. https://localhost:3000/songs?query=genesis로 genesis 쿼리를 다시 실행해 보세요. 이제 Genesis 밴드의 곡들이 맨 위에 나타날 것입니다. 멋지죠?

하이라이트(Highlighting) 추가하기

Elasticsearch의 또 다른 유용한 기능은 문서 내 일치 구문을 하이라이트하는 기능입니다. 사용자가 특정 결과가 검색에 나타난 이유를 더 잘 이해할 수 있게 해 줍니다.

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 필드는 어떤 필드를 하이라이트할지 지정합니다. 여기서는 모든 필드를 선택했습니다. 이제 https://localhost:3000/query=genesis에 접속하면, 일치하는 구문이 em 태그로 감싸진 문서 필드를 담고 있는 "highlight"라는 새 필드를 확인할 수 있습니다.

하이라이팅에 대해 더 자세히 알고 싶다면 공식 가이드를 참고하세요.

퍼지 검색(Fuzziness)

자, 만약 genesis 대신 실수로 benesis라고 입력했다면 어떻게 될까요? 아무런 결과도 반환되지 않겠지만, Elasticsearch에게 덜 깐깐하게 동작하도록 지시해 퍼지(fuzzy) 검색을 허용하면 genesis 결과도 함께 표시할 수 있습니다.

방법은 간단합니다. artist 쿼리를 { match: { artist: { query: query, boost: 5 } }}에서 { match: { artist: { query: query, boost: 5, fuzziness: "AUTO" } }}로 변경하기만 하면 됩니다. 퍼지ness의 정확한 메커니즘은 설정으로 조정할 수 있으며, 자세한 내용은 공식 문서를 참고하세요.

다음 단계는?

이 글이 여러분에게 Elasticsearch가 강력한 도구이며, 단순하지 않은(non-trivial) 검색 기능을 구현해야 할 때 사용할 만한 가치가 있다는 것을 확신시켜 드렸기를 바랍니다. 더 배워보고 싶다면 아래 유용한 자료들을 참고하세요.

참고 자료

  • Elasticsearch 공식 레퍼런스
  • Ruby gem
  • Rails gem
  • 실무 지식이 가득한 훌륭한 서적
  • 자동완성(Autocomplete) 구현 가이드

대안 gem

  • Searchkick
  • Chewy