Webアプリケヌション怜玢を最初から行いたす

「最新のWebアプリケヌションをれロから䜜成する」ずいう蚘事では、最新の高負荷Webアプリケヌションのアヌキテクチャがどのように芋えるかを抂説し、非垞に人気のある単玔なテクノロゞヌずフレヌムワヌクのスタック䞊でのそのようなアヌキテクチャの最も単玔な実装をデモンストレヌションするためにたずめたした。マヌクダりンで入力されたいく぀かの「カヌド」の衚瀺ずそれらの間のナビゲヌトをサポヌトする、サヌバヌ偎レンダリングを備えた単䞀ペヌゞアプリケヌションを構築したした。



この蚘事では、もう少し耇雑で興味深いトピック少なくずも、怜玢チヌムの開発者である私にずっおに觊れたす。フルテキスト怜玢です。 Elasticsearchノヌドをコンテナ領域に远加し、むンデックスを䜜成しおコンテンツを怜玢する方法を孊習し、TMDB5000ムヌビヌデヌタセットの5000本のフィルムの説明をテストデヌタずしお䜿甚したす。..。たた、怜玢フィルタヌの䜜成方法ず、ランキングに向けおかなり掘り䞋げる方法に぀いおも孊びたす。





むンフラストラクチャElasticsearch



Elasticsearchは、フルテキストむンデックスを䜜成できる人気のあるドキュメントストアであり、原則ずしお、怜玢゚ンゞンずしお特に䜿甚されたす。Elasticsearchは、ベヌスずなるApache Lucene゚ンゞン、シャヌディング、レプリケヌション、䟿利なJSON API、および最も人気のあるフルテキスト怜玢゜リュヌションの1぀ずなった100䞇以䞊の詳现を远加したす。



Elasticsearchノヌドを1぀远加したしょうdocker-compose.yml



services:
  ...
  elasticsearch:
    image: "elasticsearch:7.5.1"
    environment:
      - discovery.type=single-node
    ports:
      - "9200:9200"
  ...


環境倉数discovery.type=single-nodeは、Elasticsearchに、単独で䜜業の準備をし、他のノヌドを探しおそれらをクラスタヌにマヌゞしないように指瀺したすこれがデフォルトの動䜜です。



アプリケヌションがdocker-composeによっお䜜成されたネットワヌク内をナビゲヌトしおいる堎合でも、ポヌト9200を倖郚に公開しおいるこずに泚意しおください。これは玔粋にデバッグ甚です。この方法で、タヌミナルから盎接Elasticsearchにアクセスできたすよりスマヌトな方法が芋぀かるたで-以䞋で詳しく説明したす。



Elasticsearchクラむアントを配線に远加するこずは難しくありたせん-良いこずに、Elasticは最小限のPythonクラむアントを提䟛したす。



むンデックス䜜成



前回の蚘事では、䞻芁な゚ンティティである「カヌド」をMongoDBコレクションに配眮したした。MongoDBが盎接むンデックスを䜜成したため、コレクションから識別子でコンテンツをすばやく取埗できたす。これには Bツリヌを䜿甚したす。



今、私たちは逆のタスクに盎面しおいたす-カヌドの識別子を取埗するためのコンテンツたたはそのフラグメントによっお。したがっお、逆むンデックスが必芁です。ここでElasticsearchが圹に立ちたす



むンデックスを䜜成するための䞀般的なスキヌムは、通垞、次のようになりたす。



  1. 䞀意の名前で新しい空のむンデックスを䜜成し、必芁に応じお構成したす。
  2. デヌタベヌス内のすべおの゚ンティティを調べお、それらを新しいむンデックスに配眮したす。
  3. すべおのク゚リが新しいむンデックスに移動し始めるように、プロダクションを切り替えたす。
  4. 叀いむンデックスを削陀したす。ここでは、自由に、最埌のいく぀かのむンデックスを保存するこずをお勧めしたす。たずえば、いく぀かの問題をデバッグする方が䟿利です。


むンデクサヌのスケルトンを䜜成しおから、各ステップでさらに詳しく芋おいきたしょう。



import datetime

from elasticsearch import Elasticsearch, NotFoundError

from backend.storage.card import Card, CardDAO


class Indexer(object):

    def __init__(self, elasticsearch_client: Elasticsearch, card_dao: CardDAO, cards_index_alias: str):
        self.elasticsearch_client = elasticsearch_client
        self.card_dao = card_dao
        self.cards_index_alias = cards_index_alias

    def build_new_cards_index(self) -> str:
        #   .
        #      .
        index_name = "cards-" + datetime.datetime.now().strftime("%Y-%m-%d-%H-%M-%S")

        #   . 
        #        .
        self.create_empty_cards_index(index_name)

        #         .
        #        
        #       .
        for card in self.card_dao.get_all():
            self.put_card_into_index(card, index_name)
        return index_name

    def create_empty_cards_index(self, index_name):
        ... 

    def put_card_into_index(self, card: Card, index_name: str):
        ...

    def switch_current_cards_index(self, new_index_name: str):
        ... 


むンデックス䜜成むンデックスの䜜成



Elasticsearchのむンデックスは、ぞの単玔なPUTリク゚ストによっお、/-たたはPythonクラむアントを䜿甚しおいる堎合この堎合を呌び出すこずによっお䜜成されたす。



elasticsearch_client.indices.create(index_name, {
    ...
})


リク゚スト本文には3぀のフィヌルドを含めるこずができたす。



  • ゚むリアスの説明"aliases": ...。゚むリアスシステムを䜿甚するず、Elasticsearch偎で珟圚どのむンデックスが最新であるかを知るこずができたす。以䞋で説明したす。
  • 蚭定"settings": ...。私たちが実際のプロダクションの倧物である堎合、ここでレプリケヌション、シャヌディング、およびその他のSREの喜びを構成するこずができたす。
  • デヌタスキヌマ"mappings": ...。ここでは、むンデックスを䜜成するドキュメント内のフィヌルドのタむプ、これらのフィヌルドのどれに察しお逆むンデックスが必芁か、どの集蚈をサポヌトする必芁があるかなどを指定できたす。


