LangChain+PGVectorの環境でメタデータに基づいたDocument削除を行う

- タイトルの件についての記事です
- AIで生成したまとめ、その後に本文(まとめの元ネタである自分の文章)という構成になっています

## OpenAI（GPT-4o）によるまとめ
### 経緯
LangChainのバージョン0.2.14を使用し、VectorStoreとしてPostgreSQLとPGVectorを組み合わせた環境を前提としています。
この環境で、ドキュメント（Document）のメタデータを利用して特定のドキュメントを削除する方法が分からず、
最終的にはデータベースのテーブルに直接アクセスして削除する方法を選択しました。

### 目的
Embeddingの元となったファイルが更新された際に、対応するドキュメントをメタデータ（例：`source: ファイル名`）に基づいて削除し、
新しいドキュメントを追加するプロセスを実現することが目的です。

### 原因
LangChainの公式ドキュメントやGitHubのディスカッションを調べても、メタデータに基づいてドキュメントを削除する方法が明確に記載されていなかったためです。
特に、`similarity_search`メソッドではメタデータによるフィルタリングが可能ですが、フィルタリングされたすべてのドキュメントを返してくれるかどうかが不明でした。

### 構成要素
- **LangChain**: バージョン0.2.14
- **VectorStore**: PostgreSQLとPGVector
- **テーブル**:
  - `langchain_pg_collection`: `uuid`, `name`などの列があり、コレクション名を指定して`uuid`（=collection_id）を取得可能
  - `langchain_pg_embedding`: `id`, `collection_id`, `cmetadata`などの列があり、`collection_id`とメタデータの情報を指定して`id`（=DocumentのID）を取得可能

### 具体的な手順
1. **メタデータの設定**:
   - ドキュメントにメタデータ（例：`source: ファイル名`）を設定する

2. **ドキュメントの削除と追加**:
   - Embedding対象のファイルが更新された場合、メタデータとして`source: 対象ファイル名`を持つドキュメントを削除し、新しいドキュメントを追加する

3. **LangChainの制約**:
   - LangChainの公式ドキュメントでは、ドキュメントIDを指定して削除する方法は記載されていますが、メタデータ内の情報を指定して削除する方法は記載されていません

4. **DBの直接操作**:
   - LangChainの機能でメタデータに基づいたドキュメントの削除方法が不明なため、データベースのテーブルから直接データを削除することにした

5. **テーブル構造**:
   - `langchain_pg_collection`テーブルには`uuid`と`name`の列があり、コレクション名を指定して`uuid`（=collection_id）を取得可能
   - `langchain_pg_embedding`テーブルには`id`, `collection_id`, `cmetadata`の列があり、`collection_id`とメタデータの情報を指定して`id`（=DocumentのID）を取得可能

6. **削除の実行**:
   - 取得した`id`をLangChainの`delete`メソッドに渡し、メタデータに基づいたドキュメント削除を行う

