pgvectorを使ってPostgreSQLをベクトルデータベースとして使ってみる

# はじめに
この記事はNTTテクノクロス Advent Calendar 2023　シリーズ1の25日目（最終日）です。

NTTテクノクロスの金澤です。  
普段はPostgreSQL関連の開発や検証業務に携わっています。

今回はPostgreSQLの拡張機能[pgvector](https://github.com/pgvector/pgvector)とOpen AIのAPIを利用してベクトルデータを扱うデモ環境を作成して動かしてみたいと思います。
# pgvectorとは何か
[pgvector](https://github.com/pgvector/pgvector)とは、PostgreSQL上でベクトルデータ型(vector)を保存し、検索する機能を追加する拡張機能になります。
この拡張機能を使用することで、RDBMSであるPostgreSQLにベクトルデータベースの機能を追加することができます。
先日行われた[PostgreSQL Conference Japan 2023](https://www.postgresql.jp/jpug-pgcon2023)では[pgvectorの講演](https://www.slideshare.net/nttdata-tech/postgresql-pgvector-chatgpt-pgcon23j-nttdata)が行われていたり、AWS/Azure/GCPなどの各メガクラウドが提供しているDBサービスでもpgvector拡張機能に対応していたりと、近年注目されている拡張機能の一つであります。

# 構築するデモ環境について
今回は、Dockerコンテナで`pgvector`拡張機能をもつPostgreSQLデータベースを作成します。
vector型のカラムを持つテーブルに、OpenAI が提供しているAPIでベクトルデータ型に変換(Embedding)した文章を保存し、検索操作を実行していきます。

`pgvector`拡張機能をもつPostgreSQLとOpen AI APIを組み合わせた環境の構築については、以下のサイトを参考にしました。
- https://bugbytes.io/posts/vector-databases-pgvector-and-langchain/

また、今回使用する文章のデータセットやデータのセットアップ・操作等は、以下Google Cloud blogの内容を使用いたしました。
- https://cloud.google.com/blog/ja/products/databases/using-pgvector-llms-and-langchain-with-google-cloud-databases

上記のGoogle Cloud blogで使用しているデータセットを利用し、約 800 のおもちゃ関連商品のデータを保存するテーブルを作成します。

# 環境を構築する
[上述のサイト](https://bugbytes.io/posts/vector-databases-pgvector-and-langchain/)を参考に、今回はDockerコンテナで構築したPostgreSQLに、Jupyter Notebooksから接続していきます。
## pgvector拡張機能をもつPostgreSQLをDocker コンテナで起動する
[pgvector](https://github.com/pgvector/pgvector) 開発者の方が提供しているDockerイメージを使用して、pgvector拡張機能をもつPostgreSQLを起動します。
```bash
$ docker pull ankane/pgvector
$ docker run --name pgvector-demo -e POSTGRES_PASSWORD=pswd -p 5432:5432 -d ankane/pgvector
```

起動したPostgreSQLのコンテナは、ホストのポート5432にマッピングされているため、以下のように`psql`を実行することでDBへ接続することができます。
```bash
$ psql -p 5432 -h localhost -U postgres
```

DBへ接続することができたら、｀`CREATE DATABASE`で`vector_db`を作成し、`CREATE EXTENSION`でpgvector拡張機能を有効にします。

```sql
postgres=# CREATE DATABASE vector_db;
CREATE DATABASE
vector_db=# CREATE EXTENSION vector;
CREATE EXTENSION
```

pgvector拡張機能を有効にしたら、2つのテーブルを作成します。
商品名や値段を保存する`products`テーブルと商品の説明文と説明文をベクトルに変換したvenctor型のカラムをもつ`product_embeddings`テーブルです。
Open AI APIでは、ベクトルの次元は`1536`となるので、`vector(1536)`と定義します。

```sql
CREATE TABLE products(
	product_id VARCHAR(1024) PRIMARY KEY,
	product_name TEXT,
	description TEXT,
	list_price NUMERIC);

CREATE TABLE product_embeddings(
	product_id VARCHAR(1024) NOT NULL REFERENCES products(product_id),
	content TEXT,
	embedding vector(1536));
```

以上でデータベース側の設定は完了になります。

# テーブルにデータを入力する
Jupyter Notebooksで作成したPostgreSQLに接続して、テーブルにデータを入力していきます。

事前に、OpenAI APIのAPI Keyを取得し、`.env`ファイルに保存しておきます。
```bash
OPENAI_API_KEY=....
```

必要なライブラリをインストール、インポートします。
```python
!pip install pandas asyncio asyncpg langchain openai python-dotenv 
import pandas as pd
import asyncio
import asyncpg
from langchain.document_loaders import TextLoader
from langchain.text_splitter import RecursiveCharacterTextSplitter
from langchain.embeddings import OpenAIEmbeddings
from dotenv import load_dotenv

load_dotenv()
```

続いて、Google Cloud blogで使用していたサンプルの[データセット](https://github.com/GoogleCloudPlatform/python-docs-samples/raw/main/cloud-sql/postgres/pgvector/data/retail_toy_dataset.csv)を保存します。

```python
# データセットをダウンロードして Pandas データフレームに保存する。

DATASET_URL='https://github.com/GoogleCloudPlatform/python-docs-samples/raw/main/cloud-sql/postgres/pgvector/data/retail_toy_dataset.csv'

df = pd.read_csv(DATASET_URL)

df = df.loc[:, ['product_id', 'product_name', 'description', 'list_price']]

df.head(5)
```

データセットには以下のようなデータが含まれています。
<div>
<style scoped>
    .dataframe tbody tr th:only-of-type {
        vertical-align: middle;
    }

    .dataframe tbody tr th {
        vertical-align: top;
    }

    .dataframe thead th {
        text-align: right;
    }
</style>
<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th></th>
      <th>product_id</th>
      <th>product_name</th>
      <th>description</th>
      <th>list_price</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>0</th>
      <td>7e8697b5b7cdb5a40daf54caf1435cd5</td>
      <td>Koplow Games Set of 2 D12 12-Sided Rock, Paper...</td>
      <td>Rock, paper, scissors is a great way to resolv...</td>
      <td>3.56</td>
    </tr>
    <tr>
      <th>1</th>
      <td>7de8b315b3cb91f3680eb5b88a20dcee</td>
      <td>12"-20" Schwinn Training Wheels</td>
      <td>Turn any small bicycle into an instrument for ...</td>
      <td>28.17</td>
    </tr>
    <tr>
      <th>2</th>
      <td>fb9535c103d7d717f0414b2b111cfaaa</td>
      <td>Bicycle Pinochle Jumbo Index Playing Cards - 1...</td>
      <td>Purchase includes 1 blue deck and 1 red deck. ...</td>
      <td>6.49</td>
    </tr>
    <tr>
      <th>3</th>
      <td>c73ea622b3be6a3ffa3b0b5490e4929e</td>
      <td>Step2 Woodland Adventure Playhouse &amp; Slide</td>
      <td>The Step2 Woodland Climber Adventure Playhouse...</td>
      <td>499.99</td>
    </tr>
    <tr>
      <th>4</th>
      <td>dec7bd1f983887650715c6fafaa5b593</td>
      <td>Step2 Naturally Playful Welcome Home Playhouse...</td>
      <td>Children can play and explore in the Step2 Nat...</td>
      <td>600.00</td>
    </tr>
  </tbody>
</table>
</div>

データセットの情報を`products`テーブルにcopyします。
```python
conn = await asyncpg.connect(
user='postgres',
password='pswd',
database='vector_db',
host='localhost',
port=5432 
)

tuples = [tuple(str(value) for value in row) for row in df.itertuples(index=False)]

# ローカルの PostgreSQL にデータをコピーする
await conn.copy_records_to_table('products', records=tuples, columns=list(df), timeout=10)
```

一度、`psql`コマンドでPostgreSQLに接続し、`products`テーブルのデータ数を確認します。
```sql
vector_db=# SELECT count(*) FROM products;

 count 

-------

   850

(1 row)
```
850件のデータが保存されていることを確認できました。

続いては、`description`列に含まれる商品説明文を分割して、ベクトルデータに変換します。
```python
from langchain.text_splitter import RecursiveCharacterTextSplitter

text_splitter = RecursiveCharacterTextSplitter(
    separators=[".", "\n"],
    chunk_size=500,
    chunk_overlap=0,
    length_function=len,
)
chunked = []

for index, row in df.iterrows():
    product_id = row['product_id']
    desc = str(row['description'])
    splits = text_splitter.create_documents([desc])
    for s in splits:
        r = {'product_id': product_id, 'content': s.page_content}
        chunked.append(r)

embeddings_service = OpenAIEmbeddings()
batch_size = 5
for i in range(0, len(chunked), batch_size):
    request = [x['content'] for x in chunked[i: i + batch_size]]
    response = embeddings.embed_documents(request)
    for x, e in zip(chunked[i : i + batch_size], response):
        x["embedding"] = e
product_embeddings = pd.DataFrame(chunked)
product_embeddings.head()
```
`product_embeddings.head()`で出力した結果は以下となり、`embeding`列にベクトルデータに変換された値が格納されています。

<div>
<style scoped>
    .dataframe tbody tr th:only-of-type {
        vertical-align: middle;
    }

    .dataframe tbody tr th {
        vertical-align: top;
    }

    .dataframe thead th {
        text-align: right;
    }
</style>
<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th></th>
      <th>product_id</th>
      <th>content</th>
      <th>embedding</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>0</th>
      <td>7e8697b5b7cdb5a40daf54caf1435cd5</td>
      <td>Rock, paper, scissors is a great way to resolv...</td>
      <td>[-0.00886749972743081, 0.014060657961181389, 0...</td>
    </tr>
    <tr>
      <th>1</th>
      <td>7e8697b5b7cdb5a40daf54caf1435cd5</td>
      <td>. Great for educational games, dice games, boa...</td>
      <td>[-0.0013363465225081973, 0.02269774532722539, ...</td>
    </tr>
    <tr>
      <th>2</th>
      <td>7de8b315b3cb91f3680eb5b88a20dcee</td>
      <td>Turn any small bicycle into an instrument for ...</td>
      <td>[0.01945479213474804, -0.00412737445352915, 0....</td>
    </tr>
    <tr>
      <th>3</th>
      <td>7de8b315b3cb91f3680eb5b88a20dcee</td>
      <td>. Durable Construction: Steel brackets stand u...</td>
      <td>[0.014991591231696421, 0.00926185557582806, -0...</td>
    </tr>
    <tr>
      <th>4</th>
      <td>7de8b315b3cb91f3680eb5b88a20dcee</td>
      <td>. Tools required: Adjustable wrench. www.schwi...</td>
      <td>[0.02521163542924525, -0.001772273162437382, -...</td>
    </tr>
  </tbody>
</table>
</div>


変換したベクトルデータは`product_embeddings`にINSERTしていきます。
```python
from pgvector.asyncpg import register_vector
import numpy as np

await register_vector(conn)

for index, row in product_embeddings.iterrows():
    await conn.execute(
        "INSERT INTO product_embeddings (product_id, content, embedding) VALUES ($1, $2, $3)",
        row["product_id"],
        row["content"],
        np.array(row["embedding"]),
    )

```
実行後、PostgreSQLに接続して、`product_embeddings`のデータ数をカウントして、データが追加されていることを確認します。
```sql
vector_db=# SELECT count(*) FROM  product_embeddings ;

 count 

-------

  2671

(1 row)
```


以上で、テキストデータをベクトルに変換して、PostgreSQL上にあるテーブルへの保存が完了し環境構築が終了しました。

# データを検索してみる

続いては、データベースにクエリを投げて、動作を確認してみます。
pgvectorでは、L2距離(`<->`),内積(`<#>`),コサイン距離(`<=>`)によるベクトル距離比較がサポートされています。
それぞれの距離比較の特徴については、前述した[PostgreSQL Conference Japan 2023 発表資料](https://www.slideshare.net/nttdata-tech/postgresql-pgvector-chatgpt-pgcon23j-nttdata)で大変わかりやすくまとめらています。
関心のある方は、当該資料をご確認ください。

今回は、Google Cloud blog内でも使用していたコサイン距離(`<=>`)によるベクトル距離比較を実行してみます。
`<=>`でベクトル間のコサイン距離を比較することで、ベクトルの向きの類似度を算出できます。
コサイン類似度はドュメント検索でよく利用されます。

検索する条件として、"Do you have a beach toy set that teaches numbers and letters to kids?"というワードをベクトルに変換し、コサイン類似度を比較します。


```python
user_query = "Do you have a beach toy set that teaches numbers and letters to kids?"  

vector = embeddings.embed_query(user_query)
print (vector)
```

`print (vector)`の結果を確認すると、以下のようなベクトルデータに変換されています。
```
[-0.008257456479335568, -0.01240224910278451, 0.008906485896301887, -0.022902392836960633, -0.029688286941888034, 0.04032980079236429, -0.011476899991323739, -0.0008803669276621656, -0.00988966921081363, 0.0012844038981710644, 0.0059183791965176005, 0.0216300366445488, ...]
```

## コサイン類似度を使ったベクトル検索
以下のようなSQLクエリを作成して、検索を実行します。
```sql
--$1 ベクトルデータ
--$2 検索時の閾値条件
--$3 取得するデータ数

WITH vector_matches AS (
	  SELECT 
	    product_id, 
	    MAX(1 - (embedding <=> $1)) AS similarity
	  FROM product_embeddings
	  WHERE 1 - (embedding <=> $1) > $2
	  GROUP BY product_id
	  ORDER BY similarity DESC
	  LIMIT $3
	)
	SELECT 
	  products.product_id, 
	  products.product_name, 
	  products.list_price, 
	  products.description,
	  vector_matches.similarity
	FROM 
	  products 
	JOIN 
	  vector_matches ON products.product_id = vector_matches.product_id
	ORDER BY 
	  vector_matches.similarity DESC;
```

質問文のベクトルデータは`vector`変数内にあるため、Jupyter Notebook側からSQLを実行します。
```python
matches = []
similarity_threshold = 0.1
num_matches = 50
results = await conn.fetch(
    """
	WITH vector_matches AS (
	  SELECT 
	    product_id, 
	    MAX(1 - (embedding <=> $1)) AS similarity
	  FROM product_embeddings
	  WHERE 1 - (embedding <=> $1) > $2
	  GROUP BY product_id
	  ORDER BY similarity DESC
	  LIMIT $3
	)
	SELECT 
	  products.product_id, 
	  products.product_name, 
	  products.list_price, 
	  products.description,
	  vector_matches.similarity
	FROM 
	  products 
	JOIN 
	  vector_matches ON products.product_id = vector_matches.product_id
	ORDER BY 
	  vector_matches.similarity DESC;
    """,
    vector,
    similarity_threshold,
    num_matches,
)

if len(results) == 0:
    raise Exception("Did not find any results. Adjust the query parameters.")

for r in results:
    matches.append(
        {
            "product_id": r["product_id"],
            "product_name": r["product_name"],
            "description": r["description"],
            "list_price": round(r["list_price"],2),
            "similarity": r["similarity"]
        }
    )

matches = pd.DataFrame(matches)
matches.head(5)
```

上位5つには以下のような結果を得ることができました。
(similarityの値が1に近ければ近いほど似ていると判断する)

<div>
<style scoped>
    .dataframe tbody tr th:only-of-type {
        vertical-align: middle;
    }

    .dataframe tbody tr th {
        vertical-align: top;
    }

    .dataframe thead th {
        text-align: right;
    }
</style>
<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th></th>
      <th>product_id</th>
      <th>product_name</th>
      <th>description</th>
      <th>list_price</th>
      <th>similarity</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>0</th>
      <td>20e597d8836d9e5fa29f2bd877fc3e0a</td>
      <td>CoolSand Learning Sand Molds and Tools Kit (44 Pcs) - Wo...</td>
      <td>Start Your Kids Creativity Now! Treat your little ones ...</td>
      <td>31.00</td>
      <td>0.874993</td>
    </tr>
    <tr>
      <th>1</th>
      <td>a57aced63b89c8b5f0a53280c10e47d1</td>
      <td>Beach Toys Mega Pack - Bulk Beach &amp; Sandbox Play Set w/ ...</td>
      <td>Keep them busy at the beach?while molding their minds! A...</td>
      <td>10.99</td>
      <td>0.872064</td>
    </tr>
    <tr>
      <th>2</th>
      <td>5a91b29911ab59c8de7668df262e7231</td>
      <td>Bisontec Beach Toy Playset With Wheelbarrow (Colors May ...</td>
      <td>Toy Playset Beach Toy Great Gift for Ages 3 and up. This...</td>
      <td>14.48</td>
      <td>0.860930</td>
    </tr>
    <tr>
      <th>3</th>
      <td>e87c6524f0b88aab70f11d0234f74cdf</td>
      <td>Kids Beach Sand Toys Set for Gift with Sand Molds,Mesh B...</td>
      <td>Educational: Because sand is an excellent tool for enha...</td>
      <td>22.99</td>
      <td>0.860120</td>
    </tr>
    <tr>
      <th>4</th>
      <td>445108acb8d2304938de7835b4f8b1fd</td>
      <td>Beach Toys Bucket And Shovel Set - 8 Piece In Zipper Bag...</td>
      <td>Are you looking for a perfect baby toy play set for beac...</td>
      <td>19.99</td>
      <td>0.859285</td>
    </tr>
  </tbody>
</table>
</div>


## WHERE句でフィルタリングした検索
上記で実行したクエリについて、WHERE句で条件を指定してすることも可能です。
今回は、`products`テーブルにある料金データ`list_price`で20ドル以上100ドル以下という条件で検索してみます。
```sql
--$4が最低金額(20ドル)/$5が最高金額(100ドル)
WITH vector_matches AS (
  SELECT 
	product_id, 
	MAX(1 - (embedding <=> $1)) AS similarity
  FROM product_embeddings
  WHERE 1 - (embedding <=> $1) > $2
  GROUP BY product_id
  ORDER BY similarity DESC
  LIMIT $3
)
SELECT 
  products.product_id, 
  products.product_name, 
  products.list_price, 
  products.description,
  vector_matches.similarity
FROM 
  products 
JOIN 
  vector_matches ON products.product_id = vector_matches.product_id
WHERE list_price >= $4 AND list_price <= $5 
ORDER BY 
  vector_matches.similarity DESC;
```

`results`内のクエリを変更して実施。
```python
matches = []
results = await conn.fetch(
    """
WITH vector_matches AS (
  SELECT 
    product_id, 
    MAX(1 - (embedding <=> $1)) AS similarity
  FROM product_embeddings
  WHERE 1 - (embedding <=> $1) > $2
  GROUP BY product_id
  ORDER BY similarity DESC
  LIMIT $3
)
SELECT 
  products.product_id, 
  products.product_name, 
  products.list_price, 
  products.description,
  vector_matches.similarity
FROM 
  products 
JOIN 
  vector_matches ON products.product_id = vector_matches.product_id
WHERE list_price >= $4 AND list_price <= $5
ORDER BY 
  vector_matches.similarity DESC;

    """,
    vector,
    similarity_threshold,
    num_matches,
    min_price,
    max_price,
)

if len(results) == 0:
    raise Exception("Did not find any results. Adjust the query parameters.")

for r in results:
    matches.append(
        {
            "product_id": r["product_id"],
            "product_name": r["product_name"],
            "description": r["description"],
            "list_price": round(r["list_price"],2),
            "similarity": r["similarity"],
        }
    )

matches = pd.DataFrame(matches)
matches.head(5)
```

`list_price"`の金額が、指定した範囲のものになっていることが確認できます。

<div>
<style scoped>
    .dataframe tbody tr th:only-of-type {
        vertical-align: middle;
    }

    .dataframe tbody tr th {
        vertical-align: top;
    }

    .dataframe thead th {
        text-align: right;
    }
</style>
<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th></th>
      <th>product_id</th>
      <th>product_name</th>
      <th>description</th>
      <th>list_price</th>
      <th>similarity</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>0</th>
      <td>20e597d8836d9e5fa29f2bd877fc3e0a</td>
      <td>CoolSand Learning Sand Molds and Tools Kit (44 Pcs) - Wo...</td>
      <td>Start Your Kids Creativity Now! Treat your little ones ...</td>
      <td>31.00</td>
      <td>0.874993</td>
    </tr>
    <tr>
      <th>1</th>
      <td>e87c6524f0b88aab70f11d0234f74cdf</td>
      <td>Kids Beach Sand Toys Set for Gift with Sand Molds,Mesh B...</td>
      <td>Educational: Because sand is an excellent tool for enha...</td>
      <td>22.99</td>
      <td>0.860120</td>
    </tr>
    <tr>
      <th>2</th>
      <td>c29104ac1a4e0faeabae7c8a32e9957b</td>
      <td>American Plastic Toys Sand and Water Play Table</td>
      <td>American Plastic Toys Sand and Water Play Table will pro...</td>
      <td>24.99</td>
      <td>0.852799</td>
    </tr>
    <tr>
      <th>3</th>
      <td>1d15aa5cceefa670f0cc83de517dc4f9</td>
      <td>Melissa &amp; Doug Jumbo ABC-123 Rug (58 x 79 inches – 36 Ga...</td>
      <td>Kids will have jumbo amounts of fun exploring and learni...</td>
      <td>54.99</td>
      <td>0.851722</td>
    </tr>
    <tr>
      <th>4</th>
      <td>2eeba67dba7c262da27f05622fc4b705</td>
      <td>Soft Neon 18 Piece Playset</td>
      <td>10 months &amp; up. An ideal toy for every stage of developm...</td>
      <td>59.95</td>
      <td>0.831987</td>
    </tr>
  </tbody>
</table>
</div>

## 全文検索(キーワード検索)と組み合わせて検索
PostgreSQLには、全文検索機能が備わっています。
キーワードを問い合わせて、文書内に該当する文章が存在するかを検索できます。

PostgreSQLに組み込まれている全文検索用の関数のうち、ユーザーが一般的なウェブ検索クエリのスタイルで入力することができる`websearch_to_tsquery`関数を今回は使用します。
キーワード検索する内容として、`3　years`というテキストを含むもの検索します。

PostgreSQLの全文検索クエリ例
```sql
-- $1 は'3 years'
 SELECT 
    product_id, 
    product_name, 
    list_price, 
    description,
    ts_rank_cd(to_tsvector('english', description), websearch_to_tsquery('english', $1)) AS rank
FROM 
    products 
WHERE
    to_tsvector('english', description) @@ websearch_to_tsquery('english', '$1')
ORDER BY 

```

試しに、ベクトル検索は使用せずに、全文検索のみを実行してみます。
```sql
mmatches = []
keyword = '3 years'
full_text_results = await conn.fetch(
    """
  SELECT 
    product_id, 
    product_name, 
    list_price, 
    description,
    ts_rank_cd(to_tsvector('english', description), websearch_to_tsquery('english', $1)) AS rank
FROM 
    products 
WHERE
    to_tsvector('english', description) @@ websearch_to_tsquery('english', '$1')
ORDER BY 
    rank DESC
    """,
    keyword
)

# 結果の表示
for r in full_text_results:
    matches.append(
        {
            "product_id": r["product_id"],
            "product_name": r["product_name"],
            "description": r["description"],
            "list_price": round(r["list_price"],2),
            "rank": r["rank"],
        }
    )
print("全文検索による絞り込み:")
print(len(matches))

matches = pd.DataFrame(matches)
matches.head()
```

`print(len(matches))`で絞り込んだデータ数を表示しており、313件となっています。
```
全文検索による絞り込み:
313
```

絞り込まれたデータを出力してみたところ以下のような結果になりました。

<div>
<style scoped>
    .dataframe tbody tr th:only-of-type {
        vertical-align: middle;
    }

    .dataframe tbody tr th {
        vertical-align: top;
    }

    .dataframe thead th {
        text-align: right;
    }
</style>
<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th></th>
      <th>product_id</th>
      <th>product_name</th>
      <th>description</th>
      <th>list_price</th>
      <th>rank</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>0</th>
      <td>501257f1b3df0af591d939f53692cf77</td>
      <td>Ktaxon Tree Swing Disc Rope Swing 6.5ft - with Leg Safet...</td>
      <td>FUNNY USE. 6.5ft high performance polyethylene rope wit...</td>
      <td>25.59</td>
      <td>0.415703</td>
    </tr>
    <tr>
      <th>1</th>
      <td>45de24a6c20856ed2627b66995da7318</td>
      <td>OTVIAP Swimming Pool Toy,4pcs Swimming Pool Toys Mine Sh...</td>
      <td>Description: This swimming toy will bring lots of fun i...</td>
      <td>10.68</td>
      <td>0.304350</td>
    </tr>
    <tr>
      <th>2</th>
      <td>7e52aee88a6a6a0764cb68a69b2d1dc0</td>
      <td>Mini Lucky Jackpot Slot Machine Fun Gift Fruit Slot Mach...</td>
      <td>Mini Lucky Jackpot Slot Machine Fun Gift Fruit Slot Mach...</td>
      <td>10.55</td>
      <td>0.205263</td>
    </tr>
    <tr>
      <th>3</th>
      <td>8195e328afe639ae85e728b380e38e5b</td>
      <td>Kids Indoor Inflatable Large Castle Sand Box Sandbox Sli...</td>
      <td>60*45cm Kids IndoorInflatable Large Castle Sand Box San...</td>
      <td>9.24</td>
      <td>0.200885</td>
    </tr>
    <tr>
      <th>4</th>
      <td>f933a3aab20a8739aa70e9d1f9ba278f</td>
      <td>7Pcs Two Colors Polyhedral Dice with Free Pouches for Du...</td>
      <td>Specifications: Material: Acrylic Side Size: 16mm to 20...</td>
      <td>7.23</td>
      <td>0.200813</td>
    </tr>
  </tbody>
</table>
</div>



上記の絞り込んだ結果に対して、ベクトル検索をすることで、キーワード検索と組み合わせてみます。

以下のように、全文検索を実施した結果をJOIN句で結合して、ベクトル検索を実施します。
```sql
SELECT 
	p.product_id, 
	MAX(p.rank) AS rank,
	MAX(1 - (pe.embedding <=> $1)) AS similarity
FROM 
	product_embeddings pe
JOIN 
(
  SELECT 
	product_id,
	ts_rank_cd(to_tsvector('english', description), websearch_to_tsquery('english', $4)) AS rank
  FROM 
	products 
  WHERE 
	to_tsvector('english', description) @@ websearch_to_tsquery('english', $4)
) AS p ON pe.product_id = p.product_id
WHERE 
	1 - (pe.embedding <=> $1) > $2
GROUP BY 
	p.product_id
ORDER BY 
	similarity DESC
LIMIT $3

```

実行する内容
```python
matches = []
keyword = '3 years'

results = await conn.fetch(
    """
WITH vector_matches AS (
  SELECT 
    p.product_id, 
    MAX(p.rank) AS rank,
    MAX(1 - (pe.embedding <=> $1)) AS similarity
  FROM 
    product_embeddings pe
  JOIN 
    (
      SELECT 
        product_id,
        ts_rank_cd(to_tsvector('english', description), websearch_to_tsquery('english', $4)) AS rank
      FROM 
        products 
      WHERE 
        to_tsvector('english', description) @@ websearch_to_tsquery('english', $4)
    ) AS p ON pe.product_id = p.product_id
  WHERE 
    1 - (pe.embedding <=> $1) > $2
  GROUP BY 
    p.product_id
  ORDER BY 
    similarity DESC
  LIMIT $3
)
SELECT 
  products.product_id, 
  products.product_name, 
  products.list_price, 
  products.description,
  vector_matches.similarity,
  vector_matches.rank
FROM 
  products
JOIN 
  vector_matches ON products.product_id = vector_matches.product_id
ORDER BY 
  similarity DESC, rank DESC
LIMIT $3;

    """,
    vector,
    similarity_threshold,
    num_matches,
    keyword
)

if len(results) == 0:
    raise Exception("Did not find any results. Adjust the query parameters.")

for r in results:
    matches.append(
        {
            "product_id": r["product_id"],
            "product_name": r["product_name"],
            "description": r["description"],
            "list_price": round(r["list_price"],2),
            "similarity": r["similarity"],
            "rank": r["rank"]
        }
    )

matches = pd.DataFrame(matches)
matches.head()
```

検索結果を見ると、2以下の値が先ほど取得したベクトル検索結果と異なっていることが確認できます。
<div>
<style scoped>
    .dataframe tbody tr th:only-of-type {
        vertical-align: middle;
    }

    .dataframe tbody tr th {
        vertical-align: top;
    }

    .dataframe thead th {
        text-align: right;
    }
</style>
<table border="1" class="dataframe">
  <thead>
    <tr style="text-align: right;">
      <th></th>
      <th>product_id</th>
      <th>product_name</th>
      <th>description</th>
      <th>list_price</th>
      <th>similarity</th>
      <th>rank</th>
    </tr>
  </thead>
  <tbody>
    <tr>
      <th>0</th>
      <td>20e597d8836d9e5fa29f2bd877fc3e0a</td>
      <td>CoolSand Learning Sand Molds and Tools Kit (44 Pcs) - Wo...</td>
      <td>Start Your Kids Creativity Now! Treat your little ones ...</td>
      <td>31.00</td>
      <td>0.874993</td>
      <td>0.100000</td>
    </tr>
    <tr>
      <th>1</th>
      <td>e39855540f05c9b350c6b04e6d7b1830</td>
      <td>6Pcs Sand Kids Beach Toys Castle Bucket Spade Shovel Rak...</td>
      <td>6Pcs Sand Kids Beach Toys Castle Bucket Spade Shovel Ra...</td>
      <td>10.98</td>
      <td>0.846966</td>
      <td>0.100000</td>
    </tr>
    <tr>
      <th>2</th>
      <td>4fb5495d3825cd87fc1475af962a6eb7</td>
      <td>11pc Kids Beach Toys Set Molds Tools, Sandbox Toys On Su...</td>
      <td>11pc KidsBeach Toys Set Molds Tools, Sandbox Toys On Su...</td>
      <td>12.55</td>
      <td>0.840797</td>
      <td>0.009221</td>
    </tr>
    <tr>
      <th>3</th>
      <td>2eeba67dba7c262da27f05622fc4b705</td>
      <td>Soft Neon 18 Piece Playset</td>
      <td>10 months &amp; up. An ideal toy for every stage of developm...</td>
      <td>59.95</td>
      <td>0.831987</td>
      <td>0.200000</td>
    </tr>
    <tr>
      <th>4</th>
      <td>0e08271603b53b963f81dbdd02f989a9</td>
      <td>Spiralin Seas Waterpark</td>
      <td>The Spiralin Seas Waterpark provides toddlers a splashin...</td>
      <td>41.98</td>
      <td>0.829780</td>
      <td>0.201538</td>
    </tr>
  </tbody>
</table>
</div>

# まとめ
pgvectorでPostgreSQLにベクトルデータを入れることで、
ベクトルデータによる検索と既存のデータベースに登録している情報を使用したフィルタリングや、全文検索と組み合わせた検索を行うことができました。
条件の絞り込みやテーブルのJOINなど、RDBMSの機能も合わせて使用できるのがpgvectorならではの特徴だと思います。

今回はデモ環境に取り込んだデータ数が850程度と少なく、とりあえず動かしみるというところを目的としていたため、インデックスの設定などを行っておりません。
pgvectorはベクトル用のインデックスであるIVFFlat,HNSWが用意されており、うまく設定することで検索性能に大きく影響します。

今後は、大規模なデータセットでインデックスを追加等の検証も行っていきたいと考えています。

Advent Calendar2023に最後までお付き合いいただき、ありがとうございました！

# 参考
[pgvector](https://github.com/pgvector/pgvector)
[pgvectorを使ってChatGPTとPostgreSQLを連携してみよう！（PostgreSQL Conference Japan 2023 発表資料）](https://www.slideshare.net/nttdata-tech/postgresql-pgvector-chatgpt-pgcon23j-nttdata)
[VECTOR DATABASES - PGVECTOR AND LANGCHAIN](https://bugbytes.io/posts/vector-databases-pgvector-and-langchain/)
[pgvector、LLM、LangChain を使用して Google Cloud データベースで AI 搭載アプリを構築する](https://cloud.google.com/blog/ja/products/databases/using-pgvector-llms-and-langchain-with-google-cloud-databases)