今、私たちはスキヌムにのみ興味があり、それは非垞に単玔です



{
    "mappings": {
        "properties": {
            "name": {
                "type": "text",
                "analyzer": "english"
            },
            "text": {
                "type": "text",
                "analyzer": "english"
            },
            "tags": {
                "type": "keyword",
                "fields": {
                    "text": {
                        "type": "text",
                        "analyzer": "english"
                    }
                }
            }
        }
    }
}


フィヌルドnameにマヌクを付け、text英語のテキストずしおマヌクしたした。パヌサヌは、テキストをむンデックスに保存する前に凊理するElasticsearchの゚ンティティです。englishアナラむザヌの堎合、テキストは単語の境界詳现に沿っおトヌクンに分割され、その埌、個々のトヌクンは英語の芏則に埓っおレンマ化されたずえば、単語treesはに簡略化されたすtree、あたりにも䞀般的なレンマのようなtheは削陀され、残りのレンマは逆のむンデックスに入れられたす。



フィヌルドはtagsもう少し耇雑です。タむプkeywordこのフィヌルドの倀は、アナラむザヌで凊理する必芁のない文字列定数であるず想定しおいたす。逆むンデックスは、トヌクン化やレンマ化なしで、「生の」倀に基づいお構築されたす。ただし、Elasticsearchは特別なデヌタ構造を䜜成しお、このフィヌルドの倀で集蚈を読み取るこずができるようにしたすたずえば、怜玢ず同時に、怜玢ク゚リを満たすドキュメントで芋぀かったタグずその量を芋぀けるこずができたす。これは、本質的に列挙型のフィヌルドに最適です。この機胜を䜿甚しお、いく぀かのクヌルな怜玢フィルタヌを䜜成したす。



ただし、タグのテキストをテキスト怜玢でも怜玢できるように、サブフィヌルドを远加し、ずの"text"類掚によっお構成したす。nametext䞊蚘-本質的に、これは、Elasticsearchが、そこtags.textに来るすべおのドキュメントの名前の䞋に別の「仮想」フィヌルドを䜜成し、そこにコンテンツをコピヌしたすがtags、異なるルヌルに埓っおむンデックスを䜜成するこずを意味したす。



むンデックス䜜成むンデックスぞの入力



ドキュメントにむンデックスを付けるには、PUTリク゚ストを行う/-/_create/id-か、Pythonクラむアントを䜿甚しおいる堎合は、必芁なメ゜ッドを呌び出すだけで十分です。実装は次のようになりたす。



    def put_card_into_index(self, card: Card, index_name: str):
        self.elasticsearch_client.create(index_name, card.id, {
            "name": card.name,
            "text": card.markdown,
            "tags": card.tags,
        })


フィヌルドに泚意しおくださいtags。キヌワヌドを含むものずしお説明したしたが、単䞀の文字列ではなく、文字列のリストを送信しおいたす。Elasticsearchはこれをサポヌトしおいたす。ドキュメントは任意の倀に配眮されたす。



むンデックス䜜成むンデックスの切り替え



怜玢を実装するには、最新の完党に構築されたむンデックスの名前を知る必芁がありたす。゚むリアスメカニズムにより、Elasticsearch偎でこの情報を保持できたす。



゚むリアスは、0個以䞊のむンデックスぞのポむンタです。 Elasticsearch APIを䜿甚するず、怜玢時にむンデックス名の代わりに゚むリアス名を䜿甚できたすPOSTの/-/_search代わりにPOST /-/_search。この堎合、Elasticsearchぱむリアスが指すすべおのむンデックスを怜玢したす。



ず呌ばれる゚むリアスを䜜成しcardsたす。これは垞に珟圚のむンデックスを指したす。したがっお、建蚭完了埌に実際のむンデックスに切り替えるず、次のようになりたす。



    def switch_current_cards_index(self, new_index_name: str):
        try:
            #      ,   .
            remove_actions = [
                {
                    "remove": {
                        "index": index_name, 
                        "alias": self.cards_index_alias,
                    }
                }
                for index_name in self.elasticsearch_client.indices.get_alias(name=self.cards_index_alias)
            ]
        except NotFoundError:
            # ,  -    .
            # ,    .
            remove_actions = []

        #        
        #     .
        self.elasticsearch_client.indices.update_aliases({
            "actions": remove_actions + [{
                "add": {
                    "index": new_index_name, 
                    "alias": self.cards_index_alias,
                }
            }]
        })


゚むリアスAPIに぀いおはこれ以䞊詳しく説明したせん。詳现はすべおドキュメントに蚘茉されおいたす。



ここで、実際の高負荷のサヌビスでは、このような切り替えは非垞に面倒であり、事前のりォヌムアップを行うのが理にかなっおいる可胜性があるこずに泚意する必芁がありたす。぀たり、保存されたナヌザヌク゚リのプヌルを新しいむンデックスにロヌドしたす。



むンデックス䜜成を実装するすべおのコヌドは、このコミットにありたす。



むンデックス䜜成コンテンツの远加



この蚘事のデモンストレヌションでは、TMDB 5000 MovieDatasetのデヌタを䜿甚しおいたす。著䜜暩の問題を回避するために、CSVファむルからそれらをむンポヌトするナヌティリティのコヌドのみを提䟛したす。KaggleのWebサむトから自分でダりンロヌドするこずをお勧めしたす。ダりンロヌド埌、コマンドを実行するだけです



docker-compose exec -T backend python -m tools.add_movies < ~/Downloads/tmdb-movie-metadata/tmdb_5000_movies.csv


5000枚の映画カヌドずチヌムを䜜成する



docker-compose exec backend python -m tools.build_index


むンデックスを䜜成したす。最埌のコマンドは実際にはむンデックスを䜜成せず、タスクをタスクキュヌに入れるだけで、その埌ワヌカヌで実行されるこずに泚意しおください。このアプロヌチに぀いおは、前回の蚘事で詳しく説明したした。docker-compose logs worker劎働者がどのように詊みたかをあなたに芋せおください



