メタデータを使ったベクトルQA
概要
ChatGPT等LLMでQAする場合に、外部データを参照させ、それに基づいて回答させる要求・シーンは多いと思います。その際QAコンテクストの一部として、テキスト本文以外のメタデータも加味したい場合があります。
本項では例として、社内チャットツールを考えます。メタデータとしては、投稿ユーザ、投稿日時、投稿チャネル等が考えられます。
動作確認バージョン
- python 3.10.11
- langchain 0.0.218
- openai 0.27.8
langchainは、本件で使用するモジュール群も含めて、執筆現在(2023/06)も頻繁に改修が発生しています。
例えば今回使用した、langchain.vectorstores.chroma.Chroma クラスのメソッドも、0.0.205 => 0.0.218でかなり変わっていました。その点ご留意お願いします。
模擬データを準備
最近はテストデータを生成AIに作らせる場合も増えているようです。
今回は下記条件で、GPT4に社内チャットデータを作ってもらいました(prompt省略)。
- ユーザ数:5
- チャネル数:5
- 期間:2023/06/26-30の5日間
- メッセージ数:各ユーザが毎日5件投稿
dummy_chat.csv(データ抜粋)
message_id | user | dep | channel | timestamp | message |
---|---|---|---|---|---|
90 | 佐々木 | バックオフィス | 雑談チャネル | 2023-06-29 13:00:00 | 今日のランチは焼き魚定食でした。美味しかったです。 |
91 | 佐藤 | 開発 | 開発チャネル | 2023-06-29 14:45:40 | ProjectYの新機能開発、予定通り進んでいます。 |
92 | 田中 | 開発 | 開発チャネル | 2023-06-29 15:00:00 | APIの基本的な実装が完了しました。テストとフィードバックをお願いします。 |
93 | 佐々木 | バックオフィス | バックオフィスチャネル | 2023-06-29 15:00:00 | 田中さん、開発部のクラウドサービス料金について最終的な数字を教えていただけますか? |
94 | 高橋 | 営業 | 営業チャネル | 2023-06-29 15:45:00 | クライアントHとのミーティングも終了しました。彼らは新製品のローンチに非常に興奮していました。 |
95 | 鈴木 | バックオフィス | バックオフィスチャネル | 2023-06-29 15:45:40 | 来月のスケジュールを確定しました。詳細は後ほど共有します。 |
96 | 田中 | 開発 | 開発チャネル | 2023-06-29 18:00:00 | 明日はエンドツーエンドのテストを行います。よろしくお願いします。 |
97 | 佐々木 | バックオフィス | バックオフィスチャネル | 2023-06-29 18:00:00 | 明日は最終的な決算報告書の作成に入ります。よろしくお願いします。 |
98 | 高橋 | 営業 | 営業チャネル | 2023-06-29 18:30:00 | 明日はクライアントIとJへの訪問を予定しています。何か情報があれば教えてください。 |
promptが単純なので、ユーザ間対話などはありませんが、見ているだけで会社の様子が浮かんできて愉しめます。この会社では雑談チャネルには昼食のことを書くようです。
模擬DB準備
今回の実験では、後段でlangchainでベクトルQAを行うため、チャットデータをlangchain Documentオブジェクトに変換します。
import pandas as pd
from langchain.schema import Document
from datetime import datetime
df = pd.read_csv("../data/dummy_chat.csv")
documents = []
for idx, row in df.iterrows():
metadata = {
"source": row["message_id"],
"channel": row["channel"],
"user": row["user"],
"timestamp": datetime.strptime(row["timestamp"], '%Y-%m-%d %H:%M:%S').timestamp()
}
documents.append(Document(page_content=row["message"], metadata=metadata))
続いて、このDocumentデータを、in-memory DBであるchromaに格納します。
※ APIキーについては、環境変数OPENAI_API_KEYで設定しているため省略
from langchain.vectorstores import Chroma
from langchain.embeddings import OpenAIEmbeddings
from langchain.text_splitter import CharacterTextSplitter
text_splitter = CharacterTextSplitter(chunk_size=500, chunk_overlap=0)
texts = text_splitter.split_documents(documents)
embeddings = OpenAIEmbeddings()
db = Chroma.from_documents(texts, embeddings)
ちなみにこの状態でDBとして検索などもできます。
db.get(where={"source": 1})
output:
{'ids': ['ac54756b-15a2-11ee-88db-534e57000000'],
'embeddings': None,
'documents': ['今週のスケジュールを共有します。明日は給与計算、明後日は物品発注を行います。'],
'metadatas': [{'source': 1,
'channel': 'バックオフィスチャネル',
'user': '鈴木',
'timestamp': 1687737600.0}]}
(メタデータを用いない)通常のQA
先にQA回答を整形出力する関数を作っておきます。
sourcesには、回答時に参照したDocumentのmetadataのsource(今回はmessage_id)がカンマ区切りで入ってきますが、DBを検索してメッセージ本文に変換しています。
from typing import TypedDict
class Response(TypedDict):
answer: str
sources: str # sourceカンマ区切り
def format_answer(response: Response) -> str:
"""RetrievalQAWithSourcesChainレスポンスを整形する"""
res = ["answer:", response['answer'], "source:"]
for source_id in response["sources"].split(","):
res.append("- " + db.get(where={"source": int(source_id.strip())})["documents"][0])
return "\n".join(res)
QAを実行してみます。
from langchain.chat_models import ChatOpenAI
from langchain.chains import RetrievalQAWithSourcesChain
qna = RetrievalQAWithSourcesChain.from_chain_type(
llm=ChatOpenAI(temperature=0.0, model="gpt-4"), chain_type="stuff", retriever=db.as_retriever())
res = qna({"question": "昼食は何が人気?"}, return_only_outputs=True)
print(format_answer(res))
output:
answer:
ランチには冷やし中華や寿司が人気のようです。
source:
- 今日のランチは冷やし中華でした。暑い日には最高ですね。
- ラーメンより今日は寿司がいいな。ランチ一緒にどうですか?
メタデータを用いたQA:具体例
メタデータ(あるいはコンテキスト)が重要になるQAとは、例えば佐々木さんがするこんな質問です。
(私は)田中さんに昨日何を依頼してたっけ?
QA自体も参照メッセージと同じプラットフォーム(例:slack)上で来ると想定すると、詳細にはこんな感じです。
投稿ユーザ:佐々木
メッセージ:私は田中さんに昨日何を依頼してたっけ?
投稿日時:2023/06/30 18:00:00
このメッセージに回答するための材料をDBから集めてくるわけですが、このメッセージをそのままベクトルQAにかけても「私」「昨日」の意味が失われるので成功しません。
「私=>佐々木」「昨日=> 6/29」であることを解読し、それをDB検索条件に反映する必要があります。
この処理をLLMで実現してみます。
メタデータを用いたQA:function calling
今回はOpenAI-APIのfunction callingを使います。
適切な条件がそろえばOpenAI-APIから、このフィルタ生成関数を呼ぶ指示が、引数と共に返ってくるはずです。
今回の目的はその引数(=フィルタ条件)ですので、実際にはcreate_filterという関数を作る必要はありません。
function:
functions=[
{
"name": "create_filter",
"description": "ユーザの求める情報を検索する",
"parameters": {
"type": "object",
"properties": {
"user": {
"type": "string",
"description": "発言者",
},
"timestamp": {
"type": "string",
"description": "発言日時(yyyy-MM-dd HH:mm:ss形式)",
},
"channel": {
"type": "string",
"description": "発言チャネル",
},
},
"required": [],
},
},
]
メタデータを用いたQA:フィルタ生成
ユーザが本QAシステムに投げるメッセージ例
query = {
"user": "佐々木",
"timestamp": "2023-06-30 18:00:00",
"message": "私は田中さんに昨日何を依頼してたっけ?"
}
ここからコンテクストを取り出してfunction callingへ誘導します。
import openai
import json
response = openai.ChatCompletion.create(
model="gpt-4",
messages=[
{
"role": "system",
"content": f"""
この後にユーザが質問します。あなたはその回答材料を発言データベースから探すAIです。
発言データベースは以下の属性のデータの集合で、全ユーザの発言を含みます。
- 発言者(user)
- 発言日時(timestamp)
- 発言チャネル(channel)
- 発言内容(message)
ユーザの求める情報をデータベースから検索するためのフィルタ条件を生成してください。
- フィルタ可能条件:発言者、日時、チャネル
- 存在するユーザ:鈴木、高橋、佐藤、佐々木、田中
あなたに質問するユーザの情報:
- ユーザ名:{query['user']}
- 現在日時:{query['timestamp']}
"""},
{"role": "user", "content": query['message']},
],
functions=functions,
function_call="auto",
)
filters = json.loads(response["choices"][0]["message"]["function_call"]["arguments"])
print(f"フィルタ条件:{filters}")
output:
フィルタ条件:{'user': '佐々木', 'timestamp': '2023-06-29'}
※ 今回timestampの計算をLLMに任せてしまいましたが、そこは本来保証できないため、function callingを使う等して呼び出し側で実行すべきです。
メタデータを用いたQA:フィルタ整形
今回metadata > timestampはfloatで格納しているため、それに合うように整形します。
参考:chromaにおけるmetadata検索条件の指定方法
from datetime import datetime, timedelta
dt = datetime.strptime(filters["timestamp"], '%Y-%m-%d')
_filters = {
"$and": [
{"user": filters["user"]},
{
"timestamp": {"$gte": dt.timestamp()}
},
{
"timestamp": {"$lt": (dt + timedelta(days=1)).timestamp()}
}
]
}
print(_filters)
output:
{
'$and': [
{'user': '佐々木'},
{'timestamp': {'$gte': 1687964400.0}},
{'timestamp': {'$lt': 1688050800.0}}
]
}
メタデータを用いたQA:実行
整形したfiltersをdb.as_retriever()
でセットしてQAを実行します。
from langchain.schema import SystemMessage
qna = RetrievalQAWithSourcesChain.from_chain_type(
llm=ChatOpenAI(temperature=0.0, model="gpt-4"),
chain_type="stuff",
retriever=db.as_retriever(search_kwargs = {'filter': _filters})
)
res = qna({
"question": "私は田中さんに昨日何を依頼してたっけ?",
"chat_history": [
SystemMessage(content=f"質問者は{query['user']}さんです。{query['user']}さんの発言を添付します"),
]
}, return_only_outputs=True)
print(format_answer(res))
output:
answer:
あなたは田中さんに開発部のクラウドサービス料金について最終的な数字を教えてもらうよう依頼しました。
source:
- 田中さん、開発部のクラウドサービス料金について最終的な数字を教えていただけますか?
メタデータ=>フィルタ変換性能を確認
質問内容を少し変えるだけでコンテキストが一変します。こんな感じです。
query = {
"user": "佐々木",
"timestamp": "2023-06-30 18:00:00",
"message": "私は田中さんに昨日何を依頼されたっけ?"
}
依頼したのは田中さんなので、フィルタ条件もuser: 田中
になる必要があります。
query変数を変えて再実行してみます。
output:
フィルタ条件:{'user': '田中', 'timestamp': '2023-06-29'}
無事変更されました!
補足
- 今実験ではmetadataプロパティ(ユーザ名、チャネル名等)を正規化せず登録したが、実際のチャットデータではID化されるため対応が必要
- 今実験はデータベースとしてin-memory Chromaを使ったが、
db
生成の部分を微修正することで、postgres(+pgvector)その他各データベースに対応可(langchainが吸収) - 「田中さんに」依頼した旨がメッセージ本文にあったので今回は検索に成功したが、実際のチャットツールではmentionが多いはずなので、別途対応が必要
- これを考えていくとfunction callingでチャットツールのAPI(例:slack-api)に誘導する話も出てくる
- function callingは
openai
モジュールを使ったがlangchain経由でも可能なはず
Discussion