Google Cloudをつかっておすすめの映画を返すアプリを作る。


# はじめに
生成AIの勉強のために、GoogleのプラットフォームVertex AIを使って簡単なアプリケーションを作成します。
アプリケーションといっても、UIまで作らずに仕組みの構築までです。
[こちら](https://zenn.dev/google_cloud_jp/articles/generative-retrieval-augmented-generation)の記事を写経させていただいたのちに、データセットだけ変更して再現してみたのがこの記事の内容なので、オリジナリティはあまりないですが、自分がわかりにくかった点や**コスト**等ところどころ補強しています。

Vector Search(旧Maching Engine)とLLMを組み合わせて、映画のデータベースからおすすめを回答してくれるアプリケーションを作成します。
※Maching Engineは古い名称で、今はVector Searchとなっています。
https://cloud.google.com/vertex-ai/docs/vector-search/overview?hl=ja

1つ注意として、これを実行するとコストがかかります。やり方によりますが自分の場合は1万以上かかってしまったので注意してください。念のため、**Billingのページから予算を設定してアラートが上がるようにしておいた方がいい**と思います。

# データ探し
タイトルと、それに対する文章での説明が入っているようなデータを探します。
色々探した結果、TMDBという映画のデータを使うことに。
https://www.kaggle.com/datasets/tmdb/tmdb-movie-metadata/
※ただしこのCSVは、埋め込みベクトルに変換する際に加工が必要でした。

## 備考
DBpediaというものをうまく使えると、希望するデータをいくらでも作れそうだが、SQLに明るくないので使いこなせず…。30分くらい試行錯誤してみたができなかったのでいったん棚上げにしています。

# 環境準備
## プロジェクトの作成
Google Cloud上で新規にプロジェクトを作成します。既存のものでもよいですが、API有効化状況などが異なると再現性が悪いので新規に立てます。Google Cloudにてプロジェクトを選択する箇所があるので、そこから作成します。

![image](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159980/daae3d01-1803-b463-7b9a-eb97e783f358.png)

そのプロジェクト上でShellを起動し、以下を実行。各種APIを有効化し、Notebookのインスタンスを作成します。
このインスタンスの起動や容量の使用でコストがかかります。AWSみたいに無料枠ってないのかしら。

```shell-session
gcloud services enable \
  notebooks.googleapis.com \
  aiplatform.googleapis.com \
  servicenetworking.googleapis.com
gcloud notebooks instances create genai-first1 \
  --vm-image-project=deeplearning-platform-release \
  --vm-image-family=common-cpu-notebooks-debian-11-py310 \
  --location us-central1-a
```

ちなみにこの設定では**月に$26.35かかる**ようですので、その点はご注意ください。
Notebookが起動したら、Jupyter Notebookにアクセスします。
![image](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159980/09ba4b93-d6b1-5c1a-df2d-991ca33ce4da.png)

Notebook上で、以下のマジックコマンドを実行し、LangChainをインストールしておきます。インストール後はカーネルの再起動が必要ですので、やっておきます。

```py
!pip install langchain==0.0.260 --user
```

## Quotaの引き上げ
これから実施する内容の中で、一部Quota（わりあて）を引き上げないとできないためその設定をします。これは申請が必要で、かつ承認されるまで少し時間がかかる場合があるようです。自分の場合は2日かかりました。
変更する場所がわかりにくいのですが、以下のリンクからいけるかと思います。
https://console.cloud.google.com/apis/api/aiplatform.googleapis.com/metrics
もしくは、Google Cloudのトップ→ハンバーガーメニューから「API ＆Services」→Vertex AI API→Quotaタブ、でもいけるはずです。

![image](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159980/e619f978-2a2c-6a14-9456-03a5c5efcdf5.png)

textembedding-geckoで絞り込みし、Online Prediction Request ～というやつを600に変更します。わかりにくいですが、右上にEdit Quotaというボタンがあります。

# 構築
## データの取り込み
上記リンクから取得した、tmdb_5000_movies.csvというファイルをアップロードします。アップロードはJupyterのUIからできます。
![image](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159980/cf3780e1-f7e1-ae7e-2cc9-475b0e2103d0.png)

ルートフォルダの直下にアップロードしたとします。このCSVは一部のカラムが辞書やリストの形式になっているため、まずは組み込みベクトルに変換できるように（文字列のリストになるように）書き換えます。

```py
import csv

output = []
# VertexAIEmbeddingsにてembedするには、List[str]である必要があるが、このCSVは一部の列がリストや辞書の形式になっているので成形する
# といってもここでは特定の列だけ抜くだけにしている。
with open("./tmdb_5000_movies.csv", 'r', encoding='utf-8') as f:
    csv_reader = csv.reader(f)
    header = next(csv_reader)
    
    for row in csv_reader:
        homepage = row[2].strip()
        id = row[3].strip()
        original_title = row[6].replace(',',' ').strip()
        overview = row[7].replace(',',' ').strip()
        release_date = row[11].strip()
        
        output.append([id, original_title, homepage, overview, release_date])

with open("./tmdb_5000_movies_modified.csv", 'w', newline='') as f:
    writer = csv.writer(f)
    writer.writerow(['id', 'title', 'homepage', 'overview', 'release_date'])
    for item in output:
        writer.writerow(item)
```

出力したCSVをLangChainのCSVLoaderというモジュールで読み込みます。

```py
from langchain.document_loaders import CSVLoader

loader = CSVLoader(
    file_path='./tmdb_5000_movies_modified.csv',
    csv_args={'delimiter': ','}
)

documents = loader.load()
```

documentsはリストになっていて、各要素に1つの映画の情報が入っています。
```py
print(documents[0].page_content)
# 出力は以下のようになる
# id: 19995
# title: Avatar
# homepage: http://www.avatarmovie.com/
# overview: In the 22nd century  a paraplegic Marine is dispatched to the moon Pandora on a unique mission  but becomes torn between following # orders and protecting an alien civilization.
# release_date: 2009-12-10
```

これを埋め込みベクトルと呼ばれるものに変換します。
埋め込み(Embedding)とは、機械学習や自然言語処理などで使われる用語で、テキストや画像などを数値のベクトルとして表現する手法のことです。
なぜベクトルに変換するかといえば、ベクトル（座標）にすると、要素間の「距離」を定量化できるからという理解です。各映画を、(2次元）空間にマッピングできたとすると、各映画（点）の間の距離を計算できるのはイメージできると思います。それをものすごい多次元（この例では768次元）で実施していると考えるとわかりやすいかと。
ベクトルへの変換には同じくLangChainが用意しているクラスを使用します。

```py
from langchain.embeddings import VertexAIEmbeddings

embeddings = VertexAIEmbeddings(model_name='textembedding-gecko')
initial_vector = embeddings.embed_documents([documents[0].page_content])
```

このベクトルデータをJSON形式にして、ファイルとして保存しておきます。

```py
import json
with open('initial_vector.json', 'w') as f:
    json.dump({'id': 'initial', 'embedding': initial_vector[0]}, f)
```

Jupyter Notebook上で以下を実施して保存。

```shell-session
%%bash
PROJECT_ID=$(gcloud config get project)
BUCKET=$PROJECT_ID-embeddings
gsutil mb -l us-central1 gs://$BUCKET
gsutil cp initial_vector.json gs://$BUCKET
```

## Maching Engineの初期設定
まずはNotebookのインスタンスとMaching Engineをネットワークでつなぐ必要があります。以下はGoogle CloudのCloudshell上で実施。

```shell-session
gcloud compute addresses create matchingengine \
  --global --prefix-length=16 --network=default \
  --purpose=VPC_PEERING

gcloud services vpc-peerings connect \
  --service=servicenetworking.googleapis.com \
  --network=default --ranges=matchingengine
```

以下のようにテキストファイルを作成する。viとかで適当に。[Project ID]の部分は自分のプロジェクトID
で置き換えます。
```txt:index-metadata.json
{
  "contentsDeltaUri": "gs://[Project ID]-embeddings",
  "config": {
    "dimensions": 768,
    "approximateNeighborsCount": 30,
    "distanceMeasureType": "COSINE_DISTANCE",
    "shardSize": "SHARD_SIZE_SMALL",
    "algorithm_config": {
      "treeAhConfig": {
        "leafNodeEmbeddingCount": 5000,
        "leafNodesToSearchPercent": 3
      }
    }
  }
}
```

次に以下を実行し、インデックスの初期化を行います。（少し時間がかかる）

```shell-session
gcloud ai indexes create \
  --metadata-file=./index-metadata.json \
  --display-name=movies \
  --region=us-central1

PROJECT_NUMBER=$(gcloud projects list \
  --filter="PROJECT_ID:'$(gcloud config get project)'" \
  --format='value(PROJECT_NUMBER)')

gcloud ai index-endpoints create \
  --display-name=movies-endpoint \
  --network=projects/$PROJECT_NUMBER/global/networks/default \
  --region=us-central1
```

完了後、作成したインデックスのエンドポイントをデプロイします。この**デプロイしている時間に応じて課金される**ので、一気にやらない人は注意しましょう。私は1週間以上かけてタラタラ実施したため、それなりにお金がかかってしまいました…。

```shell-session
INDEX_ID=$(gcloud ai indexes list \
  --region=us-central1 --format="value(name)" \
  --filter="displayName=movies" \
  | rev | cut -d "/" -f 1 | rev)

INDEX_ENDPOINT_ID=$(gcloud ai index-endpoints list --region=us-central1 \
  --format="value(name)" \
  --filter="displayName=movies-endpoint" \
  | rev | cut -d "/" -f 1 | rev)

gcloud ai index-endpoints deploy-index $INDEX_ENDPOINT_ID \
  --deployed-index-id=deployed_movies_index \
  --display-name=deployed-movies-index \
  --index=$INDEX_ID \
  --region=us-central1
```

これでいったんGoogle Cloud側の準備は終わり。次にMaching Engineに対してデータの入力を行います。

## Matching Engineへのデータ入力
Notebookから以下を実施。

```py
from langchain.vectorstores import MatchingEngine

PROJECT_ID = !gcloud config get project

INDEX_ID = !gcloud ai indexes list \
  --region=us-central1 --format="value(name)" \
  --filter="displayName=movies" 2>/dev/null \
  | rev | cut -d "/" -f 1 | rev

INDEX_ENDPOINT_ID = !gcloud ai index-endpoints list --region=us-central1 \
  --format="value(name)" \
  --filter="displayName=movies-endpoint" 2>/dev/null \
  | rev | cut -d "/" -f 1 | rev

PROJECT_ID = PROJECT_ID[0]
INDEX_ID = INDEX_ID[0]
INDEX_ENDPOINT_ID = INDEX_ENDPOINT_ID[0]

vector_store = MatchingEngine.from_components(
    embedding=VertexAIEmbeddings(model_name='textembedding-gecko'),
    project_id=PROJECT_ID,
    region='us-central1',
    gcs_bucket_name='gs://{}-embeddings'.format(PROJECT_ID),
    index_id=INDEX_ID,
    endpoint_id=INDEX_ENDPOINT_ID
)
```

このvector_storeが、データベースのような役割を果たします。これに、先ほどCSVから読み込んだデータを入力します。（少し時間がかかる）
この**インデックスのアップデートや新規作成も課金対象になる**ので注意してください。私はうまくいかずに何回もやったのでそれなりになりました。

```py
vector_store.add_documents(documents)
```
※先に実施したQuotaの上限引き上げができていないとここで失敗します。

完了したので、試しに検索してみると…。

```py
result = vector_store.similarity_search('space')

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
Cell In[53], line 1
----> 1 result = vector_store.similarity_search('space')

File ~/.local/lib/python3.10/site-packages/langchain/vectorstores/matching_engine.py:179, in MatchingEngine.similarity_search(self, query, k, **kwargs)
    175 logger.debug(f"Embedding query {query}.")
    176 embedding_query = self.embedding.embed_documents([query])
    178 response = self.endpoint.match(
--> 179     deployed_index_id=self._get_index_id(),
    180     queries=embedding_query,
    181     num_neighbors=k,
    182 )
    184 if len(response) == 0:
    185     return []

File ~/.local/lib/python3.10/site-packages/langchain/vectorstores/matching_engine.py:214, in MatchingEngine._get_index_id(self)
    211     if index.index == self.index.resource_name:
    212         return index.id
--> 214 raise ValueError(
    215     f"No index with id {self.index.resource_name} "
    216     f"deployed on endpoint "
    217     f"{self.endpoint.display_name}."
    218 )

ValueError: No index with id projects/826779718456/locations/us-central1/indexes/4435064868587962368 deployed on endpoint movies-endpoint.
```

うーん、GUIで見ると、movies-endpointにキチンとDeployされているように見えるが…。

![image](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159980/5f3d9617-3697-9d95-50d1-ef79931db788.png)

## トラブルシュート
色々と試行錯誤したのですが、どうしても納得いかないので、add_documentsが完了してすぐに実行したら大丈夫でした。
※自分はadd_documentsに時間がかかるので、他のことをしてかなり時間を空けていた。
内部的にクライアントがタイムアウトか何かしているのかもしれないです。原因不明。

```py
result = vector_store.similarity_search('space')
print(result)

[Document(page_content='id: 593\ntitle: Солярис\nhomepage: \noverview: Ground control has been receiving strange transmissions from the three remaining residents of the Solaris space station. When cosmonaut and psychologist Kris Kelvin is sent to investigate  he experiences the strange phenomena that afflict the Solaris crew  sending him on a voyage into the darkest recesses of his own consciousness. Based on the novel by the same name from Polish author Stanislaw Lem.\nrelease_date: 1972-03-20', metadata={}),
 Document(page_content='id: 593\ntitle: Солярис\nhomepage: \noverview: Ground control has been receiving strange transmissions from the three remaining residents of the Solaris space station. When cosmonaut and psychologist Kris Kelvin is sent to investigate  he experiences the strange phenomena that afflict the Solaris crew  sending him on a voyage into the darkest recesses of his own consciousness. Based on the novel by the same name from Polish author Stanislaw Lem.\nrelease_date: 1972-03-20', metadata={}),
 Document(page_content='id: 18162\ntitle: Land of the Lost\nhomepage: \noverview: On his latest expedition  Dr. Rick Marshall is sucked into a space-time vortex alongside his research assistant and a redneck survivalist. In this alternate universe  the trio make friends with a primate named Chaka  their only ally in a world full of dinosaurs and other fantastic creatures.\nrelease_date: 2009-06-05', metadata={}),
 Document(page_content='id: 18162\ntitle: Land of the Lost\nhomepage: \noverview: On his latest expedition  Dr. Rick Marshall is sucked into a space-time vortex alongside his research assistant and a redneck survivalist. In this alternate universe  the trio make friends with a primate named Chaka  their only ally in a world full of dinosaurs and other fantastic creatures.\nrelease_date: 2009-06-05', metadata={})]
```

## LLMとの統合

1. ユーザからの問い合わせをまずLLMで解釈し、キーワードを抽出する。
2. 次にそのキーワードをMaching Engineに投げ、結果を取得。
3. その結果をさらにLLMで要約し、ユーザに返答する。

順番が前後しますが、以下がMaching Engineから返ってきた情報を要約するためのLLMとのやり取り(上記3. )の定義です。

```py
from langchain.llms import VertexAI
from langchain.chains import RetrievalQA
from langchain.prompts import PromptTemplate

prompt_template = """The string enclosed in the backquotes (```) is product information.
From that information, please extract title field, homepage field, and overview filed.
Your output should be in the following format.

Name: the title field
URL: the homepage field
Summary: pickup two or three keywords from the overview field

Here's the movie information: ```{context}```
"""

movie_search_llm = VertexAI(temperature=0.2, max_output_tokens=1024)
retriever = vector_store.as_retriever(search_kwargs={'include_metadata': True})
prompt = PromptTemplate(input_variables=['context'], template=prompt_template)

movie_search = RetrievalQA.from_llm(llm=movie_search_llm, prompt=prompt, retriever=retriever)
```

次にこれを組み込んだAgentを定義。temperatureは高いほど「ランダムな（攻めた）」回答が返ってくるようになります。

```py
from langchain.agents import initialize_agent, Tool
from langchain.agents import AgentType

description = 'useful for when you need to answer questions about movies. \
Input should be a comma-separated words, do not input a fully formed question.'

tools = [
    Tool(name='Movie QA System',
         func=movie_search.run,
         description=description),
]

agent_llm = VertexAI(temperature=0.4, max_output_tokens=1024)
agent = initialize_agent(
    tools, llm=agent_llm, 
    agent=AgentType.ZERO_SHOT_REACT_DESCRIPTION,
    verbose=True
)
```

最後にユーザからの質問からキーワードを抜き出し、Maching Engineで検索するためのLLMとのやり取りの定義です。

```py
prompt_template = """You are a recommendation system that introduces the product that best matches the user's request.
The string enclosed in the backquotes (```) is the user's request. You must output only the product name and the url in the format as shown below.

Name: Title
URL: homepage

Here's the request: ```{query}```
"""

query = 'Can you suggest a movie for high school boy who loves sci-fi?'
result = agent.run(prompt_template.format(query=query))
```

返ってきた結果がこちら。

```py
 The user is interested in science fiction movies.
Action: Movie QA System
Action Input: what are the best sci-fi movies for high school boys
Observation:  Name: Invaders from Mars
URL: 
Summary: aliens, invasion, 50s SF tale

Name: Invaders from Mars
URL: 
Summary: aliens, invasion, 50s SF tale

Name: Invaders from Mars
URL: 
Summary: aliens, invasion, 50s SF tale

Name: Goosebumps
URL: http://www.goosebumps-movie.com/
Summary: imaginary demons, Madison, Delaware
Thought: I now know the final answer
Final Answer: Name: Goosebumps
URL: http://www.goosebumps-movie.com/
```

だいぶ疑問なリコメンドではあるが、理解できなくはない、という感じ。同じ結果が3回出てくるのは、たぶんadd_documentsを何回かやってしまったためであるように思います。ちなみに日本語の質問でも対応してくれます。

```py
prompt_template = """You are a recommendation system that introduces the product that best matches the user's request.
The string enclosed in the backquotes (```) is the user's request. You must output only the product name and the url in the format as shown below.

Name: Title
URL: homepage

Here's the request: ```{query}```
"""

query = '20代の女性と見に行くのにおすすめの映画はなんですか'
result = agent.run(prompt_template.format(query=query))
```

リターン
```py
 The user is looking for a movie to watch with a 20-something woman.
Action: Movie QA System
Action Input: 20代, 女性, おすすめ, 映画
Observation:  Name: Antibirth
URL: 
Summary: drug-addled, conspiracy
Name: To Be Frank  Sinatra at 100
URL: 
Summary:
Thought: 
Action: Movie QA System
Action Input: 20代, 女性, おすすめ, 映画, 邦画
Observation:  Name: Antibirth
URL: 
Summary: conspiracy, drug, Marine

Name: 21
URL: http://www.sonypictures.com/movies/21/
Summary: blackjack, card counting, MIT
Thought: 
Action: Movie QA System
Action Input: 20代, 女性, おすすめ, 映画, 邦画, 恋愛
Observation:  Name: Antibirth
URL: 
Summary: conspiracy, drug, Marine
(以下略)
```

日本語だったためか、勝手に邦画と条件付けしたようで、AIもかなり困っていました。※このデータに邦画はたぶんない


# 最終的なコスト
これは全然想定してなかったんですが、10日くらいで1万以上かかってしまいました…。私の使い方が悪いのですが。
![image](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/159980/7fd28f42-8734-a414-5846-bb89bd80eee9.png)

みたところ、
- Vector Searchのインデックス起動によるコスト（ダントツ）
- Notebookの起動でかかるコスト（まぁまぁ）
- NotebookやJSON配置などのストレージコスト（ちょっと）
が主たる要因みたいですので、インデックスを使用時以外Undeployし、Notebookも都度削除してしまった方がいいかもしれません。

# まとめ
Vertex AIのプラットフォームを使って、自分で適当に拾ったデータを使っておすすめの映画を回答するアプリケーションを作成しました。Vertex AIやLangChainの理解が多少は進みました。
今までクラウドはほぼAWSで、ほとんどコストを気にしなくてもダメージなかったんですが、Google Cloudはちゃんとケアしないとダメ、という学びもありました。本来はAWSでも一緒ですが。