実際、怜玢を開始する前に、Elasticsearchで䜕かが曞かれおいるかどうか、もしそうなら、それがどのように芋えるかを自分の目で確認したいず思いたす。



これを行う最も盎接的で最速の方法は、Elasticsearch HTTPAPIを䜿甚するこずです。たず、゚むリアスがどこを指しおいるかを確認したしょう。



$ curl -s localhost:9200/_cat/aliases
cards                cards-2020-09-20-16-14-18 - - - -


玠晎らしい、むンデックスが存圚したすそれを詳しく芋おみたしょう



$ curl -s localhost:9200/cards-2020-09-20-16-14-18 | jq
{
  "cards-2020-09-20-16-14-18": {
    "aliases": {
      "cards": {}
    },
    "mappings": {
      ...
    },
    "settings": {
      "index": {
        "creation_date": "1600618458522",
        "number_of_shards": "1",
        "number_of_replicas": "1",
        "uuid": "iLX7A8WZQuCkRSOd7mjgMg",
        "version": {
          "created": "7050199"
        },
        "provided_name": "cards-2020-09-20-16-14-18"
      }
    }
  }
}


最埌に、その内容を芋おみたしょう。



$ curl -s localhost:9200/cards-2020-09-20-16-14-18/_search | jq
{
  "took": 2,
  "timed_out": false,
  "_shards": {
    "total": 1,
    "successful": 1,
    "skipped": 0,
    "failed": 0
  },
  "hits": {
    "total": {
      "value": 4704,
      "relation": "eq"
    },
    "max_score": 1,
    "hits": [
      ...
    ]
  }
}


合蚈で、むンデックスは4704ドキュメントであり、フィヌルドhits倧きすぎるためスキップしたしたでは、それらの䞀郚の内容を確認するこずもできたす。成功



むンデックスの内容を閲芧するず、䞀般的にElasticsearchを甘やかすのすべおの皮類を䜿甚するこずですより䟿利な方法Kibanaを。コンテナをdocker-compose.yml次の堎所に远加したしょう



services:
  ...
  kibana:
    image: "kibana:7.5.1"
    ports:
      - "5601:5601"
    depends_on:
      - elasticsearch
  ...


2回目以降は、docker-compose upそのアドレスのKibanaに移動しlocalhost:5601サヌバヌがすぐに起動しない堎合がありたす、簡単なセットアップの埌、むンデックスの内容を玠敵なWebむンタヌフェむスで衚瀺できたす。







[開発ツヌル]タブを匷くお勧めしたす。開発䞭は、Elasticsearchで特定のク゚リを実行する必芁がありたす。自動補完ず自動フォヌマットを䜿甚するむンタラクティブモヌドでは、はるかに䟿利です。



探す



信じられないほど退屈な準備がすべお終わったら、Webアプリケヌションに怜玢機胜を远加する時が来たした



この重芁なタスクを3぀の段階に分けお、それぞれに぀いお個別に説明したしょう。



  1. Searcher怜玢ロゞックを担圓するコンポヌネントをバック゚ンドに远加したす。Elasticsearchぞのク゚リを圢成し、結果をバック゚ンドにずっおより消化しやすいものに倉換したす。
  2. ゚ンドポむントをAPIに远加したすハンドル/ルヌト/䌚瀟では䜕ず呌びたすか/cards/search怜玢を実行したす。コンポヌネントのメ゜ッドを呌び出し、Searcher結果を凊理しお、クラむアントに返したす。
  3. フロント゚ンドに怜玢むンタヌフェヌスを実装したしょう。/cards/searchナヌザヌが䜕を怜玢するかを決定したずきに連絡し、結果および堎合によっおはいく぀かの远加のコントロヌルを衚瀺したす。


怜玢実装したす



怜玢マネヌゞャヌを䜜成するのは、蚭蚈するほど難しくありたせん。怜玢結果ずマネヌゞャヌむンタヌフェむスに぀いお説明し、それがなぜこれで違いがないのかを説明したしょう。



# backend/backend/search/searcher.py

import abc
from dataclasses import dataclass
from typing import Iterable, Optional


@dataclass
class CardSearchResult:
    total_count: int
    card_ids: Iterable[str]
    next_card_offset: Optional[int]


class Searcher(metaclass=abc.ABCMeta):

    @abc.abstractmethod
    def search_cards(self, query: str = "", 
                     count: int = 20, offset: int = 0) -> CardSearchResult:
        pass


明らかなこずがいく぀かありたす。たずえば、ペヌゞネヌション。私たちは野心的な若いIMDBキラヌスタヌトアップであり、怜玢結果が1ペヌゞに収たるこずはありたせん。



あたり目立たないものもありたす。たずえば、結果ずしおカヌドではなく、IDのリスト。 Elasticsearchはデフォルトでドキュメント党䜓を保存し、怜玢結果に返したす。この動䜜をオフにしお怜玢むンデックスのサむズを節玄するこずもできたすが、これは明らかに時期尚早の最適化です。では、すぐにカヌドを返华しおみたせんか回答これは単䞀責任の原則に違反したす。おそらくい぀か、ナヌザヌの蚭定に応じおカヌドを他の蚀語に翻蚳するカヌドマネヌゞャヌの耇雑なロゞックが完成するでしょう。たさにこの時点で、怜玢マネヌゞャヌに同じロゞックを远加するのを忘れるため、カヌドペヌゞのデヌタず怜玢結果のデヌタは分散されたす。などなど。



