PolarDB for PostgreSQL 14 で pgvector と LangChain を使ってベクトル検索してみる

これは **[PolarDB などのデータベースに関する Tips を記事投稿しよう by Alibaba Cloud Advent Calendar 2023](https://qiita.com/advent-calendar/2023/alibaba2)** 16 日目の遡り登録エントリです。

https://qiita.com/advent-calendar/2023/alibaba2

数日前までこのカレンダーを見ていたときには「ベクトル検索を試してみる」っぽい仮タイトルの記事を登録しようとしていた人がいたはずだったのですが、一昨日（12/21）見たらなくなっていたので、急遽代わり（？）に登録してみました。

:::note
これを「Tips」と呼んで良い…のか…？（まあいいか）
:::

## Alibaba Cloud でベクトル検索（with LangChain）

LangChain から Vector store として使えるサービスとしては、主に

- **[AnalyticDB for PostgreSQL](https://www.alibabacloud.com/ja/product/hybriddb-postgresql)**

https://www.alibabacloud.com/ja/product/hybriddb-postgresql

- **[PolarDB for PostgreSQL](https://www.alibabacloud.com/ja/product/polardb-for-postgresql)**

https://www.alibabacloud.com/ja/product/polardb-for-postgresql

- **[OpenSearch Vector Search Edition](https://www.alibabacloud.com/help/en/open-search/vector-search-edition/)**

https://www.alibabacloud.com/help/en/open-search/vector-search-edition/

がありますが、ここでは PolarDB for PostgreSQL 14 で [pgvector](https://github.com/pgvector/pgvector) 0.5.0 を有効化して試してみます。

## [pgvector](https://github.com/pgvector/pgvector) とは

PostgreSQL でベクトル計算とベクトルデータの保存・検索をするための拡張機能（Extension）です。

https://github.com/pgvector/pgvector

詳細説明については、すでに **[PostgreSQL Advent Calendar 2023](https://qiita.com/advent-calendar/2023/postgresql)** の記事として書きましたのでそちらを参照してください。

- **[象使いのための pgvector 入門 (1)](https://qiita.com/hmatsu47/items/b393cecef8ed9df57c35)**

https://qiita.com/hmatsu47/items/b393cecef8ed9df57c35

- **[象使いのための pgvector 入門 (2)](https://qiita.com/hmatsu47/items/d805ae2b0fdc2a0cbd5f)**

https://qiita.com/hmatsu47/items/d805ae2b0fdc2a0cbd5f

## 作って試したもの

実はすでに作って試して JAWS-UG 名古屋で発表し（て見事にスベっ）た構成を、Docker コンテナの pgvector から PolarDB for PostgreSQL に置き換えただけです。

- **[人工無能たいたん](https://www.docswell.com/s/hmatsu47/5XYDWR-2023-11-18-230246)**

https://www.docswell.com/s/hmatsu47/5XYDWR-2023-11-18-230246

[GitHub リポジトリはこちら](https://github.com/hmatsu47/munou-chat)です。

https://github.com/hmatsu47/munou-chat

:::note
**人工無能(無脳)とは？**

いまどきの ChatGPT のように「会話が成立する AI チャット」ではなく、人が入力した文章やキーワードをおうむ返ししたり、過去に人が入力した文章やキーワードの中から関係ありそうなものを選んで返してくる、微妙な会話を楽しむための（？）チャットです。

インターネット以前のパソコン通信時代に流行りました。
:::

文章のベクトル化（Embedding）には AWS の Titan Embeddings G1 - Text を使います。

また、コードを動かす環境は AWS の EC2（パブリックアクセスあり）を想定しています。

:::note
コードは Alibaba Cloud の ECS で実行することも可能です（要アクセスキー・シークレットアクセスキー設定）。
:::

### AWS 側の準備

先の [GitHub リポジトリの **README**](https://github.com/hmatsu47/munou-chat) のとおりに設定します。

- **Bedrock モデル有効化**

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/3683f648-b22b-246c-3a17-4aafdd2acab1.png)

- **以下のポリシーを持つ IAM Role を作成し、コードを動かす環境の EC2 に付与**

```json:IAMポリシー
{
    "Version": "2012-10-17",
    "Statement": [
        {
            "Effect": "Allow",
            "Action": "bedrock:*",
            "Resource": "*"
        }
    ]
}
```

- **コードを動かす環境の EC2 に GitHub のコードを展開**

コードは Python 3.10 で動作します。

ローカルリポジトリに`clone`したら、

```sh:
pip install -r requirements.txt
```

でパッケージをインストールします。

:::note
`venv`を使って環境を切り替えられるようにしておくと良いでしょう。
:::

### PolarDB for PostgreSQL の準備

クラスタを作成します。

:::note
事前に VPC・vSwitch を作成しておきます（途中で新規作成することも可能）。
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/44c6ec18-e4cc-cb6b-fd46-06a800ac5890.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/4dfc0cc2-c7ff-70ad-d5ca-3912465eca73.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/07f81e23-0291-4307-2ae4-67808c2dc0c9.png)

:::note warn
プロダクト環境で使うときは TDE を有効化します。
:::

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/8c049bce-b67c-4b0e-d883-e4cbc27167bd.png)

クラスタ作成後、プライマリエンドポイント（またはクラスターのエンドポイント）のパブリックネットワークアドレスを申請します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/af4a8e75-4e70-dc7e-ba93-ef3db69f6ac4.png)

:::note
ここでは`hmatsu47test`という名前で申請しています。
:::

IP ホワイトリストには、EC2 のパブリック IP アドレスを追加指定します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/1262639c-520c-52f8-d9c6-059c23105cc1.png)

アカウントを作成します。

まず管理者用の特権アカウント（ここでは`pgvadmin`）、次にアプリケーションからのアクセスに使う標準アカウント（同`pgvuser`）を作成します。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/f8b531c4-e14b-d184-f7f7-da6ece451da1.png)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/105e59b9-85ed-45ef-77a5-3d41117c4639.png)

続いてアプリケーションから使うデータベースを作成します（ここでは`pgvtest`）。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/84f80788-ec9c-0597-698f-feac9111dd39.png)

:::note
データベース所有者には先ほどの標準アカウントを指定します。
:::

:::note warn
プロダクト環境で使うときは必ず SSL（TLS）での接続を有効化します。
:::

### EC2 のコードで`.env`ファイルを書き換える

PolarDB 側の設定に合わせて内容を書き換えます。

```text:.env
PGVECTOR_DRIVER=psycopg2
PGVECTOR_HOST=hmatsu47test.pg.polardb.japan.rds.aliyuncs.com
PGVECTOR_PORT=1921
PGVECTOR_DATABASE=pgvtest
PGVECTOR_USER=pgvuser
PGVECTOR_PASSWORD=【設定した標準アカウントのパスワード】
```

### `psql`コマンドで PolarDB for PostgreSQL に接続し、`vector`拡張機能を有効化する

特権アカウントで接続して作業します。

```sql:vector有効化
$ psql -h hmatsu47test.pg.polardb.japan.rds.aliyuncs.com -p 1921 -U pgvadmin -d pgvtest                        
Password for user pgvadmin: 
psql (14.10, server 14.9)
Type "help" for help.

pgvtest=> CREATE EXTENSION vector;
CREATE EXTENSION
pgvtest=> SELECT extversion FROM pg_extension WHERE extname = 'vector';
 extversion 
------------
 0.5.0
(1 row)

pgvtest=> \q
```

### アプリケーションを実行する

```sh:アプリケーション実行
$ streamlit run app.py --server.port 8080

Collecting usage statistics. To deactivate, set browser.gatherUsageStats to False.


  You can now view your Streamlit app in your browser.

  Network URL: http://172.31.XX.XX:8080
  External URL: http://35.XX.XX.XX:8080
```

ブラウザで対象 URL を開きます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/28dab381-565d-ae6d-377b-5b0a6b5bb4ad.png)

この時点で DB を確認すると、

```sql:
$ psql -h hmatsu47test.pg.polardb.japan.rds.aliyuncs.com -p 1921 -U pgvuser -d pgvtest
Password for user pgvuser: 
psql (14.10, server 14.9)
Type "help" for help.

pgvtest=> \d
Did not find any relations.
```

何もありません。

何か文章を入力してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/94997882-98dd-82ac-45e8-acf3665f04ae.png)

入力した内容がそのままおうむ返しされてきました。

DB を確認します。

```sql:
pgvtest=> \d
                 List of relations
 Schema |          Name           | Type  |  Owner  
--------+-------------------------+-------+---------
 public | langchain_pg_collection | table | pgvuser
 public | langchain_pg_embedding  | table | pgvuser
(2 rows)

pgvtest=> SELECT * FROM langchain_pg_collection;
     name     | cmetadata |                 uuid                 
--------------+-----------+--------------------------------------
 munou_goroku | null      | 0aa32fd8-2f00-4d4c-b006-29fe541619c8
(1 row)

pgvtest=> \d langchain_pg_embedding
               Table "public.langchain_pg_embedding"
    Column     |       Type        | Collation | Nullable | Default 
---------------+-------------------+-----------+----------+---------
 collection_id | uuid              |           |          | 
 embedding     | vector            |           |          | 
 document      | character varying |           |          | 
 cmetadata     | json              |           |          | 
 custom_id     | character varying |           |          | 
 uuid          | uuid              |           | not null | 
Indexes:
    "langchain_pg_embedding_pkey" PRIMARY KEY, btree (uuid)
Foreign-key constraints:
    "langchain_pg_embedding_collection_id_fkey" FOREIGN KEY (collection_id) REFERENCES langchain_pg_collection(uuid) ON DELETE CASCADE

pgvtest=> SELECT collection_id, document, cmetadata, custom_id, uuid FROM langchain_pg_embedding;
            collection_id             |   document   | cmetadata |              custom_id               |                 uuid                 
--------------------------------------+--------------+-----------+--------------------------------------+--------------------------------------
 0aa32fd8-2f00-4d4c-b006-29fe541619c8 | こんばんは！ | {}        | 4499a717-a0c9-11ee-bbd5-0adb715db027 | 64682864-63a0-418c-b758-5078ee293ad9
(1 row)
```

テーブルが 2 つ作成されました。

`langchain_pg_embedding`に、入力した文章が 1 行追加されたのがわかります。

:::note
`embeddings`列にベクトル化されたデータが入っていますが、画面には表示しづらいのでここでは表示を省略しました。
:::

さらに別の文章を入力してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/5eff734c-e969-c6e1-d0a9-70952c80c046.png)

