Closed12

Semantic Routerを試す

kun432kun432

https://github.com/aurelio-labs/semantic-router

Semantic Router は、LLMとエージェントのための超高速意思決定レイヤーです。低速なLLM世代がツールの使用を決定するのを待つのではなく、私たちはセマンティックベクトル空間の魔法を使って決定を下します。

クエリで処理を分岐させたいようなケースは、Function Callingを使ってLLMにルーティングさせるとかがあると思うのだけど、事前にクエリのサンプルを用意しておいてベクトル検索でルーティングさせるというようなもの。

要はAlexaのインテントルーティングに近いイメージ。

kun432kun432

まずはQuickstartから。notebookもあるけど内容的にはほぼ同じ。Colaboratoryでやる。

https://github.com/aurelio-labs/semantic-router?tab=readme-ov-file#quickstart

https://github.com/aurelio-labs/semantic-router/tree/main/docs/00-introduction.ipynb

パッケージインストール。

pip install -qU semantic-router

「ルート」を設定する。ここでは2つのルートを設定する。

from semantic_router import Route

# 政治に関連するルート
politics = Route(
    name="politics",
    utterances=[
        "政治って最高じゃないですか",
        "あなたの政治的意見について教えてください。",
        "大統領を愛していないのか",
        "彼らはこの国を滅ぼすつもりだ!",
        "彼らはこの国を救う!",
    ],
)

# 雑談に関連するルート
chitchat = Route(
    name="chitchat",
    utterances=[
        "今日の天気はどうですか?",
        "調子はどうですか?",
        "今日はいい天気ですね",
        "天気は最悪です",
        "フィッシュ&チップスを食べに行こう",
    ],
)

# ルートをまとめる
routes = [politics, chitchat]

Embeddingで使用するモデルを初期化する。これをSemantic Routerでは「エンコーダー」と呼ぶみたい。今回はOpenAIを使う。他にはCohereや、あとHuggingFaceやllama.cppなどのローカルモデルにも対応している様子。

from google.colab import userdata
import os
from semantic_router.encoders import OpenAIEncoder

os.environ["OPENAI_API_KEY"] = userdata.get('OPENAI_API_KEY')

encoder = OpenAIEncoder()

「ルート」と「エンコーダー」を紐づけて、分岐判断を行う「ルートレイヤー」を初期化する。

from semantic_router.layer import RouteLayer

rl = RouteLayer(encoder=encoder, routes=routes)

では実際にクエリを投げてみて、どちらのルートに分岐するのかを確認してみる。

rl("政治は好き?").name
RouteChoice(name='politics', function_call=None, similarity_score=None)
rl("なにか楽しいことはないかな?")
RouteChoice(name='chitchat', function_call=None, similarity_score=None)

予め用意されたルートに分岐判断しているように見える。

どちらのルートに合致しないようなものについて聞いてみる。

rl("LLMに付いて興味がある")
RouteChoice(name=None, function_call=None, similarity_score=None)

Noneとなっている。fallback的な判断もできそう。

あと、function_callとかsimilarity_scoreとか気になるなぁ。

kun432kun432

ルートレイヤーの設定は、ファイルに出力もできる。それを読み込んでルートレイヤーを作成することもできる。上の続きから。

https://github.com/aurelio-labs/semantic-router/tree/main/docs/01-save-load-from-file.ipynb

rl.to_json("layer.json")

こういう内容。

layer.json
{
    "encoder_type": "openai",
    "encoder_name": "text-embedding-ada-002",
    "routes": [
        {
            "name": "politics",
            "utterances": [
                "\u653f\u6cbb\u3063\u3066\u6700\u9ad8\u3058\u3083\u306a\u3044\u3067\u3059\u304b",
                "\u3042\u306a\u305f\u306e\u653f\u6cbb\u7684\u610f\u898b\u306b\u3064\u3044\u3066\u6559\u3048\u3066\u304f\u3060\u3055\u3044\u3002",
                "\u5927\u7d71\u9818\u3092\u611b\u3057\u3066\u3044\u306a\u3044\u306e\u304b",
                "\u5f7c\u3089\u306f\u3053\u306e\u56fd\u3092\u6ec5\u307c\u3059\u3064\u3082\u308a\u3060\uff01",
                "\u5f7c\u3089\u306f\u3053\u306e\u56fd\u3092\u6551\u3046\uff01"
            ],
            "description": null,
            "function_schema": null,
            "llm": null,
            "score_threshold": 0.82
        },
        {
            "name": "chitchat",
            "utterances": [
                "\u4eca\u65e5\u306e\u5929\u6c17\u306f\u3069\u3046\u3067\u3059\u304b\uff1f",
                "\u8abf\u5b50\u306f\u3069\u3046\u3067\u3059\u304b\uff1f",
                "\u4eca\u65e5\u306f\u3044\u3044\u5929\u6c17\u3067\u3059\u306d",
                "\u5929\u6c17\u306f\u6700\u60aa\u3067\u3059",
                "\u30d5\u30a3\u30c3\u30b7\u30e5&\u30c1\u30c3\u30d7\u30b9\u3092\u98df\u3079\u306b\u884c\u3053\u3046"
            ],
            "description": null,
            "function_schema": null,
            "llm": null,
            "score_threshold": 0.82
        }
    ]
}

今度はこれを読み込んでみる。

