ブログ

semantic-router と Qdrant で LLM を呼ばずに振り分ける——11 倍速で済む判定、済まない判定

日本語 52 問を semantic-router + Qdrant と LLM 分類で振り分けた Zenn の実測(94 ms 対 1,048 ms、67.5% 対 100%)を三層に置き直し、どの判定をどの層に任せるかの境界を引きます。

Linden Kern約 14 分
三列の比較図:ルール層は 0.5 µs・費用ゼロ(筆者計測)、埋め込み層(semantic-router + Qdrant)は p50 94 ms・正解率 67.5%、LLM 分類(gpt-5.6-luna)は p50 1,048 ms・正解率 100%(Zenn 実測)。1,000 件あたりの費用は本稿の試算で約 0.0003 ドル対 0.11 ドル

LLM を呼ばずに振り分けられるか——できます。ただし「何を任せるか」を選んだ場合に限ります。 Zenn で shogo_h 氏が公開した実測 (2026-08-24)では、日本語の問い合わせ 52 問を 5 つの窓口へ振り分けるのに、semantic-router + Qdrant は p50 で 94 ms、LLM 分類(gpt-5.6-luna)は 1,048 ms かかりました。11 倍速い 代わりに、該当あり(5 窓口のどれかに属する)40 問の正解率は 67.5% 対 100%、圏外(どの窓口 にも当たらない)12 問を弾けた割合は既定の閾値で 50% 対 100%、閾値を 0.35 に上げると 91.7% まで届きます。費用は原記事にない本稿の試算で、1,000 件あたり約 0.0003 ドル対 0.11 ドル。 この数字が示すのは「LLM をやめられる」ではなく、判定にはルール・埋め込み・LLM という三つの層 があり、得意な判定が違う、ということです。

実測の条件——Raspberry Pi 5・5 窓口・52 問・各条件 1 回

実測を行ったのは Zenn の shogo_h 氏で、私たちのチームではありません。

