クチコミを構造化して宿のフリーワード検索をやってみる

この記事は[一休.com Advent Calendar 2024](https://qiita.com/advent-calendar/2024/ikyu)第14日目の記事です。


# 初めに
一休.comデータサイエンス部のHirataです。[一休.com](https://www.ikyu.com/)内のフリーワード検索は様々な仕組みが使われていますが、今回はその一部について試行錯誤も含め紹介します。
ここでいう「フリーワード検索」とはユーザーが自由に入力した内容に沿った宿を並べて表示することを言います。

## ユーザーの検索
ユーザーの入力は多岐にわたりますが基本的に一休が持っているデータベースにあるものと無いものに分かれます。
例えば、「[強羅花壇](https://www.ikyu.com/00001935/?lc=1&ppc=2&rc=1)」だったら宿のID: xxxで、「温泉」なら属性のID: xxx...といった感じです。一方、「可愛い宿」、「イルミネーションがきれい」とかは特にデータベースにIDを持っていません。
前者の場合は、適したIDを探し絞り込む、後者の場合は非構造的なデータ（宿の紹介文やクチコミなど）から見つけてくることになり、最適な宿を探すアプローチが異なると考えています。
今回はクチコミを構造化して後者のような検索に使うこととします。

# クチコミの構造化
## 方法
ChatGPTにやってもらいます。下のようなプロンプトを作ります。ついでに簡単なポジネガ判定もできるので便利ですね。温泉についての言及は個人的なこだわりです。
```
以下の宿泊施設に関するレビューを構造化データで要約したい
ポジティブからネガティブで項目名をスコアリングしてjsonとして提示してください
sentenceはキーワードとなる文言を必ず全て入れ、本文中にある形にしてください
tagはキーワードを配列にし、温泉は泉質や種類もキーワードにしてください
[
{"sentence": [日本語文章], "score": [-1 or 0 or 1], "tag": [キーワードの配列]},
...
]
```

プロンプト内にjsonと記述してありますが、レスポンスのjsonの形を明示的に指定できるjson_schemaモードを使うことで、ごくまれにjson.loads時にエラーが発生することを防ぐことができます。

```python
response_format = {
    "type": "json_schema", 
    "json_schema": {
        "name": "response_keyword",
        "schema": {
            "type": "object",
            "properties": {
                "result": {
                    "type": "array",
                    "items": {
                            "type": "object",
                            "properties": {
                                "sentence": {"type": "string"},
                                "score": {"type": "number"},
                                "tag": {"type": "array", "items": {"type": "string"}}
                            },
                            "required": ["sentence", "score", "tag"],
                            "additionalProperties": False
                    },
                }
            },
            "required": ["result"],
            "additionalProperties": False
        },
        "strict": True
    }
}
# OpenAI SDK Chatの各引数
model="gpt-4o-2024-08-06",
messages=[
    {"role": "system", "content": prompt},
    {"role": "user", "content": s}
],
temperature=0,
response_format=response_format
```

例えば下のようなクチコミだと

> …食事(夕食)は口コミに投稿されている通り美味しくないです。特にお刺身・お寿司は最悪でした。食材が乾燥してシャリもカピカピお勧めは致しません。朝ご飯は無難に美味しく頂けました。部屋は、シンプルで極力余計な物を置かないコンセプトなのかわかりませんがせめてレースのカーテン位あった方がいいかも\n厚手のカーテンしかなく閉めたら景色が見えないし、開けたら外から部屋が丸見えになってしまいます。\nお風呂は最高です。特に冬の時期は雪の中の露天風呂と乳白色の温泉は癒されました。...

↓
```
[
   …
   {'sentence': '特にお刺身・お寿司は最悪でした。', 'score': -1, 'tag': ['お刺身', 'お寿司']},
   {'sentence': '朝ご飯は無難に美味しく頂けました。', 'score': 1, 'tag': ['朝ご飯']},
   {'sentence': '部屋は、シンプルで極力余計な物を置かないコンセプトなのかわかりませんがせめてレースのカーテン位あった方がいいかも', 'score': 0, 'tag': ['部屋', 'シンプル', 'カーテン']},
   {'sentence': 'お風呂は最高です。', 'score': 1, 'tag': ['お風呂']},
   {'sentence': '特に冬の時期は雪の中の露天風呂と乳白色の温泉は癒されました。', 'score': 1, 'tag': ['露天風呂', '乳白色の温泉']}
   …
]
```
このtagの部分を検索して該当クチコミかどうかを判定し、scoreの部分を使うようにします。

## クエリ構造化
前述のtagに当てるため、フリーワード検索のインプット文もOpenAIのプロンプトで構造化します。

```
ユーザーの提示したクエリの意図を検索しやすいように以下のステップに従ってキーワード化したいです
step1. クエリからどんな宿泊施設をさがしているか分かるキーワード(名詞,形容詞)を抜き出してください,宿泊施設そのものを指す言葉は抜き出さないでください,
宿泊施設で使われる単語を中心に可能な限り多く抜き出してください
step2. 抜き出したワードから下に従って類語をjsonの形で提示してください
類語の抜き出し方：
(1): 意味的に近いワードを複数派生させます
(2): それらに対し、ひらがな、カタカナ、漢字などのバリエーションを追加します

例1：「おしゃれで可愛いホテル」
[
    {{"keyword": "オシャレ", "synonyms": ["おしゃれ", "オシャレ", "お洒落", "お洒落さ", "洗練された", "スタイリッシュ", "モダン"]}}
    {{"keyword": "可愛い", "synonyms": ["可愛い", "可愛さ", "かわいい", "キュート"]}}
]
…
```
そうすると、ユーザーが「バーベキュー」と入力すると -> `{"keyword":"バーベキュー","synonyms":["バーベキュー","BBQ","ばーべきゅー","バーベキューパーティー","グリル","アウトドアクッキング"]}`
のようになっていろいろな表現でマッチできるようになります。


## 施設の表示まで
ユーザーの入力とクチコミをマッチングさせることが出来ました。
次はマッチしたクチコミや売り上げを施設ごとに算出することでランキングを作ります。

![{DAF7A707-4FDA-433F-AB9C-84A73DE2FB03}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154105/466a881f-05e9-461e-ae71-d3e41c8f7afb.png)

そうすると、例えば下のようになります。

- 「バーベキュー」
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154105/645a8444-4775-738b-42e4-1db42da7f091.png)
- 「可愛いホテル」
![{6FCBCA4D-25BB-49A2-8C7A-2968627B4E7B}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154105/2772ee90-ac85-2e9f-5bb0-66c6016b7747.png)





# ベクトル検索だと？
VertexAIやOpenAIでも文章をベクトル化するembeddingがAPIとして提供されています。それを使って類似度が高いクチコミや紹介文を切り出そうとすると、関係なさそうに見える文が切り出されることがよくあります。
  - 例)「ベビーベッドあり」と「子供がベッドで飛び跳ねていて…」が類似度高い
  - 例)「花火がきれいで」と「花がきれいで」が類似度高い