先ほど入力された内容が返ってきましたね。

先ほどのテーブルを見てみます。

```sql:
pgvtest=> SELECT collection_id, document, cmetadata, custom_id, uuid FROM langchain_pg_embedding;
            collection_id             |        document        | cmetadata |              custom_id               |                 uuid                 
--------------------------------------+------------------------+-----------+--------------------------------------+--------------------------------------
 0aa32fd8-2f00-4d4c-b006-29fe541619c8 | こんばんは！           | {}        | 4499a717-a0c9-11ee-bbd5-0adb715db027 | 64682864-63a0-418c-b758-5078ee293ad9
 0aa32fd8-2f00-4d4c-b006-29fe541619c8 | 今日は良い天気ですね！ | {}        | dc61ff4d-a0c9-11ee-aac9-0adb715db027 | 4c581b75-4109-45c7-8929-75b6318f0348
(2 rows)
```

2 行になりました。

さらに別の文章を入力してみます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/123899/444865a7-3fcf-028e-c790-50986b4ca060.png)

先に 2 つ入力していた文章のうち、意味的に近そうなほうが返ってきました。

テーブルを見てみます。

```sql:
pgvtest=> SELECT collection_id, document, cmetadata, custom_id, uuid FROM langchain_pg_embedding;
            collection_id             |        document        | cmetadata |              custom_id               |                 uuid                 
--------------------------------------+------------------------+-----------+--------------------------------------+--------------------------------------
 0aa32fd8-2f00-4d4c-b006-29fe541619c8 | こんばんは！           | {}        | 4499a717-a0c9-11ee-bbd5-0adb715db027 | 64682864-63a0-418c-b758-5078ee293ad9
 0aa32fd8-2f00-4d4c-b006-29fe541619c8 | 今日は良い天気ですね！ | {}        | dc61ff4d-a0c9-11ee-aac9-0adb715db027 | 4c581b75-4109-45c7-8929-75b6318f0348
 0aa32fd8-2f00-4d4c-b006-29fe541619c8 | 明日は晴れますかね？   | {}        | 2d34fc70-a0ca-11ee-95c6-0adb715db027 | 2d540208-218c-48f2-9204-a9a9ae0cd2db
(3 rows)
```

