コンテキストを解釈する検索エンジンのためのダイアログデータモデル

[LODQA](https://www.lodqa.org/docs/intro/)というSPARQLエンドポイントに対して自然言語で検索できる検索エンジンがあります。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15964/801c4fe6-3c70-cbc7-08b1-f28b197fb93a.png)


## コンテキストを解釈する検索エンジン

検索エンジンでは、質問の回答に対して続けて質問をしたい要望があります。
たとえば「What genes are associated with Alzheimer disease?」と質問して多数の回答が返ってきたとき、つづけて「Give me mouse genes.」を質問し結果を絞り込みたいです。
このような絞り込みを検索エンジンで実装するのは難しいです。
現にGoogleでも実装されていません。

そこでChatGPTを使ってみてはどうでしょうか？
具体的にはChatGPTに「Make the following sentences into one sentence: What genes are associated with Alzheimer disease? Give me mouse genes.」のような質問をします。

![20230620141812.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/15964/8a0f3549-2559-ac92-d4fc-d70274dcefd6.png)



「Which mouse genes are associated with Alzheimer's disease?」とかえってきます。
この質問分をつかって検索すると、1つ前の質問文、つまりコンテキストを解釈する検索エンジンが出来そうです。



## データモデル

上記のアイデアを実現するためのデータモデルを考えてみましょう。

### 対話

コンテキストを表現するデータモデルを対話と考えます。
特定なユーザーとの複数の質問文をまとめて対話として扱います。
対話と質問文は1対多ですので次のような関連になります。



```mermaid
classDiagram
  class Dialog {
  }

  class NaturalLanguageExpression {
  }

  Dialog --* NaturalLanguageExpression
```

対話がDialogで、質問文がNaturalLanguageExpressionです。


### 属性

対話と質問文に何が含まれるとよいでしょうか？

対話にはユーザーを表すユーザーIDが必要です。
質問文には、ユーザーが入力した質問文が必要です。

### クラス図

図にしてみます。


```mermaid
classDiagram
  class Dialog {
    string user_id
  }

  class NaturalLanguageExpression {
   string query
   integer dialog_id
}

  Dialog --* NaturalLanguageExpression
```


## ストップセンテンスによる対話の終了

よく考えると、ユーザーと対話は1対1ではありません。
一人のユーザーが異なる対話を行うかもしれません。
関連を表すと次のようになります。


```mermaid
classDiagram
  class User {
  }

  class Dialog {
  }

  class NaturalLanguageExpression {
  }

  User --* Dialog
  Dialog --* NaturalLanguageExpression
```

どうすれば、ユーザーが持つ複数の対話から、現在の対話を取得出来るでしょうか？

### 検索エンジンのUIの特徴

検索エンジンの標準的なUIには質問文の入力しかありません。
つまり、

1. 過去の対話は参照されない
    1. 探す方法がない
1. 対話の開始・終了も質問文で行う
    1. 対話を開始・終了するためのボタンもない

### 質問文による対話の終了

そこで特定の質問文 **ストップセンテンス** として扱います。
ストップセンテンスが来たらユーザーは対話を終わりにしたとみなします。

ユーザーの持つ対話から終わっていない対話を見つければ、現在の対話が取得出来ます。

また、こうすることで、検索エンジンのUIは変更不要です。
もしかすると「対話を終わりにする」ボタンを作ったほうがいいかもしれません。
その場合もデータモデルを変更なく対応出来そうです。

### ストップセンテンス

`Begin new search` をストップセンテンスとします。
ユーザーが「新しい対話を始める」と検索エンジンは現在の対話を終わらせます。

ストップといいながらBeginなのが、ちょっと矛盾してて面白いです。
ユーザーからみたら「対話終わり」よりは「新しい対話をはじめる」を入力するほうが自然だと考えました。


## 実装

実装にはRuby on Railsを使います。

ユーザーIDは、OAuthで連携する外部のID providerから取得する想定です。
検索エンジンの入力されたユーザーIDでユーザーを識別します。
内部に、Userモデルを持ちません。
つぎのふたつのモデルを実装します。

1. Dialogモデル
1. NaturalLanguageExpressionモデル


### Dialog クラス

ジェネレータを使ってDialogモデルを作成します。

```sh
rails generate model Dialog user_id:string
```

メソッドを実装します。


```ruby:dialog.rb
class Dialog < ApplicationRecord
  has_many :natural_language_expressions, dependent: :destroy

  # ユーザーとの対話を返す。
  # ストップセンテンスを含む対話は終了済みとみなします。
  def self.with user_id
    Dialog.where.not(
      id: NaturalLanguageExpression.stop_sentence.select(:dialog_id)
    )
          .find_or_create_by user_id:
  end
end
```

特定のユーザーとの対話を返すwithメソッドを実装します。
ストップセンテンスを含まない対話があればそれを返します。
なければ新しく対話を作って返します。

### NaturalLanguageExpression クラス

ジェネレータを使ってNaturalLanguageExpressionモデルを作成します。

```sh
rails generate model NaturalLanguageExpression query:string dialog:references
```

ストップセンテンスとスコープを定義します。

```ruby:natural_language_expression.rb
class NaturalLanguageExpression < ApplicationRecord
  STOP_SENTENCE = 'Begin new search'

  belongs_to :dialog

  scope :stop_sentence, -> { where query: STOP_SENTENCE }
end
```

ストップセンテンスかどうか判別するスコープを定義します。
Dialogのnotに指定するidを取れればいいので、実装する物はスコープで十分です。

質問文が万単位になると検索が遅くなるかもしれません。
その場合は、次のような対応が必要です。

1. 終わった対話と質問文を削除する
1. 検索クエリーを変更する
    1. ユーザーの最新の対話を取得
    1. 取得した対話にストップセンテンスが含まれるか判定
        1. 含まれていれば、新しい対話を作成
        1. 含まれていなければ、取得した対話を使う

いずれにせよデータモデルに変更は不要で、ロジック変更のみで対応できそうです。

現時点では、過去の対話を活用する用途があるか不明です。
特に用途はないと予想していますが、一旦、無期限に保存します。


### 想定する対話モデルの使い方

検索エンジンはあらたな質問文が入力されたら、次のようにChatGPT用のプロンプトを作成します。

```ruby
dialog = Dialog.with user_id
dialog.natural_language_expressions.create!(query:)
prompt = "Make the following sentences into one sentence: #{dialog.natural_language_expressions.join(' ')}"
```

生成したプロントをOpenAIのAPIに送ります。　
[ruby-openai gem[(https://github.com/alexrudall/ruby-openai)をつかいます。

```ruby
client = OpenAI::Client.new 
response = client.completions(parameters: { model: 'text-davinci-001', 
                                            prompt: prompt, 
                                            temperature: 0 }) 
response['choices'].first['text'].strip
```

今後、次のようなプロンプトチューニングが必要だと考えています。

- 指示文の修正
- まとめる質問文の数制限


## 参考
- [API Reference - OpenAI API](https://platform.openai.com/docs/api-reference/making-requests)
- [alexrudall/ruby-openai: OpenAI API + Ruby! 🤖❤️ Now with Azure support!](https://github.com/alexrudall/ruby-openai)
- [RubyからOpenAIのAPIを叩く - Qiita](https://qiita.com/kojix2/items/61091a42e9829bd08bd2)
- [ruby-openaiを使ってChatGPT APIを叩いてみよう - Qiita](https://qiita.com/yocajii/items/cbce3cb92c601984314c)