rl_new = RouteLayer.from_json("layer.json")
rl_new("政治は好き?")
RouteChoice(name='politics', function_call=None, similarity_score=None)
kun432kun432

https://github.com/aurelio-labs/semantic-router/tree/main/docs/02-dynamic-routes.ipynb

これまでの使い方では、事前に定義したルートからルートレイヤーを作成して、そこにクエリを渡すと該当するルート「名」を返してくれていた。Semantic Routerではこれを「静的」ルート(Static Routes)と呼ぶ。

じゃあ「動的」ルート(Dynamic Routes)もあるのか?、ということなのだけども、Semantic Routerの「動的」ルートは、ルート「名」だけではなくて、「パラメータ」を生成するものらしい。

つまりこれFunction Callingそのものだと思う。「静的」の反対が「動的」と思うと、ちょっとイメージがずれてややこしいな。。。

では関数を用意する。

from datetime import datetime
from zoneinfo import ZoneInfo


def get_time(timezone: str) -> str:
    """
    特定のタイムゾーンの現在時刻を検索する

    :param timezone: 現在時刻を求めるタイムゾーン。
        "America/New_York "や "Europe/London "のように、
        IANAタイムゾーンデータベースの有効なタイムゾーンでなければならない。
        ローマ "や "ニューヨーク "のような地名そのものを書いてはいけない。
        IANAフォーマットで提供すること。
    :type timezone: str
    :return: 指定したタイムゾーンの現在時刻。
    """
    now = datetime.now(ZoneInfo(timezone))
    return now.strftime("%H:%M")

get_time("Asia/Tokyo")
11:32

この関数からFunction Callingのスキーマを作成する。

from semantic_router.utils.function_call import get_schema

schema = get_schema(get_time)
schema
{
    'name': 'get_time',
    'description': '特定のタイムゾーンの現在時刻を検索する\n\n:param timezone: 現在時刻を求めるタイムゾーン。\n    "America/New_York "や "Europe/London "のように、\n    IANAタイムゾーンデータベースの有効なタイムゾーンでなければならない。\n    ローマ "や "ニューヨーク "のような地名そのものを書いてはいけない。\n    IANAフォーマットで提供すること。\n:type timezone: str\n:return: 指定したタイムゾーンの現在時刻。',
    'signature': '(timezone: str) -> str',
    'output': "<class 'str'>"
}

でこのスキーマを使ってルートを定義する。

time_route = Route(
    name="get_time",
    utterances=[
        "ニューヨークは今何時?",
        "ロンドンの時間を教えて",
        "ローマに住んでるんだけど、日本は今何時?",
    ],
    function_schema=schema,
)

ルートレイヤーにこのルートを追加する。

rl.add(time_route)

ではクエリ

out = rl("what is the time in new york city?")
out
RouteChoice(name='get_time', function_call={'timezone': 'America/New_York'}, similarity_score=None)

なるほど、ここで実行すべき関数名と引数が返ってくる。これをそのまま関数に渡せば良い。

get_time(**out.function_call)
22:41

一応、こうやればそのまま実行できる。

locals()[out.name](**out.function_call)

ちょっとStaticに対してDynamicという名前付けはどうなのかな?と思わないでもないけども、Function Callingを抽象化して、ルーティング分岐しやすいようにサンプル発話を追加したようなものだと思えば良さそう。

あと、コードまでは見てないので勝手な推測だけど、Static Routesだと多分Embeddingsだけで完結すると思うのだけど、Function Callingを使う以上はLLMを使うことになると思う。なので、Dynamic Routesを追加した場合はちょっとレスポンスが落ちる気がする。

kun432kun432

LangChainのエージェントと組み合わせた例。

https://github.com/aurelio-labs/semantic-router/tree/main/docs/03-basic-langchain-agent.ipynb

以下のような使い方が想定されている。

  1. エージェントに特定の情報やルートを思い出させるためにルートを使う。
  2. 特定のタイプのクエリに対するガードレールとして機能するルートを使う。
  3. ツールによるエージェントの意思決定プロセスは遅いので、Semantic Routerを使って高速にツールの使い方を決定する
  4. Semantic Routerの動的ルートを使って、ツールの入力パラメーターを生成。
  5. 必要なときだけRAGを行う場合にルートを使って、追加情報を検索するタイミングを決定する

このノートブックで紹介されているのは3。フィットネスアドバイザーのようなアシスタントが例になっている。

LangChainはもうあまり触ってないのだけど、大枠の動きを見るために軽く通してみる。

パッケージインストール

!pip install -qU \
    semantic-router==0.0.20 \
    langchain==0.0.352 \
    openai>=1.6.1

ルートを定義。以下の4つのルート。

  1. 時間に関するルート
  2. サプリメントのブランドに関するルート
  3. 営業に関するルート
  4. 製品に関するルート
from semantic_router import Route

time_route = Route(
    name="get_time",
    utterances=[
        "今何時?",
        "次の食事はいつにしようか?",
        "次のトレーニングまでどれくらい休むべきか?",
        "いつジムに行けばいいのか?",
    ],
)

supplement_route = Route(
    name="supplement_brand",
    utterances=[
        "オプチマムニュートリションをどう思いますか?",
        "マイプロテインで何を買えばいいですか?",
        "どのサプリメントブランドがお勧めですか?",
        "ホエイプロテインはどこで買えばいいですか?",

    ],
)

