Azure で ChatGPT × Cognitive Search を使ったエンタープライズサーチに履歴機能を付ける

前回の記事では、ChatGPT と Azure Cognitive Search を組み合わせてエンタープライズサーチを構築するためのアーキテクチャ、サンプルコードについて解説しました。

https://qiita.com/nohanaga/items/803c09b5a3a4e2d1776f

今回は、このエンタープライズ チャットボットを各種データベースを用いてチャット履歴（記憶）を永続化したり、複数組み合わせたりして色々とカスタムしてみた記事です。

# 1. 一時的チャット履歴
この[サンプルコード](https://github.com/nohanaga/azure-search-openai-demo)はシンプルなデモ用であり、フロントエンド側にチャット履歴の配列を持っているため、ブラウザを更新すると履歴はクリアされます。

![004.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/ced6e237-e3ba-7862-8a7f-d331171f1b6c.png)

一時的ではありますが、上記のような質問への回答もできます。

## 1.1. チャット履歴の実装
Azure OpenAI Serivce では 3/21 に [Chat Completion API](https://learn.microsoft.com/ja-jp/azure/cognitive-services/openai/how-to/chatgpt?pivots=programming-language-chat-completions#working-with-the-chat-completion-api) がプレビューで実装されました。Chat Completion API では、チャットの会話をロール別に分けてディクショナリ型の配列として与えることができるようになり、ChatML よりも直感的に理解しやすくなりました。

```python
conversation = [
    {"role": "system", "content": "Provide some context and/or instructions to the model."},
    {"role": "user", "content": "Example question goes here."},
    {"role": "assistant", "content": "Example answer goes here."},
    {"role": "user", "content": "First question/message for the model to actually respond to."}
]
```

Chat Completion API はステートレスな API のため、新しい質問をする度にこれまでの会話の履歴を最新の質問とともに送信しなければ、以前の質問と回答のコンテキストを考慮した回答ができません。

# 2. チャット履歴の永続化（Azure CosmosDB）
デフォルトのままでは、記憶は一時的なものとして扱われます。これを永続的なものにするために、データベースを用意して質問と回答のペアを保存します。こうすることで、以下のように新たにチャットセッションを開始した直後に、過去の問い合わせを参照することができるようになります。

![001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/24299b68-ff48-bf69-da19-abd9fe030260.png)

今回 Enterprise Search on Azure を実現するにあたり、スキーマ定義いらずで簡単に JSON のままぶち込める [Azure Cosmos DB (SQL API)](https://azure.microsoft.com/products/cosmos-db)を利用しています。Cosmos DB では、[Time to Live (TTL)](https://learn.microsoft.com/azure/cosmos-db/nosql/time-to-live) を使って一定の期間が経過したらアイテムを削除する機能も提供されているので、長期記憶の忘却も簡単に実装できると考えました。しかし、将来的なことを考えたらいったん保存したチャット履歴はすべて残すことをお勧めします。

将来的に企業はドメインに最適化された GPT モデルを獲得したいと考えるでしょう。そのためには、数百、数千の質問・回答のセットを用いてモデルをファインチューニングする必要があります。ですので、実運用におけるチャット履歴は非常に重要な資産となります。あと運用初期は、In-context Learning による精度向上策が有効ですが、徐々にプロンプト肥大化によるレイテンシーの増加も無視できなくなります。

## 2.1. アーキテクチャ
![005.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/e30b7cf4-f7a5-5c45-dfd5-a0c0621485ee.png)


## 2.2. チャット履歴を管理

### 1. 与えられたトークン制限内で会話を維持
Chat Completion API に送信されたディクショナリ型の配列とモデル応答の両方からのトークン数が含まれます。max_tokens パラメーターの値と組み合わせたディクショナリ型の配列内のトークンの数が、これらの制限を超えないように制御する必要があります。実装例は以下を参考にできます。

https://learn.microsoft.com/azure/cognitive-services/openai/how-to/chatgpt?pivots=programming-language-chat-completions#managing-conversations

### 2. 会話を所定のターン数内に収める
会話を所定のターン数内に収めるために、conversation 配列が最大数を超える場合、最も古いメッセージを削除することで、リストの長さを一定に保ちます。

```python
while len(conversation) > max_conversation:
    conversation.pop(0)
```

### 3. 会話を要約する
会話のトークン数が長い場合、要約することも検討します。

## 2.3. Cosmos DB の実装例
Cosmos DB の [Python SDK](https://learn.microsoft.com/azure/cosmos-db/nosql/samples-python) をインポートして、例えば以下のような項目を設定して保存します。
### 1. 質問回答のペアを保存
```python
new_item = {
    "id": str(uuid.uuid4()),
    "chat_session_id": self.chat_session_id,
    "user_id": "A00000001",
    "timestamp": datetime.utcnow().strftime('%Y-%m-%dT%H:%M:%S.%fZ'),
    "conversation": [
        {"role": "user", "content": history[-1]["user"]},
        {"role": "assistant", "content": completion.choices[0]['message']['content']}
    ],
    "feedback": 1
}
self.cosmos_container.create_item(new_item)
```
後から結果を評価するために、できればユーザーのフィードバックも一緒に格納したいです。👍👎

### 2. チャット履歴のロード
```python
QUERY = "SELECT c.conversation,c.timestamp FROM ChatLogs c WHERE c.user_id = 'A00000001' ORDER BY c.timestamp DESC OFFSET 0 LIMIT 10"
items = self.cosmos_container.query_items(
    query=QUERY, enable_cross_partition_query=True
)
```

# 3. チャット履歴の検索（Azure Cache for Redis）
新しい質問の度に必要な記憶のみをロードするアプローチはどうでしょうか。質問と回答のペアを [Embeddings API](https://learn.microsoft.com/azure/cognitive-services/openai/how-to/embeddings?tabs=console) で埋め込みベクトルに変換して、ベクトルデータベースへ保管し、質問する度にベクトル類似検索して質問に関係ある記憶のみをチャット履歴として配列に追加します。

[Azure Cache for Redis](https://azure.microsoft.com/products/cache) は通常インメモリ キャッシュとしての利用が想定されますが、[データ永続化](https://learn.microsoft.com/azure/azure-cache-for-redis/cache-how-to-premium-persistence)機能を有効化して Azure Storage アカウントに RDB/AOF を書き出すことができます。

実装の方法については、[Cosmos DB の実装例](#23-cosmos-db-%E3%81%AE%E5%AE%9F%E8%A3%85%E4%BE%8B)と同様です。

# 4. 事前知識の検索（Azure Cache for Redis）
事前知識として `system` ロールのコンテキスト内に埋め込むこともできます。今回信頼性の高い知識が保管されているナレッジベースがもう一つあると考えましょうか。実験なので Azure Cognitive Search と Redis の全部載せ構成です。

## 4.1. プロンプトの改良
[デフォルト](https://github.com/nohanaga/azure-search-openai-demo/blob/main/app/backend/approaches/chatreadretrieveread.py)のプロンプトでは、幻覚(Hallucination)が多くなってきたため、OpenAI のプロンプトエンジニアリング [ベストプラクティス](https://help.openai.com/en/articles/6654000-best-practices-for-prompt-engineering-with-openai-api)や、[こちら](https://zenn.dev/brainyblog/articles/chatgpt-no-more-lies)で検証されているようなロールプレイや制約条件を使って以下のようにプロンプトを改善しました。

```yaml
prompt_prefix_new = """
日本の鎌倉時代の歴史の読解問題を出題します。
事前知識 FACTS に基づいて、SOURCES を読み、FACTS にない情報を追加しなさい。
FACTS と SOURCES から問題の答えが推測できない場合は、「わかりません」と答えなさい。

# 制約条件
- SOURCES の接頭辞には、ファイル名の後にコロンと実際の情報があり、回答で使用する各事実には必ず出典名を記載しなさい。
- 出典を参照するには、四角いブラケットを使用します。例えば、[info1.txt]です。出典を組み合わせず、各出典を別々に記載すること。例えば、[info1.txt][info2.pdf] など。
{follow_up_questions_prompt}
{injected_prompt}
FACTS:###
{facts}
###
SOURCES:###
{sources}
###
"""
```

ちなみに GPT-4 では幻覚が[大幅に低減](https://openai.com/research/gpt-4#limitations)されており、GPT-3.5 よりも 40% 高いスコアを獲得しているとのことなので、早く比較したいところです。

Redis に事前知識としてわざと間違った知識を注入しておきます。
```python
insert_item("一ノ谷の戦いには誰が参加しましたか？", "山田太郎は山田一郎と次郎を率いて約3000騎で参陣した。[山田太郎-30.txt]")
```

![006.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/068c31f4-211e-e52f-0405-012a0152dab5.png)

はい、`FACTS` と `SOURCES` の内容が統合されて出力されました。

## 4.2. Redis での実装

### 1. 質問回答のペアを保存
```python
def insert_item(user, assistant):
    data = user + "：" + assistant
    embedding = get_embedding(str(data), engine = 'text-search-davinci-doc-001')
    nd_embedding = np.array(embedding)

    key= str(uuid.uuid4())
    item_keywords_vector =nd_embedding.astype(np.float32).tobytes()
    item_metadata = {'user_id': "A00000001", 'assistant': assistant, 'user': user, ITEM_KEYWORD_EMBEDDING_FIELD: item_keywords_vector}

    redis_conn.hset(key, mapping=item_metadata)
```


### 2. 事前知識のロード
ユーザーからの検索クエリーを `text-search-davinci-query-001` モデルを利用して 12,288 次元の埋め込みベクトルに変換して Redis 内を検索し、上位 2 件を得ています。`user` と `assistant` のセットを `FACTS` コンテキストに代入します。
```python
def get_chat_history_from_redis(self, history, include_last_turn=True, approx_max_tokens=1000) -> str:
    topK = 2
    user_query = history[-1]["user"]
    embedding = get_embedding(user_query, engine="text-search-davinci-query-001")
    query_byte_array = np.array(embedding).astype(np.float32).tobytes()

    q = Query(f'*=>[KNN {topK} @davinci_search $vec AS vector_score]').return_fields("vector_score", "assistant", "user", "davinci_search").sort_by('vector_score').paging(0,topK).dialect(2)
    results = self.redis_conn.ft().search(q, query_params={"vec":  query_byte_array})
    
    history_array = []
    for item in results.docs:
        history_array.append(item.user + "：" + item.assistant + "\n")

    return "".join(history_array)
```

## 4.3. キーワード検索 vs ベクトル類似性検索
On Azure でキーワード検索およびベクトル類似性検索を実装する際の細かい違いなどは以前[こちら](https://qiita.com/nohanaga/items/59e07f5e00a4ced1e840)で解説しました。
![スクリーンショット 2023-04-01 10.51.48.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/b42eb5d5-4890-9abc-4032-6b67e276f26a.png)

今回使用しているサンプルでは GPT-3.5 を使ってユーザーのクエリーから検索キーワードを生成していました。実際のところ、この手法とベクトル類似性検索ってどちらを選ぶべきなのでしょうか。私自身は検索キーワードを生成するアプローチで満足してしまっているのですが、例えば [Tudor Golubenco](https://xata.io/blog/keyword-vs-semantic-search-chatgpt) 氏による比較検証記事が参考になります。彼の検証では僅差でキーワード検索アプローチが優位となりましたが、これについては使用するデータで全然変わってくるので彼の観点を参考に自身で検証が必要だと思います。

すでに組織内にキーワード検索のナレッジベースがある場合は前者がすぐにハマりますね。しかも GPT-3.5 で類義語を複数生成して OR 検索すればヒット率はより上がるはずです。

## 4.4. LangChain ReAct アプローチ
ここまでは LLM オーケストレーションライブラリを利用せずに実装しましたが、[こちら](https://qiita.com/nohanaga/items/803c09b5a3a4e2d1776f#32-read-retrive-read)で解説しているように、LangChain の zero-shot-react-description で必要な Tool からの情報を受け取ったらそれで解決というような実装もできます。

# 5. エンタープライズセキュリティ
Azure OpenAI Service の大きなメリットとして、エンタープライズグレードのシステムには欠かせない[セキュリティ](https://learn.microsoft.com/legal/cognitive-services/openai/data-privacy?context=%2Fazure%2Fcognitive-services%2Fopenai%2Fcontext%2Fcontext)、[コンプライアンス](https://azure.microsoft.com/ja-jp/resources/microsoft-azure-compliance-offerings/)、[RBAC](https://learn.microsoft.com/azure/cognitive-services/openai/how-to/managed-identity)、[BCDR](https://learn.microsoft.com/azure/cognitive-services/openai/how-to/business-continuity-disaster-recovery)、[SLA](https://learn.microsoft.com/azure/cognitive-services/openai/faq#azure-openai----api-----sla---------------) があります。まずこのサンプルコードを Bicep で構築すると、Azure OpenAI Service リソースの Cognitive Services OpenAI User ロールに自分自身が割り当てられ、自分の [Azure AD アカウント](https://learn.microsoft.com/azure/cognitive-services/openai/how-to/managed-identity)を使用して接続するように設定されます。

```python
openai.api_type = "azure_ad"
azure_credential = DefaultAzureCredential(exclude_shared_token_cache_credential=True)
openai_token = azure_credential.get_token("https://cognitiveservices.azure.com/.default")
openai.api_key = openai_token.token
```

## 5.1. VNET による閉域化
Azure OpenAI Serivce の API エンドポイントをインターネットに露出するのはセキュリティ上好ましくはありません。Azure OpenAI Serivce の [VNET とプライベート エンドポイント](https://learn.microsoft.com/azure/cognitive-services/cognitive-services-virtual-networks?context=%2Fazure%2Fcognitive-services%2Fopenai%2Fcontext%2Fcontext&tabs=portal)機能を使って、プライベートネットワークに閉じた環境にリソースをデプロイ可能です。これによって、他のサービス Azure Cognitive Search、Azure Cosmos DB、Storage アカウント、App Services（VNET 統合）等と同様プライベートネットワーク内でセキュアに通信することができます。

![vnet001.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/674344/3bdf409a-975e-8d6e-fab2-fe6990192e4e.png)

# 6. サンプルコード
TBD

## おわりに
次は [Semantic Kernel(SK)](https://qiita.com/nohanaga/items/430b59209b02c298ef2a) で実装してみますか。