全事象を学習したembeddingでは確かに上の例の類似度が近くなるのは分かるんですが、宿泊ドメインに限定するとでは両者はかなり異なります。

また、コサイン類似度を使う場合、どんな文章間でも定量的に数値が出てしまい、関連が無いものを切り捨てる閾値などの決定が難しくなります。（しかも関連性が全く無い文でもコサイン類似度0.5くらいにはなる）基本的には上からいくつ…みたいな取り方になるでしょう。

RAGなどを使う前提であれば良いかもしれませんが、単独で使うのはかなり頑張らないといけないと判断しembeddingを使うのは止めました。


# Neural Sparse Encoding
1年前にOpenSearchで新しいセマンティック検索、[Neural Sparse Encoding](https://huggingface.co/opensearch-project/opensearch-neural-sparse-encoding-v1)がサポートされました。
Embeddingは文章を密ベクトルに変換するものですが、Neural Sparse Encodingは各要素が単語ごとに対応した疎ベクトルに変換します。
[Improving document retrieval with sparse semantic encoders](https://opensearch.org/blog/improving-document-retrieval-with-sparse-semantic-encoders/)
疎ベクトル同士の内積を取ると関連のあるものだけ値がでてくるようになるので上述の閾値の問題がなくなり単独で使いやすくなりそうです。

だけど今年はあんまり動きがなく日本語にも対応していないですね……。
ちなみに、どういうベクトルに変換するかを見てみましょう。

- 文章: accommodations where you cannot stay with pets

- 変換されたベクトルの要素ごとの値: 
![{9A0C94AD-B304-49B9-88E5-99B08D0F35B9}.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/154105/dcfd5039-8fa7-1da7-b163-36775da70cda.png)


文章に存在する単語だけでなく、dogs, animals, hotelなどにも値があるのが分かります。
クエリを構造化して関連した単語を提示することと近いことをやっていそうです。


# こんなことも出来たらいいな
ユーザーのクエリに合わせて宿の最適な情報が提示できると納得感が増して良さそうです。
たとえば「イルミネーション」で検索すると部屋から見えるライトアップの画像や、「ペットと泊まれる」で検索すると「この施設は小型犬までOK」などの情報が提示されるようなイメージです。
こちらはChatGPTのtools(function calling)を使えばできるかもしれません。



# 
一休.comではデータサイエンスで切磋琢磨して事業を成長させていける仲間を募集しています！

https://hrmos.co/pages/ikyu/jobs/1693126708022206469