🦔

メタデータを使ったベクトルQA

2023/06/29に公開

概要

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検索条件の指定方法
https://docs.trychroma.com/usage-guide

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経由でも可能なはず
株式会社Algomatic

Discussion