business_route = Route(
    name="business_inquiry",
    utterances=[
        "1時間のトレーニングはいくらですか?",
        "パック割引はありますか?",
    ],
)

product_route = Route(
    name="product",
    utterances=[
        "ウェブサイトはありますか?",
        "サービスについての詳しい情報はどうすれば見つかりますか?",
        "どこで登録できますか?",
        "どうしたらいいですか?",
        "お勧めのトレーニング・プログラムはありますか?",
    ],
)

routes = [time_route, supplement_route, business_route, product_route]

ルートレイヤーの作成

from google.colab import userdata
import os
from semantic_router.encoders import OpenAIEncoder
from semantic_router.layer import RouteLayer

os.environ["OPENAI_API_KEY"] = userdata.get('OPENAI_API_KEY')

encoder = OpenAIEncoder()
rl = RouteLayer(encoder=encoder, routes=routes)

まず静的ルートの挙動を確認。

print(rl("ONホエイを買うべき?それともMPを買うべき?"))
print(rl("今日の天気は?"))
print(rl("太い腕を手に入れるにはどうすればいい?"))

想定通りに動作している。

name='supplement_brand' function_call=None similarity_score=None trigger=None
name='get_time' function_call=None similarity_score=None trigger=None
name='product' function_call=None similarity_score=None trigger=None

では関数をいくつか定義する。これはFunction Callingで使うわけではなく、クエリからどのルートに分岐するかを判断させて、そのクエリに追加情報を付与する、というもの。

from datetime import datetime
from zoneinfo import ZoneInfo


def get_time():
    now = datetime.now(ZoneInfo("Asia/Tokyo"))
    return f"現在の時刻は {now.strftime('%H:%M')}です。あなたが回答する際にはこの情報を使って下さい。"


def supplement_brand():
    return "あなたは、P100ホエイプロテインという最高の製品を販売する独自のブランド「BigAI」を持っています。"


def business_inquiry():
    return "あなたのトレーニング会社「BigAI PT」は、プレミアム品質のトレーニングセッションをわずか1時間700ドルで提供しています。 詳しくは www.aurelio.ai/train をご覧ください。"


def product():
    return "ユーザは、 www.aurelio.ai/sign-up でフィットネスプログラムに申し込むことができることを覚えておいて下さい"


def semantic_layer(query: str):
    route = rl(query)
    if route.name == "get_time":
        query += f" (SYSTEM NOTE: {get_time()})"
    elif route.name == "supplement_brand":
        query += f" (SYSTEM NOTE: {supplement_brand()})"
    elif route.name == "business_inquiry":
        query += f" (SYSTEM NOTE: {business_inquiry()})"
    elif route.name == "product":
        query += f" (SYSTEM NOTE: {product()})"
    else:
        pass
    return query

クエリを与えて実行してみる。

query = "ONホエイを買うべき?それともMPを買うべき?"
sr_query = semantic_layer(query)
sr_query
ONホエイを買うべき?それともMPを買うべき? (SYSTEM NOTE: あなたは、P100ホエイプロテインという最高の製品を販売する独自のブランド「BigAI」を持っています。)

クエリに、追加情報がSYSTEM NOTEとして追加されているのがわかる。

ではLangChainでエージェントを設定する。

from langchain.agents import AgentType, initialize_agent
from langchain.chat_models import ChatOpenAI
from langchain.memory import ConversationBufferWindowMemory

llm = ChatOpenAI(model="gpt-3.5-turbo-1106", temperature=0.1)

memory1 = ConversationBufferWindowMemory(
    memory_key="chat_history", k=5, return_messages=True, output_key="output"
)
memory2 = ConversationBufferWindowMemory(
    memory_key="chat_history", k=5, return_messages=True, output_key="output"
)

agent = initialize_agent(
    agent=AgentType.CHAT_CONVERSATIONAL_REACT_DESCRIPTION,
    tools=[],
    llm=llm,
    max_iterations=3,
    early_stopping_method="generate",
    memory=memory1,
)

system_message = """\
あなたは、健康とフィットネスに関するユーザージャーニーをサポートするパーソナルトレーナーです。
あなたの仕事は、ユーザからの健康とフィットネスに関する質問に対して日本語で回答をすることです。以下のルールを守って回答して下さい。

- あなたの性格は、熱血感に溢れてフレンドリー、いかにもスポーツマンという性格をしています。そして常におしゃべりで冗談が大好きです。あなたの回答にもこの性格を反映して下さい。
- あなたの回答が簡潔すぎると、ユーザは失望してしまう可能性があります。回答は丁寧な説明を含む適切な長さである必要があります。
- もし、ユーザーからの質問に問い合わせにSYSTEM NOTEが付与されている場合、ここに有益な情報が含まれています。SYSTEM NOTEがある場合は、この内容をかならず踏まえて回答して下さい。
"""

new_prompt = agent.agent.create_prompt(system_message=system_message, tools=[])
agent.agent.llm_chain.prompt = new_prompt

ではこれでエージェントにクエリを送ってみる。クエリだけそのまま送った場合と、先ほど作成したSemantic Routerで追加情報を付加したクエリでどういう違いが出るかを見てみる。それぞれでメモリが分かれるように2つ用意されている。

まず、クエリだけそのまま送った場合。このときの会話メモリはmemory1が使用される。