項目
semantic-router / qdrant-client / Qdrant サーバー0.1.16 / 1.19.0 / 1.19.0(Docker qdrant/qdrant:latest
埋め込みモデルOpenAI text-embedding-3-small(既定の閾値 0.3)
比較対象の LLMOpenAI gpt-5.6-luna、reasoning effort は none
実行環境Raspberry Pi 5、Python 3.12、openai 2.53.0
ルートdelivery / returns / billing / tech_support / account の 5 窓口、各 8 発話(計 40)
評価登録と別の言い回しを窓口ごとに 8 問(計 40)+ 圏外 12 問 = 52 問、各条件 1 回

LLM 側にも同じ 40 発話だけを例として渡し、窓口の説明文は入れていません。n=52 で各条件 1 回 なので、原記事自身が 95% 信頼区間(Wilson)を併記しています。

指標semantic-router + QdrantLLM 分類
該当あり 40 問の正解率27/40 = 67.5%(52.0%–79.9%)40/40 = 100%(91.2%–100%)
圏外 12 問を棄却できた割合6/12 = 50.0%(25.4%–74.6%)12/12 = 100%(75.8%–100%)
レイテンシ p5094 ms(埋め込み API 91 ms + ルーティング 3.0 ms)1,048 ms
レイテンシ p95 / 最大109 ms / 137 ms2,057 ms / 3,691 ms
トークン(52 問合計)入力 28,078・出力 268
Zenn 原記事「結果」節のページ截图:上部の注意ボックスに「測ったのは各条件1回ずつ、n=52 です」「95%信頼区間(Wilson)を併記しています」、その下の表に semantic-router 27/40 = 67.5% [52.0%, 79.9%] 対 LLM 分類 40/40 = 100% [91.2%, 100%]、棄却 6/12 = 50.0% 対 12/12 = 100%、レイテンシ p50 94ms(うち埋め込み 91ms / ルーティング 3.0ms)対 1,048ms、p95 109ms 対 2,057ms、最大 137ms 対 3,691ms
図 1 — 上の表の元データ。原記事自身が「各条件1回ずつ、n=52」と明記し、区間を併記している。ページ取得日 2026-08-26。出典:Zenn・shogo_h 氏「Qdrant × semantic-router」

91 ms は埋め込み API の往復、Qdrant の検索・集約・閾値 判定は 3 ms。 ローカルの埋め込みモデル(encoder)に替えればそこがローカル推論の時間に置き換わり ますが、Raspberry Pi 5 の CPU で何 ms になるかは原記事も測っておらず、私も推測で置きません。 ローカル化は、問い合わせの本文を社外に出さない構成にもなります。

「どの窓口でもない」を返せる——RouteChoice の name が None

semantic-router(Aurelio AI、MIT、GitHub スター約 3,800・2026-08-25 時点)は README の言葉で “a superfast decision-making layer for your LLMs and agents”。部品は Route(窓口名と発話例)、encoder(文を数値のベクトルに変える 埋め込みモデル)、index(ベクトルの置き場所。メモリ上のほか Qdrant・Pinecone・PostgreSQL)の 三つで、問い合わせに近い発話を上位 top_k 件(既定 5) 拾い、ルートごとに平均し、閾値を超えたルートを返します。原記事の例を引きます (コメント一行は筆者)。

from semantic_router import Route
from semantic_router.encoders import OpenAIEncoder
from semantic_router.index import QdrantIndex
from semantic_router.routers import SemanticRouter

delivery = Route(
    name="delivery",
    utterances=[
        "注文した商品はいつ届きますか",
        "配送状況を確認したいです",
        "追跡番号を教えてください",
        "荷物がまだ届きません",
    ],
)
returns = Route(
    name="returns",
    utterances=[
        "返品したいのですが手続きを教えてください",
        "サイズが合わないので交換をお願いします",
        "届いた商品が壊れていました",
        "返金はいつ振り込まれますか",
    ],
)

router = SemanticRouter(
    encoder=OpenAIEncoder(name="text-embedding-3-small"),
    routes=[delivery, returns],
    index=QdrantIndex(location=":memory:"),
    auto_sync="local",  # local route definitions are the source of truth
)

router("荷物が届かないんだけど").name   # 'delivery'   score=0.574
router("買った服が小さかった").name     # 'returns'    score=0.413
router("今日の天気はどう?").name       # None         score=None

三つ目の戻り値は Python の None ではなく、nameNoneRouteChoice です (schema.py、 通過 0 件のとき return RouteChoice())。 圏外は router(text).name is None で判定します。

私はこの「圏外を返せる」ことを、速さより重要な性質だと考えています。黙った分だけを次の層に 渡せるからです。ただし今回のデータでは LLM 分類も圏外 12 問をすべて弾いており、押し込んだのは むしろ埋め込み側でした(次節)。出口の幅は閾値で決まり、原記事は score_threshold だけを振って 再評価しています。

score_threshold該当あり 40 問の正解率圏外 12 問の棄却率
0.2070.0%16.7%
0.2570.0%25.0%
0.30(既定)67.5%50.0%
0.3567.5%91.7%
0.4052.5%100%
0.5015.0%100%

0.30 から 0.35 で、正解率を落とさずに棄却率が 50% から 91.7% へ。0.40 以上では該当ありが崩れます。 閾値は Route(score_threshold=...) でルート単位にも置け、訓練データがあれば router.fit(X, y) で決まります。

埋め込みが外す場所——スコアは当たり外れを分けず、語の一致は苦手

外した 13 問のうち 12 問は別の窓口に振ったもので、None になったのは 1 問だけ。誤った 12 問の スコアは 0.301–0.526(中央値 0.403)、正解した 27 問は 0.359–0.571(中央値 0.452)で、大きく 重なっています。 原記事の言葉では「スコアの高さだけでは当たり外れを切り分けられません」。

ではスコアで切る設計はどこまで効くのか、原記事の閾値表と誤判定表を重ねて数えます(正解 27 問の 個別スコアは非公開のため、内訳は閾値表からの逆算です)。

閾値正解別窓口に押し込みNone(次の層へ)圏外 12 問の棄却
0.30(既定)271216
0.35279411
0.402161312
0.451412512

「自信の低い判定だけ LLM に回す」設計は、機能しないのではなく範囲との交換です。0.45 なら 埋め込み層が確定した 15 問のうち 14 問が正解ですが、52 問のうち 37 問が LLM に回ります。逆に 圏外の棄却が最も効く 0.35 では、該当あり 40 問のうち 9 問が別の窓口に黙って送られ、None に ならないので下の層に落ちません。「圏外だけを LLM に渡す」前段として使うなら、この 9 問 (該当ありの 22.5%)がそのまま通ります。

外した文を引きます。「いつ出荷されるか知りたい」(正解 delivery → returns)、「インボイスを ダウンロードしたい」(billing → tech_support)。窓口を決める語がはっきり入っている文でも外して います。 密ベクトル(埋め込み)は文全体の意味を捉えますが、語そのものの一致には弱い。原記事は 発話例の不足と名詞で切ったルート設計を見立てに挙げつつ、原因は保留としています。

ルートの切り方は、同梱の 医療事務ルーティングの例 が指針を出しています。名詞でなく要求されている行動で切る、窓口ごとに受けないものも書く、 該当しない例を入れる、判断できなければ棄却して聞き返す。

語の一致が要るなら、語の出現で当てるスパース検索(BM25 など)を密ベクトルと併用する HybridRouter がありますが、原記事は日本語で二つの落とし穴を報告しています。既定の BM25Encoder の分割器は英語の google-bert/bert-base-uncased で、「プラン X の料金」と「プラン Y の料金」の日本語部分は同じトークンに潰れます。閾値は密ベクトル 側の値に alpha(既定 0.3)を掛けた 0.09 になって 0.35 は持ち込めず、しかも 0.1.16 では alpha を明示しても反映されません。

三層の請求書——1,000 件あたりの費用と遅延

ここからは私の勘定です。単価は OpenAI の公式価格表 (2026-08-25 確認、Standard)で、以下すべて 100 万トークンあたり。text-embedding-3-small は 0.02 ドル、gpt-5.6-luna は入力 0.20・キャッシュ入力 0.02・出力 1.20 ドルです。

LLM 分類。 原記事の実測は 52 問で入力 28,078・出力 268 トークンなので、1 問あたり入力 約 540・出力約 5 トークン。1,000 件なら入力 54 万トークン × 0.20 ドル/100 万トークン = 0.108 ドル、 出力約 5,200 トークン × 1.20 ドル/100 万トークン ≈ 0.006 ドル、合計約 0.114 ドル。キャッシュ 単価(10 分の 1)は効きません。OpenAI のプロンプトキャッシュは GPT-5.6 以降(gpt-5.6-luna も同系列として読んでいます)で 1,024 トークン以上の接頭辞が条件で、 540 トークンでは全額入力単価です。窓口が増えて 1,024 を超えれば逆に安くなり得ます。

埋め込み。 原記事は埋め込み側のトークン数を記録していないので、公開された誤判定 13 問を cl100k_base(OpenAI の tokenizer)で数えました。平均 15.3 トークン(12–23)。1 問 15 トークン として 1,000 件で 15,000 トークン × 0.02 ドル/100 万トークン ≈ 0.0003 ドル。Qdrant はローカル モードなら費用ゼロ、Docker サーバーでも同一ホストなら往復 4 ms ほど増えるだけです(原記事の別計測)。

データカード:1,000 件の振り分け費用、埋め込み約 0.0003 ドル 対 LLM 分類約 0.114 ドル
図 2 — 1,000 件あたりの費用。埋め込み側は 1 問 15 トークンという本稿の前提、LLM 側は Zenn の実測トークン数に公式単価を掛けた値。月 10 万件に換算しても LLM 分類は約 11 ドル。出典:OpenAI Pricing(2026-08-25 確認)。

ルール。 原記事にない層なので最低限だけ測りました。誤判定 13 問に、窓口を決める名詞だけの 正規表現を 5 本書いて Python の re で回すと 1 問あたり p50 0.51 µs(Apple M4 Pro、2 万回 反復)、API 費用ゼロ。命中 6 問、誤り 0、残る 7 問は一致なしで「次の層へ」。この 5 本は 外した文を見てから書いた規則で、6/13 は上限の目安です。正解 27 問と圏外 12 問は非公開なので、 そちらでの誤命中は測れていません。言えるのは「語で決まる分岐に限れば速く、一致しなければ何も 言わない」までです。

遅延 p50(1 問)費用(1,000 件)該当あり正解率圏外を返せるか
ルール(正規表現)0.5 µs(筆者計測)0 ドル一致した問いに限る一致なし = 次の層へ
埋め込み(semantic-router + Qdrant)94 ms(うち API 91 ms)≈ 0.0003 ドル67.5%閾値次第(50%→91.7%)。押し込みは残る
LLM 分類(gpt-5.6-luna, effort none)1,048 ms≈ 0.114 ドル100%100%
タイムライン図:ルール(0.5 µs)→ 埋め込み(94 ms)→ LLM 分類(1,048 ms)→ 圏外(name が None)
図 3 — 判定は上の層から順に落ち、費用は止まった層の分だけ、遅延は通った層の合計。埋め込みと LLM の数字は Zenn の実測、ルール層は筆者計測。出典:Zenn・shogo_h 氏の記事

規模を入れて読み直します。1,000 件 0.114 ドルは、月 10 万件で約 11 ドル、月 100 万件でも約 114 ドル。 この規模では費用は層を分ける理由になりません。理由になるのは、規模に関係なく効く 1 秒の遅延と、 圏外を返せるかどうかです。費用が判断に乗るのは日に 100 万件(月 3,400 ドル前後)あたりから。 API 費用より、各層で誰が何を保守するか(正規表現、発話例と閾値、プロンプトの窓口定義)が総費用を 決めます。

どの判定をどの層に置くか——境界の引き方

印は原記事のデータとライブラリの構造から私が読み取った判断で、あなたのデータで測り直す前提です。

判定の種類ルール埋め込みLLM
型番・プラン名・エラーコードなど語で決まる分岐最初にここで確定する語の一致に弱い(HybridRouter でも分割器の差し替えが要る)正確だが 1 秒と費用を払う理由がない
話題のガードレール(答えない話題を弾く)言い換えに追いつけないQdrant 公式例もこの用途。閾値は自分のデータで決める残余の判定にだけ
意味で決まる窓口の振り分け(本稿の 52 問)7/13 が無一致67.5%。前段に置くと別窓口への押し込み(0.35 で 9/40)がそのまま通る100%。圏外 12 問も弾いた

「自信の低い判定だけ LLM へ」は判定の種類ではなく閾値の置き方で、前節のとおり範囲との交換です。 圏外の閾値(0.35)とは別に、自分のデータで決めます。

一言で言えば、上の層で確定できた問いはそこで止め、確定できなかった問いだけ下へ渡す。52 問に 当てると、語が一致する分をルールで確定し、残りを埋め込みで振り、閾値 0.35 で圏外になった分だけ LLM に回す。ただしこの実測のままでは、埋め込み層が確定した 36 問のうち 9 問が別の窓口で、下の層に 落ちません。前段に置くなら、発話例の増強や行動で切るルート設計で押し込みを下げてからです。

私たちは en 版の Routers aren’t dead — per-request routing is で、 リクエストごとに「どのモデルに送るか」を決める方式はプロンプトキャッシュの前に退場し、判定の単位は セッション境界へ上がる、と書きました。本稿の「どの窓口か」は別の問いです。共通するのは、判定の 単位と費用を先に数えるという姿勢だけです。私たち(pirouter)はルーターを作っており、本稿で扱った判定層はまさに私たちが手を入れている層です——だからこそ、 ここで自社の話はしません。

不確実なことも書いておきます。n=52 各 1 回なので 67.5% の信頼区間は 52%–80% と広く、encoder を 替えれば既定閾値も分布も変わります。ローカル encoder の遅延は誰も測っていません。ルール層の 6/13 は後知恵です。この数字は、あなたの窓口と言い回しで測り直すための出発点であって、結論ではありません。

月曜に測ること

セクション 2 のコードは routes を自分の窓口に替えればそのまま使えます。import は semantic_router.routersSemanticRouterQdrant 公式ページRouteLayer は旧 API で、原記事の環境では ModuleNotFoundError でした)、semantic-router 0.1.16 は Python 3.14 未対応です。

  • 問い合わせログから 50 問ほど取り、1 割強は「どの窓口でもない」文にする。比べたいなら、同じ 50 問を今の LLM 分類にも通す。
  • 既定の閾値 0.3 で走らせず、0.25 から 0.45 まで 0.05 刻みで正解率と棄却率の表を作る。
  • 外した問いを、None になった数と別の窓口へ行った数に分けて数える。別の窓口へ行った割合が、 埋め込み層を前段に置いたときそのまま通ってしまう誤りの率です。
  • 外した問いのうち、窓口を決める語がはっきり入っている文の割合を数える。それがルール層を前に 置く価値で、残りが埋め込み層、圏外が LLM の仕事です。