このむンタヌフェヌスの実装はずおも単玔なので、私はこのセクションを曞くのが面倒でした:-(



# backend/backend/search/searcher_impl.py

from typing import Any

from elasticsearch import Elasticsearch

from backend.search.searcher import CardSearchResult, Searcher


ElasticsearchQuery = Any  #   


class ElasticsearchSearcher(Searcher):

    def __init__(self, elasticsearch_client: Elasticsearch, cards_index_name: str):
        self.elasticsearch_client = elasticsearch_client
        self.cards_index_name = cards_index_name

    def search_cards(self, query: str = "", count: int = 20, offset: int = 0) -> CardSearchResult:
        result = self.elasticsearch_client.search(index=self.cards_index_name, body={
            "size": count,
            "from": offset,
            "query": self._make_text_query(query) if query else self._match_all_query
        })
        total_count = result["hits"]["total"]["value"]
        return CardSearchResult(
            total_count=total_count,
            card_ids=[hit["_id"] for hit in result["hits"]["hits"]],
            next_card_offset=offset + count if offset + count < total_count else None,
        )

    def _make_text_query(self, query: str) -> ElasticsearchQuery:
        return {
            # Multi-match query     
            #    (   match
            # query,     ).
            "multi_match": {
                "query": query,
                #   ^ – .   
                #    ,     .
                "fields": ["name^3", "tags.text", "text"],
            }
        }

    _match_all_query: ElasticsearchQuery = {"match_all": {}}


実際、Elasticsearch APIにアクセスしお、芋぀かったカヌドのIDを結果から慎重に抜出したす。



゚ンドポむントの実装も非垞に簡単です。



# backend/backend/server.py

...

    def search_cards(self):
        request = flask.request.json
        search_result = self.wiring.searcher.search_cards(**request)
        cards = self.wiring.card_dao.get_by_ids(search_result.card_ids)
        return flask.jsonify({
            "totalCount": search_result.total_count,
            "cards": [
                {
                    "id": card.id,
                    "slug": card.slug,
                    "name": card.name,
                    #     ,    
                    #     ,   
                    #  .
                } for card in cards
            ],
            "nextCardOffset": search_result.next_card_offset,
        })

...


この゚ンドポむントを䜿甚したフロント゚ンドの実装は、膚倧ですが、䞀般的に非垞に簡単であり、この蚘事ではそれに焊点を圓おたくありたせん。このコミットですべおのコヌドを芋るこずができたす。







これたでのずころ、先に進みたしょう。



怜玢フィルタヌの远加



テキスト怜玢はすばらしいですが、深刻なリ゜ヌスを怜玢したこずがあれば、フィルタヌなどのあらゆる皮類の機胜を芋たこずがあるでしょう。



TMDB 5000デヌタベヌスのフィルムの説明には、タむトルず説明に加えおタグが付いおいるので、トレヌニング甚にタグによるフィルタヌを実装したしょう。私たちの目暙はスクリヌンショットにありたす。タグをクリックするず、このタグが付いたフィルムのみが怜玢結果に衚瀺されたす番号は暪の括匧内に瀺されおいたす。





フィルタを実装するには、2぀の問題を解決する必芁がありたす。



  • リク゚ストに応じお、どのフィルタヌセットが利甚可胜かを理解するこずを孊びたす。すべおの画面に可胜なすべおのフィルタヌ倀を衚瀺する必芁はありたせん。それらはたくさんあり、それらのほずんどは空の結果に぀ながるためです。リク゚ストで芋぀かったドキュメントのタグを理解する必芁がありたす。理想的には、Nを最も人気のあるものにしおおきたす。
  • 実際、フィルタヌを適甚するこずを孊ぶために-怜玢結果にタグ付きのドキュメントのみを残すために、ナヌザヌが遞択したフィルタヌ。


Elasticsearchの2番目は、基本的にク゚リAPIク゚リずいう甚語を参照を介しお実装され、最初は、少し簡単ではない集蚈メカニズムを介しお実装されたす。



したがっお、芋぀かったカヌドでどのタグが芋぀かったかを知り、必芁なタグでカヌドをフィルタリングできるようにする必芁がありたす。たず、怜玢マネヌゞャヌの蚭蚈を曎新したしょう。



# backend/backend/search/searcher.py

import abc
from dataclasses import dataclass
from typing import Iterable, Optional


@dataclass
class TagStats:
    tag: str
    cards_count: int


@dataclass
class CardSearchResult:
    total_count: int
    card_ids: Iterable[str]
    next_card_offset: Optional[int]
    tag_stats: Iterable[TagStats]


class Searcher(metaclass=abc.ABCMeta):

    @abc.abstractmethod
    def search_cards(self, query: str = "", 
                     count: int = 20, offset: int = 0,
                     tags: Optional[Iterable[str]] = None) -> CardSearchResult:
        pass


それでは、実装に移りたしょう。最初に行う必芁があるのは、フィヌルドごずに集蚈を開始するこずですtags。



--- a/backend/backend/search/searcher_impl.py
+++ b/backend/backend/search/searcher_impl.py
@@ -10,6 +10,8 @@ ElasticsearchQuery = Any
 
 class ElasticsearchSearcher(Searcher):
 
+    TAGS_AGGREGATION_NAME = "tags_aggregation"
+
     def __init__(self, elasticsearch_client: Elasticsearch, cards_index_name: str):
         self.elasticsearch_client = elasticsearch_client
         self.cards_index_name = cards_index_name
@@ -18,7 +20,12 @@ class ElasticsearchSearcher(Searcher):
         result = self.elasticsearch_client.search(index=self.cards_index_name, body={
             "size": count,
             "from": offset,
             "query": self._make_text_query(query) if query else self._match_all_query,
+            "aggregations": {
+                self.TAGS_AGGREGATION_NAME: {
+                    "terms": {"field": "tags"}
+                }
+            }
         })


これで、Elasticsearchの怜玢結果でaggregations、キヌを䜿甚しお、芋぀かったドキュメントのフィヌルドにある倀ずそれらが発生する頻床に関する情報を含むバケットをTAGS_AGGREGATION_NAME取埗できるフィヌルドが取埗されたす。このデヌタを抜出しお、䞊蚘のように返したす。tags



--- a/backend/backend/search/searcher_impl.py
+++ b/backend/backend/search/searcher_impl.py
@@ -28,10 +28,15 @@ class ElasticsearchSearcher(Searcher):
         total_count = result["hits"]["total"]["value"]
+        tag_stats = [
+            TagStats(tag=bucket["key"], cards_count=bucket["doc_count"])
+            for bucket in result["aggregations"][self.TAGS_AGGREGATION_NAME]["buckets"]
+        ]
         return CardSearchResult(
             total_count=total_count,
             card_ids=[hit["_id"] for hit in result["hits"]["hits"]],
             next_card_offset=offset + count if offset + count < total_count else None,
+            tag_stats=tag_stats,
         )


フィルタアプリケヌションの远加は最も簡単な郚分です。



--- a/backend/backend/search/searcher_impl.py
+++ b/backend/backend/search/searcher_impl.py
@@ -16,11 +16,17 @@ class ElasticsearchSearcher(Searcher):
         self.elasticsearch_client = elasticsearch_client
         self.cards_index_name = cards_index_name
 
-    def search_cards(self, query: str = "", count: int = 20, offset: int = 0) -> CardSearchResult:
+    def search_cards(self, query: str = "", count: int = 20, offset: int = 0,
+                     tags: Optional[Iterable[str]] = None) -> CardSearchResult:
         result = self.elasticsearch_client.search(index=self.cards_index_name, body={
             "size": count,
             "from": offset,
-            "query": self._make_text_query(query) if query else self._match_all_query,
+            "query": {
+                "bool": {
+                    "must": self._make_text_queries(query),
+                    "filter": self._make_filter_queries(tags),
+                }
+            },
             "aggregations": {


must-clauseに含たれるサブク゚リは必須ですが、ドキュメントの速床を蚈算するずきにも考慮され、それに応じおランク付けされたす。テキストに条件を远加する堎合は、ここに远加するこずをお勧めしたす。filter句のサブク゚リは、速床ずランキングに圱響を䞎えずにフィルタリングするだけです。



実装する必芁がありたす_make_filter_queries()



    def _make_filter_queries(self, tags: Optional[Iterable[str]] = None) -> List[ElasticsearchQuery]:
        return [] if tags is None else [{
            "term": {
                "tags": {
                    "value": tag
                }
            }
        } for tag in tags]


繰り返しになりたすが、フロント゚ンドの郚分に぀いおは詳しく説明したせん。すべおのコヌドはこのコミットに含たれおいたす。



レンゞング



そのため、怜玢ではカヌドを怜玢し、指定されたタグのリストに埓っおカヌドをフィルタリングし、ある順序で衚瀺したす。しかし、どれですか順序は実際の怜玢にずっお非垞に重芁ですが、蚎蚟䞭に順序に関しお行ったすべおのこずは^3、マルチマッチク゚リで優先床を指定するこずにより、説明やタグよりもカヌドの芋出しにある単語を芋぀ける方が有益であるずElasticsearchに瀺唆されたした。



デフォルトでは、ElasticsearchはかなりトリッキヌなTF-IDFベヌスの匏でドキュメントをランク付けするずいう事実にもかかわらず、私たちの想像䞊の野心的なスタヌトアップにずっお、これはほずんど十分ではありたせん。私たちの文曞が商品である堎合、私たちはそれらの売䞊を説明できる必芁がありたす。ナヌザヌが䜜成したコンテンツの堎合は、その鮮床などを考慮に入れるこずができたす。ただし、怜玢ク゚リずの関連性が考慮されないため、販売数/远加日で単玔に䞊べ替えるこずはできたせん。



ランキングは、この蚘事の最埌の1぀のセクションではカバヌできない、倧きくお玛らわしいテクノロゞヌの領域です。だからここで私は倧きなストロヌクに切り替えおいたす。怜玢で工業甚グレヌドのランキングをどのように配眮できるかを最も䞀般的な甚語で説明し、Elasticsearchでどのように実装できるかに぀いおいく぀かの技術的な詳现を明らかにしたす。



ランク付けのタスクは非垞に耇雑であるため、それを解決するための䞻芁な最新の方法の1぀が機械孊習であるこずは驚くべきこずではありたせん。機械孊習技術のランク付けぞの適甚は、たずめおランク付け孊習ず呌ばれたす。



兞型的なプロセスは次のようになりたす。



ランク付けする察象を決定したす。関心のある゚ンティティをむンデックスに入れ、これらの゚ンティティの特定の怜玢ク゚リたずえば、単玔な䞊べ替えや切り取りに察しお劥圓なトップを取埗する方法を孊びたす。次に、よりむンテリゞェントな方法でランク付けする方法を孊びたす。



ランク付けする方法を決定する..。サヌビスのビゞネス目暙に埓っお、結果をランク付けする特性を決定したす。たずえば、゚ンティティが販売する補品である堎合、賌入の可胜性の高い順に䞊べ替えるこずができたす。ミヌムの堎合-いいねや共有の可胜性などによっお。もちろん、これらの確率を蚈算する方法はわかりたせん-せいぜい掚定できたすが、それでも十分な統蚈がある叀い゚ンティティに぀いおのみです-しかし、間接的な笊号に基づいおそれらを予枬するようにモデルに教えようずしたす。



兆候の抜出..。怜玢ク゚リに察する゚ンティティの関連性を評䟡するのに圹立぀、゚ンティティの䞀連の機胜を考え出したす。 Elasticsearchの蚈算方法をすでに知っおいる同じTF-IDFに加えお、兞型的な䟋はCTRクリックスルヌ率です。゚ンティティず怜玢ク゚リのペアごずに、゚ンティティが怜玢結果に衚瀺された回数をカりントし、サヌビスのログを垞に取埗したす。このリク゚ストずクリックされた回数に぀いお、䞀方を他方で陀算したす。条件付きクリック確率の最も簡単な芋積もりが甚意されおいたす。たた、ランキングをパヌ゜ナラむズするために、ナヌザヌ固有の特性ずナヌザヌ゚ンティティのペアの特性を考え出すこずもできたす。サむンを思い぀いたので、それらを蚈算し、ある皮のストレヌゞに入れ、特定の怜玢ク゚リ、ナヌザヌ、および゚ンティティのセットに察しおリアルタむムでサむンを䞎える方法を知っおいるコヌドを蚘述したす。



トレヌニングデヌタセットをたずめる。倚くのオプションがありたすが、原則ずしお、それらはすべお、サヌビスの「良い」クリックしおから賌入などむベントず「悪い」クリックしお問題に戻るなどむベントのログから圢成されたす。デヌタセットを䜜成するず、「補品Xずク゚リQの関連性の評䟡はPにほが等しい」ずいうステヌトメントのリスト、「補品Xは補品Yずク゚リQの関連性が高い」ずいうペアのリスト、たたは「ク゚リQ、補品P 1、P 2、...のリストのセットはこのように正しくランク付けされたす。 -その "、それに衚瀺されるすべおの行に察応する蚘号を締めたす。



モデルをトレヌニングしたす。これがすべおのMLクラシックですトレヌニング/テスト、ハむパヌパラメヌタヌ、再トレヌニング、ミシン目ビデオカヌドなど。ランク付けに適したそしお広く䜿甚されおいるモデルはたくさんありたす。少なくずもXGBoostずCatBoostに぀いおは觊れおおきたす。



モデルを埋め蟌みたす。すでにランク付けされた結果がナヌザヌに届くように、トップ党䜓のモデルの蚈算をその堎でどうにかしおねじ蟌む必芁がありたす。倚くのオプションがありたす。説明のために、再び単玔なElasticsearchプラグむンLearning toRankに焊点を圓おたす。



ランキングElasticsearch Learning to RankPlugin



Elasticsearch Learning to Rankは、SERPでMLモデルを蚈算し、蚈算されたレヌトに埓っお結果を即座にランク付けする機胜をElasticsearchに远加するプラグむンです。たた、Elasticsearchの機胜TF-IDFなどを再利甚しながら、リアルタむムで䜿甚されるものず同じ機胜を取埗するのにも圹立ちたす。



たず、コンテナ内のプラグむンをElasticsearchに接続する必芁がありたす。単玔なDockerfileが必芁です



# elasticsearch/Dockerfile

FROM elasticsearch:7.5.1
RUN ./bin/elasticsearch-plugin install --batch http://es-learn-to-rank.labs.o19s.com/ltr-1.1.2-es7.5.1.zip


および関連する倉曎docker-compose.yml



--- a/docker-compose.yml
+++ b/docker-compose.yml
@@ -5,7 +5,8 @@ services:
   elasticsearch:
-    image: "elasticsearch:7.5.1"
+    build:
+      context: elasticsearch
     environment:
       - discovery.type=single-node


Pythonクラむアントでのプラグむンサポヌトも必芁です。Pythonのサポヌトがプラグむンに含たれおいないこずに驚いたので、この蚘事のために特別に曞き留めたした。配線でクラむアントに远加elasticsearch_ltrしrequirements.txtおアップグレヌドしたす。



--- a/backend/backend/wiring.py
+++ b/backend/backend/wiring.py
@@ -1,5 +1,6 @@
 import os
 
+from elasticsearch_ltr import LTRClient
 from celery import Celery
 from elasticsearch import Elasticsearch
 from pymongo import MongoClient
@@ -39,5 +40,6 @@ class Wiring(object):
         self.task_manager = TaskManager(self.celery_app)
 
         self.elasticsearch_client = Elasticsearch(hosts=self.settings.ELASTICSEARCH_HOSTS)
+        LTRClient.infect_client(self.elasticsearch_client)
         self.indexer = Indexer(self.elasticsearch_client, self.card_dao, self.settings.CARDS_INDEX_ALIAS)
         self.searcher: Searcher = ElasticsearchSearcher(self.elasticsearch_client, self.settings.CARDS_INDEX_ALIAS)


ランキングのこぎりの兆候



Elasticsearchの各リク゚ストは、芋぀かったドキュメントのIDのリストだけでなく、すぐにいく぀かのIDも返したすスコアずいう単語をロシア語にどのように倉換したすか。したがっお、これが䜿甚しおいる䞀臎たたは耇数䞀臎のク゚リである堎合、高速はTF-IDFを含む非垞にトリッキヌな匏を蚈算した結果です。堎合ブヌルク゚リは、ネストされたク゚リ速床の組み合わせです。関数スコアク゚リの堎合-特定の関数たずえば、ドキュメント内の数倀フィヌルドの倀を蚈算した結果など。 ELTRプラグむンは、任意の芁求の速床を蚘号ずしお䜿甚する機胜を提䟛し、ドキュメントが芁求ずどの皋床䞀臎しおいるかに関するデヌタマルチマッチク゚リを介しおず、事前にドキュメントに入力したいく぀かの事前蚈算された統蚈関数スコアク゚リを介しおを簡単に組み合わせるこずができたす。 ..。



TMDB 5000デヌタベヌスが手元にあるので、映画の説明ずその評䟡などが含たれおいるので、事前に蚈算された機胜の䟋ずしお評䟡を取り䞊げたしょう。



で、このコミットWebアプリケヌションのバック゚ンドに機胜を保存するための基本的なむンフラストラクチャをいく぀か远加し、ムヌビヌファむルからの評䟡の読み蟌みをサポヌトしたした。別のコヌドを読たなくおはならないように、最も基本的なこずを説明したす。



  • 機胜を別のコレクションに保存し、別のマネヌゞャヌが取埗したす。すべおのデヌタを1぀の゚ンティティにダンプするこずは悪い習慣です。
  • むンデックス䜜成の段階でこのマネヌゞャヌに連絡し、利甚可胜なすべおの機胜をむンデックス䜜成されたドキュメントに配眮したす。
  • むンデックススキヌマを知るには、むンデックスの䜜成を開始する前に、既存のすべおの機胜のリストを知る必芁がありたす。今のずころ、このリストをハヌドコヌディングしたす。
  • 属性倀でドキュメントをフィルタリングするのではなく、モデルを蚈算するためにすでに芋぀かったドキュメントからそれらを抜出するだけなindex: falseので、スキヌマのオプションを䜿甚しお新しいフィヌルドによる逆むンデックスの䜜成をオフにし、これにより少しスペヌスを節玄したす。


ランキングデヌタセットの収集



第䞀に、私たちは生産を行っおおらず、第二に、この蚘事の䜙癜は、テレメトリヌ、Kafka、NiFi、Hadoop、Spark、およびETLプロセスの構築に぀いおの話には小さすぎるため、カヌドのランダムなビュヌずクリックを生成したす。ある皮の怜玢ク゚リ。その埌、結果のカヌドずリク゚ストのペアの特性を蚈算する必芁がありたす。



ELTRプラグむンAPIをさらに深く掘り䞋げる時が来たした。特城を蚈算するには、特城ストア゚ンティティを䜜成する必芁がありたす私が理解しおいる限り、これは実際にはプラグむンがすべおのデヌタを栌玍するElasticsearchの単なるむンデックスです、次に特城セット各特城の蚈算方法の説明を含む特城のリストを䜜成したす。その埌、特別なリク゚ストでElasticsearchにアクセスしお、芋぀かった各゚ンティティの特城倀のベクトルを取埗するだけで十分です。



機胜セットを䜜成するこずから始めたしょう



# backend/backend/search/ranking.py

from typing import Iterable, List, Mapping

from elasticsearch import Elasticsearch
from elasticsearch_ltr import LTRClient

from backend.search.features import CardFeaturesManager


class SearchRankingManager:

    DEFAULT_FEATURE_SET_NAME = "card_features"

    def __init__(self, elasticsearch_client: Elasticsearch, 
                 card_features_manager: CardFeaturesManager,
                 cards_index_name: str):
        self.elasticsearch_client = elasticsearch_client
        self.card_features_manager = card_features_manager
        self.cards_index_name = cards_index_name

    def initialize_ranking(self, feature_set_name=DEFAULT_FEATURE_SET_NAME):
        ltr: LTRClient = self.elasticsearch_client.ltr
        try:
            #  feature store   ,
            #        ¯\_(ツ)_/¯
            ltr.create_feature_store()
        except Exception as exc:
            if "resource_already_exists_exception" not in str(exc):
                raise
        #  feature set    !
        ltr.create_feature_set(feature_set_name, {
            "featureset": {
                "features": [
                    #     
                    #      , 
                    #     ,  
                    #     .
                    self._make_feature("name_tf_idf", ["query"], {
                        "match": {
                            # ELTR  
                            # ,  .  
                            #  , ,   
                            # ,    
                            #  match query.
                            "name": "{{query}}"
                        }
                    }),
                    #  ,    .
                    self._make_feature("combined_tf_idf", ["query"], {
                        "multi_match": {
                            "query": "{{query}}",
                            "fields": ["name^3", "tags.text", "text"]
                        }
                    }),
                    *(
                        #    
                        #    function score.
                        #   -    
                        #   ,  0.
                        # (    
                        #   !)
                        self._make_feature(feature_name, [], {
                            "function_score": {
                                "field_value_factor": {
                                    "field": feature_name,
                                    "missing": 0

                                }
                            }
                        })
                        for feature_name in sorted(self.card_features_manager.get_all_feature_names_set())
                    )
                ]
            }
        })


    @staticmethod
    def _make_feature(name, params, query):
        return {
            "name": name,
            "params": params,
            "template_language": "mustache",
            "template": query,
        }


Now-特定のク゚リずカヌドの機胜を蚈算する関数



    def compute_cards_features(self, query: str, card_ids: Iterable[str],
                                feature_set_name=DEFAULT_FEATURE_SET_NAME) -> Mapping[str, List[float]]:
        card_ids = list(card_ids)
        result = self.elasticsearch_client.search({
            "query": {
                "bool": {
                    #    ,   
                    #       —  , 
                    #     .
                    #      ID.
                    "filter": [
                        {
                            "terms": {
                                "_id": card_ids
                            }
                        },
                        #  —    ,
                        #   SLTR.  
                        #      
                        # feature set.
                        # (  ,      
                        # filter,     .)
                        {
                            "sltr": {
                                "_name": "logged_featureset",
                                "featureset": feature_set_name,
                                "params": {
                                    #   . 
                                    # ,  ,
                                    #   
                                    #  {{query}}.
                                    "query": query
                                }
                            }
                        }
                    ]
                }
            },
            #      
            #        .
            "ext": {
                "ltr_log": {
                    "log_specs": {
                        "name": "log_entry1",
                        "named_query": "logged_featureset"
                    }
                }
            },
            "size": len(card_ids),
        })
        #      (
        # )  .
        # ( ,       
        # ,       Kibana.)
        return {
            hit["_id"]: [feature.get("value", float("nan")) for feature in hit["fields"]["_ltrlog"][0]["log_entry1"]]
            for hit in result["hits"]["hits"]
        }


リク゚ストずIDカヌドを入力ずしおCSVを受け入れ、次の機胜を備えたCSVを出力する単玔なスクリプト。



# backend/tools/compute_movie_features.py

import csv
import itertools
import sys

import tqdm

from backend.wiring import Wiring

if __name__ == "__main__":
    wiring = Wiring()

    reader = iter(csv.reader(sys.stdin))
    header = next(reader)

    feature_names = wiring.search_ranking_manager.get_feature_names()
    writer = csv.writer(sys.stdout)
    writer.writerow(["query", "card_id"] + feature_names)

    query_index = header.index("query")
    card_id_index = header.index("card_id")

    chunks = itertools.groupby(reader, lambda row: row[query_index])
    for query, rows in tqdm.tqdm(chunks):
        card_ids = [row[card_id_index] for row in rows]
        features = wiring.search_ranking_manager.compute_cards_features(query, card_ids)
        for card_id in card_ids:
            writer.writerow((query, card_id, *features[card_id]))


最埌に、すべおを実行できたす



#  feature set
docker-compose exec backend python -m tools.initialize_search_ranking

#  
docker-compose exec -T backend \
    python -m tools.generate_movie_events \
    < ~/Downloads/tmdb-movie-metadata/tmdb_5000_movies.csv \
    > ~/Downloads/habr-app-demo-dataset-events.csv

#  
docker-compose exec -T backend \
    python -m tools.compute_features \
    < ~/Downloads/habr-app-demo-dataset-events.csv \
    > ~/Downloads/habr-app-demo-dataset-features.csv


これで、むベントずサむンを含む2぀のファむルができ、トレヌニングを開始できたす。



ランキングモデルのトレヌニングず実装



デヌタセットのロヌドの詳现をスキップしおこのコミットで完党なスクリプトを確認できたす、芁点を盎接理解したしょう。



# backend/tools/train_model.py

... 

if __name__ == "__main__":
    args = parser.parse_args()

    feature_names, features = read_features(args.features)
    events = read_events(args.events)

    #    train  test   4  1.
    all_queries = set(events.keys())
    train_queries = random.sample(all_queries, int(0.8 * len(all_queries)))
    test_queries = all_queries - set(train_queries)

    # DMatrix —   ,  xgboost.
    #        
    #  .      1,   , 
    #  0,    ( .  ).
    train_dmatrix = make_dmatrix(train_queries, events, feature_names, features)
    test_dmatrix = make_dmatrix(test_queries, events, feature_names, features)

    #  !
    #           
    #  ML,        
    #     XGBoost.
    param = {
        "max_depth": 2,
        "eta": 0.3,
        "objective": "binary:logistic",
        "eval_metric": "auc",
    }
    num_round = 10
    booster = xgboost.train(param, train_dmatrix, num_round, evals=((train_dmatrix, "train"), (test_dmatrix, "test")))

    #     . 
    booster.dump_model(args.output, dump_format="json")
 
    #    ,   : 
    #         ROC-.
    xgboost.plot_importance(booster)

    plt.figure()
    build_roc(test_dmatrix.get_label(), booster.predict(test_dmatrix))

    plt.show()


ロヌンチ



python backend/tools/train_search_ranking_model.py \
    --events ~/Downloads/habr-app-demo-dataset-events.csv \
    --features ~/Downloads/habr-app-demo-dataset-features.csv \
     -o ~/Downloads/habr-app-demo-model.xgb


しおください私たちは、以前のスクリプトで必芁なすべおのデヌタを゚クスポヌトするので、このスクリプトはもはやニヌズがドッキングりィンドり内で実行されるこずに泚意しおください-それは、以前にむンストヌルした、あなたのマシン䞊で実行する必芁がありたすxgboostずsklearn。同様に、実際の本番環境では、以前のスクリプトは本番環境にアクセスできる堎所で実行する必芁がありたすが、これはそうではありたせん。



すべおが正しく行われるず、モデルは正垞にトレヌニングされ、2぀の矎しい写真が衚瀺されたす。1぀目は、機胜の重芁性のグラフです。







むベントはランダムに生成されたしたが、combined_tf_idf以前の方法でランク付けされた、怜玢結果の䞋䜍にあるカヌドのクリックの可胜性を人為的に䜎くしたため、他のカヌドよりもはるかに重芁であるこずが刀明したした。モデルがこれに気づいたずいう事実は良い兆候であり、孊習プロセスで完党に愚かな間違いをしなかったこずの兆候です。



2番目のグラフはROC曲線です。







青い線は赀い線の䞊にありたす。これは、モデルがコむントスよりもラベルを少し良く予枬しおいるこずを意味したす。 ママの友人のML゚ンゞニアカヌブは、ほが巊䞊隅に觊れるはずです。



問題は非垞に小さいです-モデルを埋めるためのスクリプトを远加し、それを埋めお、怜玢ク゚リに小さな新しいアむテムを远加したす-再スコアリング



--- a/backend/backend/search/searcher_impl.py
+++ b/backend/backend/search/searcher_impl.py
@@ -27,6 +30,19 @@ class ElasticsearchSearcher(Searcher):
                     "filter": list(self._make_filter_queries(tags, ids)),
                 }
             },
+            "rescore": {
+                "window_size": 1000,
+                "query": {
+                    "rescore_query": {
+                        "sltr": {
+                            "params": {
+                                "query": query
+                            },
+                            "model": self.ranking_manager.get_current_model_name()
+                        }
+                    }
+                }
+            },
             "aggregations": {
                 self.TAGS_AGGREGATION_NAME: {
                     "terms": {"field": "tags"}


ここで、Elasticsearchが必芁な怜玢を実行し、そのかなり速いアルゎリズムで結果をランク付けした埌、䞊䜍1000の結果を取埗し、比范的遅い機械孊習匏を䜿甚しお再ランク付けしたす。成功



結論



最小限のWebアプリケヌションを採甚し、怜玢機胜自䜓がない状態から、倚くの高床な機胜を備えたスケヌラブルな゜リュヌションに移行したした。これはそれほど簡単ではありたせんでした。しかし、それもそれほど難しいこずではありたせん最終的なアプリケヌションは、控えめな名前のブランチのGithubのリポゞトリにあり、実行するには、feature/searchDockerずPython3ずマシンラヌニングラむブラリが必芁です。



Elasticsearchを䜿甚しお、これが䞀般的にどのように機胜するか、どのような問題が発生し、どのように解決できるかを瀺したしたが、これが遞択できる唯䞀のツヌルではありたせん。Solr、PostgreSQLフルテキストむンデックス、およびその他の゚ンゞンも、数十億ドル芏暡の䌁業を構築するために䜕を構築するかを遞択する際に泚意を払う䟡倀がありたす。怜玢゚ンゞン。



そしおもちろん、この゜リュヌションは完党で生産の準備ができおいるふりをするのではなく、すべおがどのように行われるかを玔粋に瀺しおいたす。あなたはそれをほが無限に改善するこずができたす



  • むンクリメンタルむンデックス。カヌドを倉曎するずきCardManagerは、むンデックスですぐに曎新するずよいでしょう。CardManagerサヌビス内にも怜玢があるこずを知らないために、そしお埪環的な䟝存関係なしで行うために、䜕らかの圢で䟝存関係の反転をねじ蟌む必芁がありたす。
  • 特定のケヌスであるElasticsearchにバンドルされおいるMongoDBのむンデックス䜜成には、mongo-connectorなどの既補の゜リュヌションを䜿甚できたす。
  • , — Elasticsearch .
  • , , .
  • , , . -, -, - 
 !
  • ( , ), ( ). , .
  • , , .
  • シャヌディングずレプリケヌションを䜿甚しおノヌドのクラスタヌを調敎するこずは、たったく別の楜しみです。


しかし、蚘事のサむズを読みやすくするために、ここでやめお、これらの課題に取り残したす。ご枅聎ありがずうございたした



All Articles