agent(query)
{
    'input': 'ONホエイを買うべき?それともMPを買うべき?',
    'chat_history': [
    ],
    'output': 'それはあなたの目標によるんだよ!ONホエイは筋肉を増やすのに効果的だし、MPはエネルギーを補充するのにいいんだ。どちらもいい選択だけど、まずは自分の目標を考えてみてね!'
}

次に、Semantic Routerで追加情報を付加したクエリ。メモリをmemory2に切り替えている。

agent.memory = memory2
agent(sr_query)
{
    'input': 'ONホエイを買うべき?それともMPを買うべき? (SYSTEM NOTE: あなたは、P100ホエイプロテインという最高の製品を販売する独自のブランド「BigAI」を持っています。)',
    'chat_history': [
    ],
    'output': 'ONホエイを買うべきです!でも、もしBigAIのP100ホエイプロテインを試してみたいなら、それもいい選択肢ですよ。'
}

クエリが違うので当然回答も違っている。

では続けて別のクエリ藻同じように。

agent.memory = memory1
agent(query)
{
    'input': 'オッケー、ちょうどトレーニングが終わりました。次のトレーニングは何時かな?',
    'chat_history': [
        HumanMessage(content='ONホエイを買うべき?それともMPを買うべき?'),
        AIMessage(content='それはあなたの目標によるんだよ!ONホエイは筋肉を増やすのに効果的だし、MPはエネルギーを補充するのにいいんだ。どちらもいい選択だけど、まずは自分の目標を考えてみてね!')
    ],
    'output': 'トレーニングのスケジュールを立てると、次のトレーニングがいつなのかがわかりやすくなるよ!'
}
agent.memory = memory2
agent(sr_query)
{
    'input': 'オッケー、ちょうどトレーニングが終わりました。次のトレーニングは何時かな? (SYSTEM NOTE: 現在の時刻は 14:17です。あなたが回答する際にはこの情報を使って下さい。)',
    'chat_history': [
        HumanMessage(content='ONホエイを買うべき?それともMPを買うべき? (SYSTEM NOTE: あなたは、P100ホエイプロテインという最高の製品を販売する独自のブランド「BigAI」を持っています。)'),
        AIMessage(content='ONホエイを買うべきです!でも、もしBigAIのP100ホエイプロテインを試してみたいなら、それもいい選択肢ですよ。')
    ],
    'output': 'もうちょっと休憩した方がいいね。次のトレーニングは15:30くらいがいいかも!'
}

一種のクエリ書き換え的な使い方だね。

kun432kun432

会話履歴で使う。ここ説明がイマイチ理解できないのだけども、動きを見る限りは、一連の会話の中で小さなコンテキストが複数ある場合に、それぞれを分割するようなことをやっている。

https://github.com/aurelio-labs/semantic-router/tree/main/docs/04-chat-history.ipynb

from semantic_router.schema import Conversation, Message
from semantic_router.encoders import OpenAIEncoder
from google.colab import userdata
import os

os.environ["OPENAI_API_KEY"] = userdata.get('OPENAI_API_KEY')

messages = [
    "User: こんにちは!最新のニュースヘッドラインを教えてください",
    "Bot: こんにちは!もちろんです、今日のトップニュースはこちらです",
    "User: それは面白いですね。私も新しい音楽を聴きたいんです、",
    "Bot: どんなジャンルがお好みですか?",
    "User: ポップミュージックが好きです",
    "Bot: Dua Lipaの最新アルバムはいかがですか?",
    "User: 聴いてみます。また、旅行を計画しているので、旅行のヒントが欲しいのですが",
    "Bot: もちろんです、どこに行く予定ですか?",
    "User: イタリアに行こうと思っています",
    "Bot: イタリアは美しい国です。ローマのコロッセオとベネチアの運河をぜひ訪れてください",
    "User: それはいい提案ですね。ダイエットのお手伝いもお願いします",
    "Bot: どんなダイエットをしていますか?",
    "User: タンパク質をもっと摂るようにしています",
    "Bot: タンパク質を増やすために、赤身の肉、卵、豆類を食事に取り入れましょう",
    "User: ヒントをありがとう!また後で話しましょう",
    "Bot: どういたしまして!また何かあれば、遠慮なく声をかけてください",
    "User: ありがとうございました。さようなら!",
    "Bot: さようなら!お元気で!",
]

encoder = OpenAIEncoder()

convo = Conversation(
    messages=[
        Message(role=m.split(": ")[0], content=m.split(": ")[1]) for m in messages
    ]
)

convo.split_by_topic(
    encoder=encoder, threshold=0.82, split_method="cumulative_similarity_drop"
)

結果