### 参考情報
- [PGVector | LangChain](https://python.langchain.com/v0.2/docs/integrations/vectorstores/pgvector/)
- [How to get IDs of documents stored in postgres pgvector #17361](https://github.com/langchain-ai/langchain/issues/17361)
- [How to get source/Metadata in Pgvector? #17497](https://github.com/langchain-ai/langchain/discussions/17497)

## 本文
### 目的
LangChainのDocumentオブジェクトにはメタデータを設定できます。例えば、Embeddingの元となったファイルの場所（例：`source: ファイル名`）を指定できます。
Embedding対象のファイルが更新された際、対応するメタデータ（例：`source: ファイル名`）を持つドキュメントを削除し、
新しいドキュメントを追加する処理を実現したいと考えています。

### 前提環境
LangChain v0.2.14を使用し、VectorStoreとしてPostgreSQLとPGVectorを組み合わせた環境を前提としています。

### 課題
[PGVector | LangChain](https://python.langchain.com/v0.2/docs/integrations/vectorstores/pgvector/)を参照すると、
ドキュメントIDを指定してドキュメントを削除する方法は記載されていますが、メタデータに基づいて削除する方法は記載されていません。
`similarity_search`メソッドではメタデータによるフィルタリングが可能ですが、フィルタリングされた全てのドキュメントを返してくれるかはよくわかりません。

同様の悩みを抱えている人もいるようです：
- [How to get IDs of documents stored in postgres pgvector #17361](https://github.com/langchain-ai/langchain/issues/17361)
- [How to get source/Metadata in Pgvector? #17497](https://github.com/langchain-ai/langchain/discussions/17497)

目的のドキュメントをすべて削除するにはどうすればよいのでしょうか？  
どうやら、LangChainの機能でメタデータに基づいたドキュメントの削除方法は見当たらないみたいなので、
上記のイシューでも述べられているように、データベースのテーブルから直接データを削除することにしました。

### 対応
LangChainでPGVectorをVectorStoreとして使用する場合、以下のテーブルが作成されます：
- `langchain_pg_collection`：`uuid`、`name`などの列があり、コレクション名を指定して`uuid`（=collection_id）を取得可能です。  
- `langchain_pg_embedding`：`id`、`collection_id`、`cmetadata(=Documentオブジェクトのmetadata)`などの列があります

LangChainでベクトル格納時に指定するコレクションを指定してlangchain_pg_collectionから`collection_id`を取得し、
langchain_pg_embeddingから`collection_id`とメタデータの情報を指定して`id`（=ドキュメントのID）を取得。  
取得したIDをLangChainの`delete`メソッドに渡すことで、メタデータに基づいたドキュメント削除を行うことにしました。

### 検証

#### 環境の準備
[PGVector | LangChain](https://python.langchain.com/v0.2/docs/integrations/vectorstores/pgvector/)を参考にして、PostgreSQLとPGVectorを準備し、ドキュメントを格納します。  
なお、サンプルコードはWindowsおよびPython 3.11の環境で試しています。

##### 必要なパッケージのインストール
まず、必要なパッケージをインストールします。

```bash
pip install langchain langchain_postgres sqlalchemy psycopg-binary openai langchain-openai
```

##### PostgreSQLコンテナの起動
次に、PostgreSQLコンテナを起動します。

```bash
docker run --name pgvector-container -e POSTGRES_USER=langchain -e POSTGRES_PASSWORD=langchain -e POSTGRES_DB=langchain -p 6024:5432 -d pgvector/pgvector:pg16
```

#### ドキュメントを追加し、ベクトル検索を行う

##### サンプルコード

```python
import os
import getpass
from sqlalchemy.orm import Session
from langchain_openai import OpenAIEmbeddings
from langchain_core.documents import Document
from langchain_postgres import PGVector

# 環境変数の設定
os.environ["OPENAI_API_KEY"] = getpass.getpass()

# Embeddingsの初期化
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")

# PostgreSQL接続情報
connection = "postgresql+psycopg://langchain:langchain@localhost:6024/langchain"
collection_name = "my_docs"

# PGVectorの初期化
vector_store = PGVector(
    embeddings=embeddings,
    collection_name=collection_name,
    connection=connection,
    use_jsonb=True,
)

# ドキュメントの追加
docs = [
    Document(page_content="there are cats in the pond", metadata={"source": "file1.txt"}),
    Document(page_content="ducks are also found in the pond", metadata={"source": "file1.txt"}),
    Document(page_content="elephants are also found in the pond", metadata={"source": "file2.txt"}),
]

vector_store.add_documents(docs)

# ベクトル検索
results = vector_store.similarity_search("cats", k=10)
print("search results:")
for doc in results:
    print(f"* {doc.page_content} [{doc.metadata}]")
```

##### 実行結果

```
(venv-langchain-sample1) C:\Users\user\source\repos\venv-langchain-sample1>python sample1.py
Password:
search results:
* there are cats in the pond [{'source': 'file1.txt'}]
* elephants are also found in the pond [{'source': 'file2.txt'}]
* ducks are also found in the pond [{'source': 'file1.txt'}]
```

#### メタデータ`source=file1.txt`のドキュメントIDを取得する

##### サンプルコード

```python
import os
from sqlalchemy.orm import Session
from sqlalchemy import create_engine
from sqlalchemy.sql import text

connection = "postgresql+psycopg://langchain:langchain@localhost:6024/langchain"
collection_name = "my_docs"
metadata_key = "source"
metadata_value = "file1.txt"
engine = create_engine(connection)
with Session(engine) as session:
    stmt = text("select uuid from langchain_pg_collection where name=:name")
    stmt = stmt.bindparams(name=collection_name)
    rows = session.execute(stmt).fetchone()
    if len(rows) == 0:
        exit()
    collection_id = rows[0]
    print(f'collection_id:{collection_id}')
    stmt = text("select id, document, cmetadata from langchain_pg_embedding where collection_id=:collection_id and cmetadata->>:key=:value")
    stmt = stmt.bindparams(collection_id=collection_id, key=metadata_key, value=metadata_value)
    rows = session.execute(stmt).all()
    print('query results:')
    for row in rows:
        print(row)
```

##### 実行結果

```
(venv-langchain-sample1) C:\Users\user\source\repos\venv-langchain-sample1>python sample2.py
collection_id:580c57cb-6bbe-45c8-b525-dfec94862192
query results:
('33c5721f-cb1f-42f0-89e5-3e061babe1f0', 'there are cats in the pond', {'source': 'file1.txt'})
('230211cd-192b-4971-99f9-3484957408ca', 'ducks are also found in the pond', {'source': 'file1.txt'})
```

#### ドキュメントIDを取得してから削除する

##### サンプルコード

```python
import os
import getpass
from sqlalchemy.orm import Session
from sqlalchemy import create_engine
from sqlalchemy.sql import text
from langchain_openai import OpenAIEmbeddings
from langchain_core.documents import Document
from langchain_postgres import PGVector

# 環境変数の設定
os.environ["OPENAI_API_KEY"] = getpass.getpass()

# Embeddingsの初期化
embeddings = OpenAIEmbeddings(model="text-embedding-3-large")

# PostgreSQL接続情報
connection = "postgresql+psycopg://langchain:langchain@localhost:6024/langchain"
collection_name = "my_docs"

# PGVectorの初期化
vector_store = PGVector(
    embeddings=embeddings,
    collection_name=collection_name,
    connection=connection,
    use_jsonb=True,
)

# metadataのsource=file1.txtのドキュメントIDの取得
metadata_key = "source"
metadata_value = "file1.txt"
engine = create_engine(connection)
with Session(engine) as session:
    stmt = text("select uuid from langchain_pg_collection where name=:name")
    stmt = stmt.bindparams(name=collection_name)
    rows = session.execute(stmt).fetchone()
    if len(rows) == 0:
        exit()
    collection_id = rows[0]
    print(f'collection_id:{collection_id}')
    stmt = text("select id, document, cmetadata from langchain_pg_embedding where collection_id=:collection_id and cmetadata->>:key=:value")
    stmt = stmt.bindparams(collection_id=collection_id, key=metadata_key, value=metadata_value)
    rows = session.execute(stmt).all()
    print('query results:')
    for row in rows:
        print(row)
document_ids = [row[0] for row in rows]

# vector_storeのdeleteメソッドを実行
vector_store.delete(document_ids)

# 削除した結果
with Session(engine) as session:
    stmt = text("select uuid from langchain_pg_collection where name=:name")
    stmt = stmt.bindparams(name=collection_name)
    rows = session.execute(stmt).fetchone()
    if len(rows) == 0:
        exit()
    collection_id = rows[0]
    print(f'collection_id:{collection_id}')
    stmt = text("select id, document, cmetadata from langchain_pg_embedding where collection_id=:collection_id and cmetadata->>:key=:value")
    stmt = stmt.bindparams(collection_id=collection_id, key=metadata_key, value=metadata_value)
    rows = session.execute(stmt).all()
    print('query results:')
    for row in rows:
        print(row)
```

##### 実行結果

```
(venv-langchain-sample1) C:\Users\user\source\repos\venv-langchain-sample1>python sample3.py
Password:
collection_id:580c57cb-6bbe-45c8-b525-dfec94862192
delete前のlangchain_pg_embeddingの検索結果:
('33c5721f-cb1f-42f0-89e5-3e061babe1f0', 'there are cats in the pond', {'source': 'file1.txt'})
('230211cd-192b-4971-99f9-3484957408ca', 'ducks are also found in the pond', {'source': 'file1.txt'})
collection_id:580c57cb-6bbe-45c8-b525-dfec94862192
delete後のlangchain_pg_embeddingの検索結果:
```
delete後はsource:file1.txtのデータは0件となった。

### まとめ
- langchain+PGVectorで、langchainのAPIでメタデータの情報に基づいたドキュメント取得、削除する方法がわからず、DBのテーブルから直接データを削除する方法で対処しました。  
- 目的のDocumentを削除することが出来ましたが、この方法がベストなものかどうかは不明で、ほかにも良いやり方や実はlangcnain公式のやり方などがあるのかもしれません。皆様何かご存じであれば、教えていただけると幸いです