3 行になりました。

:::note
見づらいですが、一応ベクトル化されたデータだけ見てみると…
```sql:
pgvtest=> SELECT embedding FROM langchain_pg_embedding;
（中略）
[1.234375,-0.032470703,-0.28515625,0.22070312,0.25585938,-0.69921875,…
[1.40625,0.765625,-0.104003906,0.10644531,0.296875,-0.26953125,…
[2.34375,0.73828125,-0.22167969,-0.29101562,0.65234375,0.33789062,…
(3 rows)
```
というような感じで各行 1536 個の配列がベクトルデータとして入っています。
:::

## 何をしているのか？

[Python のコード](https://github.com/hmatsu47/munou-chat/blob/main/app.py)を見てみます。

https://github.com/hmatsu47/munou-chat/blob/main/app.py

13 行目から 20 行目、

```python:
CONNECTION_STRING = PGVector.connection_string_from_db_params(
    driver=os.environ.get("PGVECTOR_DRIVER", "psycopg2"),
    host=os.environ.get("PGVECTOR_HOST", "localhost"),
    port=int(os.environ.get("PGVECTOR_PORT", "5432")),
    database=os.environ.get("PGVECTOR_DATABASE", "postgres"),
    user=os.environ.get("PGVECTOR_USER", "postgres"),
    password=os.environ.get("PGVECTOR_PASSWORD", ""),
)
```

で DB（PolarDB for PostgreSQL）への接続文字列を定義しています。

:::note
`.env`から変数を取得可能な場合はその値が入ります。
なければ 2 つ目のパラメータとして指定した値を（デフォルト値として）使います。
:::

48 行目から 59 行目、

```python:
        # ベクターストアから応答を取得
        bedrock_client = boto3.client('bedrock-runtime', region_name="ap-northeast-1")
        embeddings = BedrockEmbeddings(
            client=bedrock_client,
            model_id="amazon.titan-embed-text-v1"
        )
        store = PGVector(
            collection_name=COLLECTION_NAME,
            connection_string=CONNECTION_STRING,
            embedding_function=embeddings,
        )
        docs = store.similarity_search_with_score(trimed_prompt)
```

で入力文字列に近いレコードをベクトル検索し、

60 行目から 73 行目、

```python:
        count = len(docs)
        add_flag = True
        if count == 0:
            # ベクターストアに文章がなければおうむ返しする
            response = trimed_prompt
        else:
            # 近い文章を返す
            response = docs[0][0].page_content
            if trimed_prompt == response:
                # すでに登録済みの文章と同じならベクターストアに登録しない
                add_flag = False
        if add_flag:
            # ベクターストアに入力を追加
            store.add_documents([Document(page_content=trimed_prompt)])
```

で

- 検索結果が 0 件なら入力文字列をそのままおうむ返し
- 近い文書があればそれを返す
  - それが入力文字列と違う場合は DB にレコードを追加
    - このとき入力文字列を Titan Embeddings T1 - Text でベクトル化

しています。

:::note
LangChain を使っているので SQL 文は完全に隠蔽されています。
実際にどのような SQL 文でベクトル検索できるのか？については、[前掲の記事](https://qiita.com/hmatsu47/items/b393cecef8ed9df57c35#%E6%A4%9C%E7%B4%A2%E6%9C%80%E8%BF%91%E5%82%8D%E6%8E%A2%E7%B4%A2)を参照してください。
なお、pgvector を使う場合、デフォルトで[`<->`、つまりユークリッド距離（L2 距離）による最近傍探索](https://github.com/langchain-ai/langchain/blob/master/templates/sql-pgvector/sql_pgvector/prompt_templates.py)が行われるようです（その際、IVFFlat・HNSW インデックスは使いません）。
:::

## RAG として使うには

`similarity_search_with_score()`で返ってきたものをそのまま表示するのではなく、`RetrievalQA.from_chain_type()`などのパラメータとしてストアの`retriever`を渡し、

- AWS の Bedrock であれば Anthropic Claude 2.1
- OpenAI であれば ChatGPT（3.5/4 など）

などの LLM のプロンプトとして入力文字列（や履歴など）とあわせて送ります。

:::note
**2024/3/5 追記：**
現状は（というかアドベントカレンダーの時点ですでに）`RetrievalQA.from_chain_type()`などのレガシーな記法ではなく、[LCEL](https://python.langchain.com/docs/expression_language/) の使用が推奨されています。

- **[LangChain の新記法「LangChain Expression Language (LCEL)」入門](https://zenn.dev/os1ma/articles/acd3472c3a6755)（oshima_123 さん）**
:::