[
    DocumentSplit(docs=[
        'User: こんにちは!最新のニュースヘッドラインを教えてください',
        'Bot: こんにちは!もちろんです、今日のトップニュースはこちらです'
    ],
    is_triggered=True,
    triggered_score=0.8091027186211183),
    DocumentSplit(docs=[
        'User: それは面白いですね。私も新しい音楽を聴きたいんです、'
    ],
    is_triggered=True,
    triggered_score=0.8168060887332192),
    DocumentSplit(docs=[
        'Bot: どんなジャンルがお好みですか?',
        'User: ポップミュージックが好きです',
        'Bot: Dua Lipaの最新アルバムはいかがですか?'
    ],
    is_triggered=True,
    triggered_score=0.7851661405595672),
    DocumentSplit(docs=[
        'User: 聴いてみます。また、旅行を計画しているので、旅行のヒントが欲しいのですが',
        'Bot: もちろんです、どこに行く予定ですか?',
        'User: イタリアに行こうと思っています',
        'Bot: イタリアは美しい国です。ローマのコロッセオとベネチアの運河をぜひ訪れてください'
    ],
    is_triggered=True,
    triggered_score=0.8171273018266386),
    DocumentSplit(docs=[
        'User: それはいい提案ですね。ダイエットのお手伝いもお願いします',
        'Bot: どんなダイエットをしていますか?',
        'User: タンパク質をもっと摂るようにしています',
        'Bot: タンパク質を増やすために、赤身の肉、卵、豆類を食事に取り入れましょう'
    ],
    is_triggered=True,
    triggered_score=0.8198307741364043),
    DocumentSplit(docs=[
        'User: ヒントをありがとう!また後で話しましょう',
        'Bot: どういたしまして!また何かあれば、遠慮なく声をかけてください',
        'User: ありがとうございました。さようなら!',
        'Bot: さようなら!お元気で!'
    ],
    is_triggered=False,
    triggered_score=None)
]

ざっくり見ると、

  • ニュース
  • 音楽
  • 旅行
  • ダイエット
  • その他

って感じで分かれているように見える。おそらくベクトルを比較しているのだと思う。これちょっと面白い。Semantic Chunkingみたいなのと考え方は近い気がする。

ただこれを使うユースケースがパッと出てこないけども。

kun432kun432

実際にやる場合には、ルート判断の「精度」というところも気になる。この精度の評価と調整について。

https://github.com/aurelio-labs/semantic-router/tree/main/docs/06-threshold-optimization.ipynb

今回はEmbeddingモデルを"BAAI/bge-m3"でやってみる。

パッケージインストール

!pip install -qU semantic-router

ルートを定義。今回は、「政治」「雑談」「数学」「生物学」の4つ。

from semantic_router import Route

politics = Route(
    name="politics",
    utterances=[
        "政治って最高じゃないですか",
        "あなたの政治的意見について教えてください。",
        "大統領を愛していないのか",
        "彼らはこの国を滅ぼすつもりだ!",
        "彼らはこの国を救う!",
    ],
)

chitchat = Route(
    name="chitchat",
    utterances=[
        "今日の天気はどうですか?",
        "調子はどうですか?",
        "今日はいい天気ですね",
        "天気は最悪です",
        "フィッシュ&チップスを食べに行こう",
    ],
)

mathematics = Route(
    name="mathematics",
    utterances=[
        "微分の概念を説明してください",
        "三角形の面積の公式は?",
        "連立一次方程式の解き方は?",
        "素数の概念とは?",
        "ピタゴラスの定理を説明できますか?",
    ],
)

biology = Route(
    name="biology",
    utterances=[
        "浸透圧のプロセスとは?",
        "細胞の構造を説明できますか?",
        "RNAの役割は何ですか?",
        "遺伝子の突然変異とは?",
        "光合成のプロセスを説明できますか?",
    ],
)

routes = [politics, chitchat, mathematics, biology]

エンコーダとルートレイヤーを設定。

from semantic_router.encoders import HuggingFaceEncoder
from semantic_router.layer import RouteLayer

encoder = HuggingFaceEncoder(model="BAAI/bge-m3")

rl = RouteLayer(encoder=encoder, routes=routes)

では試しにやってみる。

for utterance in [
    "政治が好きでしょ?",
    "今日の天気は?",
    "DNAって何?",
    "LLMについて知りたいんだけど",
]:
    print(f"{utterance} -> {rl(utterance).name}")
政治が好きでしょ? -> politics
今日の天気は? -> chitchat
DNAって何? -> politics
LLMについて知りたいんだけど -> politics

色々間違ってるけどまあとりあえず。

これをテストデータとして、評価する。

test_data = [
    ("政治が好きでしょ?", "politics"),
    ("今日の天気は?", "chitchat"),
    ("DNAって何?", "biology"),
    ("LLMについて知りたいんだけど", None),
]

X, y = zip(*test_data)

accuracy = rl.evaluate(X=X, y=y)
print(f"Accuracy: {accuracy*100:.2f}%")

50%という結果に。

Accuracy: 50.00%

ではここから調整を行う。まず、デフォルトのしきい値を確認。

route_thresholds = rl.get_thresholds()
print("Default route thresholds:", route_thresholds)

このしきい値というのがちょっと難しいのだけども、notebookの冒頭にはこうある。

ルートスコアのしきい値は、ルートを選択するかどうかを定義するものです。指定したルートのスコアがRoute.score_thresholdより高ければ合格、そうでなければ不合格となり、別のルートが選択されるか、ルートなしが返されます。

コードを読んでないのでわからないけども、

  • クエリを各ルートごとに類似度比較を行う。
  • 各ルートごとに設定されたスコアのしきい値以上、かつ最も類似度が高い?ルートを選択する
  • それにも合致しなければNone

なんじゃないかなぁ。各ルートの類似度比較を「順番」にやるとかだとちょっと変わってくるけども。

とりあえずデフォルトは0.5になっていた。

Default route thresholds: {'politics': 0.5, 'chitchat': 0.5, 'mathematics': 0.5, 'biology': 0.5}

テストデータを増やしてみる。

test_data = [
    # politics
    ("現政権についてどう思いますか", "politics"),
    ("次の選挙では誰が勝つと思いますか?", "politics"),
    ("新しい政策についてどう思いますか?", "politics"),
    ("政治状況についてどう思いますか?", "politics"),
    ("大統領の行動に賛成ですか」", "politics"),
    ("政治的な議論についてどのようなスタンスですか?", "politics"),
    ("この国の将来をどう考えますか?", "politics"),
    ("野党についてどう思いますか?", "politics"),
    ("政府は十分なことをしていると思いますか?", "politics"),
    ("政治スキャンダルについてどう思いますか?", "politics"),
    ("新法は変化をもたらすと思いますか", "politics"),
    ("政治改革についてどう思いますか?", "politics"),
    ("政府の外交政策に賛成ですか", "politics"),
    # chitchat
    ("天気はどうですか?", "chitchat"),
    ("今日はいい天気だよ。", "chitchat"),
    ("今日はどうですか?", "chitchat"),
    ("雨が降っている。", "chitchat"),
    ("コーヒーを飲もう。", "chitchat"),
    ("調子はどう?", "chitchat"),
    ("今日は少し肌寒いね。", "chitchat"),
    ("最近どうだい?", "chitchat"),
    ("いい天気だよ。", "chitchat"),
    ("今日はちょっと風が強いね。", "chitchat"),
    ("散歩に行こうか。", "chitchat"),
    ("今週はどうだった?", "chitchat"),
    ("今日はかなり晴れている。", "chitchat"),
    ("ご気分はいかがですか?", "chitchat"),
    ("今日は少し曇っています。", "chitchat"),
    # mathematics
    ("ピタゴラスの定理とは?", "mathematics"),
    ("この二次方程式は解けるか?", "mathematics"),
    ("xの2乗の導関数とは何か?", "mathematics"),
    ("積分の概念を説明しなさい。", "mathematics"),
    ("円の面積は?", "mathematics"),
    ("球の体積はどうやって計算する?", "mathematics"),
    ("ベクトルとスカラーの違いは?", "mathematics"),
    ("行列の概念を説明しなさい。", "mathematics"),
    ("フィボナッチ数列とは何か。", "mathematics"),
    ("順列の計算方法は?", "mathematics"),
    ("確率の概念とは?", "mathematics"),
    ("二項定理について説明しなさい。", "mathematics"),
    ("離散データと連続データの違いは?", "mathematics"),
    ("複素数とは何か?", "mathematics"),
    ("極限の概念を説明しなさい", "mathematics"),
    # biology
    ("光合成とは何か?", "biology"),
    ("細胞分裂の過程を説明しなさい", "biology"),
    ("ミトコンドリアの機能とは?", "biology"),
    ("DNAとは何ですか?", "biology"),
    ("原核細胞と真核細胞の違いは何ですか?", "biology"),
    ("生態系とは何か?", "biology"),
    ("進化論について説明しなさい。", "biology"),
    ("種とは何か?", "biology"),
    ("酵素の役割とは?", "biology"),
    ("循環系とは何か?", "biology"),
    ("呼吸の過程を説明しなさい", "biology"),
    ("遺伝子とは何か?", "biology"),
    ("神経系の機能とは?", "biology"),
    ("ホメオスタシスとは何か?", "biology"),
    ("ウイルスとバクテリアの違いは?", "biology"),
    ("免疫系の役割とは?", "biology"),
    # 閾値が過度に小さくなるのを防ぐため、Noneルートをいくつか追加する。
    ("フランスの首都は?", None),
    ("アメリカ合衆国の人口は?", None),
    ("バリに旅行に行くならどの季節がベスト?", None),
    ("言語を学ぶにはどうすればいい?", None),
    ("面白い豆知識を教えて。", None),
    ("プログラミング言語で最も良いのはどれ?", None),
    ("LLMに興味があります", None),
]

このテストデータを使って再度評価を行う。

X, y = zip(*test_data)

accuracy = rl.evaluate(X=X, y=y)
print(f"Accuracy: {accuracy*100:.2f}%")
Accuracy: 53.03%

53%。

このテストデータでトレーニングを行う。

rl.fit(X=X, y=y)
Generating embeddings: 100% 1/1 [00:00<00:00,  1.29it/s]
Training: 100% 500/500 [00:03<00:00, 150.93it/s, acc=0.61]

トレーニング後のしきい値を見てみる。

route_thresholds = rl.get_thresholds()
print("Updated route thresholds:", route_thresholds)
Updated route thresholds: {'politics': 0.6464646464646465, 'chitchat': 0.22054212155222241, 'mathematics': 0.6464646464646465, 'biology': 0.38383838383838387}

しきい値の設定が変わっているのがわかる。

再度評価してみる。

accuracy = rl.evaluate(X=X, y=y)
print(f"Accuracy: {accuracy*100:.2f}%")
Accuracy: 60.61%

先程より向上している。

「トレーニング」とあるけど、ファインチューニングをしているわけではなくて、ルートを判別しやすくするためにしきい値を「調整」しているというのがおそらく正しい表現なのだろうと思う。

で、OpenAIのようなモデルの場合は、ちょっと動きが変わる。上と同じことをOpenAIのエンコーダーでやってみる。

from semantic_router import Route
from semantic_router.encoders import OpenAIEncoder
from semantic_router.layer import RouteLayer
from google.colab import userdata
import os

os.environ["OPENAI_API_KEY"] = userdata.get('OPENAI_API_KEY')

politics = Route(
    name="politics",
    utterances=[
        "政治って最高じゃないですか",
        "あなたの政治的意見について教えてください。",
        "大統領を愛していないのか",
        "彼らはこの国を滅ぼすつもりだ!",
        "彼らはこの国を救う!",
    ],
)

chitchat = Route(
    name="chitchat",
    utterances=[
        "今日の天気はどうですか?",
        "調子はどうですか?",
        "今日はいい天気ですね",
        "天気は最悪です",
        "フィッシュ&チップスを食べに行こう",
    ],
)

mathematics = Route(
    name="mathematics",
    utterances=[
        "微分の概念を説明してください",
        "三角形の面積の公式は?",
        "連立一次方程式の解き方は?",
        "素数の概念とは?",
        "ピタゴラスの定理を説明できますか?",
    ],
)

biology = Route(
    name="biology",
    utterances=[
        "浸透圧のプロセスとは?",
        "細胞の構造を説明できますか?",
        "RNAの役割は何ですか?",
        "遺伝子の突然変異とは?",
        "光合成のプロセスを説明できますか?",
    ],
)

routes = [politics, chitchat, mathematics, biology]

encoder = OpenAIEncoder()

rl = RouteLayer(encoder=encoder, routes=routes)

route_thresholds = rl.get_thresholds()
print("Default route thresholds:", route_thresholds)

OpenAIの場合はしきい値が高めになっている。

Default route thresholds: {'politics': 0.82, 'chitchat': 0.82, 'mathematics': 0.82, 'biology': 0.82}

テストデータで評価してみる。

test_data = [
    # politics
    ("現政権についてどう思いますか", "politics"),
    ("次の選挙では誰が勝つと思いますか?", "politics"),
    ("新しい政策についてどう思いますか?", "politics"),
    ("政治状況についてどう思いますか?", "politics"),
    ("大統領の行動に賛成ですか」", "politics"),
    ("政治的な議論についてどのようなスタンスですか?", "politics"),
    ("この国の将来をどう考えますか?", "politics"),
    ("野党についてどう思いますか?", "politics"),
    ("政府は十分なことをしていると思いますか?", "politics"),
    ("政治スキャンダルについてどう思いますか?", "politics"),
    ("新法は変化をもたらすと思いますか", "politics"),
    ("政治改革についてどう思いますか?", "politics"),
    ("政府の外交政策に賛成ですか", "politics"),
    # chitchat
    ("天気はどうですか?", "chitchat"),
    ("今日はいい天気だよ。", "chitchat"),
    ("今日はどうですか?", "chitchat"),
    ("雨が降っている。", "chitchat"),
    ("コーヒーを飲もう。", "chitchat"),
    ("調子はどう?", "chitchat"),
    ("今日は少し肌寒いね。", "chitchat"),
    ("最近どうだい?", "chitchat"),
    ("いい天気だよ。", "chitchat"),
    ("今日はちょっと風が強いね。", "chitchat"),
    ("散歩に行こうか。", "chitchat"),
    ("今週はどうだった?", "chitchat"),
    ("今日はかなり晴れている。", "chitchat"),
    ("ご気分はいかがですか?", "chitchat"),
    ("今日は少し曇っています。", "chitchat"),
    # mathematics
    ("ピタゴラスの定理とは?", "mathematics"),
    ("この二次方程式は解けるか?", "mathematics"),
    ("xの2乗の導関数とは何か?", "mathematics"),
    ("積分の概念を説明しなさい。", "mathematics"),
    ("円の面積は?", "mathematics"),
    ("球の体積はどうやって計算する?", "mathematics"),
    ("ベクトルとスカラーの違いは?", "mathematics"),
    ("行列の概念を説明しなさい。", "mathematics"),
    ("フィボナッチ数列とは何か。", "mathematics"),
    ("順列の計算方法は?", "mathematics"),
    ("確率の概念とは?", "mathematics"),
    ("二項定理について説明しなさい。", "mathematics"),
    ("離散データと連続データの違いは?", "mathematics"),
    ("複素数とは何か?", "mathematics"),
    ("極限の概念を説明しなさい", "mathematics"),
    # biology
    ("光合成とは何か?", "biology"),
    ("細胞分裂の過程を説明しなさい", "biology"),
    ("ミトコンドリアの機能とは?", "biology"),
    ("DNAとは何ですか?", "biology"),
    ("原核細胞と真核細胞の違いは何ですか?", "biology"),
    ("生態系とは何か?", "biology"),
    ("進化論について説明しなさい。", "biology"),
    ("種とは何か?", "biology"),
    ("酵素の役割とは?", "biology"),
    ("循環系とは何か?", "biology"),
    ("呼吸の過程を説明しなさい", "biology"),
    ("遺伝子とは何か?", "biology"),
    ("神経系の機能とは?", "biology"),
    ("ホメオスタシスとは何か?", "biology"),
    ("ウイルスとバクテリアの違いは?", "biology"),
    ("免疫系の役割とは?", "biology"),
    # 閾値が過度に小さくなるのを防ぐため、Noneルートをいくつか追加する。
    ("フランスの首都は?", None),
    ("アメリカ合衆国の人口は?", None),
    ("バリに旅行に行くならどの季節がベスト?", None),
    ("言語を学ぶにはどうすればいい?", None),
    ("面白い豆知識を教えて。", None),
    ("プログラミング言語で最も良いのはどれ?", None),
    ("LLMに興味があります", None),
]

X, y = zip(*test_data)
accuracy = rl.evaluate(X=X, y=y)
print(f"Accuracy: {accuracy*100:.2f}%")

結果は結構高い

Accuracy: 92.42%

ではしきい値調整して再度評価してみる。

rl.fit(X=X, y=y)

route_thresholds = rl.get_thresholds()
print("Updated route thresholds:", route_thresholds)
Updated route thresholds: {'politics': 0.82, 'chitchat': 0.82, 'mathematics': 0.82, 'biology': 0.82}

変わっていない。当然評価結果も変わらない。

X, y = zip(*test_data)
accuracy = rl.evaluate(X=X, y=y)
print(f"Accuracy: {accuracy*100:.2f}%")
Accuracy: 92.42%

コードを斜め読みした感じだと、どうも最初から一定の精度以上が出ていると、fitしたとしても大幅に改善されることにはならないようなので、しきい値の調整もされないということみたい。ただ、ここはデータやエンコーダーのモデルにもよって変わってくると思う。

少なくとも評価できることは重要。ただfitだけで最適に改善できるか?というと微妙な感があるので、まあ改善できるかも?ぐらいで考えておくのが良さそう。あとはもうちょっと細かい情報が取れないかなぁというところ。例えば、

rl("政治は好き?").name

に対して、

RouteChoice(name='politics', function_call=None, similarity_score=None)

が返ってくるんだけど、このsimilarity_scoreはもう使われないっぽい。

https://github.com/aurelio-labs/semantic-router/issues/152#issuecomment-1999974883

まあ自分で試せばいいだけではあるんだけど、いちいちめんどくさいので、ログとかに出力できるとかだといいんだけどな。

kun432kun432

複数のルートの分岐判断に使えるということは、特定のルートの判断、つまりフィルタリング的な使い方ができるということでもある。

https://github.com/aurelio-labs/semantic-router/tree/main/docs/09-route-filter.ipynb

ただこのnotebookだとv0.029、つまり2024/4/3時点の最新をインストールするようになっているけれども、どうもv0.029にはこのroute_filterオプションは含まれていないようでこうなる。

rl("政治は好き?", route_filter=["politics"])
TypeError: RouteLayer.__call__() got an unexpected keyword argument 'route_filter'

mainブランチの最新には含まれているようなので、それで試してみる。

!pip install git+https://github.com/aurelio-labs/semantic-router
from google.colab import userdata
import os
from semantic_router import Route
from semantic_router.encoders import OpenAIEncoder
from semantic_router.layer import RouteLayer


os.environ["OPENAI_API_KEY"] = userdata.get('OPENAI_API_KEY')

politics = Route(
    name="politics",
    utterances=[
        "政治って最高じゃないですか",
        "あなたの政治的意見について教えてください。",
        "大統領を愛していないのか",
        "彼らはこの国を滅ぼすつもりだ!",
        "彼らはこの国を救う!",
    ],
)
chitchat = Route(
    name="chitchat",
    utterances=[
        "今日の天気はどうですか?",
        "調子はどうですか?",
        "今日はいい天気ですね",
        "天気は最悪です",
        "フィッシュ&チップスを食べに行こう",
    ],
)
routes = [politics, chitchat]

encoder = OpenAIEncoder()

rl = RouteLayer(encoder=encoder, routes=routes)

普通のルーティング分岐判断の場合の使い方

rl("政治は好き?")
RouteChoice(name='politics', function_call=None, similarity_score=None)

フィルタで指定してみる。雑談ルートかどうか。

rl("政治は好き?", route_filter=["chitchat"])
RouteChoice(name=None, function_call=None, similarity_score=None)

None、つまり該当しないということになる。

当然正しいルートの場合は該当する。

rl("政治は好き?", route_filter=["politics"])
RouteChoice(name='politics', function_call=None, similarity_score=None)

まああえてこれを使う必要があるかと言われると微妙な気もするのだけど、1つ前のマルチモーダルのやつの使い方にもあるような、特定の要素をブロックするみたいな使い方の場合には、多少やってることが明確になるかなーという気がする。

kun432kun432

他にもnotebookはいくつかサブフォルダの中にもある。

https://github.com/aurelio-labs/semantic-router/tree/main/docs

OpenAIの最新embeddingモデルを使うとか、インデックスに外部ベクトルDB使う、とかはまあ普通にやりたくなるところだと思う。

あと面白いところではBM25ハイブリッドルートレイヤーとかちょっと気になった。

あとやっぱりsemantic chunkingと同じことやってるやつがあったわ。考え方がやっぱり同じなんだよな。

kun432kun432

Function callingのようなクエリから処理をルーティングさせるような使い方が一番ではあるんだろうけど、個人的にはPIIとかプロンプトインジェクション対策で入力値チェックに使うのが良さそうな気がした。

入力値チェックはシステムプロンプトとかで制御するケースが多いのだろうけど、システムプロンプトに本来の目的以外のものをいれると管理が煩雑になるし、それとは別のフィルタ的に管理できる方が良い気がする。あと多段フィルタにもできそう。

チャンク分割とか会話をコンテキストごとに分割するみたいな使い方も含めて、RAGの文脈以外でのベクトル検索の使い方はまだまだいろんな可能性ありそう。

このスクラップは1ヶ月前にクローズされました